dorfteich/docs/architecture/audit-events.md
Claude Opus 5 3310ae3926
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m19s
CI / Build container images (pull_request) Successful in 1m23s
CI / Auth e2e pack (pull_request) Successful in 8m55s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Build and push images (push) Successful in 14s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m28s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m52s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m6s
CI / Import/export fidelity gate (push) Successful in 58s
#305: a full pond archive before deletion and before purge
Deleting a pond already had a strict prompt — typing the pond name, stricter
than a confirm dialog. That was never the gap. The gap is that the person who
deletes it loses access the moment they do: the pond leaves their view, only a
Site Admin can bring it back, and the export is no longer reachable for them.
So the archive is offered INSIDE the deletion flow, before the button.

What it contains, and why it is not the existing export:

- Every page the requester may read, as Markdown, as before.
- **Every attachment of the pond**, not only the embedded ones. An
  attachment nobody put on a page would otherwise vanish unnoticed — which
  is the whole reason this issue exists.
- `manifest.json`: pond settings (EFFECTIVE, defaults filled in — a
  preservation format must not require its reader to know Dorfteich's
  defaults), labels, the page hierarchy and sort keys, comments, and
  attachment metadata including the #199 hash so a reader can verify bytes.
  It extends the #210 manifest rather than adding a second descriptor, and
  carries an explicit `formatVersion`.
- `README.txt`, because the manifest is for machines: whoever unpacks a
  folder of Markdown a year from now must not believe they hold a one-click
  restore.

Decisions worth naming:

- **"Complete" describes the RESULT, not the route.** A pond admin who may
  read every page gets `complete: true`; only an archive that actually
  leaves pages out is incomplete. The Site-Admin route skips the read filter
  (an archive taken before an irreversible purge must not depend on which
  ponds the operator happens to be a member of) — those are two different
  questions and the first version of this conflated them.
- **The omission is named before the download**, with its number, in the UI
  and in the manifest. An archive silently missing content is worse than no
  archive, because it ends the search.
- **Not downloading stays allowed.** A pond of test pages should not require
  one, and the server cannot tell whether a file arrived anyway — so the
  finality is stated in text instead of enforced.
- **A plain link, not fetch-into-a-blob.** The api streams the ZIP; buffering
  a whole pond in the tab to draw a progress bar would trade memory for
  cosmetics. The browser reports progress and completion; what it cannot say
  — that the archive is being BUILT — is announced in a live region.
- Read trail unchanged in kind (ADR 0023): one `export` event per classified
  page before any classified byte enters the stream. Attachments never travel
  without their page, so the same events cover them.
- New audit action `pond.archived` (catalogue v1.7) with page and attachment
  counts, omitted pages, and completeness.

Format documented in `docs/architecture/pond-archive-format.md`, including
what is deliberately NOT in it (history, permissions, trash).

Verified by hand, not only asserted: a real pond's archive downloaded and
unpacked — README, manifest, three page files, the media file; the manifest's
effective settings, per-page classification, the VS-NfD frontmatter and
marking preserved in the classified page's Markdown, and the attachment's
sha256 present. Plus six api tests (including that an unembedded attachment
travels and that a Site Admin gets a complete archive without membership) and
the a11y pack 11/11 in both schemes, which now also scans the pond settings
screen.

Not done, because there is nothing to attach it to: the Site Admin's purge
dialog (#193) exists only as an api endpoint — there is no pond-trash UI in
the web app. The api half is here and tested, so it becomes a link when that
screen is built.
2026-08-01 20:24:35 +02:00

18 KiB
Raw Blame History

Audit event catalogue

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

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