|
370 | 370 | "title": "Admin user-lifecycle operations (ban/unban, set-password, impersonate, create/set-role/remove, revoke-sessions) enforce, persist, and stay closed to non-admins", |
371 | 371 | "since": "v16", |
372 | 372 | "status": "active", |
373 | | - "revision": 5, |
| 373 | + "revision": 6, |
374 | 374 | "priority": "P1", |
375 | 375 | "surface": "mixed", |
376 | 376 | "personas": ["platform admin", "target user", "non-admin forger"], |
|
393 | 393 | "attempt to sign in as the banned user (POST /api/v1/auth/sign-in/email) and capture the refusal", |
394 | 394 | "unban, then verify the same sign-in now succeeds", |
395 | 395 | "set the target's password via the admin set-password (out-of-band recovery); verify the NEW password signs in and the OLD one is refused", |
396 | | - "sign the target in to establish a LIVE session, then as admin POST /api/v1/auth/admin/revoke-user-sessions for that target; the target's very next PROTECTED authed request (e.g. GET /api/v1/data/<an object the target could read a moment ago>) must be refused mid-flight — the kill is immediate, not deferred to expiry. Do NOT score this off get-session's status code: better-auth answers get-session with HTTP 200 and a JSON null body when the session is gone (session-of-record.test.ts), so a status-only assertion passes against a fully revoked session", |
| 396 | + "sign the target in to establish a LIVE session, then as admin POST /api/v1/auth/admin/revoke-user-sessions for that target; the target's very next PROTECTED authed request (e.g. GET /api/v1/data/<an object the target could read a moment ago>) must be refused mid-flight — the kill is immediate, not deferred to expiry. Do NOT score this off get-session's status code — and since #17881 that is no longer because it cannot discriminate: an anonymous OR revoked get-session answers 401 with the ADR-0112 refusal envelope (code UNAUTHENTICATED), converted from better-auth's bare 200+null by packages/plugins/plugin-auth/src/anonymous-session-refusal.ts, while a live session still answers 200 with { user, session }. It is not the oracle because this clause's contract is an immediate kill on a PROTECTED request, and one auth-route seam's status is not proof the AUTHORIZATION layer refuses the target's in-flight protected reads", |
397 | 397 | "change the target's role via POST /api/v1/auth/admin/set-role and prove the change bites: an operation the new role gates flips outcome (e.g. promote → an admin-only read now 2xx; demote → it now 403)", |
398 | 398 | "impersonate the target from the admin surface; verify via the API that the impersonation session carries impersonated_by, and screenshot the console's impersonation state; stop impersonating and verify the admin's own session is restored", |
399 | 399 | "POST /api/v1/auth/admin/remove-user for a throwaway user that OWNS at least one showcase row (task/note), then read that owned row back: its owner_id is cleared to null (engine referential-integrity FK clear), the row itself survives, and the owner-anchor transfer guard did NOT veto the cascade (#3023/#3048)", |
|
428 | 428 | { |
429 | 429 | "clause": "revoke-user-sessions kills the target's LIVE session mid-flight: a PROTECTED authed request that succeeded a moment earlier is refused immediately after the admin revoke — not at token expiry", |
430 | 430 | "oracle": "api", |
431 | | - "verify": "ORACLE = a protected authed request as the target (a data read the target was entitled to), 2xx before the revoke and refused on the very next call after it. get-session is NOT the oracle for this clause: better-auth's no-session convention is HTTP 200 with a JSON null body, so a 401 expectation misdescribes a correct implementation and a status-only assertion passes against a revoked session (packages/plugins/plugin-auth/src/session-of-record.test.ts). If get-session is captured at all, read its BODY (user null) as corroboration only", |
| 431 | + "verify": "ORACLE = a protected authed request as the target (a data read the target was entitled to), 2xx before the revoke and refused on the very next call after it. get-session is NOT the oracle for this clause. Since #17881 an anonymous OR revoked get-session answers 401 UNAUTHENTICATED in the ADR-0112 refusal envelope (packages/plugins/plugin-auth/src/anonymous-session-refusal.ts) while a live session still answers 200 with { user, session }, so a 401 there is the CORRECT answer and must never be filed as the defect. It stays off the oracle seat for a reason that does not move with the wire: this clause's contract is an immediate kill on a PROTECTED request, and a refusal emitted by that one auth-route seam is not proof the authorization layer refuses the target's in-flight protected reads. If get-session is captured at all it is corroboration only — read its BODY as well as its status, the discipline packages/plugins/plugin-auth/src/session-of-record.test.ts states: a session that is gone is proven gone by the absence of a user, not by a status the file would then be trusting a single seam to keep emitting", |
432 | 432 | "evidence": "the before/after protected-request pair (plus the get-session body, if captured)" |
433 | 433 | }, |
434 | 434 | { |
|
465 | 465 | "negative": [ |
466 | 466 | "a non-admin forged admin operation succeeding is a FAIL of the highest severity — apply RUNNER rule 7 (independent re-derivation) before acting on it", |
467 | 467 | "a ban that hides the user in the UI while their sign-in still works is a FAIL — the sign-in refusal is the enforcement, not the list filter", |
468 | | - "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", |
| 468 | + "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. Two scoring errors to avoid on get-session, and since #17881 inverted the wire answer they point OPPOSITE ways. (a) A 401 there after the revoke is the CORRECT answer, not the failure — filing it as one is the false positive run #7663 corrected, and the remedy that mis-filing invites is undoing anonymous-session-refusal.ts, an auth tightening. (b) A 200 carrying a user after the revoke is no longer the benign no-session convention this line once described; the live-session answer is the only 200 left, so it IS a real signal the kill did not land. Neither is this clause's oracle: read the body, or better, re-drive a protected request", |
469 | 469 | "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" |
470 | 470 | ], |
471 | 471 | "automated": { |
|
480 | 480 | "packages/plugins/plugin-security/src/security-plugin.ts#__referentialFieldClear (§A5 #3023 EXEMPTION: __referentialFieldClear owner_id-null cascade rides a server-derived context, the owner-anchor guard must not veto it) + security-plugin.test.ts '[#3023] … engine referential FK clear … is exempt'", |
481 | 481 | "packages/spec/src/kernel/public-auth-features.ts#sys_user (admin flag gates the sys_user lifecycle actions; SCIM forces it on — ADR-0134)", |
482 | 482 | "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts", |
483 | | - "packages/plugins/plugin-auth/src/session-of-record.test.ts#body (better-auth answers /get-session with HTTP 200 + a JSON null body when the session is gone — NOT 401; a status-only assertion would pass against a fully revoked session)" |
| 483 | + "packages/plugins/plugin-auth/src/anonymous-session-refusal.ts#ANONYMOUS_SESSION_REFUSAL_STATUS (since #17881 an anonymous or revoked /get-session answers 401 with the ADR-0112 refusal envelope, code UNAUTHENTICATED derived from that status; a live session still answers 200 with { user, session }. Both legs are driven end to end in packages/plugins/plugin-auth/src/anonymous-session-refusal.test.ts, and the body-not-status discipline for a revoke is kept in packages/plugins/plugin-auth/src/session-of-record.test.ts)" |
484 | 484 | ], |
485 | 485 | "history": [ |
486 | 486 | { "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" }, |
|
492 | 492 | "date": "2026-08-24", |
493 | 493 | "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", |
494 | 494 | "ref": "#9482" |
| 495 | + }, |
| 496 | + { |
| 497 | + "revision": 6, |
| 498 | + "date": "2026-09-17", |
| 499 | + "change": "CORRECTION, and a correction OF a correction. Revision 3 was RIGHT when it was written: in August 2026 better-auth's bare no-session answer really was HTTP 200 with a JSON null body, so the literal 401 expectation it removed really did misdescribe a correct implementation. Its cited authority was then INVERTED underneath it — #17238 ruled the answer, #17881 landed refuseAnonymousSession (packages/plugins/plugin-auth/src/anonymous-session-refusal.ts), and packages/plugins/plugin-auth/src/session-of-record.test.ts now pins the very 401 it was cited for denying. Revision 3 is therefore left standing and UNEDITED: it records what the judgement rested on at the time, which is the whole point of keeping a revision history. This revision re-points the four instructional sites (the step, clause 5's verify, the third negative and the evidence row) at today's live wire answer, measured on this checkout rather than relayed: a sign-up read 200 with { user, session }, a revoke-sessions then read 401 with {\"success\":false,\"error\":{\"code\":\"UNAUTHENTICATED\"}}, and a fresh manager's new session still read 200 as the control — so the refusal covers the REVOKED path and not only the never-signed-in one, because the seam keys on the answer shape (/get-session + 200 + a body that is exactly null) rather than on how the caller became anonymous. The SUBSTANTIVE advice is unchanged and now rests on something that does not move with the wire: get-session is still not this clause's oracle, because the contract is an immediate kill on a PROTECTED request and one auth-route seam's status is not proof the authorization layer refuses one. Consistency between this file and the runtime's refusal envelope is now pinned by packages/plugins/plugin-auth/src/checklist-refusal-envelope-consistency.test.ts, which reads the status and code from the runtime constants rather than from a copy, so the next behaviour change reds instead of replaying this drift", |
| 500 | + "ref": "#18650" |
495 | 501 | } |
496 | 502 | ] |
497 | 503 | }, |
|
0 commit comments