""" Audit trail writer — appends governance.AuditEvent rows for create/edit/delete on the registered PHI and decision models, plus submit_to_cms on CMS requests. Design (see docs/Review_Guide.md Appendix A item 1): - Explicit model registry: only listed models are audited. Reference/vocabulary tables (code lists, templates) and high-churn machine logs (raw ingestion, submission-status polling rows, execution/daily logs) are deliberately out. - Signals fire on every save/delete path — admin, management commands, webhook consumers, workers. Human actions carry the acting user + IP + user-agent via a request-scoped ContextVar set by AuditRequestMiddleware; system paths get a null user. - Row hashes are SHA-256 over all concrete field values except auto_now fields (which change on every save). No raw PHI values are stored — only changed field names and row-level hashes, matching the AuditEvent model design. - Immutability is enforced at the Django layer (append-only admin, no update paths in code); direct database access is not granted to any operator by policy, so no DB trigger is used. - Fail-safe: an audit write failure logs and swallows — auditing must never break the primary operation it observes. """ import hashlib import json import logging import threading import time from contextvars import ContextVar from django.db.models.signals import post_save, pre_delete, pre_save logger = logging.getLogger(__name__) # Request-scoped actor context: {"user": auth.User|None, "ip_address", "user_agent"} _request_context: ContextVar[dict | None] = ContextVar("audit_request_context") class AuditRequestMiddleware: """Populates the audit request context for every HTTP request.""" def __init__(self, get_response): self.get_response = get_response def __call__(self, request): user = getattr(request, "user", None) if not (user is not None and user.is_authenticated): user = None context = { "user": user, "ip_address": _client_ip(request), "user_agent": (request.META.get("HTTP_USER_AGENT") or "")[:512] or None, } token = _request_context.set(context) try: return self.get_response(request) finally: _request_context.reset(token) def _client_ip(request): """Best-effort client IP for audit rows. X-Forwarded-For is client-controlled and only honored when the direct peer (REMOTE_ADDR) is in settings.AUDIT_TRUSTED_PROXY_ADDRESSES; otherwise the spoofable header is ignored entirely. When trusted, AUDIT_PROXY_HOPS tells us how many proxy layers appended to the chain (rightmost entries), so we step back that many and take the next value as the client IP. Example (CloudFront + ALB in front of Django): REMOTE_ADDR=ALB, XFF is ", , " → hops=2 → real-client. """ remote = (request.META.get("REMOTE_ADDR") or "")[:64] forwarded = request.META.get("HTTP_X_FORWARDED_FOR") if not forwarded: return remote from django.conf import settings trusted_proxies = set(getattr(settings, "AUDIT_TRUSTED_PROXY_ADDRESSES", []) or []) if (request.META.get("REMOTE_ADDR") or "") not in trusted_proxies: return remote # untrusted peer — XFF is attacker-controlled hops = int(getattr(settings, "AUDIT_PROXY_HOPS", 1)) parts = [p.strip() for p in forwarded.split(",") if p.strip()] # Each trusted proxy appends the peer it saw; with `hops` proxies the real # client IP is the entry at index len - hops (client-sent garbage sits left). idx = len(parts) - hops if 0 <= idx < len(parts): return parts[idx][:64] return remote # ------------------------------------------------------------------ # Registry — populated by configure_audit_trail() from apps.ready() # ------------------------------------------------------------------ _AUDITED_MODELS: list = [] _CREATE_ACTION_OVERRIDES: dict = {} _configured = False def _build_registry(): # Imported here (not at module top) to keep app-load order safe. from access_core.clinical import models as clinical_models from access_core.cms_integration import models as cms_models from access_core.curation import models as curation_models from access_core.governance import models as governance_models from access_core.identity import models as identity_models from access_core.intake import models as intake_models from access_core.monitoring import models as monitoring_models registry = [ # intake — full PHI; RawIngestionRecord + FormSubmissionEvent excluded (machine logs) intake_models.IntakeRecord, intake_models.IntakeIdentifier, intake_models.IntakeAssessment, intake_models.IntakeLifestyle, intake_models.IntakeFemaleHealth, intake_models.IntakeMaleHealth, intake_models.IntakeMSK, intake_models.SafetyScreening, intake_models.ParticipationReadiness, intake_models.IntakeReviewStatus, # identity — all models (users, roles/grants, beneficiaries, identifiers) identity_models.User, identity_models.Role, identity_models.UserRole, identity_models.RolePermission, identity_models.AccessGrant, identity_models.ExternalSystemIdentifier, identity_models.Beneficiary, identity_models.BeneficiaryIdentifier, identity_models.PatientConsent, # clinical — observations/results; code tables + instrument templates excluded clinical_models.ClinicalObservation, clinical_models.ClinicalObservationSource, clinical_models.LabResult, clinical_models.LabResultItem, clinical_models.PromResponse, clinical_models.PromScore, clinical_models.ImagingStudy, clinical_models.CarotidUltrasoundFinding, clinical_models.LiverUltrasoundFinding, clinical_models.ImagingStudyComparison, clinical_models.StructuralEvidence, clinical_models.StructuralEvidenceSource, clinical_models.OAPMeasureObservation, clinical_models.OAPReportingWindow, # cms_integration — CMSApiSubmissionStatus excluded (polling churn); # CMSApiRequest creation is reported as submit_to_cms cms_models.CMSApiRequest, cms_models.CMSApiResponse, cms_models.CMSClaimsFile, cms_models.CMSPaymentFile, cms_models.RecoupmentEvent, # governance — decisions/signals; AuditEvent itself excluded (recursion) governance_models.PhysicianReview, governance_models.ClinicalReviewTask, governance_models.SafetySignalEvent, governance_models.ClinicalEventLog, governance_models.CareTeamReviewTask, governance_models.PseudonymousCaseRecord, governance_models.PseudonymousClinicalPacket, # monitoring — patient-level state; templates/reference + raw/daily machine # logs excluded (Icd10Code also has a non-UUID pk) monitoring_models.EligibilityRequest, monitoring_models.EligibilityRequestCondition, monitoring_models.EligibilityCheck, monitoring_models.Alignment, monitoring_models.Unalignment, monitoring_models.EnrollmentDecision, monitoring_models.CarePeriod, monitoring_models.ProtocolAssignmentVersion, monitoring_models.MonitoringPlan, monitoring_models.MonitoringPlanItem, monitoring_models.MonitoringPlanResult, monitoring_models.DailyCheckinSubmission, monitoring_models.DailyCheckinResponse, monitoring_models.MissedCheckinEvent, monitoring_models.PatientAdherenceSummary, monitoring_models.MonthlyPatientSummary, # curation — documents and reporting artifacts (all) curation_models.SourceDocumentRecord, curation_models.ClinicalDocument, curation_models.DocumentMetadata, curation_models.DocumentVersion, curation_models.DocumentAccessEvent, curation_models.DataCurationTask, curation_models.CuratedObservationDraft, curation_models.CurationReviewEvent, curation_models.ReportingView, curation_models.AggregateOutcome, curation_models.SuppressionRule, curation_models.PublicReportExport, ] overrides = {cms_models.CMSApiRequest: "submit_to_cms"} return registry, overrides def configure_audit_trail(): """Connect audit signals for every registered model. Called from apps.ready().""" global _configured, _AUDITED_MODELS, _CREATE_ACTION_OVERRIDES if _configured: return _AUDITED_MODELS, _CREATE_ACTION_OVERRIDES = _build_registry() for model in _AUDITED_MODELS: pre_save.connect(_on_pre_save, sender=model) post_save.connect(_on_post_save, sender=model) pre_delete.connect(_on_pre_delete, sender=model) _configured = True # ------------------------------------------------------------------ # Snapshot + hash helpers # ------------------------------------------------------------------ # key -> (snapshot, monotonic timestamp). Entries pair pre_save with the # matching post_save; if a save raises in between they would leak forever, so # the map is bounded by age and size. _old_states: dict = {} _lock = threading.Lock() _OLD_STATE_MAX_AGE_SECONDS = 300.0 _OLD_STATE_MAX_ENTRIES = 4096 def _prune_old_states(now): stale_cutoff = now - _OLD_STATE_MAX_AGE_SECONDS for key in [k for k, (_, ts) in _old_states.items() if ts < stale_cutoff]: del _old_states[key] def _audited_fields(model): """Concrete fields eligible for change detection (auto_now excluded).""" return [f for f in model._meta.concrete_fields if not getattr(f, "auto_now", False)] def _snapshot(instance): return { f.name: getattr(instance, f.name) for f in _audited_fields(instance.__class__) } def _row_hash(snapshot): payload = json.dumps(snapshot, sort_keys=True, default=str) return hashlib.sha256(payload.encode()).hexdigest() # ------------------------------------------------------------------ # Signal handlers # ------------------------------------------------------------------ def _on_pre_save(sender, instance, raw=False, **kwargs): if raw or not instance.pk: return try: old = sender.objects.get(pk=instance.pk) except sender.DoesNotExist: return with _lock: now = time.monotonic() if len(_old_states) >= _OLD_STATE_MAX_ENTRIES: # Age-based prune first; if still over the cap (all entries fresh), # drop oldest until under it — a hard bound no matter what. _prune_old_states(now) while len(_old_states) >= _OLD_STATE_MAX_ENTRIES: oldest = min(_old_states, key=lambda k: _old_states[k][1]) del _old_states[oldest] _old_states[(sender._meta.label, str(instance.pk))] = ( _snapshot(old), now, ) def _on_post_save(sender, instance, created, raw=False, **kwargs): if raw: return new_hash = _row_hash(_snapshot(instance)) if created: action = _CREATE_ACTION_OVERRIDES.get(sender, "create") record_audit_event(action, instance, new_value_hash=new_hash) return with _lock: popped = _old_states.pop((sender._meta.label, str(instance.pk)), None) old_snapshot = popped[0] if isinstance(popped, tuple) else None if old_snapshot is None: # First save in this process (e.g. created then updated in one request # path that bypassed pre_save capture) — nothing to diff against. return new_snapshot = _snapshot(instance) changed = [ name for name, value in old_snapshot.items() if value != new_snapshot.get(name) ] if not changed: return # no-op save — do not clutter the trail record_audit_event( "edit", instance, changed_fields=changed, old_value_hash=_row_hash(old_snapshot), new_value_hash=new_hash, ) def _on_pre_delete(sender, instance, **kwargs): record_audit_event( "delete", instance, old_value_hash=_row_hash(_snapshot(instance)) ) # ------------------------------------------------------------------ # Event writer # ------------------------------------------------------------------ def record_audit_event( action: str, instance, changed_fields=None, old_value_hash: str | None = None, new_value_hash: str | None = None, ): """Append an AuditEvent for `instance`. Never raises.""" from access_core.governance.models import AuditEvent try: raw_context = _request_context.get(None) context = raw_context if isinstance(raw_context, dict) else {} user = context.get("user") ip_address = context.get("ip_address") or None user_agent = context.get("user_agent") or None AuditEvent.objects.create( user=user, action=action, target_table=f"{instance._meta.app_label}.{type(instance).__name__}", target_record_id=instance.pk, changed_fields=changed_fields, old_value_hash=old_value_hash, new_value_hash=new_value_hash, ip_address=ip_address or None, user_agent=user_agent or None, ) except Exception: logger.exception( "Audit trail write failed: %s on %s", action, type(instance).__name__ ) def audited_model_labels() -> list[str]: """Labels of all registered models (used by tests and tooling).""" return [m._meta.label for m in _AUDITED_MODELS]