Skip to content

Commit f6c71ea

Browse files
os-zhuangclaude
andauthored
tests(identity): pin the admin credential clauses and a derived non-admin refusal sweep over the whole /admin/ surface (#9664)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4c178c1 commit f6c71ea

3 files changed

Lines changed: 870 additions & 3 deletions

File tree

docs/qa/platform-checklist/areas/identity-auth.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@
368368
"title": "Admin user-lifecycle operations (ban/unban, set-password, impersonate, create/set-role/remove, revoke-sessions) enforce, persist, and stay closed to non-admins",
369369
"since": "v16",
370370
"status": "active",
371-
"revision": 3,
371+
"revision": 4,
372372
"priority": "P1",
373373
"surface": "mixed",
374374
"personas": ["platform admin", "target user", "non-admin forger"],
@@ -377,6 +377,9 @@
377377
"requires": [
378378
"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",
379379
"a disposable target user with a known password (create one; do not ban a seeded persona other items depend on)"
380+
],
381+
"knownGaps": [
382+
"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."
380383
]
381384
},
382385
"steps": [
@@ -462,7 +465,10 @@
462465
"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",
463466
"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"
464467
],
465-
"automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts" },
468+
"automated": {
469+
"kind": "e2e",
470+
"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."
471+
},
466472
"traps": ["wrong-persona", "shared-browser-tab"],
467473
"source": [
468474
"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 @@
476482
"history": [
477483
{ "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" },
478484
{ "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" },
479-
{ "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" }
485+
{ "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" },
486+
{ "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" }
480487
]
481488
},
482489
{

0 commit comments

Comments
 (0)