# Audit event catalogue **Catalogue version 1.4 (2026-07-31; 1.4 adds `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: 1. **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. 2. **`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: ` — 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.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.