Moves phases 08–11 execution artifacts from .planning/phases/ to .planning/milestones/v0.2-phases/ to keep .planning/phases/ clean for the next milestone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
20 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | tags | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 08-stack-upgrade-backend-decomposition | 06 | execute | 2 |
|
|
true |
|
|
|
Purpose: CODE-03 (decomposition) + CODE-08 (single definition of auth Pydantic request models in shared.py) while preserving CR-01/CR-02/CR-03 production behavior and its passing test coverage.
Output: backend/api/auth/ package; backend/api/auth.py monolith deleted; all auth + CR + rate-limit + security-header tests still pass.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md @.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md @.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md @backend/api/auth.py @backend/services/auth.py @backend/main.py @backend/tests/conftest.py @CLAUDE.md Endpoint to sub-module map (locked per RESEARCH.md §"api/auth.py → api/auth/ package"):tokens.py: register (POST /register, line 110-188), login (POST /login, line 190-323), refresh_token (POST /refresh, line 325-387), logout (POST /logout, line 389-422), logout_all (POST /logout-all, line 424-449), get_me (GET /me, line 451-457), get_my_quota (GET /me/quota, line 459-475), get_my_preferences (GET /me/preferences, line 790-806), update_my_preferences (PATCH /me/preferences, line 808 onwards) totp.py: totp_setup (GET /totp/setup, line 557-577), enable_totp (POST /totp/enable, line 579-639), disable_totp (DELETE /totp, line 641-685) password.py: change_password (POST /change-password, line 477-540), password_reset_request (POST /password-reset, line 687-720), password_reset_confirm (POST /password-reset/confirm, line 722-778)
Pydantic model + helper map (shared.py):
shared.py: limiter (the slowapi Limiter instance, line 46), RegisterRequest (line 51-55), LoginRequest (line 57-63), ChangePasswordRequest (line 65-69), TotpEnableRequest (line 542-544), PasswordResetRequest (line 546-548), PasswordResetConfirmRequest (line 550-554), PreferencesUpdate (line 780-787), _set_refresh_cookie (line 72-94), _user_dict (line 96-105)
Package aggregator pattern for backend/api/auth/__init__.py — note the limiter re-export:
from fastapi import APIRouter
from api.auth.tokens import router as tokens_router
from api.auth.totp import router as totp_router
from api.auth.password import router as password_router
from api.auth.shared import limiter # re-exported so from api.auth import limiter still works
router = APIRouter(prefix="/api/auth", tags=["auth"]) router.include_router(tokens_router) router.include_router(totp_router) router.include_router(password_router)
Critical: 5 production/test files import limiter:
- backend/main.py line 21
- backend/tests/conftest.py line 222
- backend/tests/test_auth_api.py line 99
- backend/tests/test_auth_totp.py line 98
- backend/tests/test_totp_replay.py line 76
- backend/tests/test_security_headers.py line 72
None of these files may be modified by this plan — the
__init__.pyre-export keeps the import path stable.
(A) Create backend/api/auth/shared.py first because every other sub-module imports from it. Add from __future__ import annotations. Imports: from typing import Optional, from fastapi import Response, from pydantic import BaseModel, EmailStr, from slowapi import Limiter, from config import settings, from deps.utils import get_client_ip. Then declare limiter = Limiter(key_func=get_client_ip) (line 46 of monolith). Move these Pydantic models verbatim from monolith: RegisterRequest (51-55), LoginRequest (57-63 — keep remember_me: bool = False field), ChangePasswordRequest (65-69), TotpEnableRequest (542-544), PasswordResetRequest (546-548), PasswordResetConfirmRequest (550-554), PreferencesUpdate (780-787). Move helpers verbatim: _set_refresh_cookie(response, raw_token, remember_me=False) (72-94 — note the remember_me-aware max_age branching from Phase 7.3) and _user_dict(user) (96-105).
(B) Create backend/api/auth/tokens.py. Header: from __future__ import annotations. Imports per PATTERNS.md §"backend/api/auth/tokens.py" plus from api.auth.shared import limiter, RegisterRequest, LoginRequest, PreferencesUpdate, _set_refresh_cookie, _user_dict. router = APIRouter() — NO prefix (D-04). Move handlers verbatim INCLUDING the @limiter.limit("10/minute") decorators: register (110-188), login (190-323), refresh_token (325-387), logout (389-422), logout_all (424-449), get_me (451-457), get_my_quota (459-475), get_my_preferences (790-806), update_my_preferences (808 onwards). The ES256 + JTI + fgp + Redis user_nbf logic in login/refresh_token is preserved verbatim (CLAUDE.md non-negotiable).
(C) Create backend/api/auth/totp.py. Header + imports per PATTERNS.md §"backend/api/auth/totp.py" plus from api.auth.shared import limiter, TotpEnableRequest, _set_refresh_cookie. router = APIRouter() — NO prefix. Move handlers verbatim: totp_setup (557-577), enable_totp (579-639), disable_totp (641-685). The CR-02 (enable_totp) and CR-03 (disable_totp) skip-hash + revoke_all_refresh_tokens + sessions_revoked logic is preserved verbatim — these handlers are covered by passing tests from plan 08-03, which this plan MUST not break. Copy hashlib and time imports as needed at the top.
(D) Create backend/api/auth/password.py. Header + imports per PATTERNS.md §"backend/api/auth/password.py" plus from api.auth.shared import limiter, ChangePasswordRequest, PasswordResetRequest, PasswordResetConfirmRequest. router = APIRouter() — NO prefix. Move handlers verbatim: change_password (477-540), password_reset_request (687-720), password_reset_confirm (722-778). The CR-01 (change_password) skip-hash + revoke_all_refresh_tokens + sessions_revoked + Redis user_nbf logic is preserved verbatim from monolith lines 514-540.
(E) Create backend/api/auth/__init__.py. Contents:
from fastapi import APIRouter from api.auth.tokens import router as tokens_router from api.auth.totp import router as totp_router from api.auth.password import router as password_router from api.auth.shared import limiter # re-export
router = APIRouter(prefix="/api/auth", tags=["auth"]) router.include_router(tokens_router) router.include_router(totp_router) router.include_router(password_router)
The from api.auth.shared import limiter line makes from api.auth import limiter work for main.py and the 5 test files (because module attribute access resolves through __init__.py).
To avoid the package-vs-module name collision, immediately after creating the package directory, rename backend/api/auth.py to backend/api/auth_OLD_REMOVE_IN_TASK_2.py. Task 2 verifies tests pass and deletes the renamed file.
cd backend && python -c "from api.auth import router, limiter; print('auth routes:', len(router.routes)); print('limiter type:', type(limiter).name)"
<acceptance_criteria>
- Files exist: backend/api/auth/__init__.py, backend/api/auth/shared.py, backend/api/auth/tokens.py, backend/api/auth/totp.py, backend/api/auth/password.py
- grep -v '^#' backend/api/auth/__init__.py | grep -c 'router = APIRouter(prefix="/api/auth"' returns 1
- grep -v '^#' backend/api/auth/tokens.py | grep -c 'router = APIRouter()' returns 1 (NO prefix)
- grep -v '^#' backend/api/auth/totp.py | grep -c 'router = APIRouter()' returns 1 (NO prefix)
- grep -v '^#' backend/api/auth/password.py | grep -c 'router = APIRouter()' returns 1 (NO prefix)
- grep -c "limiter = Limiter" backend/api/auth/shared.py returns 1
- grep -c "from api.auth.shared import limiter" backend/api/auth/__init__.py returns 1 (re-export)
- grep -c "skip_token_hash=skip_hash" backend/api/auth/password.py returns 1 (CR-01 preserved)
- grep -c "skip_token_hash=skip_hash" backend/api/auth/totp.py returns 2 (CR-02 enable_totp + CR-03 disable_totp preserved)
- cd backend && python -c "from api.auth import limiter, router; assert callable(getattr(limiter, 'limit', None)); assert len(router.routes) >= 14" exits 0 (9 tokens + 3 totp + 3 password = at minimum 14, likely 15)
</acceptance_criteria>
Package exists with all 5 files; limiter re-exported from __init__.py; CR-01/02/03 skip_token_hash calls preserved; monolith renamed (not yet deleted).
Command: cd backend && pytest tests/test_auth.py tests/test_auth_api.py tests/test_auth_totp.py tests/test_totp_replay.py tests/test_security_headers.py -x -v.
If ANY test fails, diagnose, fix the sub-module, and re-run. Special attention: if a test fails with ImportError: cannot import name 'limiter' from 'api.auth', the __init__.py re-export line is missing or misspelled. If a CR test fails, compare the exact skip_hash computation block in password.py / totp.py against the original monolith — every byte must match.
Only after all five test files pass: delete backend/api/auth_OLD_REMOVE_IN_TASK_2.py. Re-run cd backend && pytest -v to confirm no other tests regressed.
cd backend && pytest tests/test_auth.py tests/test_auth_api.py tests/test_auth_totp.py tests/test_totp_replay.py tests/test_security_headers.py -x -v 2>&1 | tail -20 && test ! -f backend/api/auth.py && test ! -f backend/api/auth_OLD_REMOVE_IN_TASK_2.py && echo "old monolith deleted"
<acceptance_criteria>
- test ! -f backend/api/auth.py exits 0
- test ! -f backend/api/auth_OLD_REMOVE_IN_TASK_2.py exits 0
- test -d backend/api/auth exits 0
- cd backend && pytest tests/test_auth.py -x exits 0 (includes the three promoted CR tests)
- cd backend && pytest tests/test_auth_api.py -x exits 0
- cd backend && pytest tests/test_auth_totp.py -x exits 0
- cd backend && pytest tests/test_totp_replay.py -x exits 0
- cd backend && pytest tests/test_security_headers.py -x exits 0
- cd backend && pytest tests/test_auth.py::test_change_password_revokes_other_sessions tests/test_auth.py::test_enable_totp_revokes_other_sessions tests/test_auth.py::test_disable_totp_revokes_other_sessions -v shows all three as PASSED
- cd backend && pytest -v exits 0 with no new failures vs. baseline
- grep -rn "^class RegisterRequest" backend/api/auth/ returns exactly one match (shared.py)
- grep -rn "^class LoginRequest" backend/api/auth/ returns exactly one match (shared.py)
- No file in backend/tests/ was modified by this plan (test imports still use from api.auth import limiter)
</acceptance_criteria>
Old auth.py deleted; full backend suite green; CR-01/02/03 tests still PASSED; rate-limit / TOTP / security headers tests all green; no test file touched.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Unauthenticated client → /api/auth/login, /api/auth/register, /api/auth/refresh | Rate-limited via @limiter.limit("10/minute"); limiter must be the SAME instance app.state.limiter references |
| Authenticated user → /api/auth/change-password, /api/auth/totp/enable, /api/auth/totp (DELETE) | CR-01/CR-02/CR-03 require revoke_all_refresh_tokens with skip_token_hash for the current session — preserved verbatim |
| Authenticated user → /api/auth/refresh | Refresh-token rotation + family-revocation on reuse — preserved verbatim from monolith |
| Authenticated user → /api/auth/logout-all | Revoke all sessions including current; sets Redis user_nbf — preserved verbatim |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-08-06-01 | Spoofing | limiter re-export | mitigate | __init__.py re-exports limiter from shared.py so the SAME Limiter instance is accessible via the legacy import path. Acceptance criterion greps for the re-export line. |
| T-08-06-02 | Repudiation | CR-01/CR-02/CR-03 audit log entries | mitigate | write_audit_log(..., metadata_={"sessions_revoked": revoked}, ...) calls in change_password / enable_totp / disable_totp copied verbatim; passing tests from plan 08-03 verify behavior end-to-end. |
| T-08-06-03 | Tampering | Refresh-token rotation logic | mitigate | refresh_token handler in tokens.py copied verbatim including JTI, fgp, family-revocation, Redis nbf. |
| T-08-06-04 | Information Disclosure | Sub-router prefix doubling | mitigate | All three sub-routers declare router = APIRouter() with NO prefix; only __init__.py carries prefix="/api/auth". |
| T-08-06-05 | Denial of Service | Circular import via __init__.py |
mitigate | __init__.py does ONLY router aggregation + limiter re-export; shared models in shared.py. |
| T-08-06-06 | Elevation of Privilege | Session revocation skip | mitigate | skip_token_hash=skip_hash argument preserved in all three CR handlers; grep acceptance criterion counts occurrences. |
| T-08-06-SC | Supply Chain | No new packages | accept | This plan installs zero new packages. |
| </threat_model> |
<success_criteria>
backend/api/auth/package with 5 filesbackend/api/auth.pydeleted- All auth URL paths unchanged
- limiter still importable via
from api.auth import limiter(5 test files + main.py unchanged) - CR-01/CR-02/CR-03 tests still PASSED (not regressed by decomposition)
- All tests pass </success_criteria>