---
phase: 09-admin-panel-rearchitecture
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - backend/api/admin/overview.py
  - backend/api/admin/__init__.py
  - backend/tests/test_admin_overview.py
autonomous: true
requirements:
  - ADMIN-11
user_setup: []

must_haves:
  truths:
    - "GET /api/admin/overview returns 200 for admin caller with keys user_count, total_storage_bytes, doc_status, recent_audit"
    - "GET /api/admin/overview returns 401 or 403 for non-admin / unauthenticated callers"
    - "Overview response body contains no occurrence of password_hash, credentials_enc, or extracted_text"
    - "recent_audit list has at most 10 entries"
  artifacts:
    - path: "backend/api/admin/overview.py"
      provides: "GET /api/admin/overview aggregate endpoint"
      contains: "router = APIRouter()"
    - path: "backend/api/admin/__init__.py"
      provides: "overview_router registration on admin parent router"
      contains: "from api.admin.overview import router as overview_router"
    - path: "backend/tests/test_admin_overview.py"
      provides: "ADMIN-11 endpoint + security invariant tests"
      contains: "def test_overview_no_sensitive_fields"
  key_links:
    - from: "backend/api/admin/overview.py"
      to: "backend/api/audit.py"
      via: "import _build_filtered_query_with_handles, _audit_to_dict_with_handles"
      pattern: "from api.audit import"
    - from: "backend/api/admin/__init__.py"
      to: "backend/api/admin/overview.py"
      via: "include_router(overview_router)"
      pattern: "include_router\\(overview_router\\)"
---

<objective>
Add a new admin overview backend endpoint at `GET /api/admin/overview` that returns aggregated platform stats (user count, total storage, document status breakdown) plus the 10 most recent audit-log entries in a single response payload, and register it on the admin parent router. Backed by `backend/tests/test_admin_overview.py` covering ADMIN-11 + security invariants.

Purpose: ADMIN-11 requires an admin overview that combines stats + recent audit entries; D-02 mandates a new `overview.py` sub-module inside `backend/api/admin/`; D-03 requires the audit rows inline (single HTTP call, no `/api/audit` round-trip from the overview view).

Output: `backend/api/admin/overview.py` (new), `backend/api/admin/__init__.py` (modified — one import + one include_router call), `backend/tests/test_admin_overview.py` (new — 3 promoted tests).
</objective>

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

<context>
@.planning/phases/09-admin-panel-rearchitecture/09-CONTEXT.md
@.planning/phases/09-admin-panel-rearchitecture/09-RESEARCH.md
@.planning/phases/09-admin-panel-rearchitecture/09-PATTERNS.md
@CLAUDE.md
@backend/api/admin/__init__.py
@backend/api/admin/users.py
@backend/api/audit.py
@backend/tests/test_admin_api.py

<interfaces>
From backend/api/audit.py:
  - `_build_filtered_query_with_handles(start, end, user_uuid, event_type)` — returns a SQLAlchemy `Select` statement joining `AuditLog` with `users` for owner_handle + target_user_handle. Pass `None` for all four args to get the unfiltered base query; chain `.limit(10)` for the most recent 10.
  - `_audit_to_dict_with_handles(audit_log_row, owner_handle, target_user_handle, ip)` — returns a safe dict for one audit entry; the security-audited whitelist serializer reused for ADMIN-06.

From backend/db/models.py:
  - `User` (fields used: `id`, `role` literal `'user'` vs `'admin'`)
  - `Quota` (fields used: `used_bytes`)
  - `Document` (fields used: `status`)
  - `AuditLog`

From backend/deps/auth.py:
  - `get_current_admin` — FastAPI dependency that raises 401 (no token) or 403 (non-admin token).

From backend/api/admin/__init__.py (current state):
  - `router = APIRouter(prefix="/api/admin", tags=["admin"])` — parent aggregator; sub-routers MUST carry NO prefix per the "NO prefix" WHY comment at the top of the file (do not remove that comment in this plan).
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create Wave 0 test stubs for the overview endpoint</name>
  <files>backend/tests/test_admin_overview.py</files>
  <read_first>
    - backend/tests/test_admin_api.py (copy `make_admin_user` fixture verbatim + `admin_client` fixture pattern; reuse `FakeRedis` import from `tests.test_auth_api`)
    - backend/tests/test_audit.py (audit fixture seeding pattern for `recent_audit` assertion)
    - backend/tests/conftest.py (existing `async_client` and `db_session` fixtures)
    - backend/api/audit.py (signature of `_build_filtered_query_with_handles` so test data covers a query that returns at least one row)
  </read_first>
  <behavior>
    - Test 1 `test_overview_requires_admin`: unauthenticated GET /api/admin/overview returns 401 or 403.
    - Test 2 `test_overview_non_admin_forbidden`: regular-user JWT returns 403.
    - Test 3 `test_overview_returns_expected_keys`: admin GET returns 200 with all four top-level keys present (`user_count`, `total_storage_bytes`, `doc_status`, `recent_audit`).
    - Test 4 `test_overview_user_count_excludes_admins`: seed one admin + two `role='user'` users; `user_count == 2`.
    - Test 5 `test_overview_total_storage_sums_quotas`: seed quotas (1024 + 2048); `total_storage_bytes == 3072`.
    - Test 6 `test_overview_doc_status_groups_by_status`: seed documents with statuses `ready`, `ready`, `processing`, `failed`; `doc_status == {"ready": 2, "processing": 1, "failed": 1}`.
    - Test 7 `test_overview_recent_audit_limit_10`: seed 15 audit entries; `len(recent_audit) == 10`; entries ordered newest first.
    - Test 8 `test_overview_no_sensitive_fields`: response body string contains none of `password_hash`, `credentials_enc`, `extracted_text`, `totp_secret`, `api_key_enc`.
  </behavior>
  <action>Create `backend/tests/test_admin_overview.py` with the 8 tests listed in `<behavior>`. Reuse `make_admin_user` and `admin_client` patterns from `test_admin_api.py` verbatim (copy fixtures into this file or import from `tests.test_admin_api` if existing test files do that — match the prevailing pattern in `test_admin_ai_config.py`). All 8 tests SHOULD be marked `@pytest.mark.xfail(strict=True, reason="ADMIN-11: overview endpoint not yet implemented")` initially so Task 2 can flip them to passing. Each test uses `pytest.mark.asyncio`. Use the `FakeRedis` import from `tests.test_auth_api` (matches existing admin tests). The total_storage test seeds Quota rows directly; the doc_status test seeds Document rows directly; the recent_audit test seeds AuditLog rows directly via the existing `AuditLog` ORM model — do not call audit-write helpers (that introduces coupling). Body-string scan in `test_overview_no_sensitive_fields` uses `resp.text` (full raw JSON string).</action>
  <verify>
    <automated>cd backend && pytest tests/test_admin_overview.py -v</automated>
  </verify>
  <acceptance_criteria>
    - File `backend/tests/test_admin_overview.py` exists.
    - `grep -c "^async def test_" backend/tests/test_admin_overview.py` returns 8.
    - All 8 tests show XFAIL (expected fail) in pytest output since the endpoint does not exist yet — `pytest tests/test_admin_overview.py -v` exits 0.
    - File imports `pytest`, `pytest_asyncio`, `uuid`, `httpx.AsyncClient`, and `from db.models import User, Quota, Document, AuditLog`.
    - Every test function carries both `@pytest.mark.asyncio` and `@pytest.mark.xfail(strict=True, reason="ADMIN-11: ...")`.
  </acceptance_criteria>
  <done>Wave 0 test scaffolds exist for ADMIN-11; all 8 tests are xfail and pytest exits 0.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Implement overview.py endpoint and register on admin router</name>
  <files>backend/api/admin/overview.py, backend/api/admin/__init__.py</files>
  <read_first>
    - backend/api/admin/users.py (full file — exact `router = APIRouter()` pattern, dependency wiring `_admin: User = Depends(get_current_admin)`, scalar/aggregate query idioms with `func.count`)
    - backend/api/admin/__init__.py (current 24 lines — preserve the WHY comment about prefix per D-16; pattern for `include_router` ordering)
    - backend/api/audit.py (signature and body of `_build_filtered_query_with_handles` and `_audit_to_dict_with_handles` — see lines around the audit-log listing endpoint; confirm the helper returns aliased handle columns the dict serializer expects)
    - backend/db/models.py (`User.role`, `Quota.used_bytes`, `Document.status`)
    - backend/tests/test_admin_overview.py (the 8 xfail tests this implementation must satisfy)
  </read_first>
  <behavior>
    - Endpoint path `/overview` declared on a sub-router with NO prefix; aggregator carries `/api/admin` so full URL is `/api/admin/overview`.
    - Handler is `async def get_overview` returning `dict` with exact keys `user_count`, `total_storage_bytes`, `doc_status`, `recent_audit`.
    - `user_count` is `SELECT count(id) FROM users WHERE role = 'user'`.
    - `total_storage_bytes` is `SELECT sum(used_bytes) FROM quotas` coerced to `0` when NULL.
    - `doc_status` is `{status: count}` from `SELECT status, count(id) FROM documents GROUP BY status`.
    - `recent_audit` is the result of `_build_filtered_query_with_handles(None, None, None, None).order_by(AuditLog.created_at.desc()).limit(10)` mapped via `_audit_to_dict_with_handles` — the `.order_by(AuditLog.created_at.desc())` must be chained before `.limit(10)` to guarantee newest-first ordering that the test asserts.
    - Response never includes raw user records, raw audit rows, password_hash, credentials_enc, totp_secret, api_key_enc, or extracted_text.
  </behavior>
  <action>Create `backend/api/admin/overview.py`. Imports: `from __future__ import annotations`; `from fastapi import APIRouter, Depends`; `from sqlalchemy import func, select`; `from sqlalchemy.ext.asyncio import AsyncSession`; `from db.models import Document, Quota, User`; `from deps.auth import get_current_admin`; `from deps.db import get_db`; `from api.audit import _build_filtered_query_with_handles, _audit_to_dict_with_handles`. Declare `router = APIRouter()` with NO prefix (single WHY comment: "NO prefix — parent `__init__.py` carries `/api/admin` (D-02)"). Declare `@router.get("/overview")` async function `get_overview(session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin)) -> dict`. Compute user_count via `await session.scalar(select(func.count(User.id)).where(User.role == "user"))` (default to 0 if None). Compute total_storage_bytes via `await session.scalar(select(func.sum(Quota.used_bytes)))` (default 0). Compute doc_status via `(await session.execute(select(Document.status, func.count(Document.id)).group_by(Document.status))).all()` and convert to dict. Compute recent_audit by calling `_build_filtered_query_with_handles(None, None, None, None).order_by(AuditLog.created_at.desc()).limit(10)`, executing, then mapping each row tuple via `_audit_to_dict_with_handles(*row)` — confirm the row unpacking shape against the audit.py source (the existing list endpoint already does this — copy that exact iteration). Return the dict with the four keys. Modify `backend/api/admin/__init__.py`: add `from api.admin.overview import router as overview_router` alongside existing imports (preserve the docstring WHY comment); append `router.include_router(overview_router)` after the existing three `include_router` calls. Then remove the `@pytest.mark.xfail` decorators from all 8 tests in `backend/tests/test_admin_overview.py` so they execute as real tests.</action>
  <verify>
    <automated>cd backend && pytest tests/test_admin_overview.py -v -x</automated>
  </verify>
  <acceptance_criteria>
    - File `backend/api/admin/overview.py` exists.
    - `grep -c "router = APIRouter()" backend/api/admin/overview.py` returns 1.
    - `grep -E "APIRouter\\(prefix=" backend/api/admin/overview.py` returns no match (NO prefix on sub-router).
    - `grep -c "from api.audit import _build_filtered_query_with_handles" backend/api/admin/overview.py` returns 1.
    - `grep -c "_admin: User = Depends(get_current_admin)" backend/api/admin/overview.py` returns 1.
    - `grep -c "overview_router" backend/api/admin/__init__.py` returns 2 (one import, one include_router).
    - `grep -c "xfail" backend/tests/test_admin_overview.py` returns 0 (all xfails removed).
    - `pytest backend/tests/test_admin_overview.py -v` reports 8 passed, 0 failed, 0 xfailed.
    - `pytest backend/tests/test_admin_api.py backend/tests/test_audit.py -v` continues to pass (no regression in existing admin/audit tests).
    - Endpoint module is discoverable: `python -c "from api.admin.overview import router; print(router.routes[0].path)"` prints `/overview`.
  </acceptance_criteria>
  <done>GET /api/admin/overview returns 200 for admins with the four-key payload; all 8 test_admin_overview tests pass; existing admin and audit test suites stay green.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser → /api/admin/overview | admin JWT crosses; aggregate stats + audit rows returned |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-09-01-01 | Elevation of Privilege | GET /api/admin/overview | mitigate | `_admin: User = Depends(get_current_admin)` raises 401/403 for non-admin tokens; covered by `test_overview_requires_admin` + `test_overview_non_admin_forbidden` |
| T-09-01-02 | Information Disclosure | GET /api/admin/overview response | mitigate | Response is hand-rolled dict with only aggregate counts + whitelisted `_audit_to_dict_with_handles` rows; no `password_hash`/`credentials_enc`/`extracted_text`/`totp_secret`/`api_key_enc` ever serialized; covered by `test_overview_no_sensitive_fields` (raw body grep) |
| T-09-01-03 | Tampering (Reusable code) | `_build_filtered_query_with_handles` import | accept | Cross-module import from sibling `api/audit.py` is a stable existing helper (already security-audited and tested); risk: future audit.py refactor breaks the import — accepted because the import path is documented in the WHY comment and an ImportError fails loud on startup |
</threat_model>

<verification>
- `cd backend && pytest tests/test_admin_overview.py tests/test_admin_api.py tests/test_audit.py -v` → all pass, zero failures, zero xfails.
- `cd backend && pytest -v` → no regressions across the full suite.
- `cd backend && python -c "from api.admin import router; paths=[r.path for r in router.routes]; assert '/api/admin/overview' in paths or any('/overview' in p for p in paths), paths"` → endpoint registered under the `/api/admin` prefix.
- `cd backend && bandit -r api/admin/overview.py` → zero HIGH findings.
</verification>

<success_criteria>
- ADMIN-11 backend slice deliverable: `GET /api/admin/overview` returns the four-key aggregate payload to admin callers; non-admin/unauthenticated callers get 401/403; response body never contains sensitive fields.
- Sub-router carries NO prefix (constraint preserved per existing Phase 8 invariant).
- All 8 dedicated tests pass; broader admin + audit suites stay green.
</success_criteria>

<output>
Create `.planning/phases/09-admin-panel-rearchitecture/09-01-SUMMARY.md` when done. Include: files created, files modified, tests passing count, and confirmation that no sensitive fields are present in the overview response.
</output>
