Releasesv0.1.0

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

SourceEvents
Every API request authenticated with a tenant key or a user tokenOne event, whatever the outcome (a 404 on another tenant's user is recorded too)
ohs admin tenant create, key create, key revoketenant.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 workeruser.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:

FieldContent
atServer time of the event
actor.type, actor.idapi_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
actionWhat was done, see below
user_idThe end user the action concerns: the user of a user token, or the {id} of the route
method, route, statusThe HTTP method, the route pattern (GET /v1/users/{id}/samples) and the status code
request_idThe request's X-Request-Id, to match the event with your own logs
detailIdentifiers 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

ActionRequests
data.readLists, FHIR, Open mHealth, coverage, freshness, MCP
data.exportCSV and Parquet exports, the user archive
data.writeDevice uploads (/v1/ingest/...)
user.create, user.read, user.deleteThe user routes; user.delete is the request, user.erased the completed erasure
token.createMinting a user token
connection.read, connection.create, connection.deleteThe provider connection routes and sync history (sync-runs), and creating a hosted connection page link (connect-sessions)
connection.syncAsking for an immediate pull (POST .../connections/{provider}/sync)
webhook.read, webhook.writeThe webhook endpoint routes
audit.readReading this log
config.read, config.writeAnything 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 or YYYY-MM-DD dates (until is 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, DELETE and TRUNCATE on ohs_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 (poll GET /v1/audit and 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 the etl role; 0 keeps 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_total on /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.
Edit on GitHub

On this page