Moves phases 08–11 execution artifacts from .planning/phases/ to
.planning/milestones/v0.2-phases/ to keep .planning/phases/ clean
for the next milestone.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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).
@.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.
<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>
- `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.
<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).
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.