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

179 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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