--- 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\\)" --- 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). @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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 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). Task 1: Create Wave 0 test stubs for the overview endpoint backend/tests/test_admin_overview.py - 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) - 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`. Create `backend/tests/test_admin_overview.py` with the 8 tests listed in ``. 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). cd backend && pytest tests/test_admin_overview.py -v - 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: ...")`. Wave 0 test scaffolds exist for ADMIN-11; all 8 tests are xfail and pytest exits 0. Task 2: Implement overview.py endpoint and register on admin router backend/api/admin/overview.py, backend/api/admin/__init__.py - 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) - 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. 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. cd backend && pytest tests/test_admin_overview.py -v -x - 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`. 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. ## 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 | - `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. - 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. 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.