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
Related
- r-same-m1-identity-backend-core-wave-1-pass-1
- R-Dash Auth Amendment Local Auth v1
- constant-time-auth-prevents-email-enumeration
- jwt-refresh-token-rotation-must-be-atomic
- argon2id-owasp-2024-params-time3-mem64mb-par4
- refresh-token-rotation-must-be-atomic-same-transaction
- constant-time-auth-failure-prevents-email-enumeration
- drizzle-orm-fk-references-commonly-omitted
- trpc-protected-procedure-is-authn-only-not-authz
- trpc-protected-procedure-insufficient-for-resource-authoriza
- r-dash-main-branch-push-policy-blocked
- chart-catalog-sso-validate-viz-config-pattern
- cube-query-cache-column-masks-reapplied-on-cache-hit
- postgres-force-rls-security-definer-trigger-pattern
- runwal-aws-account-798513555087-is-vendor-owned-midnight-dig