"""HTTP surface for the evidence-query catalogue. Five endpoints, all under the auditor router's ``can_view_security`` / ``can_manage_security`` gates (auditor + global_admin): * ``GET /evidence-queries`` — the catalogue * ``POST /evidence-queries/{key}/run`` — freeze a bundle * ``GET /evidence-bundles`` — bundles, newest first * ``GET /evidence-bundles/{id}`` — header + gaps * ``GET /evidence-bundles/{id}/rows`` — paged evidence rows ``run`` is a POST because it **writes** — a frozen bundle plus an audit row. Modelling it as a GET would make an evidence run look cacheable and repeatable, when the whole design rests on each run being a distinct, dated artefact. Money in a bundle row's payload is redacted through the same ``redact_invoice_basis_amounts`` gate the audit read paths use. The evidence surface is a new read path over the same rows, and a new read path that skips the platform's cost-visibility gate is a leak, not a feature. """ from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.exceptions import NotFoundError from app.core.response import success_response from app.domains.auditor.evidence_query.contracts import persisted_utc from app.domains.auditor.evidence_query.service import ( GENERATED_VIA_MANUAL, list_bundle_rows, list_catalog, run_evidence_query, ) from app.domains.auditor.models import AuditorEvidenceBundle from app.identity.person_models import IdentityPerson router = APIRouter() class EvidenceQueryRunRequest(BaseModel): """Parameters for one run. Untyped on purpose: each query declares its own parameter list in the catalogue, and the service validates against that single declaration. A second, drifting copy of the parameter schema here is how the HTTP layer and the MCP twin start disagreeing about what is valid. """ params: dict[str, Any] = Field(default_factory=dict) def register_evidence_query_routes( parent: APIRouter, *, require_view, require_forensic_read, require_manage, get_current_user, trace_id_of, ) -> None: """Attach the routes to the auditor router. The RBAC helpers are injected rather than imported so this module stays free of a circular import back into ``auditor.router``, and so there is exactly one definition of "who may read security data". """ @parent.get("/evidence-queries") async def list_evidence_queries( request: Request, user: IdentityPerson = Depends(get_current_user), ): require_view(user) return success_response( data={"items": list_catalog()}, request=request, ) @parent.post("/evidence-queries/{query_key}/run") async def run_query( request: Request, query_key: str, body: EvidenceQueryRunRequest, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): # Manage rather than view: this writes a bundle and an audit row. require_manage(user) bundle = await run_evidence_query( db, query_key=query_key, params=body.params, actor_user_id=user.id, trace_id=trace_id_of(request), generated_via=GENERATED_VIA_MANUAL, ) return success_response( data=_bundle_dict(bundle), request=request, ) @parent.get("/evidence-bundles") async def list_evidence_bundles( request: Request, query_key: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): require_forensic_read(user) stmt = select(AuditorEvidenceBundle) count_stmt = select(func.count()).select_from(AuditorEvidenceBundle) if query_key: stmt = stmt.where(AuditorEvidenceBundle.query_key == query_key) count_stmt = count_stmt.where( AuditorEvidenceBundle.query_key == query_key, ) stmt = ( stmt.order_by(AuditorEvidenceBundle.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) rows = list((await db.execute(stmt)).scalars().all()) total = int((await db.execute(count_stmt)).scalar_one()) # Listing is the cheapest way to learn WHO has been investigated # — every row carries the ``person_id`` in its params — and it # was the only one of the three read endpoints leaving no trace. # Enumeration is reconnaissance; log it like the reads it # precedes. await _log_bundle_list( db, rows, user=user, trace_id=trace_id_of(request), ) # ``meta`` is nested INSIDE ``data``, not passed as the envelope # meta: the frontend's ``PaginatedList`` is read off # ``res.data.data`` and every existing auditor list endpoint puts # it there. Returning it at envelope level type-checks fine and # then explodes at runtime on ``meta.total`` — which is how this # was found (the page hit its error boundary in a live run, not # in tsc or the unit tests). return success_response( data={ "items": [_bundle_dict(b) for b in rows], "meta": { "page": page, "page_size": page_size, "total": total, }, }, request=request, ) @parent.get("/evidence-bundles/{bundle_id}") async def get_evidence_bundle( request: Request, bundle_id: uuid.UUID, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): require_forensic_read(user) bundle = await db.get(AuditorEvidenceBundle, bundle_id) if bundle is None: raise NotFoundError(f"Evidence bundle {bundle_id} not found") await _log_bundle_read( db, bundle, user=user, trace_id=trace_id_of(request), rows=False, ) return success_response(data=_bundle_dict(bundle), request=request) @parent.get("/evidence-bundles/{bundle_id}/rows") async def get_evidence_bundle_rows( request: Request, bundle_id: uuid.UUID, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): require_forensic_read(user) bundle = await db.get(AuditorEvidenceBundle, bundle_id) if bundle is None: raise NotFoundError(f"Evidence bundle {bundle_id} not found") await _log_bundle_read( db, bundle, user=user, trace_id=trace_id_of(request), rows=True, ) rows, total = await list_bundle_rows( db, bundle_id, page=page, page_size=page_size, ) return success_response( data={ "items": [_row_dict(r, user) for r in rows], "meta": { "page": page, "page_size": page_size, "total": total, }, }, request=request, ) async def _log_bundle_list( db: AsyncSession, bundles: list[AuditorEvidenceBundle], *, user: IdentityPerson, trace_id: str, ) -> None: """Record an enumeration of frozen footprints.""" from app.audit.service import AuditService # noqa: PLC0415 subjects = sorted({ str(pid) for pid in ( (b.params or {}).get("person_id") for b in bundles ) if pid }) await AuditService(db).log( trace_id=trace_id, actor_type="user", actor_user_id=user.id, action="auditor.evidence_bundle.list", status="success", resource_type="auditor_evidence_bundle", resource_id=None, detail={ "returned": len(bundles), "subject_person_ids": subjects, }, ) async def _log_bundle_read( db: AsyncSession, bundle: AuditorEvidenceBundle, *, user: IdentityPerson, trace_id: str, rows: bool, ) -> None: """Record who opened whose frozen footprint. Running a query was already audited; reading a bundle back was not — so the act an auditor actually performs when looking into a named colleague left no trace, while the act of producing the evidence did. This module's own docstring calls an unaudited forensics tool a governance hole; that claim only covered the write path. The subject is carried explicitly because ``params`` is where the person id lives, and "who looked at whom" is the question a review of this feature will be asked first. Evidence-pack downloads are already logged the same way. """ from app.audit.service import AuditService # noqa: PLC0415 await AuditService(db).log( trace_id=trace_id, actor_type="user", actor_user_id=user.id, action="auditor.evidence_bundle.read", status="success", resource_type="auditor_evidence_bundle", resource_id=str(bundle.id), detail={ "query_key": bundle.query_key, "query_version": bundle.query_version, "content_hash": bundle.content_hash, "subject_person_id": (bundle.params or {}).get("person_id"), "included_rows": rows, }, ) def _bundle_dict(bundle: AuditorEvidenceBundle) -> dict[str, Any]: return { "id": str(bundle.id), "query_key": bundle.query_key, "query_version": bundle.query_version, "params": bundle.params, "period_start": ( bundle.period_start.isoformat() if bundle.period_start else None ), "period_end": ( bundle.period_end.isoformat() if bundle.period_end else None ), "row_count": bundle.row_count, "content_hash": bundle.content_hash, "data_gaps": bundle.data_gaps or [], "generated_by_person_id": ( str(bundle.generated_by_person_id) if bundle.generated_by_person_id else None ), "generated_via": bundle.generated_via, "duration_ms": bundle.duration_ms, "created_at": bundle.created_at.isoformat(), } def _row_dict(row: Any, user: IdentityPerson) -> dict[str, Any]: return { "seq": row.seq, "subject_type": row.subject_type, "subject_id": row.subject_id, "subject_label": row.subject_label, "source_table": row.source_table, "source_pk": row.source_pk, "audit_log_id": str(row.audit_log_id) if row.audit_log_id else None, "source_trace_id": ( str(row.source_trace_id) if row.source_trace_id else None ), # ``persisted_utc``: the column drops the offset, so a bare # ``.isoformat()`` here hands the reader a timestamp that looks # local and hashes differently from the frozen bundle. "occurred_at": ( persisted_utc(row.occurred_at).isoformat() if row.occurred_at else None ), "payload": _redacted_payload(row.payload, user), } def _redacted_payload( payload: dict[str, Any] | None, user: IdentityPerson, ) -> dict[str, Any] | None: """Apply the platform's cost-visibility gate to embedded audit detail. Evidence rows sourced from the audit trail (object lifecycle, approval chain, the action half of a person footprint) carry the original ``audit_logs.detail`` verbatim, which can contain cloud money. This is a NEW read path over those same rows, and a new read path that skips the gate is a leak wearing a new endpoint. Redaction is applied to the embedded ``detail`` using the row's own ``action``, not to the whole payload with a synthetic action. The classification table is keyed by audit action; an unregistered key is treated as UNKNOWN and fails closed, which would blank out money-shaped fields on rows that have nothing to do with cloud cost — asset residual values, leave balances, budget figures. Over- redaction here is silent and makes the evidence useless, so the gate is pointed at exactly the sub-object it understands. """ from app.core.masking import redact_invoice_basis_amounts # noqa: PLC0415 from app.core.rbac import can_view_invoice_measure # noqa: PLC0415 if not payload: return payload detail = payload.get("detail") action = payload.get("action") if not isinstance(detail, dict) or not isinstance(action, str): return payload return { **payload, "detail": redact_invoice_basis_amounts( detail, action=action, can_view_invoice=can_view_invoice_measure( list(user.role_names or []), ), ), } __all__ = ["EvidenceQueryRunRequest", "register_evidence_query_routes"]