From a4362e217d205c5f6524992165d3b5a1f7b7d12c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 14:25:52 +0000 Subject: [PATCH 1/2] tests(identity): pin the admin credential clauses and a derived non-admin refusal sweep (#9482) QA run #9401 scored `identity-auth.admin-lifecycle-operations` green on a pin covering exactly one of its ten clauses (C8, the audit-log shapes). Two new dogfood pins close the clauses that are automatable on this stack, and the item's `automated.ref` is re-scoped to say precisely what stays manual, what is now pinned, and what is blocked on a product decision rather than on a test. Derived non-admin refusal sweep. The clause is "the gate holds both ways for EVERY admin operation", so the route population is read off the RUNNING stack rather than listed: `honoApp.routes` for the 9 ObjectStack raw mounts that sit ahead of better-auth's catch-all, unioned with `auth.api` for the 24 endpoints the catch-all publishes. 31 routes, against the 6 the clause prose names. 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 -- which is what makes this catch a silently unguarded new route rather than only today's. Every route is fired with a payload valid enough to REACH the authorization check. That is load-bearing: 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 while looking exactly like a passing security sweep. Credential clauses. create-user applies an EXPLICIT password supplied alongside generatePassword:true and returns no generated one, with a generatePassword-only create as the contrast so the clause cannot be satisfied by generatePassword being inert; the 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, the old one refused 401 INVALID_EMAIL_OR_PASSWORD. Both routes' refusals are asserted with the state read back: a forged create leaves no row, and a forged rotation leaves the victim's real password working. Measured and NOT pinned. C0/C3/C4/C5/C6 ride better-auth's own admin endpoints, which authorize on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 deliberately stopped synthesizing -- so they refuse the PLATFORM ADMIN himself (403 YOU_ARE_NOT_ALLOWED_TO_*). Filed as #9652 with the three options; not fixed here, because changing an admin route's behaviour is not a tests-only call. Neither side of that bucket's allowed half is asserted, so the fix will not have to edit these tests. C7's oracle is a screenshot and stays manual. Findings filed unassigned: #9652 (the gate mismatch), #9653 (the /admin/sso/* bridges carry no ObjectStack-side gate and their delegated gate is unprovable while SSO is off), #9654 (validation runs before authentication on the ObjectStack /admin/ mounts). Co-Authored-By: Claude --- .../areas/identity-auth.json | 13 +- ...admin-credential-lifecycle.dogfood.test.ts | 288 +++++++++ ...min-route-nonadmin-refusal.dogfood.test.ts | 571 ++++++++++++++++++ 3 files changed, 869 insertions(+), 3 deletions(-) create mode 100644 packages/qa/dogfood/test/admin-credential-lifecycle.dogfood.test.ts create mode 100644 packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index f2923084d9..1750004e31 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -368,7 +368,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": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": ["platform admin", "target user", "non-admin forger"], @@ -377,6 +377,9 @@ "requires": [ "the better-auth admin plugin enabled (plugins.admin: true) — it exposes /admin/ban-user, /admin/unban-user, /admin/set-user-password, /admin/impersonate-user, /admin/stop-impersonating, /admin/list-users, /admin/create-user, /admin/set-role, /admin/remove-user, /admin/revoke-user-session(s) under the auth route and augments sys_user with role/banned/ban_reason/ban_expires and sys_session with impersonated_by", "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." ] }, "steps": [ @@ -462,7 +465,10 @@ "revoke-user-sessions that only stops NEW logins while the existing live session keeps answering a PROTECTED request is a FAIL — the contract is an immediate kill. A get-session that answers 200 after the revoke is NOT that failure (better-auth's no-session convention is 200-with-null-body); filing it as one is the false positive run #7663 corrected — read the body, or better, re-drive a protected request", "remove-user aborting because the owner-anchor guard vetoed the owner_id-null cascade (instead of exempting the engine FK clear) is the #3023 regression returned — FAIL; equally, a create-user that applies the GENERATED password when an explicit one was supplied is the #3031 failure — FAIL" ], - "automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts" }, + "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." + }, "traps": ["wrong-persona", "shared-browser-tab"], "source": [ "packages/spec/src/system/auth-config.zod.ts (admin plugin: endpoint list, sys_user role/banned/ban_reason/ban_expires, sys_session.impersonated_by)", @@ -476,7 +482,8 @@ "history": [ { "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": 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" } ] }, { diff --git a/packages/qa/dogfood/test/admin-credential-lifecycle.dogfood.test.ts b/packages/qa/dogfood/test/admin-credential-lifecycle.dogfood.test.ts new file mode 100644 index 0000000000..702c6bcd77 --- /dev/null +++ b/packages/qa/dogfood/test/admin-credential-lifecycle.dogfood.test.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// C1 + C2 of `identity-auth.admin-lifecycle-operations` (#9482) — the two admin +// credential operations a platform admin can actually drive on this stack, both +// sides, with the stored state read back. +// +// C2 admin create-user mints a LOGIN-CAPABLE account, and an EXPLICIT +// password WINS over `generatePassword: true` (#3031/#3033). +// C1 admin set-user-password ROTATES the credential: the new password signs +// in and the old one is refused. +// +// ── Why only these two, when the item has ten clauses ──────────────────────── +// +// `/admin/create-user` and `/admin/set-user-password` are ObjectStack mounts: +// `auth-plugin.ts` registers them on the raw Hono app ahead of better-auth's +// catch-all, and they run the ADR-0068 platform-admin gate +// (`isPlatformAdmin` / `positions[]` / legacy `role`). They are therefore the +// admin lifecycle operations a real platform admin can complete. +// +// The item's other API clauses — ban/unban (C0), set-role (C3), +// revoke-user-sessions (C4), remove-user's owner_id cascade (C5) and +// impersonation attribution (C6) — ride better-auth's OWN admin endpoints, +// which authorize on the legacy `user.role === 'admin'` scalar that ADR-0068 D2 +// deliberately stopped synthesizing. Measured on this stack: the seeded dev +// admin carries `sys_user.role = 'user'` with `positions = +// ['user','platform_admin']`, and every one of those routes answers 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. Those clauses are +// therefore not automatable without a product decision, and #9482's report +// carries it rather than this file pretending. `admin-user-endpoints.ts` states +// the same mechanism as the reason the two routes below exist as ObjectStack +// mounts at all. +// +// ── Both sides, on the same route and fixture ─────────────────────────────── +// +// A credential suite that only proved "the old password stops working" would +// stay green if NOTHING signed in — the account locked, the credential row +// dropped, the whole sign-in path broken. So every refusal below is paired with +// the positive on the SAME account: the new password signs in, and the refused +// admin call is followed by a read proving the stored credential did not move. +// +// ── The fixture is authored at runtime ────────────────────────────────────── +// +// The stock showcase seeds no disposable second member with a known password +// (#9308 is the open card for stock fixtures). Each account below is created +// through the real routes inside this test's own stack; the showcase's +// committed metadata is untouched. +// +// @proof: admin-credential-lifecycle + +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 }; + +interface Ql { + find(object: string, query: unknown, ctx: unknown): Promise; +} + +const rowsOf = (r: unknown): Array> => + Array.isArray(r) ? r : ((r as { records?: Array> })?.records ?? []); + +describe('#9482 C1/C2: admin credential lifecycle, both sides', () => { + let stack: VerifyStack; + let ql: Ql; + let adminToken: string; + let memberToken: string; + let priorScim: string | undefined; + + /** Sign in through the real route, returning status + parsed body. */ + async function signIn(email: string, password: string) { + const res = await stack.api('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const text = await res.text(); + let parsed: Record = {}; + try { + parsed = JSON.parse(text); + } catch { + /* non-JSON body — kept in `text` for the failure message */ + } + return { status: res.status, body: text.slice(0, 300), json: parsed }; + } + + async function userRow(email: string): Promise | undefined> { + const rows = rowsOf(await ql.find('sys_user', { where: { email }, limit: 1 }, { context: SYS })); + return rows[0]; + } + + /** The credential `sys_account` rows that make an account able to sign in. */ + async function credentialAccounts(userId: string): Promise>> { + const rows = rowsOf(await ql.find('sys_account', { where: {}, limit: 500 }, { context: SYS })); + return rows.filter((a) => String(a.user_id ?? a.userId) === String(userId)); + } + + 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). Read at auth-manager construction, so it must + // precede boot. Same derivation `admin-identity-audit-trail` uses. + priorScim = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + stack = await bootStack(showcaseStack); + adminToken = await stack.signIn(); // the seeded dev admin (platform admin) + memberToken = await stack.signUp('credlife.member@example.com', 'Member-Pass-123'); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = priorScim; + }); + + // ── C2 ──────────────────────────────────────────────────────────────── + + it('create-user with BOTH an explicit password and generatePassword:true applies the EXPLICIT one', async () => { + const email = 'credlife.explicit@example.com'; + const explicit = 'Explicit-Wins-123'; + + // The console dialog sends both: `generatePassword` defaults true and the + // input is labelled "leave empty to generate". #3031/#3033 is the defect + // where the generated one was applied anyway. + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { + email, + name: 'Explicit Password User', + password: explicit, + generatePassword: true, + }); + const body = await res.text(); + expect(res.status, `create-user: ${body}`).toBe(200); + + // The generated password is never returned for an EXPLICIT request — if it + // were, that is the tell that one was minted and applied. + const created = JSON.parse(body) as { data?: { user?: { id?: string }; password?: string; temporaryPassword?: string } }; + expect( + created.data?.password ?? created.data?.temporaryPassword, + 'an explicit-password create returned a generated password — the generated one was minted', + ).toBeUndefined(); + + const userId = String(created.data?.user?.id); + expect(userId, 'create-user returned no user id').toBeTruthy(); + + // Login-capable: the credential `sys_account` row exists. A `sys_user` + // without it is exactly the un-signin-able account the ObjectStack mount + // exists to prevent. + const accounts = await credentialAccounts(userId); + expect( + accounts.map((a) => a.provider_id ?? a.providerId), + `no credential account row for the created user: ${JSON.stringify(accounts)}`, + ).toContain('credential'); + + // The EXPLICIT password signs in — the clause's positive half. + const ok = await signIn(email, explicit); + expect(ok.status, `explicit password should sign in: ${ok.body}`).toBe(200); + expect(ok.json.token, 'a successful sign-in carries a token').toBeTruthy(); + }, 300_000); + + it('a generated-password create is the contrast: the returned password is the one that works', async () => { + // The control for the case above. Without it, "the explicit password signs + // in" is consistent with `generatePassword` being inert altogether, and the + // #3031 clause would be pinned by an assertion that cannot tell the two + // apart. + const email = 'credlife.generated@example.com'; + const res = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { + email, + name: 'Generated Password User', + generatePassword: true, + }); + const body = await res.text(); + expect(res.status, `create-user (generated): ${body}`).toBe(200); + + const parsed = JSON.parse(body) as { data?: { password?: string; temporaryPassword?: string } }; + const generated = parsed.data?.password ?? parsed.data?.temporaryPassword; + expect( + generated, + `a generatePassword-only create must surface the temporary password once: ${body}`, + ).toBeTruthy(); + + const ok = await signIn(email, String(generated)); + expect(ok.status, `the generated password should sign in: ${ok.body}`).toBe(200); + }, 300_000); + + // ── C1 ──────────────────────────────────────────────────────────────── + + it('set-user-password rotates the credential: the new password signs in, the old is refused', async () => { + const email = 'credlife.rotate@example.com'; + const original = 'Original-Pass-123'; + const rotated = 'Rotated-Pass-456'; + + const create = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { + email, + name: 'Rotation Target', + password: original, + }); + expect(create.status, `create-user: ${await create.clone().text()}`).toBe(200); + const userId = String(((await create.json()) as { data?: { user?: { id?: string } } }).data?.user?.id); + + // Baseline: the original password works BEFORE the rotation. Without this + // the "old password is refused" assertion below cannot distinguish a + // rotation from a password that never worked. + const before = await signIn(email, original); + expect(before.status, `the original password should work before rotation: ${before.body}`).toBe(200); + + const rotate = await stack.apiAs(adminToken, 'POST', '/auth/admin/set-user-password', { + userId, + newPassword: rotated, + }); + const rotateBody = await rotate.text(); + expect(rotate.status, `set-user-password: ${rotateBody}`).toBe(200); + + // Both halves of the rotation, on the same account. + const withNew = await signIn(email, rotated); + expect(withNew.status, `the NEW password should sign in: ${withNew.body}`).toBe(200); + + const withOld = await signIn(email, original); + expect(withOld.status, `the OLD password must be refused: ${withOld.body}`).toBe(401); + expect(withOld.json.code, 'the old password is refused with the named credential error').toBe( + 'INVALID_EMAIL_OR_PASSWORD', + ); + }, 300_000); + + // ── the gate on both routes, with the state read back ───────────────── + + it('a non-admin cannot create a user or rotate a password, and neither leaves a trace', async () => { + const forgedEmail = 'credlife.forged@example.com'; + + // create-user, forged. + const forgedCreate = await stack.apiAs(memberToken, 'POST', '/auth/admin/create-user', { + email: forgedEmail, + name: 'Should Not Exist', + password: 'Forged-Pass-123', + }); + const forgedCreateBody = await forgedCreate.text(); + expect(forgedCreate.status, `member create-user: ${forgedCreateBody}`).toBe(403); + expect(JSON.parse(forgedCreateBody)?.error?.code).toBe('PERMISSION_DENIED'); + // State oracle: the refusal left no user behind. + expect( + await userRow(forgedEmail), + 'a refused create-user still created the account', + ).toBeUndefined(); + + // set-user-password, forged against a real account whose password we know. + const victimEmail = 'credlife.victim@example.com'; + const victimPass = 'Victim-Pass-123'; + const create = await stack.apiAs(adminToken, 'POST', '/auth/admin/create-user', { + email: victimEmail, + name: 'Rotation Victim', + password: victimPass, + }); + expect(create.status, `create-user: ${await create.clone().text()}`).toBe(200); + const victimId = String(((await create.json()) as { data?: { user?: { id?: string } } }).data?.user?.id); + + const forgedRotate = await stack.apiAs(memberToken, 'POST', '/auth/admin/set-user-password', { + userId: victimId, + newPassword: 'Hijacked-Pass-789', + }); + const forgedRotateBody = await forgedRotate.text(); + expect(forgedRotate.status, `member set-user-password: ${forgedRotateBody}`).toBe(403); + expect(JSON.parse(forgedRotateBody)?.error?.code).toBe('PERMISSION_DENIED'); + + // State oracle, both directions: the hijack password does NOT work and the + // victim's real password still does. A refusal that had quietly rotated the + // credential would pass a status-only assertion. + const hijack = await signIn(victimEmail, 'Hijacked-Pass-789'); + expect(hijack.status, `the forged rotation took effect: ${hijack.body}`).toBe(401); + const intact = await signIn(victimEmail, victimPass); + expect(intact.status, `the victim's real password stopped working: ${intact.body}`).toBe(200); + }, 300_000); + + it('an anonymous caller is refused both routes before any state is touched', async () => { + const email = 'credlife.anon@example.com'; + const res = await stack.api('/auth/admin/create-user', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, name: 'Anon Created', password: 'Anon-Pass-123' }), + }); + const body = await res.text(); + expect(res.status, `anonymous create-user: ${body}`).toBe(401); + expect(JSON.parse(body)?.error?.code).toBe('UNAUTHENTICATED'); + expect(await userRow(email), 'an anonymous create-user still created the account').toBeUndefined(); + }, 300_000); +}); diff --git a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts new file mode 100644 index 0000000000..07e74c3d8b --- /dev/null +++ b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts @@ -0,0 +1,571 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// C9 of `identity-auth.admin-lifecycle-operations` (#9482) — every `/admin/` +// route refuses a non-admin, over a population DERIVED from the running stack. +// +// ── Why the population is derived and not listed ───────────────────────────── +// +// The clause this file pins is "the gate holds both ways for EVERY admin +// operation". A test that hardcodes today's routes cannot pin that: it passes +// forever while route N+1 ships unguarded, which is the failure this repo has +// already paid for twice (a tripwire aimed at the wrong file, and one keyed off +// a single path spelling rather than the mechanics). So the route set is read +// off the LIVE stack, from the two places admin routes can actually come from: +// +// HALF A — `honoApp.routes`, the ObjectStack raw mounts. `auth-plugin.ts` +// registers these directly on the Hono app AHEAD of better-auth's catch-all +// (`rawApp.all(`${basePath}/*`)`), so they shadow the vendor's handler and +// never appear in `auth.api`. Measured here: 9 routes. +// +// HALF B — `auth.api`, the better-auth endpoint table. The catch-all publishes +// whatever the vendor registers, so there are no per-route registration +// calls to capture; every endpoint object carries `.path` and +// `.options.method`, which is why `auth-route-ledger.conformance.test.ts` +// uses the same seam. Measured here: 24 routes. +// +// Union: 31 routes at the configuration this file boots (5 + 11 + 2 + 4 + 9). Both halves are +// asserted non-empty, and the union is cross-checked against a small ANCHOR set, +// so a derivation that silently returns nothing cannot make the sweep vacuous. +// +// ⛔ The item's own C9 text names SIX routes (ban-user, list-users, create-user, +// set-role, remove-user, revoke-user-sessions). That enumeration is a fifth of +// the real surface. It is left alone here — correcting checklist prose is not +// this file's job — but the re-scoped `automated.ref` records the measured count +// so the next runner does not tick 31 routes off a 6-route reading. +// +// ── A new route fails until it is CLASSIFIED, not merely until it is guarded ── +// +// `EXPECTED` is checked against the derived set in BOTH directions: a route the +// stack serves with no entry fails, and an entry the stack no longer serves +// fails as stale. That is the mechanism that puts route N+1 in scope +// automatically — it cannot be added without someone writing down what its +// non-admin answer is supposed to be. +// +// ── The payloads are load-bearing, and this is the file's sharpest edge ─────── +// +// MEASURED: better-auth validates the request body BEFORE it reaches the admin +// check, and so do the ObjectStack `/admin/sso/*`, `/admin/unlock-user` and +// `/admin/oauth2/toggle-disabled` mounts (their handlers read and shape-check +// `body` before calling `getSession`). Fire an EMPTY body at +// `/admin/ban-user` and a plain member receives: +// +// 400 {"message":"[body.userId] Invalid input: …","code":"VALIDATION_ERROR"} +// +// — byte-identical to what the platform admin receives. A route-walk built on +// empty bodies therefore asserts NOTHING about authorization while looking +// exactly like a passing security sweep: every response is non-2xx, every +// assertion green, and the gate is never consulted. So every route below is +// fired with a payload valid enough to reach the authorization check, and the +// refusal assertions carry the `code` as well as the status. +// +// ── Both sides, and the one place this file cannot have them ───────────────── +// +// A refusal-only suite stays green if a route starts refusing EVERYONE, so each +// bucket that can carry an allowed side does: +// +// `objectstack-gate` (5 routes) — the full contrast. A plain member is refused +// 403 PERMISSION_DENIED, an anonymous caller 401 UNAUTHENTICATED, and the +// platform admin is NOT refused: the same request reaches the handler and +// comes back 2xx (unlock-user) or a SEMANTIC error (404 RESOURCE_NOT_FOUND +// for a missing OAuth client). That is what proves the member's 403 is a +// gate verdict and not a payload the server rejects for everyone. +// +// `better-auth-gate` (11 routes) — refusal side only, DELIBERATELY. On this +// stack the platform admin is refused these routes too, with the same +// `YOU_ARE_NOT_ALLOWED_TO_*` code as the member. That is not a harness +// artifact: better-auth's admin plugin authorizes on the legacy +// `user.role === 'admin'` scalar (constructed at `auth-manager.ts` with +// `schema` only, so the vendor default `adminRoles: ['admin']` applies), +// while ADR-0068 D2 deliberately STOPPED synthesizing that scalar — +// `auth-manager.ts` says so in as many words, and contributes +// `platform_admin` to `positions[]` instead. Measured on the seeded dev +// admin: `sys_user.role` is `'user'` and `positions` is +// `['user','platform_admin']`. It is also why `create-user` and +// `set-user-password` exist as ObjectStack mounts at all — +// `admin-user-endpoints.ts` states the reason: the stock endpoint's +// adminMiddleware "would 403 a platform admin whose legacy `role` scalar was +// never synthesized". +// +// The refusal half is still a real security assertion and is pinned. The +// ALLOWED half is not asserted here, in either direction: pinning today's +// admin-is-also-refused behaviour would turn the fix red, and pinning the +// fixed behaviour would be red today. #9482's report carries the finding. +// +// `self-scoped` (2 routes) — `has-permission` and `stop-impersonating` answer +// a non-admin without a refusal BY DESIGN, and the invariant is asserted in +// the shape that actually holds: they must not leak a privileged result. +// `has-permission` answers 200 with `success:false` (a permission QUERY, and +// the answer is "no"); `stop-impersonating` answers 400 "You are not +// impersonating anyone" (it ends the CALLER's own impersonation, so it is +// self-scoped, not an admin operation). Asserting a 403 on these would be +// asserting a bug. +// +// `capability-disabled` (4 routes) — the `/admin/sso/*` bridges. Unlike their +// five ObjectStack siblings these carry NO ObjectStack-side gate: they +// re-dispatch the request into better-auth "so all of its gates run" +// (`register-sso-provider.ts`). On this stack the SSO capability is off, so +// all three callers — anonymous, member AND platform admin — receive the +// identical capability error (404 SSO_REGISTER_FAILED / 404 +// SAML_REGISTER_FAILED / 400 DOMAIN_VERIFICATION_DISABLED / 404 +// verify_domain_failed). Authorization is therefore NOT OBSERVABLE on these +// four here, and this file says so instead of pretending: their delegated +// gate is UNPROVEN by this pin, and an SSO-enabled deployment is where it +// would have to be proven. The universal invariant still covers them. +// +// The one assertion made is a tripwire, not a pin on the disabled state: +// member and admin must receive the SAME answer. The day SSO is enabled in +// this fixture those answers diverge, this goes red, and whoever enabled it +// has to move the route into a bucket that actually checks its gate — +// rather than inheriting a green that stopped meaning anything. +// +// `not-mounted` (9 routes) — better-auth publishes the `/admin/oauth2/*` +// resource and client endpoints from the oidcProvider plugin, which this +// boot does not enable, so they answer 404 to everyone. A 404 discloses +// nothing, and the universal invariant below still covers them: no +// non-admin gets a 2xx. +// +// ── The universal invariant ───────────────────────────────────────────────── +// +// Independent of bucket, and asserted over the WHOLE derived set: neither an +// anonymous caller nor a plain member ever receives a 2xx from any `/admin/` +// route, with the single classified exception of `has-permission`, whose 200 +// body is checked to be the negative answer. That is the assertion a newly +// added, silently unguarded route trips. +// +// @proof: admin-route-nonadmin-refusal + +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'; + +/** How a non-admin must be answered by one derived route. */ +type Bucket = + | 'objectstack-gate' + | 'better-auth-gate' + | 'self-scoped' + | 'capability-disabled' + | 'not-mounted'; + +interface RouteExpectation { + bucket: Bucket; + /** Body valid enough to reach the AUTHORIZATION check. */ + body?: Record; + /** Query string for GET routes, for the same reason. */ + query?: string; + /** Why this route is in its bucket, when that is not obvious. */ + note?: string; +} + +/** + * Anchors — a derivation that returns an empty or truncated table cannot make + * this suite pass. Deliberately the six routes the item's C9 names, so the + * clause's own surface is provably inside the swept set. + */ +const ANCHORS = [ + 'POST /api/v1/auth/admin/ban-user', + 'GET /api/v1/auth/admin/list-users', + 'POST /api/v1/auth/admin/create-user', + 'POST /api/v1/auth/admin/set-role', + 'POST /api/v1/auth/admin/remove-user', + 'POST /api/v1/auth/admin/revoke-user-sessions', +] as const; + +/** + * Every `/admin/` route this stack serves, and what a non-admin must get from + * it. Checked for EXACT agreement with the derived set in both directions — a + * new route with no entry here fails the suite by name. + */ +function expectationsFor(targetUserId: string): Record { + return { + // ── ObjectStack raw mounts (ADR-0068 platform-admin gate) ────────────── + 'POST /api/v1/auth/admin/create-user': { + bucket: 'objectstack-gate', + body: { email: 'refusal.probe.created@example.com', name: 'Refusal Probe', password: 'Explicit-Pass-123' }, + }, + 'POST /api/v1/auth/admin/set-user-password': { + bucket: 'objectstack-gate', + body: { userId: targetUserId, newPassword: 'Rotated-By-Probe-456' }, + }, + 'POST /api/v1/auth/admin/import-users': { + bucket: 'objectstack-gate', + body: { format: 'json', rows: [{ email: 'refusal.probe.imported@example.com', name: 'Imported Probe' }] }, + }, + 'POST /api/v1/auth/admin/unlock-user': { + bucket: 'objectstack-gate', + body: { userId: targetUserId }, + }, + 'POST /api/v1/auth/admin/oauth2/toggle-disabled': { + bucket: 'objectstack-gate', + body: { client_id: 'refusal-probe-client', disabled: true }, + note: 'admin passes the gate and lands on RESOURCE_NOT_FOUND for the unknown client', + }, + 'POST /api/v1/auth/admin/sso/register': { + bucket: 'capability-disabled', + body: { + providerId: 'refusal-probe-oidc', + issuer: 'https://issuer.example', + domain: 'refusal-probe.example', + clientId: 'probe-client', + clientSecret: 'probe-secret', + }, + }, + 'POST /api/v1/auth/admin/sso/register-saml': { + bucket: 'capability-disabled', + body: { + providerId: 'refusal-probe-saml', + issuer: 'https://saml-issuer.example', + domain: 'refusal-probe-saml.example', + entryPoint: 'https://saml-issuer.example/sso', + cert: 'PROBE-CERT', + }, + }, + 'POST /api/v1/auth/admin/sso/request-domain-verification': { + bucket: 'capability-disabled', + body: { providerId: 'refusal-probe-oidc' }, + }, + 'POST /api/v1/auth/admin/sso/verify-domain': { + bucket: 'capability-disabled', + body: { providerId: 'refusal-probe-oidc' }, + }, + + // ── better-auth admin plugin (legacy `role` scalar gate) ──────────────── + 'POST /api/v1/auth/admin/ban-user': { bucket: 'better-auth-gate', body: { userId: targetUserId, banReason: 'probe' } }, + 'POST /api/v1/auth/admin/unban-user': { bucket: 'better-auth-gate', body: { userId: targetUserId } }, + 'POST /api/v1/auth/admin/set-role': { bucket: 'better-auth-gate', body: { userId: targetUserId, role: 'admin' } }, + 'POST /api/v1/auth/admin/remove-user': { bucket: 'better-auth-gate', body: { userId: targetUserId } }, + 'POST /api/v1/auth/admin/impersonate-user': { bucket: 'better-auth-gate', body: { userId: targetUserId } }, + 'POST /api/v1/auth/admin/revoke-user-sessions': { bucket: 'better-auth-gate', body: { userId: targetUserId } }, + 'POST /api/v1/auth/admin/revoke-user-session': { bucket: 'better-auth-gate', body: { sessionToken: 'probe-session-token' } }, + 'POST /api/v1/auth/admin/list-user-sessions': { bucket: 'better-auth-gate', body: { userId: targetUserId } }, + 'POST /api/v1/auth/admin/update-user': { bucket: 'better-auth-gate', body: { userId: targetUserId, data: { name: 'Renamed By Probe' } } }, + 'GET /api/v1/auth/admin/list-users': { bucket: 'better-auth-gate', query: '?limit=1' }, + 'GET /api/v1/auth/admin/get-user': { bucket: 'better-auth-gate', query: `?id=${targetUserId}` }, + + // ── answered without a refusal, by design ────────────────────────────── + 'POST /api/v1/auth/admin/has-permission': { + bucket: 'self-scoped', + body: { permissions: { user: ['list'] } }, + note: 'a permission QUERY — 200 with success:false is the negative answer, not a leak', + }, + 'POST /api/v1/auth/admin/stop-impersonating': { + bucket: 'self-scoped', + body: {}, + note: 'ends the CALLING session\'s own impersonation; a non-impersonating member gets 400', + }, + + // ── published by the catch-all, not mounted at this configuration ─────── + 'GET /api/v1/auth/admin/oauth2/resources': { bucket: 'not-mounted' }, + 'POST /api/v1/auth/admin/oauth2/resources': { bucket: 'not-mounted' }, + 'GET /api/v1/auth/admin/oauth2/resources/:identifier': { bucket: 'not-mounted' }, + 'PATCH /api/v1/auth/admin/oauth2/resources/:identifier': { bucket: 'not-mounted' }, + 'DELETE /api/v1/auth/admin/oauth2/resources/:identifier': { bucket: 'not-mounted' }, + 'POST /api/v1/auth/admin/oauth2/resources/:identifier/clients/:client_id': { bucket: 'not-mounted' }, + 'DELETE /api/v1/auth/admin/oauth2/resources/:identifier/clients/:client_id': { bucket: 'not-mounted' }, + 'POST /api/v1/auth/admin/oauth2/create-client': { bucket: 'not-mounted' }, + 'PATCH /api/v1/auth/admin/oauth2/update-client': { bucket: 'not-mounted' }, + }; +} + +/** `VERB /wire/path` for every `/admin/` route the RUNNING stack serves. */ +async function deriveAdminRoutes(stack: VerifyStack): Promise<{ + half: { objectstack: string[]; betterAuth: string[] }; + all: string[]; +}> { + // HALF A — raw Hono mounts registered ahead of the catch-all. + const http = await stack.kernel.getServiceAsync<{ getRawApp(): { routes?: Array<{ method?: string; path?: string }> } }>( + 'http-server', + ); + const honoRoutes = http.getRawApp().routes ?? []; + const objectstack = [ + ...new Set( + honoRoutes + .filter((r) => typeof r?.path === 'string' && r.path.includes('/auth/admin/')) + .map((r) => `${String(r.method).toUpperCase()} ${r.path}`), + ), + ]; + + // HALF B — better-auth's own endpoint table, the seam the route ledger uses. + 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}`); + } + } + + return { + half: { objectstack, betterAuth: [...betterAuth] }, + all: [...new Set([...objectstack, ...betterAuth])].sort(), + }; +} + +/** Concrete request path for a derived route (path params filled with a miss). */ +const wireOf = (route: string, query = ''): string => + route.split(' ')[1].replace(/:[A-Za-z_]+/g, 'nonexistent-probe-id').replace('/api/v1', '') + query; + +interface Answer { + status: number; + code: string | undefined; + body: string; +} + +describe('#9482 C9: every derived /admin/ route refuses a non-admin', () => { + let stack: VerifyStack; + let adminToken: string; + let memberToken: string; + let targetUserId: string; + let derived: Awaited>; + let expectations: Record; + let priorScim: string | undefined; + + beforeAll(async () => { + // The `/admin/` surface 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 — `AuthManager.buildPluginList` resolves + // `admin: pluginConfig.admin ?? scimEffective` (ADR-0071, SCIM forces admin + // on), the same derivation `admin-identity-audit-trail.dogfood.test.ts` + // uses. 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(); // seeded dev admin (platform admin) + memberToken = await stack.signUp('refusal.probe.member@example.com', 'Member-Pass-123'); + + // A disposable target the payloads can name. 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('refusal.probe.target@example.com', 'Target-Pass-123'); + const ql = await stack.kernel.getServiceAsync<{ + find(o: string, q: unknown, c: unknown): Promise>>; + }>('objectql'); + const [target] = await ql.find( + 'sys_user', + { where: { email: 'refusal.probe.target@example.com' }, limit: 1 }, + { context: SYS }, + ); + targetUserId = String(target.id); + + derived = await deriveAdminRoutes(stack); + expectations = expectationsFor(targetUserId); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (priorScim === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = priorScim; + }); + + /** Fire one derived route as `token` (or anonymously when undefined). */ + async function fire(route: string, token: string | undefined): Promise { + const [verb] = route.split(' '); + const spec = expectations[route]; + const path = wireOf(route, spec?.query ?? ''); + const res = token + ? await stack.apiAs(token, verb, path, spec?.body) + : await stack.api(path, { + method: verb, + headers: { 'Content-Type': 'application/json' }, + ...(spec?.body ? { body: JSON.stringify(spec.body) } : {}), + }); + const body = await res.text(); + let code: string | undefined; + try { + const parsed = JSON.parse(body); + // Two envelopes meet on this surface: ObjectStack's ADR-0112 + // `{success,error:{code}}` and better-auth's flat `{message,code}`. + code = parsed?.error?.code ?? parsed?.code; + } catch { + code = undefined; + } + return { status: res.status, code, body: body.slice(0, 300) }; + } + + // ── Guard the guard ──────────────────────────────────────────────────── + + it('the derivation reads a real route table from both halves', () => { + expect( + derived.half.objectstack.length, + 'no ObjectStack raw /admin/ mounts found — honoApp.routes did not resolve, and every ' + + 'assertion over the derived set would be vacuous', + ).toBeGreaterThan(0); + expect( + derived.half.betterAuth.length, + 'no better-auth /admin/ endpoints found — auth.api did not resolve (a construction ' + + 'failure or a mocked module), and the sweep would assert nothing', + ).toBeGreaterThan(0); + // A floor, not an equality: this must not need editing when the vendor adds + // an endpoint. The EXACT set is pinned by the coverage test below. + expect(derived.all.length, 'the derived /admin/ surface collapsed').toBeGreaterThanOrEqual(20); + + const missingAnchors = ANCHORS.filter((a) => !derived.all.includes(a)); + expect( + missingAnchors, + `routes the checklist item names by hand are absent from the derived set — either the ` + + `derivation broke or these were renamed:\n${missingAnchors.join('\n')}`, + ).toEqual([]); + }); + + it('every derived route is classified, and every classification is still served', () => { + const classified = new Set(Object.keys(expectations)); + const unclassified = derived.all.filter((r) => !classified.has(r)).sort(); + const stale = [...classified].filter((r) => !derived.all.includes(r)).sort(); + + expect( + unclassified, + 'the stack serves /admin/ route(s) this suite does not classify. A new admin route is ' + + 'IN SCOPE the moment it is mounted: add it to EXPECTED with the answer a non-admin ' + + 'must get, and it will be swept from then on.\n' + unclassified.join('\n'), + ).toEqual([]); + expect( + stale, + `classified route(s) the stack no longer serves — the entries are stale:\n${stale.join('\n')}`, + ).toEqual([]); + + // The allowed-side contrast has to have somewhere to run, or the both-sides + // half of this file is quietly empty. + const gated = Object.values(expectations).filter((e) => e.bucket === 'objectstack-gate'); + expect(gated.length, 'no route carries the allowed-side contrast').toBeGreaterThan(0); + }); + + // ── The universal invariant, over the WHOLE derived set ──────────────── + + it('no anonymous caller and no plain member ever gets a 2xx from an /admin/ route', async () => { + const leaks: string[] = []; + for (const route of derived.all) { + for (const [who, token] of [ + ['anonymous', undefined], + ['member', memberToken], + ] as const) { + const answer = await fire(route, token); + const is2xx = answer.status >= 200 && answer.status < 300; + if (!is2xx) continue; + // The one classified exception: a permission QUERY answering "no". + if (expectations[route]?.bucket === 'self-scoped' && route.endsWith('/has-permission')) { + expect( + answer.body, + `${route} as ${who}: has-permission answered 2xx but not with the negative result`, + ).toContain('"success":false'); + continue; + } + leaks.push(`${route} as ${who} -> ${answer.status} ${answer.body}`); + } + } + expect( + leaks, + `an /admin/ route answered a non-admin with success. This is the highest-severity ` + + `shape this suite exists to catch:\n${leaks.join('\n')}`, + ).toEqual([]); + }, 600_000); + + // ── Per-bucket refusal shape, with the allowed side where it exists ──── + + it('the ObjectStack-mounted admin routes refuse both ways and admit the platform admin', async () => { + const routes = derived.all.filter((r) => expectations[r]?.bucket === 'objectstack-gate'); + expect(routes.length, 'no objectstack-gate routes were derived').toBeGreaterThan(0); + + for (const route of routes) { + const anon = await fire(route, undefined); + expect(anon.status, `${route} anonymous: ${anon.body}`).toBe(401); + expect(anon.code, `${route} anonymous code`).toBe('UNAUTHENTICATED'); + + const member = await fire(route, memberToken); + expect(member.status, `${route} member: ${member.body}`).toBe(403); + expect(member.code, `${route} member code`).toBe('PERMISSION_DENIED'); + + // The allowed side on the SAME route and payload: the platform admin is + // not turned away by the gate. The handler may still answer a semantic + // error (an unknown OAuth client, an unregistered SSO provider) — what + // must not happen is the member's refusal. + const admin = await fire(route, adminToken); + expect( + [401, 403].includes(admin.status), + `${route} platform admin was refused by the gate (${admin.status} ${admin.body}) — ` + + `the member's 403 above therefore proves nothing about authorization`, + ).toBe(false); + expect(admin.code, `${route} platform admin`).not.toBe('PERMISSION_DENIED'); + } + }, 600_000); + + it('the better-auth admin routes refuse a non-admin with a named vendor code', async () => { + const routes = derived.all.filter((r) => expectations[r]?.bucket === 'better-auth-gate'); + expect(routes.length, 'no better-auth-gate routes were derived').toBeGreaterThan(0); + + for (const route of routes) { + const anon = await fire(route, undefined); + expect( + [401, 403].includes(anon.status), + `${route} anonymous should be refused, got ${anon.status} ${anon.body}`, + ).toBe(true); + + const member = await fire(route, memberToken); + expect( + [401, 403].includes(member.status), + `${route} member should be refused, got ${member.status} ${member.body}`, + ).toBe(true); + // When the vendor answers with a body, it must be its own denial + // vocabulary — not a validation error, which would mean the request died + // before the gate and this assertion measured nothing. + if (member.code !== undefined) { + expect( + member.code, + `${route} member: refused with ${member.code}, which is not a denial code. A ` + + `VALIDATION_ERROR here means the payload never reached the gate.`, + ).toMatch(/^YOU_ARE_NOT_ALLOWED/); + } + } + // ⛔ No allowed-side assertion in this bucket — see the header: the platform + // admin is currently refused these routes too (ADR-0068 D2 vs better-auth's + // `adminRoles: ['admin']`), and pinning EITHER side of that would be wrong. + }, 600_000); + + it('the /admin/sso/* bridges answer identically to member and admin — authorization is not observable here', async () => { + // NOT a pin on the capability being off. It is the tripwire described in + // the header: while SSO is disabled these four cannot distinguish a caller, + // so a bucket that claimed to check their gate would be checking nothing. + // Enabling SSO makes member and admin diverge and turns this red on + // purpose, so the routes get reclassified instead of coasting on a green. + const routes = derived.all.filter((r) => expectations[r]?.bucket === 'capability-disabled'); + expect(routes.length, 'no capability-disabled routes were derived').toBeGreaterThan(0); + + for (const route of routes) { + const member = await fire(route, memberToken); + const admin = await fire(route, adminToken); + expect( + member.status >= 200 && member.status < 300, + `${route}: a plain member got a success answer — ${member.body}`, + ).toBe(false); + expect( + `${member.status} ${member.code}`, + `${route}: member and platform admin no longer receive the same answer, so this route ` + + `IS now authorization-observable. Move it to a bucket that asserts its gate — ` + + `member=${member.status} ${member.code}, admin=${admin.status} ${admin.code}`, + ).toBe(`${admin.status} ${admin.code}`); + } + }, 300_000); + + it('the self-scoped admin routes answer a non-admin without leaking a privileged result', async () => { + const hasPermission = 'POST /api/v1/auth/admin/has-permission'; + const stopImpersonating = 'POST /api/v1/auth/admin/stop-impersonating'; + + const member = await fire(hasPermission, memberToken); + expect(member.status, `has-permission member: ${member.body}`).toBe(200); + expect( + member.body, + 'has-permission must answer a plain member "no" — a true answer would be the leak', + ).toContain('"success":false'); + + const stop = await fire(stopImpersonating, memberToken); + expect( + stop.status >= 200 && stop.status < 300, + `stop-impersonating answered a non-impersonating member with success: ${stop.body}`, + ).toBe(false); + }, 300_000); +}); From 2a3fb5835b903707405b694d838dcf4b0dc875cd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 14:40:54 +0000 Subject: [PATCH 2/2] tests(identity): correct a stale comment in the admin-gate contrast (#9482) The `objectstack-gate` bucket no longer contains the /admin/sso/* routes, so the example of a post-gate semantic error no longer applies to them. Names the two measured cases instead: 200 for unlock-user, 404 RESOURCE_NOT_FOUND for toggle-disabled's unknown client. Comment only, no assertion changes. Co-Authored-By: Claude --- .../test/admin-route-nonadmin-refusal.dogfood.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts index 07e74c3d8b..e4138be401 100644 --- a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts @@ -481,9 +481,10 @@ describe('#9482 C9: every derived /admin/ route refuses a non-admin', () => { expect(member.code, `${route} member code`).toBe('PERMISSION_DENIED'); // The allowed side on the SAME route and payload: the platform admin is - // not turned away by the gate. The handler may still answer a semantic - // error (an unknown OAuth client, an unregistered SSO provider) — what - // must not happen is the member's refusal. + // not turned away by the gate. The handler may still answer a SEMANTIC + // error once past it — measured: 200 for unlock-user, 404 + // RESOURCE_NOT_FOUND for toggle-disabled's unknown client — and that is + // fine. What must not happen is the member's refusal. const admin = await fire(route, adminToken); expect( [401, 403].includes(admin.status),