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
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"`.
165 lines
18 KiB
Markdown
165 lines
18 KiB
Markdown
# 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.
|