Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
7 changes: 7 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Empty file.
89 changes: 89 additions & 0 deletions backend/src/control_center/compliance/audit_log.py
Original file line number Diff line number Diff line change
@@ -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)
156 changes: 156 additions & 0 deletions backend/src/control_center/compliance/auth_client.py
Original file line number Diff line number Diff line change
@@ -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
87 changes: 87 additions & 0 deletions backend/src/control_center/compliance/billing_client.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading