---
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"
---

<objective>
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.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<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/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

<interfaces>
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
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add validate_provider_id helper to services/ai_config.py (D-11 migration)</name>
  <files>backend/services/ai_config.py</files>
  <read_first>
    - 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)
  </read_first>
  <action>
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.
  </action>
  <verify>
    <automated>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')"</automated>
  </verify>
  <acceptance_criteria>
    - `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)
  </acceptance_criteria>
  <done>validate_provider_id function exists, raises ValueError per CLAUDE.md service-layer rule, importable.</done>
</task>

<task type="auto">
  <name>Task 2: Create backend/api/admin/ package — shared.py, users.py, quotas.py, ai.py, __init__.py</name>
  <files>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</files>
  <read_first>
    - 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)
  </read_first>
  <action>
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.
  </action>
  <verify>
    <automated>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))"</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>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.</done>
</task>

<task type="auto">
  <name>Task 3: Run URL regression suite, then delete the old monolith</name>
  <files>backend/api/admin.py</files>
  <read_first>
    - 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)
  </read_first>
  <action>
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.
  </action>
  <verify>
    <automated>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"</automated>
  </verify>
  <acceptance_criteria>
    - `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`
  </acceptance_criteria>
  <done>Old admin.py file deleted; admin/cloud/ai-config test suites pass; full backend suite green; each Pydantic model defined exactly once.</done>
</task>

</tasks>

<threat_model>
## 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. |
</threat_model>

<verification>
- `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`
</verification>

<success_criteria>
- `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
</success_criteria>

<output>
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.
</output>
