--- phase: 08-stack-upgrade-backend-decomposition plan: 04 type: execute wave: 2 depends_on: [08-02, 08-03] files_modified: - backend/api/admin/__init__.py - backend/api/admin/users.py - backend/api/admin/quotas.py - backend/api/admin/ai.py - backend/api/admin/shared.py - backend/api/admin.py - backend/services/ai_config.py autonomous: true requirements: [CODE-01, CODE-08] tags: [backend-decomposition, admin, sub-router] must_haves: truths: - "backend/api/admin/ is a Python package (has __init__.py) with sub-modules users.py, quotas.py, ai.py, shared.py" - "All admin endpoints respond on the same URL paths as before (no doubled segments, no missing routes)" - "Every admin sub-router handler explicitly injects _admin: User = Depends(get_current_admin)" - "CloudConnectionOut is defined exactly once in the codebase (in api/schemas.py); the old definition in admin.py is deleted along with admin.py" - "No sub-router declares a prefix on APIRouter(); only api/admin/__init__.py carries prefix=/api/admin" - "validate_provider_id helper lives in services/ai_config.py; both SystemAiConfigUpdate and TestConnectionRequest call it" - "Old backend/api/admin.py monolith file is deleted (replaced by the package)" - "main.py import `from api.admin import router as admin_router` continues to work because api/admin/__init__.py re-exports router" artifacts: - path: "backend/api/admin/__init__.py" provides: "Router aggregator with prefix=/api/admin; includes users_router, quotas_router, ai_router" contains: "router = APIRouter(prefix" - path: "backend/api/admin/users.py" provides: "User CRUD + AI per-user config + create_system_topic handlers" contains: "async def list_users" - path: "backend/api/admin/quotas.py" provides: "Per-user quota GET + PATCH handlers" contains: "async def get_user_quota" - path: "backend/api/admin/ai.py" provides: "System AI config GET/PUT, test-connection, models endpoints" contains: "async def get_ai_config" - path: "backend/api/admin/shared.py" provides: "_user_to_dict helper shared between users.py and quotas.py" contains: "def _user_to_dict" - path: "backend/services/ai_config.py" provides: "validate_provider_id helper migrated from inline router validators per D-11" contains: "def validate_provider_id" key_links: - from: "backend/main.py" to: "backend/api/admin/__init__.py" via: "from api.admin import router as admin_router" pattern: "from api.admin import router as admin_router" - from: "backend/api/admin/__init__.py" to: "backend/api/admin/{users,quotas,ai}.py" via: "include_router calls" pattern: "router\\.include_router" - from: "backend/api/admin/users.py and quotas.py" to: "backend/api/admin/shared.py" via: "from api.admin.shared import _user_to_dict" pattern: "from api.admin.shared import" --- Decompose the 934-line `backend/api/admin.py` monolith into a focused Python package `backend/api/admin/` containing `users.py`, `quotas.py`, `ai.py` per locked decision D-05. Move shared helpers to `shared.py`. Migrate the duplicated `provider_must_be_known` validator to `services/ai_config.py` per D-11. Delete the old monolith. Zero URL changes, zero behavior changes — `pytest tests/test_admin.py -x` must pass identically before and after. Purpose: CODE-01 (decomposition) + CODE-08 (single definition of the `provider_must_be_known` rule, plus the already-completed `CloudConnectionOut` migration from plan 08-02). Output: `backend/api/admin/` package; one new helper in `services/ai_config.py`; `backend/api/admin.py` monolith file deleted. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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/admin.py @backend/api/folders.py @backend/api/cloud.py @backend/api/schemas.py @backend/services/ai_config.py @backend/ai/provider_config.py @backend/main.py @CLAUDE.md Endpoint to sub-module map (locked per RESEARCH.md §"Recommended sub-module assignment"): users.py: list_users (GET /users), create_user (POST /users), update_user_status (PATCH /users/{id}/status), initiate_password_reset (POST /users/{id}/password-reset), update_ai_config (PATCH /users/{id}/ai-config), delete_user (DELETE /users/{id}), create_system_topic (POST /topics) quotas.py: get_user_quota (GET /users/{id}/quota), update_user_quota (PATCH /users/{id}/quota) ai.py: get_ai_config_models (GET /ai-config/models), test_ai_connection (POST /ai-config/test-connection), get_ai_config (GET /ai-config), update_system_ai_config (PUT /ai-config) Pydantic model to file map: users.py: UserCreate, UserStatusUpdate, UserAiConfigUpdate, UserDeleteConfirm, SystemTopicCreate quotas.py: QuotaUpdate ai.py: SystemAiConfigUpdate, TestConnectionRequest Helper map: shared.py: _user_to_dict (used by users.py and quotas.py) ai.py: _ai_config_to_dict (used only by ai.py — keeps it local) New helper to add (D-11 migration) in backend/services/ai_config.py: def validate_provider_id(v: str) -> str: """Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule.""" if v not in PROVIDER_DEFAULTS: raise ValueError(f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}") return v Package aggregator pattern for backend/api/admin/__init__.py: from fastapi import APIRouter from api.admin.users import router as users_router from api.admin.quotas import router as quotas_router from api.admin.ai import router as ai_router router = APIRouter(prefix="/api/admin", tags=["admin"]) router.include_router(users_router) router.include_router(quotas_router) router.include_router(ai_router) Sub-router pattern in each sub-module (D-04 — NO prefix on the sub-router): router = APIRouter() # NO prefix — parent __init__.py carries it @router.get("/users") # becomes /api/admin/users via parent Task 1: Add validate_provider_id helper to services/ai_config.py (D-11 migration) backend/services/ai_config.py - backend/services/ai_config.py (current contents — confirm PROVIDER_DEFAULTS is already imported at line 34) - backend/api/admin.py lines 147-179 (current inline validators in SystemAiConfigUpdate and TestConnectionRequest) - backend/ai/provider_config.py (confirm PROVIDER_DEFAULTS keys) Add a new top-level function `validate_provider_id(v: str) -> str` to `backend/services/ai_config.py`. The function MUST: (a) take a single string argument, (b) raise `ValueError(f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}")` if `v not in PROVIDER_DEFAULTS`, (c) otherwise return `v` unchanged. Place the function after the existing `load_provider_config` helpers, before any HKDF helpers. PROVIDER_DEFAULTS is already imported at line 34 of the file. Add a single-line docstring: `"""Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule."""`. Do not modify any existing function in this file. cd backend && python -c "from services.ai_config import validate_provider_id; assert validate_provider_id('openai') == 'openai'; thrown=False try: validate_provider_id('not-a-provider') except ValueError as e: assert 'Unknown provider_id' in str(e); thrown=True assert thrown; print('ok')" - `grep -c "^def validate_provider_id" backend/services/ai_config.py` returns 1 - Running `python -c "from services.ai_config import validate_provider_id; validate_provider_id('openai')"` from `backend/` exits 0 - Running `python -c "from services.ai_config import validate_provider_id; validate_provider_id('xxx')"` from `backend/` exits non-zero with `ValueError` - No existing function in `backend/services/ai_config.py` was modified (verify by reading the file) validate_provider_id function exists, raises ValueError per CLAUDE.md service-layer rule, importable. Task 2: Create backend/api/admin/ package — shared.py, users.py, quotas.py, ai.py, __init__.py backend/api/admin/__init__.py, backend/api/admin/shared.py, backend/api/admin/users.py, backend/api/admin/quotas.py, backend/api/admin/ai.py - backend/api/admin.py lines 1-934 (full source — every endpoint, every Pydantic model, every helper) - backend/api/folders.py (analog sub-router structure, ownership check pattern, error handling style) - .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md sections for `backend/api/admin/*` (exact import lists, sub-router declaration, auth guard, ValueError-to-HTTPException bridge) - CLAUDE.md §"Backend: shared module map" (must not violate; helpers go in deps/utils.py / services/auth.py / api/admin/shared.py — never duplicated) This task creates the package `backend/api/admin/` and ALL five files in one logical unit. Execute in this order so the package is in a valid state on disk after each sub-step: (A) Create `backend/api/admin/shared.py` first. Add `from __future__ import annotations`. Import `User`, `SystemSettings` from `db.models`. Define `_user_to_dict(user: User) -> dict` exactly per the body at `backend/api/admin.py` lines 75-91 (copy verbatim — same field set, same `created_at.isoformat()` handling, same docstring). The `_ai_config_to_dict` helper does NOT go in shared.py — it goes in ai.py because only ai.py uses it. (B) Create `backend/api/admin/users.py`. Header: `from __future__ import annotations`. Imports per PATTERNS.md §"backend/api/admin/users.py" plus `from api.admin.shared import _user_to_dict`. Declare `router = APIRouter()` with NO prefix (D-04). Move these Pydantic models verbatim from `backend/api/admin.py`: `UserCreate` (lines 96-107), `UserStatusUpdate` (109-111), `UserAiConfigUpdate` (124-127), `UserDeleteConfirm` (190-195), `SystemTopicCreate` (182-187). Move these handler functions verbatim including decorators from `backend/api/admin.py`: `list_users` (228-243), `create_user` (245-316), `update_user_status` (318-383), `initiate_password_reset` (385-415), `update_ai_config` (494-534 — the per-user one), `delete_user` (536-637 — confirm exact end line by reading source), `create_system_topic` (639-662). Every handler keeps its `_admin: User = Depends(get_current_admin)` injection — never omit. (C) Create `backend/api/admin/quotas.py`. Header + imports per PATTERNS.md §"backend/api/admin/quotas.py", plus `from api.admin.shared import _user_to_dict`. `router = APIRouter()` no prefix. Move `QuotaUpdate` Pydantic model (lines 113-121). Move handlers `get_user_quota` (417-439) and `update_user_quota` (441-492). Both inject `_admin: User = Depends(get_current_admin)`. (D) Create `backend/api/admin/ai.py`. Header + imports per PATTERNS.md §"backend/api/admin/ai.py". Add `from services.ai_config import encrypt_api_key, load_provider_config_by_id, validate_provider_id` (note the new `validate_provider_id` from task 1). `router = APIRouter()` no prefix. Move `_ai_config_to_dict` helper (lines 58-73) here as a module-level private helper (not in shared.py — only ai.py uses it). Move Pydantic models `SystemAiConfigUpdate` (129-155) and `TestConnectionRequest` (157-179). In both models, replace the inline `provider_must_be_known` validator body with `return validate_provider_id(v)` — the validator becomes a one-line call-through to the service-layer function (D-11). Move handlers `get_ai_config_models` (664-725), `test_ai_connection` (727-784), `get_ai_config` (786-824), `update_system_ai_config` (826-934). Every handler injects `_admin: User = Depends(get_current_admin)`. (E) Create `backend/api/admin/__init__.py`. Contents per PATTERNS.md §"backend/api/admin/__init__.py": import APIRouter from fastapi, import `router as users_router`, `router as quotas_router`, `router as ai_router` from the three sub-modules, declare `router = APIRouter(prefix="/api/admin", tags=["admin"])`, call `router.include_router(users_router)`, `router.include_router(quotas_router)`, `router.include_router(ai_router)`. The `__init__.py` does ONLY router aggregation — no helpers, no models, no logic (Pitfall 2 prevention). Do NOT delete `backend/api/admin.py` yet — that happens in task 3 after URL regression passes. Do NOT modify `backend/main.py` — the existing `from api.admin import router as admin_router` will continue to work after task 3 because the package's `__init__.py` exports `router`. IMPORTANT URL regression: After creating the package, Python's import resolution prefers the package (directory with `__init__.py`) over the module (admin.py file) ONLY if both exist at the same level — but here they collide. To avoid an import ambiguity error, ensure the package directory is created and immediately rename `backend/api/admin.py` to `backend/api/admin_OLD_REMOVE_IN_TASK_3.py` as part of the same atomic edit. This sidelines the monolith without deleting it so task 3 can confirm tests pass and then delete the renamed file. cd backend && python -c "from api.admin import router; print('admin routes:', len(router.routes))" && python -c "from api.admin.users import router as r; print('users routes:', len(r.routes))" && python -c "from api.admin.quotas import router as r; print('quotas routes:', len(r.routes))" && python -c "from api.admin.ai import router as r; print('ai routes:', len(r.routes))" - Files exist: `backend/api/admin/__init__.py`, `backend/api/admin/shared.py`, `backend/api/admin/users.py`, `backend/api/admin/quotas.py`, `backend/api/admin/ai.py` - `grep -v '^#' backend/api/admin/__init__.py | grep -c 'router = APIRouter(prefix="/api/admin"'` returns 1 - `grep -v '^#' backend/api/admin/users.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix) - `grep -v '^#' backend/api/admin/quotas.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix) - `grep -v '^#' backend/api/admin/ai.py | grep -c 'router = APIRouter()'` returns 1 (NO prefix) - `grep -c "Depends(get_current_admin)" backend/api/admin/users.py` ≥ 7 (one per handler) - `grep -c "Depends(get_current_admin)" backend/api/admin/quotas.py` ≥ 2 - `grep -c "Depends(get_current_admin)" backend/api/admin/ai.py` ≥ 4 - `grep -c "return validate_provider_id(v)" backend/api/admin/ai.py` returns 2 (SystemAiConfigUpdate + TestConnectionRequest) - `cd backend && python -c "from api.admin import router; assert len(router.routes) >= 13"` exits 0 (total: 7 users + 2 quotas + 4 ai = 13) - `cd backend && python -c "from main import app; routes = [r.path for r in app.routes]; assert '/api/admin/users' in routes" 2>&1 | tail -3` shows no AssertionError Package exists with all five files; sub-router count totals 13; no sub-router carries a prefix; main.py imports still resolve; admin.py monolith is renamed but not yet deleted. Task 3: Run URL regression suite, then delete the old monolith backend/api/admin.py - backend/tests/test_admin.py (full file — confirm test surface) - backend/api/admin_OLD_REMOVE_IN_TASK_3.py (the renamed monolith from task 2) Run `cd backend && pytest tests/test_admin.py tests/test_cloud.py tests/test_admin_ai_config.py -x -v` to confirm every admin URL still responds correctly (URLs unchanged, response shapes unchanged, auth guards still effective). If ANY test fails, do NOT delete the renamed monolith — diagnose, fix, and re-run. Only after all admin + admin_ai_config + cloud tests pass: delete `backend/api/admin_OLD_REMOVE_IN_TASK_3.py` (the renamed monolith from task 2). The old file is now superseded by the package. Re-run the full backend suite `cd backend && pytest -v` to confirm no other tests regressed. cd backend && pytest tests/test_admin.py tests/test_cloud.py tests/test_admin_ai_config.py -x -v 2>&1 | tail -15 && test ! -f backend/api/admin.py && test ! -f backend/api/admin_OLD_REMOVE_IN_TASK_3.py && echo "old monolith deleted" - `test ! -f backend/api/admin.py` exits 0 (monolith deleted) - `test ! -f backend/api/admin_OLD_REMOVE_IN_TASK_3.py` exits 0 (renamed monolith also deleted) - `test -d backend/api/admin` exits 0 (package directory exists) - `cd backend && pytest tests/test_admin.py -x` exits 0 - `cd backend && pytest tests/test_cloud.py -x` exits 0 (because plan 08-02 already migrated CloudConnectionOut to api/schemas.py) - `cd backend && pytest -v` exits 0 with no new failures vs. baseline before this plan - `grep -rn "class CloudConnectionOut" backend/` returns exactly one match — `backend/api/schemas.py` - `grep -rn "^class UserCreate" backend/api/admin/` returns exactly one match (in users.py); same single-definition check for `QuotaUpdate`, `SystemAiConfigUpdate`, `TestConnectionRequest` Old admin.py file deleted; admin/cloud/ai-config test suites pass; full backend suite green; each Pydantic model defined exactly once. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Unauthenticated/regular user → admin endpoints | All sub-router handlers must enforce `get_current_admin` to prevent privilege escalation | | Admin endpoint → CloudConnectionOut serialization | SEC-08: `credentials_enc` must remain absent from the schema after the move | | Admin endpoint → response body | T-02-27 / SEC-07: `_user_to_dict` must continue to exclude `password_hash`, `credentials_enc`, `totp_secret`, document content | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-08-04-01 | Spoofing/Elevation of Privilege | Admin sub-router auth gate | mitigate | Every handler in users.py / quotas.py / ai.py declares `_admin: User = Depends(get_current_admin)`. Acceptance criterion counts these injections. | | T-08-04-02 | Tampering | Sub-router prefix doubling | mitigate | Sub-routers declare `router = APIRouter()` with NO prefix per D-04; only `__init__.py` carries `prefix="/api/admin"`. Acceptance criterion greps for this. | | T-08-04-03 | Information Disclosure | `_user_to_dict` field set | mitigate | Helper copied verbatim from admin.py lines 75-91; existing tests covering admin user list endpoint guard against accidental field inclusion. | | T-08-04-04 | Denial of Service | Circular import via `__init__.py` | mitigate | `__init__.py` does ONLY aggregation; `_user_to_dict` lives in `shared.py` not `__init__.py` (Pitfall 2). | | T-08-04-05 | Tampering | Validator duplication | mitigate | `provider_must_be_known` migrated to `services/ai_config.py` as `validate_provider_id`; both Pydantic models call the same service helper (D-11). | | T-08-04-06 | Information Disclosure | Old admin.py left in place | mitigate | Task 3 deletes the file only after URL regression tests pass; renamed file as an intermediate state cannot be imported because no code imports from `api.admin_OLD_REMOVE_IN_TASK_3`. | | T-08-04-SC | Supply Chain | No new packages | accept | This plan installs zero new packages. | - `cd backend && pytest tests/test_admin.py tests/test_admin_ai_config.py tests/test_cloud.py -x -v` — zero failures - `cd backend && pytest -v` — full suite zero failures - `grep -rn "from api.admin import CloudConnectionOut" backend/` returns no matches - `grep -rn "class CloudConnectionOut" backend/` returns exactly one match (api/schemas.py) - `cd backend && python -c "from main import app; admin_paths = sorted({r.path for r in app.routes if r.path.startswith('/api/admin')}); print('\\n'.join(admin_paths))"` lists at minimum: `/api/admin/users`, `/api/admin/users/{user_id}/status`, `/api/admin/users/{user_id}/quota`, `/api/admin/users/{user_id}/ai-config`, `/api/admin/users/{user_id}/password-reset`, `/api/admin/users/{user_id}`, `/api/admin/topics`, `/api/admin/ai-config`, `/api/admin/ai-config/models`, `/api/admin/ai-config/test-connection` - `backend/api/admin/` package with 5 files - `backend/api/admin.py` deleted - All admin URL paths unchanged - CODE-08: CloudConnectionOut + provider_must_be_known single-defined - All tests pass Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-04-SUMMARY.md` when done. Include: (a) full list of admin paths emitted by `app.routes` before vs. after (must be identical), (b) exact admin/cloud/ai-config test counts pre vs. post, (c) confirmation that `_admin: User = Depends(get_current_admin)` count matches the number of handlers per module.