Audit event catalogue
Catalogue version 1.5 (2026-07-31; 1.5 adds plugin.rejected,
issue #232; 1.4 added auth.proxy_rejected, issue #215; 1.3 added auth.identity_linked, issue #214; 1.2 added
read_trail.pruned, issue #224; 1.1 added page.classification_*,
issue #205).
This is the operator-facing contract for the audit trail: every event id
the application can emit, with its trigger, severity, actor/target
semantics, and fields. SIEM/syslog rules written against this document
survive application updates because of the compatibility promise below.
The code half of this catalogue is apps/api/src/audit/audit-actions.ts
(a typed union — an uncatalogued id cannot be emitted), and
audit-catalogue.test.ts fails CI whenever this document and that code
drift. Changing either alone is impossible.
Compatibility promise
- Ids are never repurposed. The meaning of an id listed here is frozen.
- Adding events bumps the catalogue's minor version; existing rules
are unaffected.
- Retiring an event (it stops being emitted) keeps its row here,
marked retired, forever; removal of a row is a major version and is
called out in the release notes.
- Fields listed per event are stable; new optional fields may be added
(minor version), fields are never renamed or repurposed.
Transport & field set
Audit events reach the operator on two channels, both fed by the same
AuditService.record() call:
- stdout log line (pino JSON, the forwarding channel): the container
runtime captures stdout (Docker
json-file with rotation); the
operator's collector (promtail/fluent-bit/vector/…) picks it up from
there and forwards to syslog/SIEM. There is deliberately no
application-side syslog client — transport, TLS and buffering are the
collector's job, one layer below the app.
audit_log table (the queryable trail with its own retention,
#196), shown in the Site-Admin panel.
Every audit stdout line carries this stable field set:
| Field |
Meaning |
msg |
audit: <event id> — the selector; collectors match on the audit: prefix and take the id from the remainder. |
severity |
The catalogue severity of the id (info | notice | warning | critical) — the routing hint for rules. |
actor |
Acting user id, or null for anonymous/system events (each row below states which). |
targetType / targetId |
What the event is about (absent when the event has no target). |
| event fields |
The per-event fields from the tables below, flattened into the line's top level. |
level, time, context |
pino plumbing: always 30/epoch-ms/AuditService. Severity routing uses severity, not level. |
The audit_log row stores the same data structurally: action,
actor_id, target_type, target_id, details (the per-event fields as
JSON), at. Secrets, tokens, request bodies, and page content never
appear in either channel (security.md §Logging).
Events
Severity vocabulary: critical = page someone (integrity/security
failure), warning = feeds detection (suspicious or destructive),
notice = configuration/privilege change, info = normal lifecycle.
Authentication (auth.*)
| Id |
Trigger |
Severity |
Actor |
Target |
Fields |
auth.signup |
Account created via self-registration or OIDC just-in-time (issue #214) |
info |
the new user |
— |
provider (optional; absent = local) |
auth.email_verified |
E-mail double-opt-in completed |
info |
the verified user |
— |
— |
auth.login_failed |
Login rejected (bad credentials) |
warning |
matched user, null if unknown name |
— |
— |
auth.login_succeeded |
Session created |
info |
the user |
— |
provider (optional; absent = local) |
auth.password_reset |
Password changed via reset token |
notice |
the user |
— |
— |
auth.identity_linked |
OIDC identity linked to an existing account via the explicit link flow (issue #214) |
notice |
the linking user |
— |
provider |
auth.proxy_rejected |
Proxy-auth header received from a peer outside the allowlist — spoof attempt (issue #215) |
warning |
null (unauthenticated) |
— |
peer, header |
Access & membership (grant.*, member.*)
| Id |
Trigger |
Severity |
Actor |
Target |
Fields |
grant.created |
Access rule added to a pond |
notice |
granting user |
pond |
grantId, subject, subjectId, role, scope, scopeId, effect |
grant.deleted |
Access rule removed |
notice |
acting user |
pond |
grantId, subjectId, role |
member.added |
User added to a pond |
notice |
acting user |
pond |
member (user id), role |
member.role_changed |
Member's role changed |
notice |
acting user |
pond |
member, role (new) |
member.removed |
Member removed from a pond |
notice |
acting user |
pond |
member |
Administration (user.*, quota.*, settings.*, job.*)
| Id |
Trigger |
Severity |
Actor |
Target |
Fields |
user.disabled_set |
Site Admin disables/enables an account |
notice |
the admin |
user |
disabled (bool) |
user.site_admin_set |
Site-Admin privilege granted/revoked |
notice |
the admin |
user |
isSiteAdmin (bool) |
user.deleted |
Account deleted by a Site Admin |
notice |
the admin |
user |
— |
user.pseudonymized |
GDPR pseudonymization of authorship completed |
notice |
null (system) |
user |
— |
user.verification_resent |
Site Admin re-sends the verification mail |
info |
the admin |
user |
— |
quota.override_set |
Per-user/per-pond quota override set |
notice |
the admin |
user | pond |
quotaKey, value |
quota.override_cleared |
Quota override removed |
notice |
the admin |
user | pond |
quotaKey |
settings.changed |
Instance setting written |
notice |
the admin |
setting (key) |
— |
job.triggered |
Maintenance job started manually from the System panel |
info |
the admin |
job (name) |
outcome |
Classification (page.classification_*, ADR 0022, issue #205)
| Id |
Trigger |
Severity |
Actor |
Target |
Fields |
page.classification_raised |
Page's VS-NfD level raised — by an editor, or automatically when moved under a higher-classified parent |
notice |
the acting user |
page |
from, to (levels, lowercase), trigger (edit | move), pondId |
page.classification_lowered |
Page's VS-NfD level lowered — requires the dedicated capability (pond-wide Pond Admin) |
warning |
the acting user |
page |
from, to, trigger (edit), pondId |
Content integrity & lifecycle (file.*, pond.*, audit.*)
| Id |
Trigger |
Severity |
Actor |
Target |
Fields |
file.integrity_failed |
Attachment download hash mismatch — fail-closed (issue #199) |
critical |
null (any downloader; detection) |
attachment |
pondId, expected (stored sha256), actual (computed sha256) |
pond.purged |
Pond irreversibly destroyed (manual or trash retention, issue #193) |
notice |
admin, null when retention-run |
pond |
trigger (manual | retention) plus per-object-type deletion counts (e.g. pages, attachments, … — informational, keys may grow) |
audit.pruned |
Audit retention deleted rows past the period (issue #196) |
info |
null (system) |
— |
count, cutoff (ISO), retentionDays |
read_trail.pruned |
Read-trail retention removed events past its own period (issue #224) |
info |
null (system) |
— |
count, cutoff (ISO), retentionDays |
Public API (api.*)
| Id |
Trigger |
Severity |
Actor |
Target |
Fields |
api.token_created |
Personal access token created |
info |
token owner |
api_token |
name, scope, ponds (count) |
api.token_revoked |
Personal access token revoked |
info |
token owner |
api_token |
— |
api.write |
Mutation performed through the public API |
info |
token owner |
api_write (object id) |
op, tokenId, tokenName |
Plugins (plugin.*)
| Id |
Trigger |
Severity |
Actor |
Target |
Fields |
plugin.installed |
Plugin package installed or updated |
notice |
admin, null for dropzone drop |
plugin |
version, update (bool) |
plugin.rejected |
Install or load blocked by the hash-pinning allowlist (#232) |
warning |
admin for installs, null for loads |
plugin |
surface (install/load), reason (not_pinned/hash_mismatch/unpinned/mismatch), version, bundleHash (installs) |
plugin.mode_set |
Instance mode changed (disabled/optional/required) |
notice |
the admin |
plugin |
mode |
plugin.uninstalled |
Plugin removed |
notice |
the admin |
plugin |
— |
plugin.pond_toggled |
Optional plugin toggled for one pond |
info |
the pond admin |
pond |
plugin, enabled |
Backup & restore (backup.*)
| Id |
Trigger |
Severity |
Actor |
Target |
Fields |
backup.settings_changed |
Backup target settings written |
notice |
the admin |
— |
nextcloudEnabled |
backup.run_triggered |
On-demand backup requested ("Back up now") |
info |
the admin |
— |
— |
backup.restore_requested |
In-app restore requested (type-to-confirm passed) |
warning |
the admin |
backup (backup id) |
source |
First-run setup (setup.*)
| Id |
Trigger |
Severity |
Actor |
Target |
Fields |
setup.preseeded |
Instance pre-configured from stage env |
info |
the seeded admin |
— |
— |
setup.admin_created |
First Site Admin created by the wizard |
notice |
the created admin |
user |
— |
setup.smtp_stored |
SMTP settings stored by the wizard |
info |
the admin |
— |
— |
setup.completed |
Setup wizard finished; instance unlocked |
info |
the admin |
— |
— |
Scope boundary
Content activity (who edited which page, file up/downloads that succeed,
exports, labels) intentionally stays out of this trail — it answers "who
changed access/configuration and did the platform detect tampering", not
"who edited what" (security.md §Logging). The read-access trail for
classified content is a separate, VS-NfD-specific mechanism (#222–#225)
with its own catalogue entry when it lands.