Skip to content

Audit log API

The audit log records every mutating admin API call. The gateway writes one row per POST, PATCH, PUT, or DELETE request against /admin/v1/. The recorded fields are the timestamp, the calling user ID, the source IP, the HTTP method, the request path, and the response status.

The audit log does not capture the request body or a JSON diff of the change.

Security events

In addition to the generic access trail, the gateway records security-relevant events for anomaly review. Each carries an action label so it can be distinguished from an ordinary access row. The last three are configuration- and data-lifecycle events rather than denials — they are here because each one is a moment a protection stops applying, which is exactly what an anomaly review needs to see:

action When Recorded actor
access.denied Any admin request that is answered with 403 Forbidden — a permission denial (including on GET), or a business-rule denial such as a protected-account guard. The signed-in user, when present.
auth.otp_failed An OTP verification with an invalid or expired code (401), only when the email maps to a real account. An OTP failure for an unknown email is deliberately not recorded — rotating the email would otherwise let an unauthenticated caller grow the log without bound (anti-amplification). The target user.
auth.otp_blocked Recorded once per block episode, on the wrong guess that spends the code's last of five attempts (the response to that guess is still 401; every later attempt is 429 otp_attempt_cap and records nothing). A new code starts a new budget and a new episode. In a multi-node deployment "once" is per node: the no-live-code counter is node-local, so an episode split across nodes can end without a row on one of them (see Where each counter lives in Authentication). The target user, when known.
auth.login_denied A valid OTP for which no account exists (403). None.
auth.demo_denied A demo-login request for a slug that is not a demo account (404) — the escalation boundary for public demo links. Dormant (AGF-2997): the public no-login demo entry is disabled, so POST /admin/auth/demo-login now returns 404 for every request before the slug lookup and this action is no longer emitted. None.
sso.* / saml.* SSO / SAML login, logout, denial, and replay outcomes. The user, plus the tenant and identity-provider entity.
impersonation.* Admin "View as User" start / stop / action / denied outcomes, dual-identity recorded. See Admin impersonation. The admin and the impersonated user.
support_request.emailed A Contact Support request left the platform by email — recorded because it carries tenant content and a named user's identity to an address outside the workspace. Carries the recipient and whether delivery succeeded, never the summary or description. A row with delivered: false means the request is still waiting in the feedback inbox. The submitting user, plus the tenant. During impersonation the actor is recorded as the actor and the impersonated user appears as on_behalf_of — dual-identity, like the impersonation.* rows.
guardrail_masker.deactivated A gateway config write (PATCH or POST to an existing slug) that deactivates a PII-masking detector — toggling its enabled to false or removing it. Deactivating a masker lets that PII flow to the model unmasked (see Guardrails → enabled), so who/which/when is recorded. Carries the detector name and type only — never its keywords or entities. The signed-in admin, plus the gateway.
conversation.residency_released A conversation was moved out of a project that restricts data residency (Local only or PII mandatory) — detached, or moved elsewhere. The move itself requires owner rank on that project, so this is an authorised act; it is recorded because it is the moment a residency restriction stops applying to that conversation's future turns. Carries the project it left and that project's tier. The signed-in user, plus the conversation.
project.conversations_released A project that restricts data residency was deleted while it still held conversations. Deleting detaches every one of them — including other members' — so the restriction is lifted from all of them at once. Carries the project's tier and the number of conversations released. The deleting admin, plus the project.
project.conversation_retention_changed A project's conversation retention period (Löschfrist) was changed. Carries the new value only — retention days are not personal data. The signed-in admin, plus the project.
tenant.conversation_retention_changed / tenant.request_log_retention_changed A tenant's conversation / request-log Löschfrist was changed (platform admin only). Written only on an actual change — an unchanged echo on a tenant save emits no row (previously every save that carried the field did). Before/after values only. The signed-in admin, plus the tenant.

These rows record only the actor ID, source IP, method, path, status, and action. A failed-login attempt never stores the submitted email address, OTP code, password, or any token — no secret or account-enumeration data enters the audit log.

SIEM streaming

Security-event failures (status >= 400) that can be attributed to a tenant are also streamed to that tenant's configured SIEM under the security event category. A SIEM with no explicit siem.events list receives them by default; a SIEM configured with an explicit events list must include security (or all) to receive them — a list that omits both (for example ["blocked"]) streams no security events, though the durable audit-log row is always written regardless. Successful logins/logouts are recorded in the audit log but are not streamed, so routine authentication cannot trip SIEM alerting. Events that are recorded but cannot be attributed to a tenant (for example an auth.login_denied for a valid OTP with no matching account) are kept in the durable audit log — retrievable by a platform administrator below — but are not streamed to any SIEM.


Listing audit entries

GET /admin/v1/audit-log

Required role: platform admin only. Lower-privileged callers receive 403 forbidden.

Optional query parameters:

Parameter Type Description
limit integer Maximum rows to return. Default 100, capped at 500.
offset integer Offset for paging. Default 0.

The response is an array, ordered by descending row ID:

Field Type Description
id integer Auto-increment row identifier.
ts unix seconds Event timestamp. The audit_log.ts column is stored in milliseconds; this endpoint projects ROUND(ts / 1000), so the returned value is in seconds.
actor_id string | null The signed-in user who made the request. null for unauthenticated calls.
actor_ip string Source IP of the request.
method string HTTP method.
path string Request path under /admin/v1/.
status integer | null HTTP response status.
action string | null Security-event label (e.g. access.denied, auth.otp_failed), or null for a generic access row.
entity_type string | null Category of a structured event (authz, auth, sso, agent, …), or null for a generic access row.
tenant_id string | null Tenant the actor belongs to, when known.

Tamper-evidence (revisionssichere Protokollierung)

The audit log is tamper-evident: any retroactive change to a recorded event — altering a field, deleting a row, reordering, or inserting a back-dated entry — is detectable. This satisfies the revisionssichere Protokollierung (audit-proof logging) requirement for tenders such as BKK mkk M9.3.

How it works

Audit rows are written normally (concurrently, from every mutating admin action and every security event). A single background worker then seals each committed row into an append-only hash-chain:

  • every sealed row is assigned a dense, monotonic sequence number and a keyed hash (HMAC-SHA-256) computed over the row's immutable content and the previous row's hash, so the rows form a chain where each entry commits to all prior entries;
  • the HMAC key is a dedicated secret held outside the database — so an actor with only database access (a rogue administrator, a stolen backup, an injection) cannot recompute a consistent chain after tampering;
  • periodic checkpoints anchor the chain digest at sequence boundaries.

Verifying integrity

An operator verification routine walks the sealed chain and recomputes every hash. It reports success, or the first point of divergence, distinguishing:

Result Meaning
ok Every sealed row (and every checkpoint) matches — no tampering detected.
row_tampered A row's recorded content was altered after it was sealed.
seq_gap A sealed row was deleted or reordered.
prev_hash_break The chain linkage was broken (relink / reorder).
chain_truncated Rows were removed up to a checkpointed point.
checkpoint_divergence An anchor digest no longer matches the recomputed chain.
prune_boundary_mismatch / missing_anchor_for_pruned_prefix A retained row's anchor to a pruned range is inconsistent or missing.

Scope and trust boundary

The chain proves that no already-sealed event was retroactively changed by a database-level actor. Two residuals are covered by the off-box SIEM mirror (see SIEM streaming above) and externally-stored checkpoint anchors: (1) a forged row inserted in the brief window before the next seal pass, and (2) a full compromise of the application environment that also steals the HMAC key.

The source IP (actor_ip) is deliberately outside the integrity boundary: it is subject to the GDPR right-to-erasure (it is nulled when a user is erased), and an immutable chain cannot cover an erasable field. Every other recorded field is covered.

Operator configuration

Tamper-evidence is configured by Myra for your deployment and is on by default wherever a chain key is provisioned. Two things govern it:

  • A dedicated HMAC chain key. It must be stable (rotating it is a re-anchoring event, not a routine rotation) and non-empty — an empty key makes sealing refuse to run rather than silently produce an unkeyed (forgeable) chain. Its presence is what enables the chain by default.
  • A master switch for sealing, checkpointing, and audit-log retention. Left at its default, the chain is on where a key is provisioned and off where none is — so a keyed deployment is tamper-evident by default, while a keyless one (dev/test) stays inert with no log noise. Your operator can force it on (a missing key then makes sealing refuse) or force it off (for example, to stage activation before provisioning).

⚠️ Enabling the chain also enables audit-log retention. Sealing is a prerequisite for the chain-aware prune, which is the only mechanism that deletes old audit_log rows. The first activation on a long-lived deployment seals the entire historical backlog (background, off the request path) and then begins pruning rows older than the retention horizon (default 365 days, floored at 180). Pruning is incremental (it needs at least two checkpoints before any range is deleted), so there is no instant mass-delete — but confirm the retention horizon is intended before provisioning a key on a deployment that was previously keyless.

Audit-log retention is documented under Data retention.