Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions docs/qa/platform-checklist/areas/identity-auth.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"],
Expand All@@ -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": [
Expand DownExpand Up@@ -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)",
Expand All@@ -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" }
]
},
{
Expand Down
Loading
Loading