Audit log
Who read, exported or deleted which user's data, when and with which key.
Open Health Sync keeps an append-only audit log per tenant: who read, exported, changed or deleted which user's data, when, with which credential, and with what outcome. It is the record a data protection officer, a security questionnaire or an incident review asks for (GDPR articles 30 and 32 and comparable health data rules expect one).
What is recorded
| Source | Events |
|---|---|
| Every API request authenticated with a tenant key or a user token | One event, whatever the outcome (a 404 on another tenant's user is recorded too) |
ohs admin tenant create, key create, key revoke | tenant.create, key.create, key.revoke, actor admin |
The first-run setup (POST /v1/setup) | tenant.create, actor system with id setup, the key prefix in detail |
| The deletion worker | user.erased when a user's data is gone, with the job id, actor system |
A request without a valid credential (401) is not an audit event: /metrics counts those by
route. Provider-facing routes (/oauth/..., /inbound/...) take no tenant credential and are
not recorded either.
Each event holds:
| Field | Content |
|---|---|
at | Server time of the event |
actor.type, actor.id | api_key and the key's id (as ohs admin key list shows); user_token and the first 16 hex characters of the token's SHA-256 hash (the token cannot be recovered from it); admin or system and the command or worker |
action | What was done, see below |
user_id | The end user the action concerns: the user of a user token, or the {id} of the route |
method, route, status | The HTTP method, the route pattern (GET /v1/users/{id}/samples) and the status code |
request_id | The request's X-Request-Id, to match the event with your own logs |
detail | Identifiers only, such as a deletion job id or a key prefix |
An event never holds a health value, a request or response body, a query string (which may hold dates or provider codes), a token, a key or an IP address.
Actions
| Action | Requests |
|---|---|
data.read | Lists, FHIR, Open mHealth, coverage, freshness, MCP |
data.export | CSV and Parquet exports, the user archive |
data.write | Device uploads (/v1/ingest/...) |
user.create, user.read, user.delete | The user routes; user.delete is the request, user.erased the completed erasure |
token.create | Minting a user token |
connection.read, connection.create, connection.delete | The provider connection routes and sync history (sync-runs), and creating a hosted connection page link (connect-sessions) |
connection.sync | Asking for an immediate pull (POST .../connections/{provider}/sync) |
webhook.read, webhook.write | The webhook endpoint routes |
audit.read | Reading this log |
config.read, config.write | Anything else (the provider list, for example) |
New actions may be added; treat an unknown one as config.read or config.write by its method.
Reading it
GET /v1/audit with a tenant key, newest first, paged with cursor and limit (1 to 1000,
default 100). Filters:
user_id: one end user;action: an exact action (data.export) or a prefix ending in a dot (data.);since,until: RFC 3339 times orYYYY-MM-DDdates (untilis exclusive).
# Everything done with one user's data in October.
curl -s "$OHS/v1/audit?user_id=$USER_ID&action=data.&since=2026-10-01&until=2026-11-01" \
-H "Authorization: Bearer $KEY"{
"data": [
{
"id": 1042,
"at": "2026-10-13T09:12:44.120511Z",
"actor": { "type": "api_key", "id": "7d0c9f1e-5b2a-4c36-9e0d-2b8f4a1c6e53" },
"action": "data.export",
"user_id": "1b9e2c4a-0f3d-4e8b-a7c6-5d2e1f0a9b8c",
"method": "GET",
"route": "GET /v1/users/{id}/exports/{dataset}",
"status": 200,
"request_id": "4c1f0e9a2b7d4e6f8a3c5b1d9e0f2a7c",
"detail": {}
}
],
"next_cursor": null
}A user token cannot read the log. Reading it is itself recorded (audit.read).
Guarantees and limits
- Append only. A database trigger refuses
UPDATE,DELETEandTRUNCATEonohs_core.audit_events, for the application and for anyone connecting with its database role. The only exception is the retention job, which sets a flag for its own transaction. A database superuser or the table owner can still drop the trigger: if you need evidence that holds against your own database administrators, ship the events to write-once storage (pollGET /v1/auditand store the pages elsewhere). - Kept after erasure. Deleting a user removes their data, tokens and connections, but not
the audit events that name them: those hold the user's id only, no personal data, and are the
proof the erasure happened (
user.erased). - Retention. Events older than
OHS_AUDIT_RETENTION_DAYS(default 365) are deleted once a day by theetlrole;0keeps every event. Set it to what your legal basis requires. - Best effort on a database failure. An event is written after the response, on the same
request. If the write fails (the database went away mid-request), the response stands, the
failure is logged and counted in
ohs_audit_write_failures_totalon/metrics. Alert on that counter. Erasures and admin commands are recorded in the same transaction as the change, so they are never lost. - Volume. One row per authenticated request. A client polling every user every minute writes that many rows; size retention accordingly.