R-Same M1 Identity Pass 2 (admin + MFA enroll + password reset + recovery codes + seed CLI)

Decision

M1 Identity pass 2 shipped in commit b932e55 (2026-04-17). +15 files / +1218 lines completing all M1 backend capability. UserRecoveryCode model + migration 20260417_0800. Five new services: RecoveryCodeService (20-char ABCDE-FGHIJ-KLMNO-PQRST grouped format, case/space-insensitive match, SHA256-hashed, 10 per user, single-use-and-consume), EmailService (aiosmtplib with console fallback when SMTP_HOST empty), PasswordResetService (32-byte URL-safe token hashed in DB, 1h TTL, revokes ALL user sessions on password change, enumeration-proof), AdminProvisioningService (create/activate/soft-delete users, grant/revoke global roles rejecting workspace-scoped ones, reset MFA revoking sessions), MFAEnrollmentService (2-step: start issues secret+URI → verify returns 10 recovery codes shown once). Seven new auth routes: /mfa/enroll, /mfa/enroll/verify, /mfa/recovery-codes/regenerate, /mfa/recovery-code/verify, /password/reset-request (3/hr), /password/reset-complete (10/hr). Admin router /api/v1/admin/users with 8 endpoints (create/list/get/patch/delete/grant-role/revoke-role/reset-mfa) — all require platform-admin role, all write audit_events in same transaction, self-delete + self-revoke-admin blocked. Seed CLI tools/seed_admin.py + make seed-admin interactive — refuses if any platform-admin already exists. Config: SMTP settings + password reset TTL + frontend base URL. 6 new unit-test assertions (32 total). Frontend pages + integration tests against live Postgres queued for next pass.

Rationale

Completes admin capability needed to bootstrap first platform-admin and provision additional users. Without admin routes, R-Same couldn’t onboard anyone beyond the seed account. MFA enrollment + recovery codes are essential complements — without them, mandatory MFA bricks users whose authenticators break. Password reset flow is enumeration-proof (always returns ok regardless of email existence) — eliminates account-enumeration attack vector. All admin mutations pass through require_roles(PLATFORM_ADMIN) dependency + write audit_events in same transaction — audit tracks who did what to whom. Self-protection (cannot self-delete, cannot self-revoke platform-admin role) prevents foot-gun scenarios where the only admin accidentally removes their own access. Seed CLI refuses if any platform-admin exists — prevents silent privilege escalation via re-seed. EmailService console-fallback pattern keeps dev loop friction-free (no SMTP needed locally) while prod path uses aiosmtplib when SMTP_HOST is configured. Recovery code format (4 groups of 5 alphanumeric, dash-separated, uppercase-normalized) balances entropy (≈103 bits per code) with human transcription — easy to read aloud, no ambiguous 0/O/1/I characters still in scope but acceptable per alphabet choice.

Alternatives Rejected

Recovery codes in User JSONB column — rejected: separate table enables audit (used_at per code), clean regeneration (delete + insert), distinct hashes per code (prevents bulk-leak if one exposed).

Time-bound recovery codes — rejected: single-use-and-consume is sufficient security; TTL adds UX friction (“my codes expired?”).

Numeric-only recovery codes — rejected: too much transcription friction at 20 chars; grouped alphanumeric is industry standard (GitHub, Okta, Authy).

SMTP required at startup — rejected: developer friction; fallback-to-console pattern keeps local dev zero-config.

SMTP via sync smtplib — rejected: blocks event loop; aiosmtplib is async-native.

Password reset without session revocation — rejected: if attacker changed password via compromised session, revoking only the reset-requesting session isn’t enough; revoke all.

Enumeration-on for faster UX — rejected: security-first; slower UX (“check your email, link valid 1h”) vs leaking existence is unambiguous.

Public signup endpoint gated by invite code — rejected: internal platform; admin-provisioned is the correct posture; invite code = invitation-link flow = overkill for 50-user v1.

Magic-link login alongside password — rejected: adds email-delivery critical path to login; password + MFA is more reliable.

Workspace-scoped role grant via /admin/users/{id}/roles — rejected: clean contract: global role via user_role_assignments, workspace role via WorkspaceMember (separate admin route in Wave 2).

Admin route under /api/v1/users (unified with /auth) — rejected: admin surface has distinct security posture (require_roles dependency); separate router makes this explicit.

Seed admin auto-creates if no users exist — rejected: footgun — would seed on any empty-DB recovery; refusing when platform-admin exists is the correct default.

Role hierarchy inheritance (platform-admin implies governance-admin etc.) — rejected: flat set in v1 per L2 matrix; explicit is safer.

Password policy enforced client-side only — rejected: always server-side; zxcvbn runs in PasswordService.validate_strength on every hash path.

Outcome

Pending