|
368 | 368 | "title": "Admin user-lifecycle operations (ban/unban, set-password, impersonate, create/set-role/remove, revoke-sessions) enforce, persist, and stay closed to non-admins", |
369 | 369 | "since": "v16", |
370 | 370 | "status": "active", |
371 | | - "revision": 3, |
| 371 | + "revision": 4, |
372 | 372 | "priority": "P1", |
373 | 373 | "surface": "mixed", |
374 | 374 | "personas": ["platform admin", "target user", "non-admin forger"], |
|
377 | 377 | "requires": [ |
378 | 378 | "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", |
379 | 379 | "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." |
380 | 383 | ] |
381 | 384 | }, |
382 | 385 | "steps": [ |
|
462 | 465 | "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", |
463 | 466 | "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" |
464 | 467 | ], |
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 | + }, |
466 | 472 | "traps": ["wrong-persona", "shared-browser-tab"], |
467 | 473 | "source": [ |
468 | 474 | "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 | 482 | "history": [ |
477 | 483 | { "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" }, |
478 | 484 | { "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" } |
480 | 487 | ] |
481 | 488 | }, |
482 | 489 | { |
|
0 commit comments