Files
curo1305 16584ade00 docs(02): create phase 2 plan — Users & Authentication
5 plans across 5 waves covering AUTH-01..08, SEC-01..03/05..07,
ADMIN-01..05/07. Includes security hardening (Origin validation,
per-account rate limiting, TOTP replay prevention, refresh token
family revocation with security alert), TOTP + backup code login,
and admin panel frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:13:44 +02:00

313 lines
18 KiB
Markdown

---
phase: 02-users-authentication
plan: 04
type: execute
wave: 4
depends_on:
- 02-02
- 02-03
files_modified:
- backend/api/admin.py
- backend/tests/test_admin_api.py
autonomous: true
requirements:
- ADMIN-01
- ADMIN-02
- ADMIN-03
- ADMIN-04
- ADMIN-05
- ADMIN-07
- SEC-07
must_haves:
truths:
- "Admin can create a user account with a temporary password that must be changed on first login"
- "Admin can deactivate and reactivate a user account — deactivated users cannot log in"
- "Admin can initiate password reset for a user (sends email, does not grant admin access)"
- "Admin can view and adjust individual user storage quotas with a warning when new limit is below current usage"
- "Admin can assign AI provider and model per user"
- "Admin endpoints NEVER return document content, extracted text, or credentials_enc"
- "Admin impersonation endpoint does not exist anywhere in the codebase"
- "Every admin handler verifies role via get_current_admin dependency — no handler uses get_current_user alone"
artifacts:
- path: "backend/api/admin.py"
provides: "GET /api/admin/users, POST /api/admin/users, PATCH /api/admin/users/{id}/status, POST /api/admin/users/{id}/password-reset, GET /api/admin/users/{id}/quota, PATCH /api/admin/users/{id}/quota, PATCH /api/admin/users/{id}/ai-config"
exports:
- "router"
- path: "backend/tests/test_admin_api.py"
provides: "Admin API tests: list users, create user, deactivate, quota, AI config"
key_links:
- from: "backend/api/admin.py"
to: "backend/deps/auth.py:get_current_admin"
via: "Depends(get_current_admin) on every handler"
pattern: "get_current_admin"
- from: "backend/api/admin.py"
to: "backend/main.py"
via: "app.include_router(admin_router)"
pattern: "admin_router"
---
<objective>
Build the complete admin backend: user management (create, deactivate, reactivate, password reset), quota management, and AI provider assignment — all enforced by get_current_admin. Admin-created users are flagged with password_must_change=True so they are forced to set a new password on first login. Admin impersonation is explicitly excluded by architecture.
Purpose: After this plan, the admin panel frontend (Plan 05) can be wired to real endpoints. This plan runs after Plans 02 and 03 confirm the auth dependency chain is functional.
Output: backend/api/admin.py with all 7 admin endpoints, registered in main.py, covered by tests.
</objective>
<execution_context>
@/Users/nik/.claude/get-shit-done/workflows/execute-plan.md
@/Users/nik/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/02-users-authentication/02-CONTEXT.md
@.planning/phases/02-users-authentication/02-PATTERNS.md
@.planning/phases/02-users-authentication/02-02-SUMMARY.md
@.planning/phases/02-users-authentication/02-03-SUMMARY.md
</context>
<interfaces>
From backend/deps/auth.py (Plan 01):
async def get_current_admin(user: User = Depends(get_current_user)) -> User
# Raises 403 if user.role != "admin"
From backend/db/models.py:
class User: id, handle, email, password_hash, role, is_active, totp_enabled, ai_provider, ai_model, password_must_change, created_at
class Quota: user_id, limit_bytes, used_bytes
class RefreshToken: id, user_id, token_hash, revoked
From backend/services/auth.py (Plan 01):
async def hash_password(plain: str) -> str
async def revoke_all_refresh_tokens(session, user_id) -> int
def create_password_reset_token(user_id: str) -> str
From backend/tasks/email_tasks.py (Plan 01):
send_reset_email.delay(to_address: str, reset_link: str)
From backend/api/settings.py (pattern analog for admin router):
router = APIRouter(prefix="/api/settings", tags=["settings"])
class SomeRequest(BaseModel): field: type
@router.get("/") async def handler(session = Depends(get_db)): ...
From backend/config.py (Plan 03 addition):
settings.frontend_url: str # used to build reset link
User response shape (NEVER include: password_hash, credentials_enc, extracted_text, document content):
{ id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, created_at }
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create backend/api/admin.py with all admin endpoints</name>
<files>
backend/api/admin.py,
backend/main.py
</files>
<read_first>
- backend/api/settings.py (router declaration, Pydantic body pattern, error mapping — full file)
- backend/main.py (current state after Plans 02/03 — verify admin router not yet included before adding it)
- backend/db/models.py (User, Quota model fields — full file; note password_must_change field added in Plan 01)
- backend/deps/auth.py (get_current_user, get_current_admin signatures)
- backend/services/auth.py (hash_password, revoke_all_refresh_tokens, create_password_reset_token)
- backend/tasks/email_tasks.py (send_reset_email.delay call pattern)
- .planning/phases/02-users-authentication/02-PATTERNS.md (api/admin.py section — router with admin dep, Pydantic models)
- .planning/phases/02-users-authentication/02-CONTEXT.md (D-06, D-08 — admin router, quota default, ADMIN-07 exclusion)
</read_first>
<behavior>
GET /api/admin/users:
- _admin = Depends(get_current_admin) (on every handler)
- SELECT all User rows ordered by created_at DESC
- Return { items: [{ id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, created_at }] }
- NEVER include password_hash, credentials_enc, or any document data in response
POST /api/admin/users (ADMIN-01):
- Body: { handle, email, password, role="user" }
- Validate password strength (same rules: ≥12 chars, uppercase, lowercase, digit, special)
- hash_password(password), insert User (is_active=True, totp_enabled=False, password_must_change=True) — admin-created users must change their password on first login
- insert Quota (limit_bytes=104857600, used_bytes=0)
- Return 201 { id, handle, email, role, created_at }
- 409 if email or handle taken
PATCH /api/admin/users/{id}/status (ADMIN-02):
- Body: { is_active: bool }
- Fetch User by id — 404 if not found
- Prevent deactivating the only admin account: if is_active=False and user.role="admin": count remaining active admins; if count would be 0, raise 400 "Cannot deactivate the only admin"
- Update user.is_active = body.is_active; if deactivating: revoke_all_refresh_tokens(session, user.id)
- Return { id, handle, email, is_active }
POST /api/admin/users/{id}/password-reset (ADMIN-03):
- Fetch User by id — 404 if not found
- create_password_reset_token(str(user.id)) → build reset_link
- send_reset_email.delay(user.email, reset_link)
- Return 202 { message: "Password reset email sent" }
- Does NOT grant admin access to the account; does NOT log admin in as user (ADMIN-07 exclusion)
GET /api/admin/users/{id}/quota (ADMIN-04):
- Fetch Quota by user_id — 404 if not found
- Return { user_id, limit_bytes, used_bytes, limit_mb: limit_bytes//1048576, used_mb: used_bytes//1048576 }
PATCH /api/admin/users/{id}/quota (ADMIN-04):
- Body: { limit_bytes: int } (must be > 0)
- Fetch Quota by user_id — 404 if not found
- If new limit_bytes < quota.used_bytes: return 200 with warning=True and message "New limit is below current usage. Uploads will be blocked but existing documents are preserved." — still apply the update
- Update quota.limit_bytes; return { user_id, limit_bytes, used_bytes, warning }
PATCH /api/admin/users/{id}/ai-config (ADMIN-05):
- Body: { ai_provider: str | None, ai_model: str | None }
- Fetch User — 404 if not found
- Update user.ai_provider and user.ai_model
- Return { id, email, ai_provider, ai_model }
ADMIN-07: No impersonation endpoint. No handler that creates a JWT with sub set to a different user than the authenticated admin. No route path containing "/impersonate" or "/login-as". This is enforced by omission — no such code exists.
In main.py: add `from api.admin import router as admin_router` and `app.include_router(admin_router)` after the auth router include.
</behavior>
<action>
Create backend/api/admin.py from scratch. Router: APIRouter(prefix="/api/admin", tags=["admin"]).
Pydantic request models (inside admin.py):
class UserCreate(BaseModel): handle: str; email: EmailStr; password: str; role: str = "user"
class UserStatusUpdate(BaseModel): is_active: bool
class QuotaUpdate(BaseModel): limit_bytes: int
class AiConfigUpdate(BaseModel): ai_provider: str | None = None; ai_model: str | None = None
Helper: _user_to_dict(user: User) -> dict — returns only safe fields: id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, created_at. This function is used on every response to prevent accidental field leakage.
Password strength validation: extract into a helper validate_password_strength(password: str) -> None that raises ValueError with the spec message if any rule fails. Reuse between register and admin create.
All handlers use: session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin).
In POST /api/admin/users: set user.password_must_change = True before db.add(user). This ensures admin-created users are forced to change their password on first login (ADMIN-01 per D-06).
Update backend/main.py to include admin_router (do not recreate the file — append the include after existing router includes).
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from api.admin import router; paths = [r.path for r in router.routes]; print(paths)"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "import ast, sys; tree = ast.parse(open('api/admin.py').read()); has_impersonate = any('impersonate' in ast.dump(node) or 'login_as' in ast.dump(node) for node in ast.walk(tree)); sys.exit(1) if has_impersonate else print('No impersonation code found')"</automated>
</verify>
<acceptance_criteria>
- backend/api/admin.py exists with APIRouter prefix="/api/admin"
- Every route handler in admin.py includes `Depends(get_current_admin)` — grep -c "get_current_admin" backend/api/admin.py returns at least 7 (one per handler)
- Response helper _user_to_dict or equivalent exists and does NOT include password_hash field
- grep -c "password_hash" backend/api/admin.py returns 0 (never in responses)
- grep -c "impersonate\|login_as\|login-as" backend/api/admin.py returns 0 (ADMIN-07)
- backend/main.py contains "admin_router" (router is registered)
- PATCH /api/admin/users/{id}/status with { is_active: false } calls revoke_all_refresh_tokens
- POST /api/admin/users creates user with password_must_change=True in DB — grep -c "password_must_change" backend/api/admin.py returns at least 1
</acceptance_criteria>
<done>All admin endpoints created with get_current_admin enforced on every handler. Admin-created users have password_must_change=True. User responses use safe field helper. No impersonation endpoint exists. Router registered in main.py.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Admin API tests</name>
<files>
backend/tests/test_admin_api.py
</files>
<read_first>
- backend/tests/conftest.py (async_client fixture, db_session fixture, dependency override pattern)
- backend/api/admin.py (after Task 1 — understand endpoint paths and response shapes before writing tests)
- backend/deps/auth.py (get_current_admin dep — needs to be overridden in tests)
- .planning/phases/02-users-authentication/02-PATTERNS.md (Test — Async Client Override section, make_authed_client pattern)
</read_first>
<behavior>
- test_list_users_requires_admin: GET /api/admin/users with non-admin user Bearer → 403
- test_list_users_as_admin: override get_current_admin to return an admin User; GET /api/admin/users → 200 with "items" key
- test_create_user_as_admin: POST /api/admin/users with valid body → 201; response contains "id", "email"; no "password_hash" in response
- test_create_user_sets_password_must_change: POST /api/admin/users → 201; query DB for created user and assert user.password_must_change is True
- test_create_user_weak_password: POST /api/admin/users with password "short" → 422
- test_deactivate_user: create user, PATCH /api/admin/users/{id}/status { is_active: false } → 200, user.is_active False
- test_update_quota: PATCH /api/admin/users/{id}/quota { limit_bytes: 52428800 } → 200, response.limit_bytes = 52428800
- test_quota_below_usage_warning: set quota.used_bytes > new limit_bytes → 200 with warning=True in response
- test_update_ai_config: PATCH /api/admin/users/{id}/ai-config { ai_provider: "openai", ai_model: "gpt-4o" } → 200
- test_admin_impersonation_not_found: GET /api/admin/users/impersonate, GET /api/admin/login-as → 404 or 405 (route does not exist)
- test_admin_response_no_password_hash: list users response items do not contain "password_hash" key
</behavior>
<action>
Write backend/tests/test_admin_api.py using async_client fixture + db_session.
Override pattern for admin tests: in each test, define an async fixture or inline override:
app.dependency_overrides[get_current_admin] = lambda: admin_user_obj
app.dependency_overrides[get_db] = lambda: db_session
Helper function make_admin_user(db_session) that inserts a User(role="admin", is_active=True, password_hash=...) and Quota row, returns the ORM object.
Helper function make_regular_user(db_session) similarly with role="user".
For test_create_user_sets_password_must_change: after POST /api/admin/users returns 201, query db_session for User by the returned id and assert user.password_must_change is True.
For test_admin_impersonation_not_found: use async_client to GET "/api/admin/users/impersonate" and verify it returns 404 or 422 (not 200) — proving no such route exists.
For test_admin_response_no_password_hash: call GET /api/admin/users with admin dep override; parse response JSON; assert "password_hash" not in response["items"][0].
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_admin_api.py -x -q 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- pytest tests/test_admin_api.py exits 0 (all tests pass)
- test_create_user_sets_password_must_change passes (user.password_must_change is True in DB after admin create)
- test_admin_impersonation_not_found passes (route does not exist)
- test_admin_response_no_password_hash passes (no password_hash in response)
- test_list_users_requires_admin passes (403 for non-admin users)
- test_quota_below_usage_warning passes (warning=True when limit < used)
</acceptance_criteria>
<done>Admin API fully tested: all CRUD operations, role enforcement, quota warning, password_must_change=True on create, no impersonation route, no password_hash in responses.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin JWT→API (admin endpoints) | Admin Bearer token verified on every request via get_current_admin |
| admin→user data | Admin can read user metadata but must never see document content or credentials |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-26 | Elevation of Privilege | Admin endpoint without role check | mitigate | get_current_admin Depends() on every handler — no handler uses only get_current_user (SEC-07); verified by grep count in acceptance criteria |
| T-02-27 | Information Disclosure | Admin user list returns sensitive fields | mitigate | _user_to_dict() helper explicitly whitelists safe fields; password_hash and credentials_enc excluded by construction (SEC-07) |
| T-02-28 | Elevation of Privilege | Admin impersonation | mitigate | No endpoint exists (ADMIN-07); test_admin_impersonation_not_found asserts 404; AST check in acceptance criteria confirms no impersonation code |
| T-02-29 | Denial of Service | Admin deactivating all admins | mitigate | PATCH /status checks remaining active admin count before applying deactivation; raises 400 if count would reach 0 |
| T-02-30 | Tampering | Admin-initiated password reset grants admin access | mitigate | create_password_reset_token issues a reset-type JWT only; admin never gets the token; email goes to user's inbox; endpoint returns 202 not a token |
| T-02-31 | Information Disclosure | Quota endpoint exposes storage details | accept | Quota (limit_bytes, used_bytes) is admin-visible operational data — no PII, no document content |
| T-02-32 | Elevation of Privilege | Admin-created user skips password change | mitigate | password_must_change=True set on POST /api/admin/users; /login returns 200 {requires_password_change: true} without tokens until password is changed (ADMIN-01) |
</threat_model>
<verification>
1. GET /api/admin/users with user-role Bearer → 403
2. GET /api/admin/users with admin Bearer → 200, items[0] has no "password_hash"
3. GET /api/admin/users/impersonate → 404 or 405
4. PATCH /api/admin/users/{id}/quota { limit_bytes: 0 } → 422 (limit must be > 0)
5. PATCH /api/admin/users/{id}/status { is_active: false } → 200; subsequent login attempt returns 401 "Account deactivated"
6. POST /api/admin/users → 201; DB query confirms user.password_must_change is True
7. pytest tests/test_admin_api.py passes
8. grep -c "get_current_admin" backend/api/admin.py — count equals number of route handlers (at least 7)
9. grep -c "password_must_change" backend/api/admin.py returns at least 1
</verification>
<success_criteria>
- All 7 admin endpoints operational and returning correct shapes
- get_current_admin enforced on every handler (verified by test + grep)
- Admin-created users have password_must_change=True (forced password change on first login)
- No password_hash, credentials_enc, or document content in any admin response
- No impersonation route (verified by test + AST check)
- Admin password reset sends email via Celery; does not grant admin access
- Quota warning returned when new limit below current usage
- pytest tests/test_admin_api.py passes
</success_criteria>
<output>
Create `.planning/phases/02-users-authentication/02-04-SUMMARY.md` when done.
</output>