16584ade00
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>
313 lines
18 KiB
Markdown
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>
|