From 35f9a3e6242f404a3767e96bdf49ea73045e17d9 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Mon, 24 Aug 2026 22:41:42 +0000 Subject: [PATCH 1/3] tests(identity): pin the platform admin's answer on every /admin/ route (#9482) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- ...in-platform-admin-standing.dogfood.test.ts | 673 ++++++++++++++++++ 1 file changed, 673 insertions(+) create mode 100644 packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts diff --git a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts new file mode 100644 index 0000000000..9d1ab72441 --- /dev/null +++ b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts @@ -0,0 +1,673 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The ADMIN side of `identity-auth.admin-lifecycle-operations` (#9482) — what a +// real ObjectStack platform admin gets from every `/admin/` route, both when +// the answer is "yes" and when the answer is a REFUSAL THAT IS ON PURPOSE. +// +// Its sibling `admin-route-nonadmin-refusal.dogfood.test.ts` pins the other +// principal: no anonymous caller and no plain member ever gets a 2xx. That file +// deliberately asserted NOTHING about the platform admin's answer on the +// better-auth-gated bucket, because at the time it was written the product +// decision had not been made and pinning either side would have been wrong. +// The decision has since landed. This file is that hole, closed. +// +// ── The decision this file encodes ────────────────────────────────────────── +// +// better-auth's stock `admin` plugin authorizes on the legacy +// `user.role === 'admin'` scalar. ADR-0068 D2 deliberately STOPPED synthesizing +// that scalar — `auth-manager.ts` says so in as many words — and contributes +// `platform_admin` to `positions[]` instead. So the vendor's own `/admin/` +// endpoints refuse a genuine ObjectStack platform admin. Two rulings decided, +// route by route, what to do about that: +// +// ADMITTED (#9970, #10352, and the pre-existing ObjectStack mounts) — every +// route with a real ObjectStack consumer was re-authorized on the ADR-0068 +// predicate and now answers the platform admin. `ban-user` / `unban-user` +// / `create-user` / `set-user-password` / `unlock-user` / `import-users` / +// `oauth2/toggle-disabled` are raw Hono mounts carrying the shared judge +// (`platform-admin-gate.ts`); `impersonate-user` is NOT — a raw mount is +// forbidden there (maintainer ruling 2026-08-20) because it would +// hand-roll better-auth's signed-cookie contract with +// `/admin/stop-impersonating` and silently detach the #8243 bearer-rotation +// hook. It is a better-auth PLUGIN endpoint with only the authorization +// predicate replaced, and since #11686 that predicate is the consolidated +// authority `hasPlatformAdminStanding`. +// +// ⭐ REFUSED BY DESIGN — the eight routes below answer a platform admin +// `403 YOU_ARE_NOT_ALLOWED_TO_*`, and that is a RULED OUTCOME, not a gap +// anyone forgot to close: +// +// #9969 (closed `not_planned`, re-implement-on-demand standing) — seven +// routes with NO ObjectStack consumer at all: no `type: 'api'` action, +// no console call, no SDK row, no doc that tells anyone to call them. +// `remove-user`, `revoke-user-session`, `revoke-user-sessions`, +// `list-user-sessions`, `update-user`, `list-users`, `get-user`. They +// are surface the `rawApp.all(`${basePath}/*`)` catch-all publishes +// because better-auth registers it. Startup scope discipline: do not +// build seven routes nobody calls. Whichever one first acquires a real +// consumer gets an ObjectStack mount AT THAT MOMENT. +// +// #9968 (ruled B, 2026-08-20, reaffirmed 2026-08-22) — `set-role`. Its +// only effect is `internalAdapter.updateUser(userId, { role })`, i.e. +// writing the very scalar ADR-0068 D2 retired, which `customSession` +// then folds back into `positions[]`. A working "Set Platform Role" +// button would be a supported, gated, one-user-at-a-time channel for +// resurrecting the dual identity representation the 2026-08-18 Option-3 +// veto killed. So the maintainer retired the CONSOLE ACTION (PR #11530) +// and left the vendor ROUTE mounted and vendor-gated, byte for byte. +// +// ⛔ THEREFORE: a `403` from any of those eight is the system working. Do not +// "fix" it. Re-implementing one is a product decision with a named trigger (a +// real ObjectStack consumer appearing), and it belongs to #9969 / #9968, not to +// whoever next reads this file and mistakes a deliberate refusal for a bug. +// If you are here because you added that consumer: mount the route with the +// ADR-0068 gate, move its entry from `REFUSED_BY_DESIGN` to `ADMITTED`, and the +// sweep at the bottom will hold you to it. +// +// ── Why the fixture is NOT the one the unit tests use ─────────────────────── +// +// `plugin-auth/src/remove-user-atomicity.test.ts` makes better-auth's admin +// endpoints answer by writing `role = 'admin'` onto the admin row in-process. +// That synthesizes a scalar a real deployment never has — which is exactly why +// no existing test observed the defect that produced this whole card family. +// ⛔ Not copied here. The subject below is the identity a real platform admin +// actually carries, and the FIRST test asserts that before any route answer is +// read: `positions` contains `platform_admin`, `isPlatformAdmin` is true, and +// `sys_user.role` is NOT `'admin'`. Every later test refuses to run until that +// control has passed, because without it a 403 could just mean "the fixture was +// never an admin" and the by-design half of this file would be vacuous. +// +// ── Two sides, two instruments ────────────────────────────────────────────── +// +// The transport is shared on purpose (same routes, same stack, one `fire`). +// The EXPECTATIONS are not, and must not be: `expectAdmitted` asserts a 2xx AND +// re-reads the STORED state the call was supposed to move, while +// `expectRefusedByDesign` asserts an exact status+code pair and that NOTHING +// moved. Neither can be satisfied by the other's bug — an `expectAdmitted` that +// silently passed everything would still leave the stored-state read-back +// unsatisfied, and an `expectRefusedByDesign` that silently passed everything +// would not produce the 2xx bodies the admitted half consumes. +// +// ── The sweep is the mirror of the sibling file's ─────────────────────────── +// +// The sibling asserts "no non-admin ever gets a 2xx" over a DERIVED population. +// This one asserts the dual over the same derived population: **no route +// refuses the platform admin unless it is a recorded by-design refusal.** That +// is what puts route N+1 in scope automatically — a newly mounted admin route +// that forgets the ADR-0068 gate refuses the platform admin, and fails here by +// name until someone either gates it properly or writes down why it refuses. +// +// @proof: admin-platform-admin-standing + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { isSystem: true }; +const AUTH_BASE = '/api/v1/auth'; + +interface Ql { + find(object: string, query: unknown, ctx: unknown): Promise; + insert(object: string, doc: unknown, ctx: unknown): Promise; +} + +const rowsOf = (r: unknown): Array> => + Array.isArray(r) ? r : ((r as { records?: Array> })?.records ?? []); + +/** + * The eight routes that answer a platform admin `403 YOU_ARE_NOT_ALLOWED_TO_*` + * ON PURPOSE, with the card that ruled each one and the reason in its own + * words. The `why` string is not decoration — it is printed in the assertion + * message, so a failure here reads as "the ruled refusal moved", never as + * "found a 403, presumably a bug". + */ +const REFUSED_BY_DESIGN: Record< + string, + { code: string; body?: Record; query?: string; ruledBy: string; why: string } +> = { + 'POST /api/v1/auth/admin/remove-user': { + code: 'YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS', + ruledBy: '#9969 (closed not_planned)', + why: 'no ObjectStack consumer; re-implement on demand. It also still carries better-auth\'s break-glass last-local-credential before-hook precisely BECAUSE it is not shadowed by a raw mount', + }, + 'POST /api/v1/auth/admin/revoke-user-session': { + code: 'YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS', + ruledBy: '#9969 (closed not_planned)', + why: 'no ObjectStack consumer; re-implement on demand', + }, + 'POST /api/v1/auth/admin/revoke-user-sessions': { + code: 'YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS', + ruledBy: '#9969 (closed not_planned)', + why: 'no ObjectStack consumer; re-implement on demand', + }, + 'POST /api/v1/auth/admin/list-user-sessions': { + code: 'YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS', + ruledBy: '#9969 (closed not_planned)', + why: 'no ObjectStack consumer; re-implement on demand', + }, + 'POST /api/v1/auth/admin/update-user': { + code: 'YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS', + ruledBy: '#9969 (closed not_planned)', + why: 'no ObjectStack consumer; re-implement on demand', + }, + 'GET /api/v1/auth/admin/list-users': { + code: 'YOU_ARE_NOT_ALLOWED_TO_LIST_USERS', + ruledBy: '#9969 (closed not_planned)', + why: 'no ObjectStack consumer; the console reads the roster through the ObjectQL sys_user surface, not this endpoint', + }, + 'GET /api/v1/auth/admin/get-user': { + code: 'YOU_ARE_NOT_ALLOWED_TO_GET_USER', + ruledBy: '#9969 (closed not_planned)', + why: 'no ObjectStack consumer; re-implement on demand', + }, + 'POST /api/v1/auth/admin/set-role': { + code: 'YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE', + ruledBy: '#9968 (ruled B 2026-08-20, reaffirmed 2026-08-22; action retired by PR #11530)', + why: 'its ONLY effect is writing the ADR-0068-D2-retired legacy role scalar, which customSession folds back into positions[]. The sys_user console action was RETIRED rather than the route re-implemented — a working one would resurrect the dual identity representation the 2026-08-18 Option-3 veto killed', + }, +}; + +/** + * Every `/admin/` route that answers a platform admin WITHOUT a gate refusal. + * Not all of them reach a 2xx at this boot (the four `/admin/sso/*` bridges + * land on a capability error while SSO is off, and `oauth2/toggle-disabled` + * needs a real client row) — what unites them is that the ADR-0068 gate ADMITS + * the admin and the handler is reached. The ones with an observable stored + * effect are pinned individually below. + */ +const ADMITTED = [ + 'POST /api/v1/auth/admin/ban-user', + 'POST /api/v1/auth/admin/unban-user', + 'POST /api/v1/auth/admin/create-user', + 'POST /api/v1/auth/admin/set-user-password', + 'POST /api/v1/auth/admin/unlock-user', + 'POST /api/v1/auth/admin/import-users', + 'POST /api/v1/auth/admin/oauth2/toggle-disabled', + 'POST /api/v1/auth/admin/impersonate-user', + 'POST /api/v1/auth/admin/sso/register', + 'POST /api/v1/auth/admin/sso/register-saml', + 'POST /api/v1/auth/admin/sso/request-domain-verification', + 'POST /api/v1/auth/admin/sso/verify-domain', +] as const; + +/** + * Routes that answer a non-refusal for a reason unrelated to authorization, so + * the sweep does not read their answer as evidence either way. Each is recorded + * with WHY, so this is a classification and not a mute button. + */ +const NOT_AN_AUTHORIZATION_ANSWER: Record = { + 'POST /api/v1/auth/admin/has-permission': + 'a permission QUERY, not an operation. It answers 200 {success:false} to the platform admin too, because the vendor evaluates it against the legacy role scalar — the same mismatch, but the shape is an answer, not a refusal', + 'POST /api/v1/auth/admin/stop-impersonating': + 'self-scoped: it ends the CALLER\'s own impersonation. A non-impersonating caller — admin or not — gets 400', + 'GET /api/v1/auth/admin/oauth2/resources': 'oidcProvider plugin not enabled at this boot — 404 to everyone', + 'POST /api/v1/auth/admin/oauth2/resources': 'oidcProvider plugin not enabled at this boot — 404 to everyone', + 'GET /api/v1/auth/admin/oauth2/resources/:identifier': 'oidcProvider plugin not enabled at this boot — 404 to everyone', + 'PATCH /api/v1/auth/admin/oauth2/resources/:identifier': 'oidcProvider plugin not enabled at this boot — 404 to everyone', + 'DELETE /api/v1/auth/admin/oauth2/resources/:identifier': 'oidcProvider plugin not enabled at this boot — 404 to everyone', + 'POST /api/v1/auth/admin/oauth2/resources/:identifier/clients/:client_id': + 'oidcProvider plugin not enabled at this boot — 404 to everyone', + 'DELETE /api/v1/auth/admin/oauth2/resources/:identifier/clients/:client_id': + 'oidcProvider plugin not enabled at this boot — 404 to everyone', + 'POST /api/v1/auth/admin/oauth2/create-client': 'oidcProvider plugin not enabled at this boot — 404 to everyone', + 'PATCH /api/v1/auth/admin/oauth2/update-client': 'oidcProvider plugin not enabled at this boot — 404 to everyone', +}; + +interface Answer { + status: number; + code: string | undefined; + json: Record; + body: string; +} + +describe('#9482: what an ObjectStack platform admin gets from every /admin/ route', () => { + let stack: VerifyStack; + let ql: Ql; + let adminToken: string; + let adminUserId: string; + let targetUserId: string; + let priorScim: string | undefined; + + /** + * The admin-identity control's verdict. ⛔ Every route assertion in this file + * gates on it: a 403 measured against a subject that was never a platform + * admin proves nothing, and the by-design half would be vacuous. + */ + let identityControlPassed = false; + + const requireIdentityControl = () => { + expect( + identityControlPassed, + 'the admin-identity control has not passed — no route answer in this file may be read as evidence ' + + 'about authorization until the subject is proven to be an ObjectStack platform admin who is NOT ' + + 'carrying the legacy role scalar', + ).toBe(true); + }; + + beforeAll(async () => { + // `/admin/` 501s unless better-auth's admin plugin is on, and `bootStack` + // exposes no auth-plugin override. `OS_SCIM_ENABLED` is the one env knob + // that reaches it — `buildPluginList` resolves `admin: pluginConfig.admin ?? + // scimEffective` (ADR-0071, SCIM forces admin on). Read when the auth + // manager is constructed, so it must precede boot. + priorScim = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + stack = await bootStack(showcaseStack); + adminToken = await stack.signIn(); // the seeded dev admin + ql = await stack.kernel.getServiceAsync('objectql'); + + // A disposable target, authored at runtime: the stock showcase seeds no + // second loginable member (#9308) and inventing one in committed metadata + // would change what the stock app means. + await stack.signUp('standing.probe.target@example.com', 'Target-Pass-123'); + const [target] = rowsOf( + await ql.find('sys_user', { where: { email: 'standing.probe.target@example.com' }, limit: 1 }, { context: SYS }), + ); + targetUserId = String(target.id); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = priorScim; + }); + + /** Shared TRANSPORT. Deliberately produces no expectation of its own. */ + async function fire(route: string, body?: unknown, query = ''): Promise { + const [verb, wire] = route.split(' '); + const path = wire.replace(/:[A-Za-z_]+/g, 'nonexistent-probe-id').replace('/api/v1', '') + query; + const res = await stack.apiAs(adminToken, verb, path, body); + const text = await res.text(); + let json: Record = {}; + try { + json = JSON.parse(text) as Record; + } catch { + /* non-JSON — kept in `body` for the failure message */ + } + // Two envelopes meet here: ObjectStack's ADR-0112 `{success,error:{code}}` + // and better-auth's flat `{message,code}`. + const code = + ((json.error as { code?: string } | undefined)?.code ?? (json.code as string | undefined)) || undefined; + return { status: res.status, code, json, body: text.slice(0, 400) }; + } + + /** INSTRUMENT 1 — the admitted side. 2xx, plus the stored state it moved. */ + async function expectAdmitted(route: string, body: unknown, note: string): Promise { + requireIdentityControl(); + const answer = await fire(route, body); + expect( + answer.status, + `${route} refused the platform admin (${answer.status} ${answer.body}) — ${note}. This route is on the ` + + `ADMITTED list: it carries the ADR-0068 gate and a real ObjectStack consumer, so a refusal here is the ` + + `#9652 defect returning, not a design decision.`, + ).toBe(200); + expect(answer.json.success, `${route}: ${answer.body}`).toBe(true); + return answer; + } + + /** INSTRUMENT 2 — the by-design refusal. Exact status AND exact code. */ + async function expectRefusedByDesign(route: string): Promise { + requireIdentityControl(); + const spec = REFUSED_BY_DESIGN[route]; + expect(spec, `${route} is not on the by-design list`).toBeDefined(); + const answer = await fire(route, spec.body, spec.query ?? ''); + + expect( + answer.status, + `${route} answered the platform admin ${answer.status}, not 403.\n` + + `⛔ This refusal is RULED, by ${spec.ruledBy}: ${spec.why}.\n` + + `If the route now ADMITS the admin, someone re-implemented it — move its entry from ` + + `REFUSED_BY_DESIGN to ADMITTED and pin the effect. If it answers some OTHER refusal, the vendor's ` + + `posture moved and this pin is the thing that noticed.\n${answer.body}`, + ).toBe(403); + + // ADR-0112: `code` as well as `status`. A status-only assertion here would + // stay green against a route that started refusing for a completely + // different reason — measured on this very surface in #9968's ablation, + // where a target-guard regression kept the 403 and changed only the code. + expect( + answer.code, + `${route} refused with ${answer.code}, not the vendor denial code this refusal is made of.\n` + + `A VALIDATION_ERROR here would mean the payload never reached the authorization check and this ` + + `assertion measured nothing.\n${answer.body}`, + ).toBe(spec.code); + return answer; + } + + // ── 0. THE ADMIN-IDENTITY CONTROL ───────────────────────────────────────── + + it('the subject is a real ObjectStack platform admin, and NOT by the legacy role scalar', async () => { + const res = await stack.apiAs(adminToken, 'GET', '/auth/get-session'); + expect(res.status, 'get-session did not answer for the admin bearer').toBe(200); + const session = (await res.json()) as { user?: Record; session?: Record }; + const user = session.user ?? {}; + adminUserId = String(user.id ?? ''); + expect(adminUserId, 'no user id on the admin session').not.toBe(''); + + // The ADR-0068 spellings, both of them. + expect( + user.positions, + 'the subject does not carry the platform_admin position — every refusal measured in this file would ' + + 'then mean "the fixture was never an admin" rather than anything about authorization', + ).toContain('platform_admin'); + expect(user.isPlatformAdmin, 'the subject is not isPlatformAdmin').toBe(true); + + // ⭐ And the negative half, which is the whole point: the standing above is + // NOT coming from the legacy scalar. `remove-user-atomicity.test.ts` writes + // `role = 'admin'` onto the admin row to make the vendor endpoints answer; + // a fixture that did that here would turn the by-design refusals green for + // the wrong reason and would not resemble any real deployment. + expect( + user.role, + 'the session user carries role === "admin" — the legacy scalar ADR-0068 D2 retired. This fixture is ' + + 'no longer the identity a real platform admin has, and every assertion in this file is measuring ' + + 'the wrong subject', + ).not.toBe('admin'); + + const [adminRow] = rowsOf(await ql.find('sys_user', { where: { id: adminUserId }, limit: 1 }, { context: SYS })); + expect(adminRow, 'the admin sys_user row did not read back').toBeDefined(); + expect( + adminRow.role, + 'sys_user.role is "admin" in storage — the scalar a post-ADR-0068-D2 deployment never writes', + ).not.toBe('admin'); + + identityControlPassed = true; + }, 120_000); + + // ── 1. THE ADMITTED SIDE, with the stored effect read back ──────────────── + + it('C0 — ban persists and reverses for a platform admin, through the ObjectStack mounts', async () => { + const banned = await expectAdmitted( + 'POST /api/v1/auth/admin/ban-user', + { userId: targetUserId, banReason: 'standing-probe-reason' }, + 'ban-user moved onto an ObjectStack raw mount with the ADR-0068 gate by PR #9970', + ); + expect(banned.json.data, 'ban-user data').toMatchObject({ userId: targetUserId, banned: true }); + + // The response is not the assertion — the STORED row is. + const [afterBan] = rowsOf(await ql.find('sys_user', { where: { id: targetUserId }, limit: 1 }, { context: SYS })); + expect(afterBan.banned, `sys_user.banned after ban-user: ${JSON.stringify(afterBan)}`).toBeTruthy(); + expect(afterBan.ban_reason, 'the ban reason did not persist').toBe('standing-probe-reason'); + + // Reversal — the gate is reversible, not a tombstone. + await expectAdmitted( + 'POST /api/v1/auth/admin/unban-user', + { userId: targetUserId }, + 'unban-user moved onto an ObjectStack raw mount with the ADR-0068 gate by PR #9970', + ); + const [afterUnban] = rowsOf(await ql.find('sys_user', { where: { id: targetUserId }, limit: 1 }, { context: SYS })); + expect(afterUnban.banned, `sys_user.banned after unban-user: ${JSON.stringify(afterUnban)}`).toBeFalsy(); + }, 300_000); + + it('the other ObjectStack-mounted admin operations admit the platform admin and their write lands', async () => { + await expectAdmitted('POST /api/v1/auth/admin/unlock-user', { userId: targetUserId }, 'pre-existing mount'); + + const imported = await expectAdmitted( + 'POST /api/v1/auth/admin/import-users', + { format: 'json', rows: [{ email: 'standing.probe.imported@example.com', name: 'Imported Probe' }] }, + 'pre-existing mount', + ); + expect(imported.json.data, 'import-users summary').toMatchObject({ summary: { created: 1, errors: 0 } }); + const importedRows = rowsOf( + await ql.find( + 'sys_user', + { where: { email: 'standing.probe.imported@example.com' }, limit: 1 }, + { context: SYS }, + ), + ); + expect(importedRows.length, 'import-users returned created:1 but no sys_user row exists').toBe(1); + + // `oauth2/toggle-disabled` needs a real client row to reach a 2xx — with a + // missing client it answers 404 RESOURCE_NOT_FOUND, which is the handler + // being REACHED, not the gate. Seeding one turns "the admin got past the + // gate" into "the admin's write landed", which is the stronger pin. + await ql.insert( + 'sys_oauth_application', + { + client_id: 'standing-probe-client', + name: 'Standing Probe Client', + redirect_uris: JSON.stringify(['https://standing-probe.example/cb']), + disabled: false, + }, + { context: SYS }, + ); + const toggled = await expectAdmitted( + 'POST /api/v1/auth/admin/oauth2/toggle-disabled', + { client_id: 'standing-probe-client', disabled: true }, + 'pre-existing mount', + ); + expect(toggled.json.data).toMatchObject({ client_id: 'standing-probe-client', disabled: true }); + const [client] = rowsOf( + await ql.find( + 'sys_oauth_application', + { where: { client_id: 'standing-probe-client' }, limit: 1 }, + { context: SYS }, + ), + ); + expect(client.disabled, 'toggle-disabled answered 200 but the stored row did not move').toBeTruthy(); + }, 300_000); + + // ── 2. THE BY-DESIGN REFUSALS ───────────────────────────────────────────── + + it('the eight consumer-less admin routes refuse the platform admin BY DESIGN, each with its ruled code', async () => { + requireIdentityControl(); + + // Read the target's whole row first: the refusals must move NOTHING, and + // three of these eight (remove-user, update-user, revoke-user-sessions) + // would be plainly visible in it if they had. + const [before] = rowsOf(await ql.find('sys_user', { where: { id: targetUserId }, limit: 1 }, { context: SYS })); + + const specs: Record = { + 'POST /api/v1/auth/admin/remove-user': { userId: targetUserId }, + 'POST /api/v1/auth/admin/revoke-user-session': { sessionToken: 'standing-probe-session-token' }, + 'POST /api/v1/auth/admin/revoke-user-sessions': { userId: targetUserId }, + 'POST /api/v1/auth/admin/list-user-sessions': { userId: targetUserId }, + 'POST /api/v1/auth/admin/update-user': { userId: targetUserId, data: { name: 'Renamed By A Refused Call' } }, + 'POST /api/v1/auth/admin/set-role': { userId: targetUserId, role: 'admin' }, + }; + for (const [route, body] of Object.entries(specs)) { + const spec = REFUSED_BY_DESIGN[route]; + expect(spec, `${route} has no REFUSED_BY_DESIGN entry — the by-design list and this loop disagree`).toBeDefined(); + spec.body = body as Record; + await expectRefusedByDesign(route); + } + REFUSED_BY_DESIGN['GET /api/v1/auth/admin/list-users'].query = '?limit=1'; + await expectRefusedByDesign('GET /api/v1/auth/admin/list-users'); + REFUSED_BY_DESIGN['GET /api/v1/auth/admin/get-user'].query = `?id=${targetUserId}`; + await expectRefusedByDesign('GET /api/v1/auth/admin/get-user'); + + // The no-effect control. A refusal that still did the thing is the worst + // possible reading of a green refusal assertion. + const [after] = rowsOf(await ql.find('sys_user', { where: { id: targetUserId }, limit: 1 }, { context: SYS })); + expect(after, 'remove-user answered 403 but the target row is gone').toBeDefined(); + expect(after.name, 'update-user answered 403 but the name changed anyway').toBe(before.name); + expect(after.role, 'set-role answered 403 but the role scalar moved anyway').toBe(before.role); + expect( + after.role, + '⛔ the target now carries the legacy admin scalar — a refused set-role must never be able to write it', + ).not.toBe('admin'); + }, 300_000); + + // ── 3. C6 — impersonation, the one ADMITTED route that is not a raw mount ── + + it('C6 — the platform admin may impersonate, and the session records the attribution', async () => { + requireIdentityControl(); + + // ⚠️ This test is ordered near the end and re-signs at its close ON + // PURPOSE. `rotateCallerBearerOnImpersonation` (#8243) rotates the CALLER's + // bearer as part of a successful impersonation, so `adminToken` is dead the + // instant this succeeds — measured: every later call answers 401 + // UNAUTHENTICATED. A file that fired this in the middle would look like a + // cascade of authorization failures. + const answer = await fire('POST /api/v1/auth/admin/impersonate-user', { userId: targetUserId }); + expect( + answer.status, + `impersonate-user refused the platform admin (${answer.status} ${answer.body}) — the endpoint was ` + + `re-authorized on the ADR-0068 predicate by PR #10352 and consolidated onto hasPlatformAdminStanding ` + + `by PR #11686, so a refusal here is that work regressing`, + ).toBe(200); + + const session = answer.json.session as Record | undefined; + expect(session, `impersonate-user 200 with no session in the body: ${answer.body}`).toBeDefined(); + expect(session!.userId, 'the minted session is not for the target').toBe(targetUserId); + expect( + session!.impersonatedBy, + 'the impersonated session does not attribute the admin — an unattributable support session is the ' + + 'failure this clause exists to catch', + ).toBe(adminUserId); + + // Attribution in STORAGE, not just in the response body. + const [stored] = rowsOf( + await ql.find('sys_session', { where: { id: String(session!.id) }, limit: 1 }, { context: SYS }), + ); + expect(stored, 'the impersonated session was not persisted').toBeDefined(); + expect(stored.impersonated_by, `sys_session.impersonated_by: ${JSON.stringify(stored)}`).toBe(adminUserId); + + adminToken = await stack.signIn(); // the rotation above killed the old one + }, 300_000); + + // ── 4. THE SWEEP — the dual of the sibling file's universal invariant ───── + + it('no /admin/ route refuses the platform admin unless it is a recorded by-design refusal', async () => { + requireIdentityControl(); + + // Derived from the running stack, through the same two seams the sibling + // file uses: the raw Hono mounts registered ahead of better-auth's + // catch-all, and better-auth's own endpoint table. + const http = await stack.kernel.getServiceAsync<{ + getRawApp(): { routes?: Array<{ method?: string; path?: string }> }; + }>('http-server'); + const objectstack = [ + ...new Set( + (http.getRawApp().routes ?? []) + .filter((r) => typeof r?.path === 'string' && r.path.includes('/auth/admin/')) + .map((r) => `${String(r.method).toUpperCase()} ${r.path}`), + ), + ]; + const authManager = await stack.kernel.getServiceAsync<{ + getAuthInstance(): Promise<{ api?: Record }>; + }>('auth'); + const auth = await authManager.getAuthInstance(); + const betterAuth = new Set(); + for (const endpoint of Object.values(auth?.api ?? {})) { + if (typeof endpoint?.path !== 'string' || !endpoint.path.startsWith('/admin/')) continue; + const method = endpoint.options?.method; + for (const verb of Array.isArray(method) ? method : [method ?? 'POST']) { + betterAuth.add(`${String(verb).toUpperCase()} ${AUTH_BASE}${endpoint.path}`); + } + } + const derived = [...new Set([...objectstack, ...betterAuth])].sort(); + + // Guard the guard: a derivation that silently returned nothing would make + // the sweep vacuous, and every by-design entry must still be served. + expect(objectstack.length, 'no ObjectStack raw /admin/ mounts derived').toBeGreaterThan(0); + expect(betterAuth.size, 'no better-auth /admin/ endpoints derived').toBeGreaterThan(0); + const missing = Object.keys(REFUSED_BY_DESIGN).filter((r) => !derived.includes(r)); + expect(missing, `by-design route(s) the stack no longer serves — the entries are stale:\n${missing.join('\n')}`).toEqual( + [], + ); + const admittedMissing = ADMITTED.filter((r) => !derived.includes(r)); + expect(admittedMissing, `ADMITTED route(s) no longer served:\n${admittedMissing.join('\n')}`).toEqual([]); + // The two instruments must never be pointed at the same route. + expect( + ADMITTED.filter((r) => r in REFUSED_BY_DESIGN), + 'a route is listed as both admitted and refused-by-design', + ).toEqual([]); + + // Fire everything. ⚠️ ORDER IS LOAD-BEARING: `impersonate-user` goes LAST + // because it (a) rotates the caller's bearer and (b) answers + // 403 BANNED_USER — a 403 about the TARGET, not a gate verdict — if the + // sweep's own `ban-user` call has not been reversed yet. Both are + // handled explicitly rather than left to sort order. + const impersonate = 'POST /api/v1/auth/admin/impersonate-user'; + const order = [...derived.filter((r) => r !== impersonate), ...derived.filter((r) => r === impersonate)]; + + const bodies: Record; query?: string }> = { + 'POST /api/v1/auth/admin/create-user': { + body: { email: 'standing.sweep.created@example.com', name: 'Sweep Probe', password: 'Explicit-Pass-123' }, + }, + 'POST /api/v1/auth/admin/set-user-password': { body: { userId: targetUserId, newPassword: 'Rotated-789' } }, + 'POST /api/v1/auth/admin/import-users': { + body: { format: 'json', rows: [{ email: 'standing.sweep.imported@example.com', name: 'Sweep Imported' }] }, + }, + 'POST /api/v1/auth/admin/unlock-user': { body: { userId: targetUserId } }, + 'POST /api/v1/auth/admin/oauth2/toggle-disabled': { body: { client_id: 'standing-probe-client', disabled: false } }, + 'POST /api/v1/auth/admin/sso/register': { + body: { + providerId: 'standing-probe-oidc', + issuer: 'https://issuer.example', + domain: 'standing-probe.example', + clientId: 'probe-client', + clientSecret: 'probe-secret', + }, + }, + 'POST /api/v1/auth/admin/sso/register-saml': { + body: { + providerId: 'standing-probe-saml', + issuer: 'https://saml-issuer.example', + domain: 'standing-probe-saml.example', + entryPoint: 'https://saml-issuer.example/sso', + cert: 'PROBE-CERT', + }, + }, + 'POST /api/v1/auth/admin/sso/request-domain-verification': { body: { providerId: 'standing-probe-oidc' } }, + 'POST /api/v1/auth/admin/sso/verify-domain': { body: { providerId: 'standing-probe-oidc' } }, + 'POST /api/v1/auth/admin/ban-user': { body: { userId: targetUserId, banReason: 'sweep' } }, + 'POST /api/v1/auth/admin/unban-user': { body: { userId: targetUserId } }, + 'POST /api/v1/auth/admin/impersonate-user': { body: { userId: targetUserId } }, + 'POST /api/v1/auth/admin/has-permission': { body: { permissions: { user: ['list'] } } }, + 'POST /api/v1/auth/admin/stop-impersonating': { body: {} }, + ...Object.fromEntries( + Object.entries(REFUSED_BY_DESIGN).map(([route, spec]) => [route, { body: spec.body, query: spec.query }]), + ), + }; + + const unexplainedRefusals: string[] = []; + const unexpectedlyAdmitted: string[] = []; + for (const route of order) { + if (route === impersonate) { + // Guarantee the target is not banned, so a 403 here can only be the + // gate — never the target guard. (The sweep banned it four routes ago.) + await fire('POST /api/v1/auth/admin/unban-user', { userId: targetUserId }); + } + const spec = bodies[route] ?? {}; + const answer = await fire(route, spec.body, spec.query ?? ''); + const isGateRefusal = answer.status === 401 || answer.status === 403; + + if (isGateRefusal && !(route in REFUSED_BY_DESIGN)) { + unexplainedRefusals.push(`${route} -> ${answer.status} ${answer.code ?? '(no code)'} ${answer.body}`); + } + if (!isGateRefusal && route in REFUSED_BY_DESIGN) { + unexpectedlyAdmitted.push(`${route} -> ${answer.status} ${answer.code ?? '(no code)'} ${answer.body}`); + } + if (route === impersonate) adminToken = await stack.signIn(); // #8243 rotation + } + + expect( + unexplainedRefusals, + 'an /admin/ route refused the PLATFORM ADMIN and is not on the by-design list. Either it is a new route ' + + 'that forgot the ADR-0068 gate — the #9652 defect, arriving on fresh surface — or it is a deliberate ' + + 'refusal nobody wrote down. Gate it, or add it to REFUSED_BY_DESIGN with the card that ruled it and ' + + `why:\n${unexplainedRefusals.join('\n')}`, + ).toEqual([]); + + expect( + unexpectedlyAdmitted, + 'a route recorded as refused-by-design now ADMITS the platform admin. That is a real behaviour change on ' + + 'an authorization surface: someone re-implemented the route (which #9969 explicitly permits on demand), ' + + 'or the vendor posture moved. Move its entry to ADMITTED and pin the effect — do not delete the ' + + `assertion:\n${unexpectedlyAdmitted.join('\n')}`, + ).toEqual([]); + + // Every route the sweep passed over without an authorization reading is + // classified, so "not a refusal" can never quietly mean "not measured". + const unclassified = derived.filter( + (r) => !(r in REFUSED_BY_DESIGN) && !(ADMITTED as readonly string[]).includes(r) && !(r in NOT_AN_AUTHORIZATION_ANSWER), + ); + expect( + unclassified, + 'the stack serves /admin/ route(s) this file neither admits, refuses-by-design, nor classifies as ' + + `answering for a non-authorization reason:\n${unclassified.join('\n')}`, + ).toEqual([]); + }, 900_000); +}); From c4afb77884a708424bf5b14bddf1bbf966291249 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Mon, 24 Aug 2026 22:54:41 +0000 Subject: [PATCH 2/3] tests(identity): declare the by-design payloads with their entries (#9482) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- ...in-platform-admin-standing.dogfood.test.ts | 111 ++++++++++++------ 1 file changed, 74 insertions(+), 37 deletions(-) diff --git a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts index 9d1ab72441..744e54818e 100644 --- a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts @@ -61,7 +61,7 @@ // real ObjectStack consumer appearing), and it belongs to #9969 / #9968, not to // whoever next reads this file and mistakes a deliberate refusal for a bug. // If you are here because you added that consumer: mount the route with the -// ADR-0068 gate, move its entry from `REFUSED_BY_DESIGN` to `ADMITTED`, and the +// ADR-0068 gate, move its entry from `refusedByDesignFor()` to `ADMITTED`, and the // sweep at the bottom will hold you to it. // // ── Why the fixture is NOT the one the unit tests use ─────────────────────── @@ -121,51 +121,75 @@ const rowsOf = (r: unknown): Array> => * message, so a failure here reads as "the ruled refusal moved", never as * "found a 403, presumably a bug". */ -const REFUSED_BY_DESIGN: Record< - string, - { code: string; body?: Record; query?: string; ruledBy: string; why: string } -> = { +interface RefusalSpec { + code: string; + /** + * ⚠️ LOAD-BEARING, and it cost an ablation to learn it. better-auth validates + * the request body BEFORE the admin check, so a route fired with no payload + * answers `400 VALIDATION_ERROR` — to a platform admin and a plain member + * alike — and a sweep built on empty bodies asserts NOTHING about + * authorization while looking exactly like a passing security suite. Every + * payload here is valid enough to REACH the gate, and the sweep below refuses + * to read a validation error as an authorization answer. + */ + body?: Record; + query?: string; + ruledBy: string; + why: string; +} + +function refusedByDesignFor(targetUserId: string): Record { + return { 'POST /api/v1/auth/admin/remove-user': { code: 'YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS', + body: { userId: targetUserId }, ruledBy: '#9969 (closed not_planned)', why: 'no ObjectStack consumer; re-implement on demand. It also still carries better-auth\'s break-glass last-local-credential before-hook precisely BECAUSE it is not shadowed by a raw mount', }, 'POST /api/v1/auth/admin/revoke-user-session': { code: 'YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS', + body: { sessionToken: 'standing-probe-session-token' }, ruledBy: '#9969 (closed not_planned)', why: 'no ObjectStack consumer; re-implement on demand', }, 'POST /api/v1/auth/admin/revoke-user-sessions': { code: 'YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS', + body: { userId: targetUserId }, ruledBy: '#9969 (closed not_planned)', why: 'no ObjectStack consumer; re-implement on demand', }, 'POST /api/v1/auth/admin/list-user-sessions': { code: 'YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS', + body: { userId: targetUserId }, ruledBy: '#9969 (closed not_planned)', why: 'no ObjectStack consumer; re-implement on demand', }, 'POST /api/v1/auth/admin/update-user': { code: 'YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS', + body: { userId: targetUserId, data: { name: 'Renamed By A Refused Call' } }, ruledBy: '#9969 (closed not_planned)', why: 'no ObjectStack consumer; re-implement on demand', }, 'GET /api/v1/auth/admin/list-users': { code: 'YOU_ARE_NOT_ALLOWED_TO_LIST_USERS', + query: '?limit=1', ruledBy: '#9969 (closed not_planned)', why: 'no ObjectStack consumer; the console reads the roster through the ObjectQL sys_user surface, not this endpoint', }, 'GET /api/v1/auth/admin/get-user': { code: 'YOU_ARE_NOT_ALLOWED_TO_GET_USER', + query: `?id=${targetUserId}`, ruledBy: '#9969 (closed not_planned)', why: 'no ObjectStack consumer; re-implement on demand', }, 'POST /api/v1/auth/admin/set-role': { code: 'YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE', + body: { userId: targetUserId, role: 'admin' }, ruledBy: '#9968 (ruled B 2026-08-20, reaffirmed 2026-08-22; action retired by PR #11530)', why: 'its ONLY effect is writing the ADR-0068-D2-retired legacy role scalar, which customSession folds back into positions[]. The sys_user console action was RETIRED rather than the route re-implemented — a working one would resurrect the dual identity representation the 2026-08-18 Option-3 veto killed', }, -}; + }; +} /** * Every `/admin/` route that answers a platform admin WITHOUT a gate refusal. @@ -226,6 +250,7 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout let adminToken: string; let adminUserId: string; let targetUserId: string; + let refusedByDesign: Record; let priorScim: string | undefined; /** @@ -264,6 +289,7 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout await ql.find('sys_user', { where: { email: 'standing.probe.target@example.com' }, limit: 1 }, { context: SYS }), ); targetUserId = String(target.id); + refusedByDesign = refusedByDesignFor(targetUserId); }, 300_000); afterAll(async () => { @@ -308,7 +334,7 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout /** INSTRUMENT 2 — the by-design refusal. Exact status AND exact code. */ async function expectRefusedByDesign(route: string): Promise { requireIdentityControl(); - const spec = REFUSED_BY_DESIGN[route]; + const spec = refusedByDesign[route]; expect(spec, `${route} is not on the by-design list`).toBeDefined(); const answer = await fire(route, spec.body, spec.query ?? ''); @@ -317,7 +343,7 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout `${route} answered the platform admin ${answer.status}, not 403.\n` + `⛔ This refusal is RULED, by ${spec.ruledBy}: ${spec.why}.\n` + `If the route now ADMITS the admin, someone re-implemented it — move its entry from ` + - `REFUSED_BY_DESIGN to ADMITTED and pin the effect. If it answers some OTHER refusal, the vendor's ` + + `refusedByDesignFor() to ADMITTED and pin the effect. If it answers some OTHER refusal, the vendor's ` + `posture moved and this pin is the thing that noticed.\n${answer.body}`, ).toBe(403); @@ -457,24 +483,17 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout // would be plainly visible in it if they had. const [before] = rowsOf(await ql.find('sys_user', { where: { id: targetUserId }, limit: 1 }, { context: SYS })); - const specs: Record = { - 'POST /api/v1/auth/admin/remove-user': { userId: targetUserId }, - 'POST /api/v1/auth/admin/revoke-user-session': { sessionToken: 'standing-probe-session-token' }, - 'POST /api/v1/auth/admin/revoke-user-sessions': { userId: targetUserId }, - 'POST /api/v1/auth/admin/list-user-sessions': { userId: targetUserId }, - 'POST /api/v1/auth/admin/update-user': { userId: targetUserId, data: { name: 'Renamed By A Refused Call' } }, - 'POST /api/v1/auth/admin/set-role': { userId: targetUserId, role: 'admin' }, - }; - for (const [route, body] of Object.entries(specs)) { - const spec = REFUSED_BY_DESIGN[route]; - expect(spec, `${route} has no REFUSED_BY_DESIGN entry — the by-design list and this loop disagree`).toBeDefined(); - spec.body = body as Record; - await expectRefusedByDesign(route); - } - REFUSED_BY_DESIGN['GET /api/v1/auth/admin/list-users'].query = '?limit=1'; - await expectRefusedByDesign('GET /api/v1/auth/admin/list-users'); - REFUSED_BY_DESIGN['GET /api/v1/auth/admin/get-user'].query = `?id=${targetUserId}`; - await expectRefusedByDesign('GET /api/v1/auth/admin/get-user'); + // ⛔ This loop reads the declared table and mutates nothing. It used to + // assign each payload here, which made the SWEEP below silently depend on + // this test having run first — measured during ablation: with this test + // aborted early the sweep fired every by-design route with an EMPTY body, + // drew `400 VALIDATION_ERROR` from the vendor's pre-auth body check, and + // reported it as "the route now admits the platform admin". The payloads + // live with their entries now, and the sweep refuses to read a validation + // error as an authorization answer. + const byDesign = Object.keys(refusedByDesign); + expect(byDesign.length, 'the by-design list is empty — this test would assert nothing').toBe(8); + for (const route of byDesign) await expectRefusedByDesign(route); // The no-effect control. A refusal that still did the thing is the worst // possible reading of a green refusal assertion. @@ -562,7 +581,7 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout // the sweep vacuous, and every by-design entry must still be served. expect(objectstack.length, 'no ObjectStack raw /admin/ mounts derived').toBeGreaterThan(0); expect(betterAuth.size, 'no better-auth /admin/ endpoints derived').toBeGreaterThan(0); - const missing = Object.keys(REFUSED_BY_DESIGN).filter((r) => !derived.includes(r)); + const missing = Object.keys(refusedByDesign).filter((r) => !derived.includes(r)); expect(missing, `by-design route(s) the stack no longer serves — the entries are stale:\n${missing.join('\n')}`).toEqual( [], ); @@ -570,7 +589,7 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout expect(admittedMissing, `ADMITTED route(s) no longer served:\n${admittedMissing.join('\n')}`).toEqual([]); // The two instruments must never be pointed at the same route. expect( - ADMITTED.filter((r) => r in REFUSED_BY_DESIGN), + ADMITTED.filter((r) => r in refusedByDesign), 'a route is listed as both admitted and refused-by-design', ).toEqual([]); @@ -618,12 +637,14 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout 'POST /api/v1/auth/admin/has-permission': { body: { permissions: { user: ['list'] } } }, 'POST /api/v1/auth/admin/stop-impersonating': { body: {} }, ...Object.fromEntries( - Object.entries(REFUSED_BY_DESIGN).map(([route, spec]) => [route, { body: spec.body, query: spec.query }]), + Object.entries(refusedByDesign).map(([route, spec]) => [route, { body: spec.body, query: spec.query }]), ), }; const unexplainedRefusals: string[] = []; const unexpectedlyAdmitted: string[] = []; + /** Answers that never reached the gate — a void reading, not a verdict. */ + const neverReachedTheGate: string[] = []; for (const route of order) { if (route === impersonate) { // Guarantee the target is not banned, so a 403 here can only be the @@ -633,21 +654,37 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout const spec = bodies[route] ?? {}; const answer = await fire(route, spec.body, spec.query ?? ''); const isGateRefusal = answer.status === 401 || answer.status === 403; - - if (isGateRefusal && !(route in REFUSED_BY_DESIGN)) { - unexplainedRefusals.push(`${route} -> ${answer.status} ${answer.code ?? '(no code)'} ${answer.body}`); - } - if (!isGateRefusal && route in REFUSED_BY_DESIGN) { - unexpectedlyAdmitted.push(`${route} -> ${answer.status} ${answer.code ?? '(no code)'} ${answer.body}`); + const line = `${route} -> ${answer.status} ${answer.code ?? '(no code)'} ${answer.body}`; + + // A payload the server rejects before authorizing is a VOID reading. It + // must be separated from a real verdict in BOTH directions, or a probe + // with a stale body reads as "the route stopped refusing". + const diedBeforeTheGate = + answer.code === 'VALIDATION_ERROR' || + (answer.status === 400 && /Invalid input|body\./i.test(answer.body)); + if (diedBeforeTheGate && (route in refusedByDesign || (ADMITTED as readonly string[]).includes(route))) { + neverReachedTheGate.push(line); + continue; } + + if (isGateRefusal && !(route in refusedByDesign)) unexplainedRefusals.push(line); + if (!isGateRefusal && route in refusedByDesign) unexpectedlyAdmitted.push(line); if (route === impersonate) adminToken = await stack.signIn(); // #8243 rotation } + expect( + neverReachedTheGate, + 'the probe payload for these routes was rejected before the authorization check ran, so their ' + + 'authorization was NOT measured. ⛔ This is not a pass and not a failure of the route — it is a ' + + 'void reading, and the payload in the table needs updating to whatever the handler now requires:\n' + + neverReachedTheGate.join('\n'), + ).toEqual([]); + expect( unexplainedRefusals, 'an /admin/ route refused the PLATFORM ADMIN and is not on the by-design list. Either it is a new route ' + 'that forgot the ADR-0068 gate — the #9652 defect, arriving on fresh surface — or it is a deliberate ' + - 'refusal nobody wrote down. Gate it, or add it to REFUSED_BY_DESIGN with the card that ruled it and ' + + 'refusal nobody wrote down. Gate it, or add it to refusedByDesignFor() with the card that ruled it and ' + `why:\n${unexplainedRefusals.join('\n')}`, ).toEqual([]); @@ -662,7 +699,7 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout // Every route the sweep passed over without an authorization reading is // classified, so "not a refusal" can never quietly mean "not measured". const unclassified = derived.filter( - (r) => !(r in REFUSED_BY_DESIGN) && !(ADMITTED as readonly string[]).includes(r) && !(r in NOT_AN_AUTHORIZATION_ANSWER), + (r) => !(r in refusedByDesign) && !(ADMITTED as readonly string[]).includes(r) && !(r in NOT_AN_AUTHORIZATION_ANSWER), ); expect( unclassified, From b5e458f0cb01fb30b85d69efe86b35884f339632 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Mon, 24 Aug 2026 23:22:01 +0000 Subject: [PATCH 3/3] tests(identity): re-grade the checklist item's admin clauses (#9482) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .../areas/identity-auth.json | 17 +++++-- ...in-platform-admin-standing.dogfood.test.ts | 47 +++++++++++++------ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index 78e5344fd6..1ffa3e98d9 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -370,7 +370,7 @@ "title": "Admin user-lifecycle operations (ban/unban, set-password, impersonate, create/set-role/remove, revoke-sessions) enforce, persist, and stay closed to non-admins", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": ["platform admin", "target user", "non-admin forger"], @@ -381,8 +381,9 @@ "a disposable target user with a known password (create one; do not ban a seeded persona other items depend on)" ], "knownGaps": [ - "MEASURED 2026-08-18 (#9482): the better-auth-native /admin/ endpoints are unusable by an ObjectStack platform admin on a stock boot. The vendor's adminMiddleware gates on the legacy user.role === 'admin' scalar; ADR-0068 D2 stopped synthesizing it, so the seeded dev admin (role='user', positions=['user','platform_admin']) is refused 403 YOU_ARE_NOT_ALLOWED_TO_* by ban/unban/set-role/remove-user/impersonate/revoke-user-session(s)/list-users/get-user/list-user-sessions/update-user. That is why /admin/create-user and /admin/set-user-password exist as ObjectStack raw mounts carrying the ADR-0068 gate (admin-user-endpoints.ts says so in its header). A runner hand-driving the ban or impersonate STEPS will hit that 403 — it is the recorded state of the surface, not a fixture mistake. The sys_user Ban/Unban/Impersonate actions are type:'api' proxies onto those same routes, so the console affordances inherit it." - ] + "SUPERSEDED 2026-08-24 (#9482) — the note below is kept because a runner may still meet it on an OLD build, but it no longer describes current main. The 2026-08-18 measurement said EVERY better-auth-native /admin/ endpoint refuses an ObjectStack platform admin. Since then #9970 re-mounted ban-user/unban-user with the ADR-0068 gate and #10352 re-authorized impersonate-user as a better-auth PLUGIN endpoint (consolidated onto hasPlatformAdminStanding by #11686). Re-measured on main 2026-08-24: the platform admin gets 200 from ban-user, unban-user, create-user, set-user-password, unlock-user, import-users, oauth2/toggle-disabled and impersonate-user. EIGHT routes still answer the platform admin 403 YOU_ARE_NOT_ALLOWED_TO_*, and that is now a RULED OUTCOME rather than a gap: remove-user, revoke-user-session, revoke-user-sessions, list-user-sessions, update-user, list-users and get-user (#9969, closed not_planned — no ObjectStack consumer, re-implement on demand), plus set-role (#9968, ruled B — the sys_user action was RETIRED by PR #11530 and the vendor route deliberately left vendor-gated, because its only effect is writing the legacy scalar ADR-0068 D2 retired). A runner who meets a 403 on any of those EIGHT is seeing the intended design, not a fixture mistake and not a bug to file.", + "HISTORICAL (measured 2026-08-18, superseded by the note above): the better-auth-native /admin/ endpoints are unusable by an ObjectStack platform admin on a stock boot. The vendor's adminMiddleware gates on the legacy user.role === 'admin' scalar; ADR-0068 D2 stopped synthesizing it, so the seeded dev admin (role='user', positions=['user','platform_admin']) is refused 403 YOU_ARE_NOT_ALLOWED_TO_* by ban/unban/set-role/remove-user/impersonate/revoke-user-session(s)/list-users/get-user/list-user-sessions/update-user. That is why /admin/create-user and /admin/set-user-password exist as ObjectStack raw mounts carrying the ADR-0068 gate (admin-user-endpoints.ts says so in its header). A runner hand-driving the ban or impersonate STEPS on a build from before 2026-08-24 will hit that 403." + ] }, "steps": [ "GET /api/v1/auth/config and confirm features.admin is advertised (opt-in flag; it gates the sys_user ban/unban/set-password/impersonate actions)", @@ -469,7 +470,7 @@ ], "automated": { "kind": "e2e", - "ref": "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts (clause 8 — the sys_audit_log shapes create-user / set-user-password / import-users leave) + packages/qa/dogfood/test/admin-credential-lifecycle.dogfood.test.ts (clauses 1 and 2, both sides: an explicit password supplied ALONGSIDE generatePassword:true is the one that signs in and no generated password comes back, with a generatePassword-only create as the contrast so the clause cannot be satisfied by generatePassword being inert; the created account carries its credential sys_account row; set-user-password rotates — the original password is proven to work BEFORE the rotation, the new one after, and the old one is refused 401 INVALID_EMAIL_OR_PASSWORD; a member's forged create-user and set-user-password are each 403 PERMISSION_DENIED and an anonymous call 401 UNAUTHENTICATED, each with the state read back — no user row left behind, the victim's real password still working, the hijack password not) + packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts (clause 9, REFUSAL half, over a population DERIVED from the running stack rather than listed: honoApp.routes for the 9 ObjectStack raw mounts registered ahead of better-auth's catch-all, UNION auth.api for the 24 endpoints the catch-all publishes — 31 routes at this configuration. The derived set and the suite's classification table are checked for exact agreement in BOTH directions, so a newly mounted /admin/ route fails the suite by name until someone records what a non-admin must get from it. Universal invariant over all 31: neither an anonymous caller nor a plain member ever receives a 2xx, the single classified exception being has-permission, whose 200 body is asserted to be the negative answer. Every route is fired with a payload valid enough to REACH the authorization check — measured: better-auth and the ObjectStack sso / unlock-user / oauth2 mounts validate the body FIRST, so an empty-body route-walk draws an identical 400 VALIDATION_ERROR for member and admin alike and asserts nothing about the gate). NOTE this item's clause-9 prose names SIX routes; the measured surface is 31 — do not tick 31 routes off a 6-route reading. STILL MANUAL: clause 7 (the console's impersonation indicator) — its oracle is a screenshot, which no dogfood test can drive. BLOCKED, NOT MERELY UNPINNED: clauses 0, 3, 4, 5, 6 and the ALLOWED side of clause 9's better-auth bucket are not automatable without a product decision. better-auth's admin plugin authorizes on the legacy user.role === 'admin' scalar (auth-manager.ts constructs admin() with schema only, so the vendor default adminRoles:['admin'] applies) while ADR-0068 D2 deliberately stopped synthesizing that scalar, contributing platform_admin to positions[] instead. Measured on the seeded dev admin: sys_user.role is 'user', positions is ['user','platform_admin'], and ban-user / unban-user / set-role / remove-user / impersonate-user / revoke-user-session(s) / list-users / get-user / list-user-sessions / update-user each answer the PLATFORM ADMIN 403 YOU_ARE_NOT_ALLOWED_TO_*. Confirmed by construction: writing role='admin' onto that same admin row flips ban-user, unban-user, list-users and impersonate-user to 200 in the same boot. Clause 9's refusal half is pinned and real; its allowed half is asserted NOWHERE on that bucket, because pinning today's admin-is-also-refused behaviour would turn the fix red and pinning the fixed behaviour would be red today. See #9482." + "ref": "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts (clause 8 — the sys_audit_log shapes create-user / set-user-password / import-users leave) + packages/qa/dogfood/test/admin-credential-lifecycle.dogfood.test.ts (clauses 1 and 2, both sides: an explicit password supplied ALONGSIDE generatePassword:true is the one that signs in and no generated password comes back, with a generatePassword-only create as the contrast so the clause cannot be satisfied by generatePassword being inert; the created account carries its credential sys_account row; set-user-password rotates — the original password is proven to work BEFORE the rotation, the new one after, and the old one is refused 401 INVALID_EMAIL_OR_PASSWORD; a member's forged create-user and set-user-password are each 403 PERMISSION_DENIED and an anonymous call 401 UNAUTHENTICATED, each with the state read back) + packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts (clause 9, REFUSAL half, over a population DERIVED from the running stack rather than listed: honoApp.routes for the ObjectStack raw mounts registered ahead of better-auth's catch-all, UNION auth.api for the endpoints the catch-all publishes — 31 routes at this configuration. The derived set and that suite's classification table are checked for exact agreement in BOTH directions. Universal invariant over all 31: neither an anonymous caller nor a plain member ever receives a 2xx, the single classified exception being has-permission, whose 200 body is asserted to be the negative answer. Every route is fired with a payload valid enough to REACH the authorization check) + packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts (NEW 2026-08-24 — clause 0, clause 6 and the ALLOWED half of clause 9: what the PLATFORM ADMIN gets, which was previously asserted NOWHERE. ADMIN-IDENTITY CONTROL runs first and every later assertion refuses to run until it passes: the subject carries platform_admin in positions[] and isPlatformAdmin true, and sys_user.role is NOT 'admin' — so a 403 measured there can never mean 'the fixture was never an admin'. It deliberately does NOT copy remove-user-atomicity.test.ts's in-process role='admin' write, which is exactly why no earlier test observed this defect family. CLAUSE 0 + clause 9's allowed half: ban-user persists banned + ban_reason and unban-user reverses it; unlock-user, import-users and oauth2/toggle-disabled each answer 200 — every one re-read from STORAGE rather than believed from the response body. CLAUSE 6: impersonate-user answers 200 and sys_session.impersonated_by records the admin. REFUSED-BY-DESIGN: the EIGHT consumer-less routes are pinned at exactly 403 plus their exact vendor code (status AND code, ADR-0112 — a status-only pin stays green when only the code moves), each entry naming the card that ruled it, with a no-effect control proving the refused remove-user / update-user / set-role moved nothing. SWEEP over the same derived population: no route may refuse the platform admin unless it is a recorded by-design refusal, and every derived route must be classified — the classification check is the one a newly mounted route trips, and it runs first for that reason. The admitted and refused sides use SEPARATE expectation instruments, so a bug in one cannot pass the other.) NOTE this item's clause-9 prose names SIX routes; the measured surface is 31 — do not tick 31 routes off a 6-route reading. STILL MANUAL: clause 7 (the console's impersonation indicator) — its oracle is a screenshot, which no dogfood test can drive. ⛔ CLAUSES 3, 4 AND 5 ARE RULED, NOT UNPINNED COVERAGE DEBT: clause 3 (set-role flips gate outcomes), clause 4 (revoke-user-sessions kills a live session mid-flight) and clause 5 (remove-user's owner_id FK clear) each require the platform admin to COMPLETE the operation, and all three routes are among the eight that refuse him BY DESIGN — #9969 closed not_planned for the seven consumer-less routes, #9968 ruled B and retired the set_user_role action instead of re-implementing the route. What is pinned is the refusal, pinned as intended behaviour. Scoring these as missing automation would be scoring the absence of a route the maintainer declined to build. If one of them later acquires a real ObjectStack consumer and is re-mounted, admin-platform-admin-standing.dogfood.test.ts fails until its entry moves from the by-design list to the admitted list. See #9482, #9652, #9968, #9969." }, "traps": ["wrong-persona", "shared-browser-tab"], "source": [ @@ -485,7 +486,13 @@ { "revision": 1, "date": "2026-08-07", "change": "new item: admin lifecycle operations with persistence, enforcement, attribution and both-sides gate checks", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-08", "change": "added admin/list-users, create-user (explicit-password-wins §E12 #3031/#3033, signs in), set-role (flips gate outcomes), remove-user, revoke-user-sessions (kills live session mid-flight), each non-admin-refused; plus the §A5 engine cascade exemption clause (delete sys_user → owned rows' owner_id set_null; owner-anchor guard does not veto the system-context cascade, #3023/#3048) (PENDING-GAPS §D + §G)", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 3, "date": "2026-08-11", "change": "CORRECTION from run #7663: the revoke-user-sessions clause named get-session's status code as its oracle and expected 401. better-auth's no-session convention is HTTP 200 with a JSON null body (session-of-record.test.ts:165), so the literal 401 expectation misdescribes a CORRECT implementation and a status-only assertion would also pass against a live session's absence. Re-pointed the clause, the step and the negative at the authed-request oracle — a protected request the target could serve a moment earlier, refused on the very next call — with get-session's body kept as corroboration only. The session was provably gone in the run; only the oracle was wrong", "ref": "#7740" }, - { "revision": 4, "date": "2026-08-18", "change": "clauses 1, 2 and the refusal half of clause 9 pinned (QA run #9401 scored this item partial on a pin reaching clause 8 only). The clause-9 pin DERIVES its /admin/ population from the running stack (honoApp.routes UNION auth.api = 31 routes, against the 6 the clause prose names) and fails on any newly mounted route until it is classified, which is what makes it catch a silently unguarded new route. automated.ref now separates what is MANUAL (clause 7, a screenshot oracle) from what is BLOCKED on a product decision (clauses 0, 3, 4, 5, 6 and clause 9's allowed side on the better-auth bucket): those routes refuse the platform admin himself, because ADR-0068 D2 stopped synthesizing the legacy role scalar the vendor's admin plugin authorizes on. knownGaps records the measurement so the next runner reads a 403 there as the surface's state, not as a fixture error", "ref": "#9482" } + { "revision": 4, "date": "2026-08-18", "change": "clauses 1, 2 and the refusal half of clause 9 pinned (QA run #9401 scored this item partial on a pin reaching clause 8 only). The clause-9 pin DERIVES its /admin/ population from the running stack (honoApp.routes UNION auth.api = 31 routes, against the 6 the clause prose names) and fails on any newly mounted route until it is classified, which is what makes it catch a silently unguarded new route. automated.ref now separates what is MANUAL (clause 7, a screenshot oracle) from what is BLOCKED on a product decision (clauses 0, 3, 4, 5, 6 and clause 9's allowed side on the better-auth bucket): those routes refuse the platform admin himself, because ADR-0068 D2 stopped synthesizing the legacy role scalar the vendor's admin plugin authorizes on. knownGaps records the measurement so the next runner reads a 403 there as the surface's state, not as a fixture error", "ref": "#9482" }, + { + "revision": 5, + "date": "2026-08-24", + "change": "the ADMIN side pinned, and three clauses re-graded from 'blocked on a product decision' to 'ruled'. Revision 4 recorded that ban/unban/set-role/remove-user/impersonate/revoke-user-session(s)/list-users/get-user/list-user-sessions/update-user all refuse the platform admin; that is no longer true. Re-measured on main: #9970 re-mounted ban-user/unban-user with the ADR-0068 gate and #10352 (consolidated onto hasPlatformAdminStanding by #11686) re-authorized impersonate-user, so those answer 200 and clauses 0 and 6 are now pinned with their stored effects read back. The remaining EIGHT routes refuse the platform admin BY DESIGN — #9969 closed not_planned for seven consumer-less routes, #9968 ruled B for set-role — so clauses 3, 4 and 5 are not coverage debt but clauses whose operation the maintainer declined to build. The new pin asserts both sides with SEPARATE instruments, behind an admin-identity control proving the subject is a platform admin who does NOT carry the legacy role scalar", + "ref": "#9482" + } ] }, { diff --git a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts index 744e54818e..b71de29fd6 100644 --- a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts @@ -92,10 +92,17 @@ // // The sibling asserts "no non-admin ever gets a 2xx" over a DERIVED population. // This one asserts the dual over the same derived population: **no route -// refuses the platform admin unless it is a recorded by-design refusal.** That -// is what puts route N+1 in scope automatically — a newly mounted admin route -// that forgets the ADR-0068 gate refuses the platform admin, and fails here by -// name until someone either gates it properly or writes down why it refuses. +// refuses the platform admin unless it is a recorded by-design refusal.** +// +// ⚠️ Which assertion actually catches route N+1 is worth being exact about, +// because the obvious answer is wrong. Each route is fired with the payload +// declared beside its classification — it has to be, or the vendor's pre-auth +// body check answers `400 VALIDATION_ERROR` and the reading is void. So an +// UNCLASSIFIED route has no payload, dies at that check, and never produces a +// refusal to notice. The tripwire is therefore the CLASSIFICATION-COMPLETENESS +// assertion, which runs first and fails by name; the refusal assertions cover +// routes already classified. This is not a guess — it is what this file's own +// ablation measured when one classification was deleted. // // @proof: admin-platform-admin-standing @@ -672,6 +679,28 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout if (route === impersonate) adminToken = await stack.signIn(); // #8243 rotation } + // ⭐ FIRST, because this is the assertion a NEWLY MOUNTED route trips. + // + // It has to be first, and the reason is worth stating: the sweep fires each + // route with the payload DECLARED beside its classification, so a route + // nobody has classified is fired with no payload at all and dies at the + // vendor's pre-auth body check — a `400`, not a refusal. That means + // `unexplainedRefusals` below can NOT be relied on to notice route N+1; + // THIS check is what does, and it fails by name with the instruction. + // (Measured during this file's own ablation: deleting one classification + // produced exactly that 400, and only this assertion caught it.) + const unclassified = derived.filter( + (r) => !(r in refusedByDesign) && !(ADMITTED as readonly string[]).includes(r) && !(r in NOT_AN_AUTHORIZATION_ANSWER), + ); + expect( + unclassified, + 'the stack serves /admin/ route(s) this file neither admits, refuses-by-design, nor classifies as ' + + 'answering for a non-authorization reason. A new admin route is IN SCOPE the moment it is mounted: ' + + 'record what a PLATFORM ADMIN must get from it — 2xx if it carries the ADR-0068 gate, or an entry in ' + + 'refusedByDesignFor() naming the card that ruled the refusal — together with a payload valid enough ' + + `to reach the gate:\n${unclassified.join('\n')}`, + ).toEqual([]); + expect( neverReachedTheGate, 'the probe payload for these routes was rejected before the authorization check ran, so their ' + @@ -696,15 +725,5 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout `assertion:\n${unexpectedlyAdmitted.join('\n')}`, ).toEqual([]); - // Every route the sweep passed over without an authorization reading is - // classified, so "not a refusal" can never quietly mean "not measured". - const unclassified = derived.filter( - (r) => !(r in refusedByDesign) && !(ADMITTED as readonly string[]).includes(r) && !(r in NOT_AN_AUTHORIZATION_ANSWER), - ); - expect( - unclassified, - 'the stack serves /admin/ route(s) this file neither admits, refuses-by-design, nor classifies as ' + - `answering for a non-authorization reason:\n${unclassified.join('\n')}`, - ).toEqual([]); }, 900_000); });