dorfteich/docs/architecture/audit-events.md
Claude Opus 5 6377faf332
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m28s
CI / Build container images (pull_request) Successful in 2m7s
CI / Auth e2e pack (pull_request) Successful in 9m37s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Build and push images (push) Successful in 23s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m47s
CD / Promote to Int (push) Successful in 16s
CI / Lint, typecheck, test (push) Successful in 7m25s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m41s
CI / Import/export fidelity gate (push) Successful in 1m12s
#306: instance branding — logo and favicon, cropped in the browser
An instance had no way to look like itself: the top bar said "Dorfteich"
whatever the operator called their instance, `instance.name` was never
rendered in the running app at all, and there was no favicon anywhere —
`index.html` had no `<link rel="icon">` and `public/` held only fonts and
theme-init.js.

Where the line is drawn, and why:

- **The api never decodes an image.** Cropping, scaling and the conversion
  to PNG happen on a canvas in the browser; the api checks the PNG
  signature, reads the IHDR dimensions at their fixed offsets and enforces
  the caps. An image library would put a decoder in front of
  attacker-supplied bytes AND would have to be carried through the
  `--network none` offline build. Reading two big-endian integers is not
  decoding.
- **SVG is refused**, with its own error message rather than a generic
  "not a PNG": it can carry script, and serving it from our own origin
  would be a cross-site-scripting vector. An operator who tried one should
  learn that it is deliberate.
- **The crop is driven by number inputs, not by dragging.** A drag-only
  cropper excludes keyboard and switch users outright; a number input is
  arrow-key operable and screen-reader readable without any custom aria.
  The resulting pixel size is stated in text, not only drawn as a frame.
- **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has
  already resolved `data-theme` before first paint, so the correct logo is
  the one painted rather than the one that appears after a flash. Without a
  dark variant the LIGHT logo carries both themes — the operator's own
  asset shown unchanged beats one they did not choose (the rule #307
  extends to ponds). The settings screen warns; it never blocks.
- **The favicon link is static, its resource dynamic.** index.html stays a
  static file and the api answers with the uploaded icon or a shipped
  default — that route must never 404, or the browser keeps its generic
  icon for good. The default is generated by a script from Node's own zlib
  (`gen-default-favicon.mjs`), for the same offline-build reason.
- Both favicon sizes are uploaded together: one source, one crop, so the
  tab icon and the home-screen icon can never disagree.
- Branding is served WITHOUT a session, because the login screen carries it
  and the browser fetches the favicon before anyone signs in. The admin
  screen says so — an operator may not expect their logo to be public.
- The metadata is not writable through the settings endpoint: it describes
  bytes on disk, and hand-writing it would claim an asset that is not
  there.

`./data/branding` follows the three-step rule #303 paid for: env default +
`data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the
`mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and
closes the hole that made #303's variant invisible: the nightly archive
skips a missing directory WORDLESSLY, so the fence now demands that every
`*_DIR` the backup env declares actually travels in the archive. Verified
against the real defect — removing the line fails it by name.

Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start
so #307 is the same event with a different scope, not a second id.

Verified: api suite 103 files green (a lone `public-api` ECONNRESET under
local parallel load, green in isolation — the documented local flake);
branding suite 12 tests against a real directory; crop arithmetic unit
tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the
new section (overflow 0); and the whole flow walked in the browser: upload
→ crop 780×180 → stored as 512×118 → logo in the sidebar linking home with
the instance name as its accessible name → topbar wordmark following
`instance.name` → light logo still shown under `data-theme="dark"`.
2026-08-01 19:30:52 +02:00

18 KiB
Raw Permalink Blame History

Audit event catalogue

Catalogue version 1.7 (2026-08-01; 1.7 adds 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

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)
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.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.