HIPAA audit logging v0.1: publish FHIR AuditEvent records for ACL decisions and identity events to Kafka

Implement opt-in HIPAA audit logging in the Sonador web application: every access-control decision and identity event is serialized as a FHIR AuditEvent and published to a Kafka topic.

Kafka is the delivery boundary. Sonador's responsibility ends when the AuditEvent is accepted by the broker. Downstream aggregation, retention, and SIEM integration (Aranei, Sumologic, CloudWatch, object storage) are integration decisions made by the entity adopting Sonador, and are out of scope here (§8).

Provenance. This issue was re-scoped on 2026-08-11 from the draft notes on oak-tree/medical-imaging/imaging-development-env#78, which consolidate a capability previously scattered across that tracking issue, milestone %HIPAA Audit Logging (SN MS3), OpenProject WP#43, two unmerged 2024 merge requests, and the "MDDS / Sonador Security Events" working document. The original text of this issue (the December 2023 requirement re-scoped out of oak-tree/medical-imaging/imaging-development-env#58 on 2026-07-10) described the same capability from the resource-access angle and is preserved by this specification.


1. Overview

Sonador has no audit logging of any kind today. Access-control decisions are made, PHI is served, ACL policies are granted and revoked, and destructive DICOMweb endpoints are exercised, with no durable record of who did what and whether it was permitted. This issue delivers the first working audit trail.

User-visible behavior. An operator sets a single master switch in the site config. When it is off (the default), Sonador behaves exactly as it does today and takes no Kafka dependency at runtime. When it is on, every event in the catalogue at §5.4 produces one JSON AuditEvent document on the configured Kafka topic, carrying the actor, the resource, the action, and the outcome.

Scoping headline — v0.1 is the Sonador web application only. All code lands in oak-tree/medical-imaging/sonador. The Orthanc plugin already has its own Kafka producer for a different purpose (DICOM index events); upgrading it to emit audit events and to speak TLS/SASL is deliberately deferred (§8). This keeps v0.1 to one repository, one producer, and one topic.

Three properties are non-negotiable and drive most of the design:

  1. Audit logging must never break a request. The primary hook sits on the authorization endpoint, which is on the hot path of every DICOM and DICOMweb request. A slow or unreachable broker must degrade to a log line, never to a failed authorization.
  2. The audit trail must not be silently incomplete. The one design flaw that would make this feature worse than useless is a hook that misses a class of events. §2.4 documents exactly such a trap in the current code.
  3. The audit record must not itself leak PHI. Records carry identifiers, never patient demographics, DICOM tags, or credential values.

2. Background and current state

2.1 There is no audit logging, and no Kafka, in the web application

Verified by reading master (a61ebc7e) across apps/core, apps/gateway, apps/visionaire, lib/orthancapi, and sonador/:

  • No AuditEvent, no audit model, no audit module, no audit endpoint.
  • No confluent-kafka, no kafka-python, no fhir.resources in requirements.txt.
  • No [Kafka] section reader in sonador/settings/base.py.

The only audit-adjacent behavior that exists is an unstructured logger.error(...) of rejected tokens in apps/visionaire/auth/views/service/orthanc_auth.py.

The working Kafka producer referred to as "SonadorManager" in oak-tree/medical-imaging/imaging-development-env#78 lives in a different repositoryoak-tree/medical-imaging/orthanc-sonador, at sonador_orthanc/kafka/. It publishes DICOM index events (stored instances and STABLE_* resource callbacks) to the orthanc-index topic. It is not an audit facility and is not modified by this issue.

2.2 Prior art: two merge requests, neither merged

oak-tree/medical-imaging/sonador!70 — "created kafka manager in sonador, created hel7 audit event message to audit...". State opened, detailed_merge_status: conflict, 78 commits diverged, last code push 2024-06-06. Its four audit-specific files (apps/visionaire/kafka.py, auth/signals/signals.py, auth/signals/events.py, and the emit sites) are absent from master — verified file by file. The emit site it patched, auth/views/service/__init__.py, has since been refactored into orthanc_auth.py, so the diff no longer applies.

It is nonetheless the most concrete existing statement of the FHIR field mapping and is treated here as a design reference, not a rebasable branch. Defects in it that this specification deliberately does not reproduce:

Defect in !70 Consequence Addressed by
producer.flush() on every message Blocks the authorization hot path on a broker round trip AR-4
json.dumps() applied to an already-serialized JSON string Double-encoded payloads AR-6
datetime.now() emitted with a Z suffix Local time mislabelled as UTC FR-9
user=_user sourced from get_auth_request_params Every event carried a null user (§2.5) FR-3
Topic name 'audit-event-log' hardcoded in three places Not configurable FR-2
Signal(['orthanc_id', ...]) providing_args was removed in Django 4.0; the list is bound to use_caching AR-3
R4-shaped field names against a fhir.resources==7.1.0 pin 7.x defaults to R5, where type/subtype/outcomeDesc do not exist (§2.6) AR-5
[Kafka] section added to the INI with no reader in settings/base.py gsetting('KAFKA_BOOTSTRAP_SERVER') resolves to None FR-1

oak-tree/medical-imaging/imaging-development-env!27 — "Kafka Connect Export To MinIO S3". State opened, targets the feature branch roakes/nsync-hpop rather than master, last activity 2024-06-06. Adds Kafka Connect, Schema Registry, and ksqlDB to compose plus an S3 sink config at config/kafka/s3-sink-connect-cmd.sh. Neither file exists on master, and master has since moved Kafka to KRaft, so the branch would conflict wholesale.

Two things are worth carrying forward from it: the sink's "topics" value is audit-event-log, the first written statement of an intended audit topic name; and the entire MR is a downstream integration, which under the boundary set in §1 is now explicitly out of scope.

2.3 The decision point

apps/visionaire/auth/views/service/orthanc_auth.py, OrthancServiceAuthorizationView.get_authorization_response(self, adata, *args, **kwargs) is where every Orthanc access-control decision is finalized. It has four outcomes — static-asset allow, /system allow, per-resource evaluation via PacsImagingServer.user_has_perm(...), and the terminal deny — and returns adata carrying granted (bool) and validity (int seconds).

Available on self.form (an OrthancServiceAuthorizationForm) at that moment:

Field Meaning
self.form.user Django User, or the literal string 'sonador' (the internal superuser; SONADOR_USERNAME = 'sonador', SONADOR_USER_PK = -42, SONADOR_USER_LABEL = 'Sonador Web Application' in apps/visionaire/apisettings.py)
self.form.server PacsImagingServer resolved from the serverid URL kwarg
cleaned_data['orthanc_id'] Orthanc resource UID
cleaned_data['dicom_uid'] DICOM UID
cleaned_data['level'] patient / study / series / instance / system / group
cleaned_data['method'] get / post / put / delete
cleaned_data['uri'] Raw request URI
cleaned_data['action'] Closed-enum sub-operation (comment, worklist, acl, …)
cleaned_data['token_key'] / ['token_value'] Credential carrier — value must be masked, never logged
self.form.expires_in, self.form.session, self.form.token_payload Session context

The request shape arriving here is pinned by the pydantic models in lib/orthancapi/auth/validation.py (TokenValidationRequest, TokenValidationResponse), and is produced by the orthanc-authorization C++ plugin configured in oak-tree/medical-imaging/imaging-development-env at config/orthanc/config.web-auth.json.

2.4 The response cache bypasses the decision path entirely

This is the single most important finding in this section. Same file:

def post(self, request, *args, **kwargs):
    ...
    if gsetting('CACHE_ENABLED'):
        _cache_key, _auth_response = self.cache_get_authorization_response(request, *args, **kwargs)
        if _auth_response:
            setattr(self, '_cache_auth_response', _auth_response)
            return self.render_to_response(_auth_response)
    return super(OrthancServiceAuthorizationView, self).post(request, *args, **kwargs)

On a cache hit the view returns before get_authorization_response runs and before the form is built. Only grants are cached (if authorization_response.get('granted')).

An audit emitter placed only inside get_authorization_response therefore records every denial but only the first of each repeated grant. The resulting audit log would be systematically missing successful PHI accesses while looking complete — a compliance-fatal failure mode. FR-4 and AC-6 exist to close this.

Two further details of the cache matter to the implementation:

  • The cached dict is returned to the client verbatim. post() does setattr(self, '_cache_auth_response', _auth_response) then return self.render_to_response(_auth_response). Any key added to the cached payload is therefore echoed to Orthanc in the HTTP authorization response, not merely held internally. This rules out the obvious approach of stashing the resolved username inside the cached response; see §5.7 item 11.
  • The cache TTL is a pre-existing defect. cache_set_authorization_response passes authorization_response.get('granted') — a True — as the cache.set timeout, with self.form.expires_in only as a falsy fallback. 'validity' appears in the adjacent log line but is not used as the actual TTL. Entries consequently expire far sooner than intended. Do not replicate this, and do not rely on long-lived cache entries when verifying AC-6.

2.5 get_auth_request_params returns a null user

def get_auth_request_params(self, *args, form_data=None, **kwargs):
    _form = getattr(self, 'None', None)          # literal string 'None' -- always returns None
    ...
    _user = getattr(_form, 'user', None)

getattr(self, 'None', None) looks up an attribute literally named "None", so _form is always None and _user is always None. MR !70 fed this value into its audit signal, which is why its events carried no actor. Audit code must read self.form.user directly. Fixing the helper itself is not in scope (it would change the existing rejection log line); FR-3 simply routes around it.

2.6 Upstream API verification

FHIR AuditEvent differs across releases in ways that matter. The two links in circulation point at incompatible structures:

Release Where it is referenced type / subtype outcome outcomeDesc
R4 (4.0.1) hl7.org/fhir/R4/auditevent.html, named in oak-tree/medical-imaging/imaging-development-env#78 type 1..1 Coding, subtype 0..* Coding code 0..1, values 0/4/8/12 present
R4B (4.3.0) identical to R4 identical to R4 present
R5 (5.0.0) what hl7.org/fhir/auditevent.html serves today renamed to category 0..* / code 1..1, CodeableConcept BackboneElement, outcome.code 1..1 Coding removed
R6 ballot4 build.fhir.org/auditevent.html, the link in the draft notes back to type 1..1 / subtype 0..*, CodeableConcept BackboneElement absent

Verified against the R6 StructureDefinition JSON at build.fhir.org/auditevent.profile.json and the R4B model source in fhir.resources.

Library binding. fhir.resources is at 8.3.0. Its default import path targets R5from fhir.resources.auditevent import AuditEvent yields the category/code model. R4 proper was dropped at 7.0.0 and replaced by R4B, reachable as a subpackage. The R4B model, confirmed from fhir/resources/R4B/auditevent.py, declares exactly the R4 shape:

type: fhirtypes.CodingType (required)     subtype: List[CodingType] | None
action: CodeType | None                   recorded: InstantType (required)
outcome: CodeType | None                  outcomeDesc: StringType | None
purposeOfEvent: List[CodeableConceptType] | None
agent: List[AuditEventAgentType] (required)   source: AuditEventSourceType (required)
entity: List[AuditEventEntityType] | None

This is why MR !70 was structurally invalid: R4-shaped field names against a fhir.resources==7.1.0 pin whose default model is R5.

librdkafka configuration properties (verified against librdkafka/CONFIGURATION.md) — exact names, for §5.2:

security.protocol (plaintext | ssl | sasl_plaintext | sasl_ssl, default plaintext) · ssl.ca.location · ssl.certificate.location · ssl.key.location · ssl.key.password · ssl.endpoint.identification.algorithm (default https) · enable.ssl.certificate.verification (default true) · sasl.mechanisms (aliased sasl.mechanism; GSSAPI | PLAIN | SCRAM-SHA-256 | SCRAM-SHA-512 | OAUTHBEARER, default GSSAPI) · sasl.username · sasl.password · client.id · message.timeout.ms (default 300000) · queue.buffering.max.messages (default 100000).

Note the plural sasl.mechanisms. The Confluent Connect configuration page cited in the draft notes documents Kafka Connect worker properties, not client properties; the client reference above is the applicable one.

Django. master pins Django>=5.2,<5.3; the container is Ubuntu 26.04 / Python 3.14 (Dockerfile). django.dispatch.Signal.__init__ takes (use_caching=False) — the providing_args argument was deprecated in Django 3.0 and removed in 4.0. Signal(['a', 'b']) binds the list to use_caching, silently enabling receiver caching. Signals must be declared with no arguments.

2.7 Configuration conventions

sonador/settings/base.py parses the site config with configobj.ConfigObj (not configparser), which is what makes [[nested]] sections available. The path comes from the SONADOR_SITECONFIG environment variable. Established conventions, all observed in that file:

  1. Section to local variable: siteconfig_<lowercase> = siteconfig.get('<Section-Name>', {}).
  2. Option to module-level UPPER_SNAKE Django setting, read with .get(KEY, default); the default is supplied inline at the read site.
  3. Coercion is explicit at the read site — config_str2bool(...) for booleans, int(...) for integers, tuple(...) for lists.
  4. Validation raises at import time (ValueError / TypeError), so a misconfigured deployment fails to boot rather than degrading.
  5. Runtime access via guru.helpers.gsetting(name, default=None).

The closest structural precedent for what this issue needs is [Cache] — an enable flag gating a nested payload:

# Django Response Cache
siteconfig_cache = siteconfig.get('Cache', {})
CACHE_ENABLED = config_str2bool(siteconfig_cache.get('CACHE_ENABLED', False))
if CACHE_ENABLED:
    CACHES = siteconfig_cache.get('CACHES', {})
    if not CACHES:
        raise ValueError('The Sonador cache backend is enabled, but no cache instances are configured.')

[Logging] / [[Handlers]] / [[Loggers]] is the precedent for a multi-option nested section with per-key coercion. The in-repo template is sonador/config/sonador.site.config; deployment copies live in oak-tree/medical-imaging/imaging-development-env at config/sonador/sonador.site.config and k8s/sonador.config-map.yaml.

2.8 Signal conventions

Concern Location
Signal declarations apps/visionaire/auth/signals/signals.py
Receivers apps/visionaire/auth/signals/events.py (decorator style)
Registration package __init__.py imported from VisionaireAppConfig.ready() in apps/visionaire/app.py

apps/visionaire/auth/signals/__init__.py documents the gotcha in its own docstring — importing the package is what connects the receivers, and it was previously empty, leaving revoke_openid_access_token written but never registered. Note that it imports events only, not signals.

The doctrine to imitate is stated in that receiver's docstring: "Revocation is best effort by design. A slow or unreachable provider must never turn a logout into an error page, so every failure is logged and swallowed." That is exactly the stance an audit emitter must take.

The one part of this convention not to copy is the declaration itself. The existing signal is written socialuser_first_login = django.dispatch.Signal(['request', 'social_profile', 'registration']), which is the removed-in-Django-4.0 providing_args form described in §2.6 — under Django 5.2 that list binds to use_caching. The new audit signal must be declared with no arguments (AR-3).

Also note SocialUserAccount.signal_first_login defers its from ..signals.signals import ... inside the method body to avoid a circular import — worth imitating.

2.9 The ACL and credential views define no HTTP methods

This is a trap for anyone planning to hook post() or delete() on those views. apps/visionaire/auth/views/acl.py and apps/visionaire/auth/views/cred.py are almost entirely declarative — they set model, modelform, filterform, and response_objectid_fieldname and inherit every verb from the guru base classes in the lib/guru submodule (django-apps/guru, views/base.py):

class PacsImagingServerGroupAuthorizationManagementView(GuruQueryParamFilterFormMixin, PacsImagingServerChildObjectManagementView):
	model = PacsImagingServerGroupAuthorization
	modelform = PacsImagingServerGroupAuthorizationForm
	filterform = PacsImagingServerGroupAuthorizationFilterForm
	response_objectid_fieldname = 'token'
Verb Management view Rest view
create GuruApiCreateView.post -> validateData -> saveObjectData -> forminstance.save() 405
update 405 GuruApiObjectUpdateMixin.put / .patch -> saveObjectData
delete 405 GuruApiObjectUpdateMixin.delete -> deleteObject(instance, request=..., vargs=..., vkwargs=...)

The correct hook points are therefore overrides of saveObjectData and deleteObject in the Sonador subclasses, not of the HTTP verbs. PacsImagingServerChildObjectMixin.saveObjectData is already overridden in apps/visionaire/views/dicom.py to attach the server, so the override pattern is established.

cred.py follows the same shape, with two local exceptions worth knowing: SonadorUserTokenManagementView.delete is defined locally (it resolves the token from the JSON request body rather than the URL, via getToken), and the same class overrides operationCode to permit POST. Credential deletion has no local handler and goes through the inherited deleteObject.

One incidental inconsistency: orthanc_auth.py imports from guru import apisettings as gapi while cred.py imports the same module as gapicodes. Match whichever alias the file being edited already uses.

2.10 Deployment topology

oak-tree/medical-imaging/imaging-development-env, compose/core.yaml: a single-node Kafka broker, image oaktreetech/kafka-sonador:0.4, KRaft mode, PLAINTEXT only — no TLS listener, no SASL, no ACL authorizer, no certificate volume. compose/sonador.yaml (service imaging) has no Kafka environment at all. There is no Kafka Connect, Schema Registry, or config/kafka/ directory on master.

v0.1 therefore targets the PLAINTEXT broker as deployed, while exposing the full TLS/SASL property surface in configuration (§5.2) so that a secured broker requires no code change. Adding a secure listener to the broker is out of scope (§8).


3. Functional requirements

FR-1 — Master switch. A single boolean site-config setting, AUDIT_LOGGING_ENABLED, governs the entire feature. It defaults to false. When false, no producer is constructed, no signal receiver does work, and no Kafka library call is made at runtime.

FR-2 — Configurable transport. Broker list, topic, client id, and every librdkafka security and tuning property are configuration, not code. Defaults are supplied for topic and client id; the broker list is required when the feature is enabled.

FR-3 — Orthanc ACL decisions are audited. Every terminal outcome of OrthancServiceAuthorizationView.get_authorization_response produces exactly one AuditEvent, for grants and denials alike, carrying actor, imaging server, resource identifiers, level, method, URI, action, outcome, and validity. The actor is read from self.form.user, never from get_auth_request_params (§2.5), and correctly handles the case where it is the literal string 'sonador'.

FR-4 — Cached grants are audited. A cache hit at cache_get_authorization_response produces an AuditEvent indistinguishable in completeness from an uncached grant. No PHI access may go unrecorded because a decision was served from cache (§2.4).

FR-5 — Identity events are audited. Successful login, failed login, and logout each produce an AuditEvent, sourced from Django's built-in user_logged_in, user_login_failed, and user_logged_out signals.

FR-6 — ACL policy changes are audited. Creation, modification, and revocation of a PacsImagingServerGroupAuthorization each produce an AuditEvent identifying the actor, the target group, the imaging server, the resource scope, and the permission set.

FR-7 — Credential lifecycle is audited. Issuance and revocation of API access tokens and access-id/secret pairs each produce an AuditEvent. This includes the administrative path where one user mints credentials on behalf of another (SonadorAdminUserCredentialsManagementMixin), which must record both the acting user and the subject user.

FR-8 — Records conform to FHIR R4B AuditEvent. Serialized as JSON, one document per Kafka message, per the mapping in §5.3.

FR-9 — recorded is a timezone-aware UTC instant. Produced with django.utils.timezone.now() and serialized with an explicit offset. A naive local timestamp with a Z suffix is a defect.

FR-10 — Records carry no PHI and no secrets. Resource identifiers (Orthanc ID, DICOM UID) are permitted. Patient demographics, DICOM tag content, and credential values are not. token_value is masked with the existing secure.helpers.masked_value helper if included at all; token_key may be recorded verbatim.

FR-11 — Failure is non-fatal and non-silent. Any exception in the audit path — construction, serialization, or delivery — is caught, logged at ERROR with the serialized event as context, and swallowed. It never changes an authorization outcome, never raises into a view, and never produces a 5xx.

FR-12 — Delivery is asynchronous. Producing an event does not block the request on a broker round trip.


4. Architectural requirements

AR-1 — One signal with an event-type discriminator, not one signal per event. Declare a single sonador_audit_event signal. New event types are added by extending a mapping table, not by wiring a new signal and receiver.

This mirrors the precedent already set in oak-tree/medical-imaging/orthanc-sonador, which publishes every message kind to one topic and discriminates with an opcode (kafka-export.patient, kafka-export.study, …) rather than fanning out across topics. MR !70's three-signal approach is the pattern to avoid.

AR-2 — Emit sites stay thin. A view or receiver calls one helper, emit_audit_event(...), with plain keyword data. It does not import Kafka, does not construct FHIR objects, and does not handle exceptions — the helper owns all three. This keeps audit concerns out of the authorization logic and makes the emit sites reviewable at a glance.

AR-3 — Signals are declared with no constructor arguments. Signal(), never Signal([...]). The providing_args list was removed in Django 4.0 and now binds to use_caching (§2.6). Both MR !70's three signals and the in-repo socialuser_first_login declaration use the removed form; neither is a model to follow here.

AR-4 — The producer is a lazy, per-process singleton, and never flushes per message. Four constraints combine here:

  • confluent_kafka.Producer owns background threads that do not survive fork(). Under a multi-worker uvicorn deployment a producer built at import time (pre-fork) is silently broken in the workers. Construct lazily on first use and key the singleton on os.getpid(), rebuilding if the pid changes.
  • produce() is asynchronous; call poll(0) after each produce to serve delivery callbacks from prior sends.
  • flush() is called only at process shutdown, registered via atexit. Never per message (MR !70's defect).
  • A full local queue raises BufferError from produce(). Catch it, log, and drop — per FR-11.

AR-5 — Pin the FHIR model explicitly to R4B via the subpackage import. Use from fhir.resources.R4B.auditevent import AuditEvent. A bare from fhir.resources.auditevent import ... silently binds to R5 and is the exact trap MR !70 fell into (§2.6).

R4B is chosen over R5 and R6 because it is structurally identical to the R4 model named in the original requirement; it matches the fhir.resources==6.5.0 R4-era pin already shipping in oak-tree/medical-imaging/orthanc-sonador, so both halves of the platform agree when the plugin is upgraded later; and R6 is a ballot whose structure has already changed twice, which is unsuitable for a retained compliance artifact.

AR-6 — Serialize once. The FHIR model produces the JSON document; the producer sends bytes. Do not re-encode an already-serialized string (MR !70's defect).

AR-7 — librdkafka properties pass through verbatim. The [[Connection]] config sub-section is handed to Producer(...) as a dict with its keys unchanged. Do not invent Sonador-specific aliases for security.protocol, sasl.username, and friends.

This means TLS and SASL support is a configuration concern rather than a code change, any future librdkafka property works without a release, and operators can copy values directly from Confluent's client documentation. The cost — no validation of property names at boot — is accepted; librdkafka rejects unknown properties at construction, which surfaces as a boot-time failure consistent with §2.7 convention 4.

AR-8 — Follow the existing settings conventions exactly (§2.7): read the section in sonador/settings/base.py, coerce at the read site, raise on invalid configuration at import, expose via gsetting. The [Cache] block is the model. Adding an INI section without a reader is what made MR !70's configuration inert.

AR-9 — Do not modify the wgtauth package. DataServiceAuthorizationBaseForm lives in the external acorn-wgtauth distribution (django-apps/wagtail-auth, services/forms/base.py) and is not editable from this repository. All hooks attach in sonador. Note that the draft notes' reference to AuthorizationBaseForm names no class that exists; the base is DataServiceAuthorizationBaseForm, and in any case the correct hook is the view, not the form (§2.3).

AR-10 — The audit module is self-contained. All new code lives under apps/visionaire/audit/. Nothing outside it imports confluent_kafka or fhir.resources.


5. Implementation specification

5.1 New package layout

apps/visionaire/audit/
    __init__.py         package docstring; no side effects
    apisettings.py      event-type constants, DCM code maps, config key names
    signals.py          sonador_audit_event = django.dispatch.Signal()
    kafka.py            SonadorAuditProducer -- lazy pid-keyed singleton
    fhir.py             build_audit_event(...) -> dict
    events.py           the receiver, plus emit_audit_event(...)

5.2 Configuration

INI template, added to sonador/config/sonador.site.config:

[Kafka]
AUDIT_LOGGING_ENABLED = 'false'

[[Audit]]
AUDIT_TOPIC = 'sonador-audit-event'
AUDIT_SOURCE_SITE = 'sonador'

[[Connection]]
bootstrap.servers = 'kafka:9092'
client.id = 'sonador-web'
message.timeout.ms = '300000'
# Secure deployments add, verbatim:
# security.protocol = 'sasl_ssl'
# sasl.mechanisms = 'SCRAM-SHA-512'
# sasl.username = 'sonador'
# sasl.password = '...'
# ssl.ca.location = '/etc/sonador/tls/ca.pem'

Resulting Django settings:

Setting Source Type Default Notes
AUDIT_LOGGING_ENABLED [Kafka] AUDIT_LOGGING_ENABLED bool False config_str2bool; the master switch (FR-1)
AUDIT_TOPIC [Kafka][[Audit]] AUDIT_TOPIC str 'sonador-audit-event'
AUDIT_SOURCE_SITE [Kafka][[Audit]] AUDIT_SOURCE_SITE str 'sonador' AuditEvent.source.site
KAFKA_CONNECTION [Kafka][[Connection]] dict {} Passed verbatim to Producer(...) (AR-7)

Reader block for sonador/settings/base.py, following the [Cache] precedent:

# HIPAA Audit Logging / Kafka Export
siteconfig_kafka = siteconfig.get('Kafka', {})
AUDIT_LOGGING_ENABLED = config_str2bool(siteconfig_kafka.get('AUDIT_LOGGING_ENABLED', False))

siteconfig_kafka_audit = siteconfig_kafka.get('Audit', {})
AUDIT_TOPIC = siteconfig_kafka_audit.get('AUDIT_TOPIC', 'sonador-audit-event')
AUDIT_SOURCE_SITE = siteconfig_kafka_audit.get('AUDIT_SOURCE_SITE', 'sonador')

KAFKA_CONNECTION = siteconfig_kafka.get('Connection', {})
if AUDIT_LOGGING_ENABLED:
    if not isinstance(KAFKA_CONNECTION, dict):
        raise TypeError('Invalid Kafka connection configuration (type: %s): %r'
            % (str(type(KAFKA_CONNECTION)), KAFKA_CONNECTION))
    if not KAFKA_CONNECTION.get('bootstrap.servers'):
        raise ValueError('HIPAA audit logging is enabled, but no Kafka brokers are configured. '
            + 'Set "bootstrap.servers" in the [Kafka][[Connection]] section of the site config.')
    if not AUDIT_TOPIC:
        raise ValueError('HIPAA audit logging is enabled, but no audit topic is configured.')

Confirm during implementation that ConfigObj preserves dotted keys such as bootstrap.servers verbatim within a nested section (AC-2).

5.3 FHIR R4B AuditEvent field mapping

FHIR element Value Notes
resourceType "AuditEvent"
type Coding, system http://dicom.nema.org/resources/ontology/DCM, code per §5.4 required
subtype[0] Coding, same system, code per §5.4 omitted where the catalogue has none
action C / R / U / D / E POST->C, GET->R, PUT|PATCH->U, DELETE->D, identity events->E
recorded timezone.now(), ISO-8601 with offset FR-9
outcome '0' success / '4' minor failure granted -> 0, denied -> 4
outcomeDesc adata[gapi.API_MESSAGE] where present e.g. resource-auth, static-asset, system-config
agent[0].who.identifier.value username, or 'sonador' for the internal superuser requestor
agent[0].altId str(user.pk) where a Django user exists; '-42' for the internal superuser
agent[0].requestor true
agent[0].network.address / .type client IP from the request; type '2' (IP address) omitted when no request is available
agent[1].who.display SONADOR_USER_LABEL ("Sonador Web Application")
agent[1].requestor false
source.site AUDIT_SOURCE_SITE
source.observer.display AUDIT_SOURCE_SITE required by R4B
source.type[0] Coding, system http://terminology.hl7.org/CodeSystem/security-source-type, code 4 (Application Server)
entity[0].what.identifier.value dicom_uid or orthanc_id or the target object id
entity[0].securityLabel[0] Coding, system http://terminology.hl7.org/CodeSystem/v3-Confidentiality, code R (Restricted) for PHI-bearing events
entity[0].detail[] {"type": <name>, "valueString": <value>} pairs see below

entity[0].detail carries the event-specific context as string pairs. For a resource-access event: OrthancId, DicomUid, Level, Method, Uri, Action, ImagingServer, Validity, TokenKey, CacheHit. For an ACL change: ImagingServer, Group, ResourceScope, Permissions. For a credential event: SubjectUser, CredentialType. Never TokenValue unmasked (FR-10).

5.4 Event catalogue

DCM codes verified against the FHIR audit-event-type and audit-event-sub-type ValueSets; CodeSystem URI http://dicom.nema.org/resources/ontology/DCM.

Constant Trigger site type subtype PHI
AUDIT_RESOURCE_ACCESS OrthancServiceAuthorizationView.get_authorization_response per §5.5 from action where set yes
AUDIT_RESOURCE_ACCESS_CACHED cache_get_authorization_response hit per §5.5 as above, plus CacheHit=true detail yes
AUDIT_USER_LOGIN django.contrib.auth.signals.user_logged_in 110114 User Authentication 110122 Login no
AUDIT_USER_LOGIN_FAILED user_login_failed 110114 110122 Login no
AUDIT_USER_LOGOUT user_logged_out 110114 110123 Logout no
AUDIT_ACL_GRANT PacsImagingServerGroupAuthorizationManagementView.saveObjectData (create) 110113 Security Alert 110136 Security Roles Changed no
AUDIT_ACL_MODIFY PacsImagingServerGroupAuthorizationRestView.saveObjectData (update) 110113 110136 no
AUDIT_ACL_REVOKE PacsImagingServerGroupAuthorizationRestView.deleteObject 110113 110136 no
AUDIT_CREDENTIAL_ISSUE saveObjectData on SonadorUserTokenManagementView / SonadorUserCredentialManagementView and their SonadorAdminUser* variants 110114 110137 User security Attributes Changed no
AUDIT_CREDENTIAL_REVOKE SonadorUserTokenManagementView.delete (local) and deleteObject on the credential views 110114 110137 no

Per §2.9, the ACL and credential trigger sites are saveObjectData / deleteObject overrides, not HTTP-verb overrides. saveObjectData serves both create and update, so it must distinguish them — the object's primary key is unset on create.

This set covers the rows the "MDDS / Sonador Security Events" sheet marks as required for HIPAA minimum-necessary auditing — "Access granted to imaging study", "Access denied to imaging study" — plus the Authentication & Identity and role-change rows. The remaining ~58 rows of that sheet are follow-on scope (§8).

5.5 Resource-access type code derivation

A single pure function maps the authorization request to a DCM type code:

Condition (evaluated in order) type
method == 'delete' 110105 DICOM Study Deleted
URI matches the DICOMweb archive/download branch 110106 Export
method == 'post' and the URI is a STOW/instances upload 110107 Import
method == 'get' and the URI is a QIDO-style query (no resource id) 110112 Query
otherwise 110103 DICOM Instances Accessed

Reuse the URI classification already performed by OrthancServiceAuthorizationForm.clean_auth_request, which resolves level, dicom_uid, orthanc_id, and the archive / comment / worklist / management branches. Do not re-parse the URI independently.

5.6 Signal contract

# apps/visionaire/audit/signals.py
import django.dispatch

sonador_audit_event = django.dispatch.Signal()

Sent with these keyword arguments:

Keyword Type Required Meaning
sender class yes the emitting view or module
event_type str yes one of the §5.4 constants
user User | str | None yes actor; 'sonador' for the internal superuser, None for an unauthenticated attempt
outcome bool yes True granted/succeeded, False denied/failed
request HttpRequest | None no source of the client IP
server PacsImagingServer | None no imaging server context
entity_id str | None no primary resource identifier
context dict no event-specific entity.detail pairs
outcome_desc str | None no AuditEvent.outcomeDesc
recorded datetime | None no defaults to timezone.now()

emit_audit_event(**kwargs) in events.py is a thin wrapper that short-circuits when gsetting('AUDIT_LOGGING_ENABLED') is false and otherwise sends the signal inside a try/except Exception per FR-11. Emit sites call the wrapper, never the signal directly (AR-2). Import it inside the calling function body to avoid circular imports, following SocialUserAccount.signal_first_login (§2.8).

5.7 File-by-file plan

oak-tree/medical-imaging/sonador

  1. requirements.txt — add confluent-kafka>=2.3.0 and fhir.resources>=8.3,<9. Confirm both resolve on Python 3.14 (cp314 wheels); the existing pydantic>=2.12,<3 pin satisfies fhir.resources' dependency. If no cp314 wheel exists for confluent-kafka at implementation time, record the finding on this issue before working around it.

  2. sonador/settings/base.py — add the §5.2 reader block after the [Cache] block.

  3. sonador/config/sonador.site.config — add the §5.2 template section, with AUDIT_LOGGING_ENABLED = 'false'.

  4. apps/visionaire/audit/__init__.py — new, docstring only.

  5. apps/visionaire/audit/apisettings.py — new. Event-type constants, the DCM type/subtype maps, the method-to-action map, and the security-source-type / confidentiality codings.

  6. apps/visionaire/audit/signals.py — new. sonador_audit_event per §5.6.

  7. apps/visionaire/audit/kafka.py — new. SonadorAuditProducer per AR-4: get_producer() returning the pid-keyed lazy singleton, send(document: dict) doing produce(topic, json.dumps(document).encode('utf-8'), callback=...) then poll(0), a delivery callback that logs failures without re-producing, and atexit-registered flush(timeout). Note the existing SonadorProducer.delivery_report in the Orthanc plugin re-produces blindly on failure with no backoff and references an undefined name in its error path; do not copy that method.

  8. apps/visionaire/audit/fhir.py — new. build_audit_event(event_type, user, outcome, ...) -> dict implementing §5.3 with the AR-5 import, returning the validated model dumped to a dict.

  9. apps/visionaire/audit/events.py — new. emit_audit_event(...) plus the @receiver(sonador_audit_event) handler that builds and sends.

  10. apps/visionaire/app.py — add from . import audit as audit_signals (or the package-__init__ equivalent) to VisionaireAppConfig.ready(), alongside the existing from .auth import signals as auth_signals.

  11. apps/visionaire/auth/views/service/orthanc_auth.py — emit at the end of get_authorization_response (FR-3) and on the cache-hit path (FR-4).

    The cache path needs care. The cached dict is rendered straight back to Orthanc (§2.4), so audit fields must not be added to it. Instead, when AUDIT_LOGGING_ENABLED is true, cache_set_authorization_response writes a second, parallel cache entry — same blake3 digest, distinct key prefix — holding only the audit context (username, user pk, imaging server, level, method, URI, action, resource identifiers). cache_get_authorization_response reads that companion entry on a hit and emits from it. The authorization response sent to Orthanc is unchanged.

    Accepted fallback if the companion entry proves awkward: bypass the authorization response cache entirely while AUDIT_LOGGING_ENABLED is true, trading cache performance for guaranteed completeness. Record which option was taken in a comment on this issue.

  12. apps/visionaire/auth/signals/events.py — add receivers for user_logged_in, user_login_failed, user_logged_out (FR-5).

  13. apps/visionaire/auth/views/acl.py — override saveObjectData on PacsImagingServerGroupAuthorizationManagementView and saveObjectData / deleteObject on PacsImagingServerGroupAuthorizationRestView, calling super() first and emitting after it returns (FR-6). Do not override the HTTP verbs — the views define none (§2.9).

  14. apps/visionaire/auth/views/cred.py — override saveObjectData and deleteObject across SonadorUserTokenManagementView, SonadorUserCredentialManagementView, SonadorUserCredentialRestView, and the SonadorAdminUser* variants (FR-7). SonadorUserTokenManagementView.delete is defined locally and resolves the token from the request body via getToken; emit from the inherited deleteObject rather than from that wrapper so both token and credential paths route through one hook. On the admin paths the acting user is request.user and the subject user comes from SonadorAdminUserCredentialsManagementMixin.getUser — record both.

  15. Unit tests for build_audit_event — a pure function over plain inputs, so it is directly testable: one case per event type in §5.4, asserting the DCM codes, the action letter, the outcome code, a timezone-aware recorded, and the absence of any unmasked credential value.

  16. Documentation — a section describing the settings, the event catalogue, the record shape, and how to consume the topic.

oak-tree/medical-imaging/imaging-development-env

  1. config/sonador/sonador.site.config and k8s/sonador.config-map.yaml — add the [Kafka] section with AUDIT_LOGGING_ENABLED = 'true' and bootstrap.servers = 'kafka:9092', so the dev environment exercises the feature against the existing PLAINTEXT broker.

6. Refactor analysis

This work introduces a second Kafka producer into the platform. The disposition of every existing Kafka touchpoint:

Component Location Disposition
SonadorProducer + KafkaMixin orthanc-sonador sonador_orthanc/kafka/base.py Reference implementation. Not modified. Its single-topic-plus-opcode design is the precedent for AR-1; its delivery_report and get_kafka_servers defects are explicitly not copied.
init_export_dcm / init_export_resource_data orthanc-sonador sonador_orthanc/kafka/resource.py Unchanged. DICOM index events, not audit events; different topic, different purpose.
Sonador.Kafka JSON config block imaging-development-env config/orthanc/config.web-auth.json Unchanged. The Orthanc plugin keeps its own transport config until the secure-Kafka upgrade (§8).
apps/visionaire/kafka.py (MR !70) sonador branch bharp/hippa_audit_trail Superseded. Design reference only; not rebased. Close or re-target the MR once this lands.
apps/visionaire/kafka.py (BaseKafkaManager, kafka-python-ng) sonador branch zayd/kafka-manager Abandoned. Uses a different client library than the shipping producer.
config/kafka/s3-sink-connect-cmd.sh (MR !27) imaging-development-env branch bharp/kafka-connect-to-s3 Out of scope. A downstream integration under the §1 boundary.

Normative convention going forward. The platform has exactly two Kafka producers, each owning one topic and discriminating message kinds by an in-payload field: orthanc-index from the Orthanc plugin, sonador-audit-event from the web application. New event kinds extend the discriminator table; they do not add topics or producers. Both producers read their transport configuration from their host's native configuration mechanism — the Orthanc JSON config for the plugin, the ConfigObj site config for the web application — with librdkafka property names passed through verbatim (AR-7).

No existing method signatures change and no existing behavior is altered when AUDIT_LOGGING_ENABLED is false, so no consumer-side migration is required.


7. Acceptance criteria

  • AC-1. With AUDIT_LOGGING_ENABLED = 'false' (the default), Sonador starts, serves authorization requests, and passes the existing functional suite with no behavior change and no Kafka connection attempt.
  • AC-2. With the feature enabled, bootstrap.servers and every other dotted librdkafka key round-trips from the INI through ConfigObj into Producer(...) unchanged.
  • AC-3. Enabling the feature with no bootstrap.servers configured fails at Django start-up with a clear ValueError, not at first request.
  • AC-4. A granted DICOMweb request produces exactly one AuditEvent on the topic with outcome = '0', the requesting user in agent[0], and the study or series identifier in entity[0].
  • AC-5. A denied request produces exactly one AuditEvent with outcome = '4' and a populated agent[0] — including when the denial is for an unknown or unauthenticated user.
  • AC-6. With CACHE_ENABLED = 'true', N repeated identical granted requests produce N audit events, not one. Verified by issuing the same request repeatedly inside the cache TTL and counting messages on the topic.
  • AC-7. Login, failed login, and logout each produce one event with DCM type 110114 and the correct subtype.
  • AC-8. Granting, modifying, and revoking a group ACL each produce one event with type 110113 / subtype 110136, identifying the group, the imaging server, and the resource scope.
  • AC-9. Issuing and revoking an API token each produce one event; on the administrative path both the acting user and the subject user appear on the record.
  • AC-10. Every emitted record validates against the FHIR R4B AuditEvent model and, when re-parsed from the topic, round-trips without error.
  • AC-11. recorded is timezone-aware and expressed in UTC. A record produced by a container running in a non-UTC zone carries the correct instant.
  • AC-12. No emitted record contains patient demographics, DICOM tag content, or an unmasked credential value. Verified by inspecting records from a granted study access, an ACL change, and a credential issuance.
  • AC-13. With the feature enabled and the broker stopped, authorization requests continue to succeed and return correct decisions at normal latency. Failures appear in the application log. Nothing raises into a view; no 5xx is produced.
  • AC-14. No request-handling path calls flush(). Verified by inspection and by confirming that authorization latency with the broker unreachable is unchanged from baseline.
  • AC-15. Under a multi-worker uvicorn deployment, every worker produces successfully — confirming the producer is built after fork.
  • AC-16. The event catalogue in §5.4 is complete: each listed trigger site has an emit call, verified by exercising each one and observing the corresponding message.
  • AC-17. Unit tests for build_audit_event cover every event type in §5.4 and pass.
  • AC-18. Consuming the topic with a standard Kafka console consumer yields human-readable JSON AuditEvent documents, one per message.

8. Out of scope

Each item below is deliberately excluded from v0.1. None is a gap in the design; all are follow-on work.

  • All downstream integration. Kafka is the boundary (§1). Aranei, Sumologic, CloudWatch, S3/MinIO object storage, Kafka Connect sinks, and SIEM forwarding are adopter integration decisions. oak-tree/medical-imaging/imaging-development-env!27 falls entirely under this exclusion.
  • Secure Kafka on the broker. The deployed broker is PLAINTEXT-only KRaft (§2.10). Adding a TLS/SASL listener, an ACL authorizer, and certificate distribution is separate environment work. v0.1 exposes the full client-side property surface so no code change is needed once the broker is secured.
  • The Orthanc plugin secure-Kafka upgrade. SonadorProducer in orthanc-sonador sets exactly one librdkafka property (bootstrap.servers). Widening it to accept security.protocol, ssl.*, and sasl.* from the Sonador.Kafka JSON block is a separate issue in that repository.
  • Orthanc-side audit events. DICOM C-STORE / C-FIND / C-MOVE, ChangeType.DELETED, and plugin-level events are not observable from the web application. The /manage DELETE endpoints delivered in orthanc-sonador!60 (merged) are audited here only via their authorization decision, not via the deletion itself.
  • Audit log read/review surface. No API, no admin interface, no search. Consumers read the topic.
  • Retention, immutability, and tamper-evidence. Kafka retention policy, WORM storage, and record signing are downstream concerns.
  • Fail-closed operation. v0.1 is fail-open per FR-11: a broker outage degrades to local logging rather than denying access. Whether a compliance posture requires fail-closed is a policy decision to be taken separately, with the "Audit log write failure" row of the Security Events sheet as input.
  • Alerting and anomaly detection. Brute-force thresholds, bulk-export alerts, and repeated-denial detection are consumer-side analytics.
  • The remaining ~58 rows of the Security Events sheet — Orthanc core DICOM events, plugin health events, OHIF viewer client events, networking/federation events, AI/ML pipeline events, and audit-of-the-audit-log events.
  • Fixing get_auth_request_params (§2.5). The audit path routes around it; repairing the helper would change the existing rejection log line and belongs in its own change.
  • De-duplicating the tracker. oak-tree/medical-imaging/imaging-development-env#77 is an unclosed duplicate of oak-tree/medical-imaging/imaging-development-env#78; disposing of it is tracker hygiene, not implementation.

9. References

Origin and tracking

  • oak-tree/medical-imaging/imaging-development-env#78 — HIPAA Audit Logging tracking issue; the draft notes of 2026-08-11 are the direct source for this specification.
  • Milestone %HIPAA Audit Logging (SN MS3).
  • OpenProject WP#43 — "HIPAA Audit Logging (SN3)", Sonador project.
  • oak-tree/medical-imaging/imaging-development-env#58 — the December 2023 planning issue where the requirement originates.
  • oak-tree/medical-imaging/imaging-development-env#77 — unclosed duplicate of #78 (closed).
  • "MDDS / Sonador Security Events" working document — the 68-row event inventory across 7 categories that scopes §5.4 and §8.

Prior art

  • oak-tree/medical-imaging/sonador!70 — unmerged FHIR AuditEvent + KafkaManager attempt; design reference (§2.2).
  • oak-tree/medical-imaging/imaging-development-env!27 — unmerged Kafka Connect S3 sink; out of scope (§8).
  • #69 (closed) — Kafka refactor prerequisite, closed 2026-06-29 as implemented via orthanc-sonador!42 (merged). The checklist on oak-tree/medical-imaging/imaging-development-env#78 still shows it unchecked.

Related, not duplicates

  • #58 — user/group history endpoints for policy synchronization; audit of identity-model changes rather than resource access.
  • #76 — SaMD documentation coverage; lists audit-logging documentation as a gap. That issue documents the capability, this one builds it.
  • orthanc-sonador#57 (closed) and ohif-viewers#127 (closed) — both shipped resource-removal functionality with an explicit note that no audit hook exists.

Reference implementations to read before starting

  • sonador_orthanc/kafka/base.py, .../kafka/__init__.py, .../kafka/resource.py (orthanc-sonador) — the platform's existing producer, and the single-topic-plus-opcode precedent behind AR-1.
  • sonador/settings/base.py, the [Cache] and [Logging] blocks — the settings convention (§2.7).
  • apps/visionaire/auth/signals/ and apps/visionaire/app.py — the signal declaration, receiver, and registration convention (§2.8).
  • apps/visionaire/auth/signals/events.py, revoke_openid_access_token — the best-effort, log-and-swallow doctrine behind FR-11.
  • apps/visionaire/auth/views/service/orthanc_auth.py — the decision path and the cache bypass (§2.3, §2.4).
  • apps/visionaire/auth/views/acl.py, apps/visionaire/auth/views/cred.py, and apps/visionaire/views/dicom.py — the declarative-view pattern and the established saveObjectData override (§2.9).

Upstream, pinned to the versions in use

  • FHIR R4B AuditEvent (4.3.0) — the target model. Library binding fhir.resources 8.3.0, fhir/resources/R4B/auditevent.py; import via fhir.resources.R4B (AR-5).
  • FHIR R6 ballot4 AuditEvent, build.fhir.org/auditevent.html — the link in the draft notes; a different structure, not the target (§2.6).
  • DCM audit codes — CodeSystem http://dicom.nema.org/resources/ontology/DCM; ValueSets audit-event-type and audit-event-sub-type.
  • librdkafka CONFIGURATION.md — the authoritative client property reference for §5.2, in place of the Kafka Connect worker configuration page.
  • Django 5.2 signals — Signal(use_caching=False); providing_args removed in 4.0 (AR-3).

Project Tracking

OP#43

Edited by Zayd Vanderson