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
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.
167 lines
18 KiB
Markdown
167 lines
18 KiB
Markdown
# 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.
|