dorfteich/docs/architecture/audit-events.md
Claude Fable 5 c2a4dde5cc
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m50s
CI / Build container images (pull_request) Successful in 3m57s
CI / Auth e2e pack (pull_request) Failing after 6m18s
CI / Import/export fidelity gate (pull_request) Has been skipped
Invitation flow with per-user quota (#332)
Any authenticated user can invite an e-mail address; the mailed
single-use token lets exactly one signup through even while
registration is closed. Open (pending, unexpired) invitations count
against the new instance setting invitations.maxOpenPerUser (default 5,
0 disables inviting) — plus a 20/day per-user rate limit so a
revoke-and-recreate loop cannot become a mail cannon. Only the SHA-256
token hash is stored (auth-tokens pattern); a failed signup (taken
username) un-redeems the token so the invitee can retry.

Surfaces: invitations section in the user settings (list, invite,
revoke, quota line; wide table in a focusable .table-scroll region),
signup page reads ?invitation=<token> (preview banner, e-mail prefill,
closed-mode gate opens only for a previewed-valid token), admin general
card gets the quota field (flat RHF name per #322; VS-NfD marked and
hideable).

Governance: audit actions invitation.created/revoked/accepted
(catalogue 1.10), VS-NfD profile entry (compliant: 0) + hardening-guide
row, i18n de+en including the invitation mail template.

Tests: api e2e-db (mail link, closed-mode single-use signup with
un-redeem on failure, quota + revoke frees slot, quota 0 = 403, auth
matrix), new web e2e pack invitations.spec.ts (full UI loop through
Mailpit, wired into ci.yml with its own rate-limit reset), a11y scan
waits for the new section. Full api suite (107 files / 607 tests),
auth/admin-settings/a11y packs green against a fresh local stack.

Closes #332
2026-08-05 12:44:20 +02:00

20 KiB
Raw Blame History

Audit event catalogue

Catalogue version 1.10 (2026-08-05; 1.10 adds invitation.created, invitation.revoked and invitation.accepted, issue #332; 1.9 added user.created_by_admin, issue #331; 1.8 added pond.archived, issue #305; 1.7 added branding.changed, issue #306; 1.6 added font.uploaded and font.deleted, issue #303; 1.5 added 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:

  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: <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

Invitations (invitation.*, issue #332)

Id Trigger Severity Actor Target Fields
invitation.created User invites an e-mail address (mail with signup link sent) info the inviting user invitation
invitation.revoked Open invitation withdrawn by its creator info the inviting user invitation
invitation.accepted Signup completed through an invitation link (closed-mode bypass) notice the new user invitation

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.created_by_admin Site Admin creates an account directly (#331) notice the admin user
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)
branding.changed Logo or favicon uploaded or removed (#306/#307) notice the admin setting (key) scope (instance/pond), asset (logo/logoDark/favicon), change (set/cleared), pondId (pond scope)
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.archived Full pond archive downloaded before deletion or purge (#305) notice the pond admin or Site Admin pond pages, attachments, omittedPages, complete
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)
font.uploaded Site Admin uploaded a custom font family or added a weight (#303) notice admin font family, weight (when a single weight was added)
font.deleted Site Admin removed a custom font family (#303) notice admin font family, pondsAffected (count of ponds still referencing it)
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.