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 repository** — `oak-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:
```python
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
```python
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 R5** — `from 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:
```python
# 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`):
```python
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`:
```ini
[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:
```python
# 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
```python
# 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`**
17. `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
- [x] **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.
- [x] **AC-2.** With the feature enabled, `bootstrap.servers` and every other dotted librdkafka key round-trips from the INI through ConfigObj into `Producer(...)` unchanged.
- [x] **AC-3.** Enabling the feature with no `bootstrap.servers` configured fails at Django start-up with a clear `ValueError`, not at first request.
- [x] **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]`.
- [x] **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.
- [x] **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.
- [x] **AC-7.** Login, failed login, and logout each produce one event with DCM type 110114 and the correct subtype.
- [x] **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.
- [x] **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.
- [x] **AC-10.** Every emitted record validates against the FHIR R4B `AuditEvent` model and, when re-parsed from the topic, round-trips without error.
- [x] **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.
- [x] **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.
- [x] **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.
- [x] **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.
- [x] **AC-15.** Under a multi-worker uvicorn deployment, every worker produces successfully — confirming the producer is built after fork.
- [x] **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.
- [x] **AC-17.** Unit tests for `build_audit_event` cover every event type in §5.4 and pass.
- [x] **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 oak-tree/medical-imaging/orthanc-sonador!60 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.
* "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).
* oak-tree/medical-imaging/sonador#69 — Kafka refactor prerequisite, closed 2026-06-29 as implemented via orthanc-sonador!42. The checklist on oak-tree/medical-imaging/imaging-development-env#78 still shows it unchecked.
**Related, not duplicates**
* oak-tree/medical-imaging/sonador#58 — user/group history endpoints for policy synchronization; audit of identity-model changes rather than resource access.
* oak-tree/medical-imaging/sonador#76 — SaMD documentation coverage; lists audit-logging documentation as a gap. That issue documents the capability, this one builds it.
* oak-tree/medical-imaging/orthanc-sonador#57 and oak-tree/medical-imaging/ohif-viewers#127 — 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
issue