From 000d110727b730b70df7fae8808cb5e1f5a3f3ea Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 04:51:09 +0200 Subject: [PATCH] #201: stable audit event catalogue for syslog/SIEM export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 36 audit action ids become a typed union (AUDIT_EVENTS in audit-actions.ts) — an uncatalogued id is now a compile error; every existing id keeps its name. The published, versioned catalogue (docs/architecture/audit-events.md, v1.0) documents per event: trigger, severity, actor and target semantics, and every field, plus the compatibility promise (ids are never repurposed; retiring keeps the row forever) and the stable stdout field set. audit-catalogue.test.ts is the fence: it parses the document's event tables and fails when ids or severities drift from the code (negative case verified). Audit stdout lines now carry the catalogue severity as a routing hint — pino level stays 30 so transport is unaffected; no DB migration. Forwarding path documented: container stdout -> operator's collector; deliberately no application-side syslog client. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ --- apps/api/src/audit/audit-actions.ts | 61 +++++++++ apps/api/src/audit/audit-catalogue.test.ts | 47 +++++++ apps/api/src/audit/audit.service.ts | 18 ++- docs/architecture/audit-events.md | 145 +++++++++++++++++++++ docs/architecture/security.md | 9 ++ docs/vs-nfd/20-massnahmenplan.md | 2 +- 6 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/audit/audit-actions.ts create mode 100644 apps/api/src/audit/audit-catalogue.test.ts create mode 100644 docs/architecture/audit-events.md diff --git a/apps/api/src/audit/audit-actions.ts b/apps/api/src/audit/audit-actions.ts new file mode 100644 index 0000000..6667bac --- /dev/null +++ b/apps/api/src/audit/audit-actions.ts @@ -0,0 +1,61 @@ +/** + * The audit event catalogue (issue #201): every action id the trail may + * carry, with the severity the stdout line is stamped with. This const is + * the CODE half of the published catalogue in + * `docs/architecture/audit-events.md` — `audit-catalogue.test.ts` fails + * whenever the two drift, so an id cannot be added, renamed, or removed + * without its documentation moving in the same commit. + * + * Compatibility promise (the reason this exists): ids are never repurposed. + * New events may be added (minor catalogue version); an id that stops being + * emitted is retired in the catalogue document, its meaning frozen forever — + * so an operator's SIEM rules survive our releases. + */ +export const AUDIT_EVENTS = { + 'api.token_created': { severity: 'info' }, + 'api.token_revoked': { severity: 'info' }, + 'api.write': { severity: 'info' }, + 'audit.pruned': { severity: 'info' }, + 'auth.email_verified': { severity: 'info' }, + 'auth.login_failed': { severity: 'warning' }, + 'auth.login_succeeded': { severity: 'info' }, + 'auth.password_reset': { severity: 'notice' }, + 'auth.signup': { severity: 'info' }, + 'backup.restore_requested': { severity: 'warning' }, + 'backup.run_triggered': { severity: 'info' }, + 'backup.settings_changed': { severity: 'notice' }, + 'file.integrity_failed': { severity: 'critical' }, + 'grant.created': { severity: 'notice' }, + 'grant.deleted': { severity: 'notice' }, + 'job.triggered': { severity: 'info' }, + 'member.added': { severity: 'notice' }, + 'member.removed': { severity: 'notice' }, + 'member.role_changed': { severity: 'notice' }, + 'plugin.installed': { severity: 'notice' }, + 'plugin.mode_set': { severity: 'notice' }, + 'plugin.pond_toggled': { severity: 'info' }, + 'plugin.uninstalled': { severity: 'notice' }, + 'pond.purged': { severity: 'notice' }, + 'quota.override_cleared': { severity: 'notice' }, + 'quota.override_set': { severity: 'notice' }, + 'settings.changed': { severity: 'notice' }, + 'setup.admin_created': { severity: 'notice' }, + 'setup.completed': { severity: 'info' }, + 'setup.preseeded': { severity: 'info' }, + 'setup.smtp_stored': { severity: 'info' }, + 'user.deleted': { severity: 'notice' }, + 'user.disabled_set': { severity: 'notice' }, + 'user.pseudonymized': { severity: 'notice' }, + 'user.site_admin_set': { severity: 'notice' }, + 'user.verification_resent': { severity: 'info' }, +} as const satisfies Record; + +/** Severity vocabulary of the catalogue — syslog-inspired, four levels are + * enough for rule routing (critical pages someone, warning feeds detection, + * notice is configuration drift, info is lifecycle noise). */ +export type AuditSeverity = 'info' | 'notice' | 'warning' | 'critical'; + +/** A catalogued action id — the ONLY thing {@link AuditService.record} + * accepts, so an uncatalogued event cannot be emitted (compile-time), and + * the doc fence keeps the catalogue document in step (test-time). */ +export type AuditAction = keyof typeof AUDIT_EVENTS; diff --git a/apps/api/src/audit/audit-catalogue.test.ts b/apps/api/src/audit/audit-catalogue.test.ts new file mode 100644 index 0000000..04ef16e --- /dev/null +++ b/apps/api/src/audit/audit-catalogue.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { AUDIT_EVENTS } from './audit-actions'; + +/** + * The fence that keeps the published audit catalogue and the code together + * (issue #201): every id in `AUDIT_EVENTS` must appear as an event row in + * `docs/architecture/audit-events.md` with the same severity, and the + * document may not describe ids the code does not know. Emission of an + * uncatalogued id is already a TYPE error (AuditAction union) — this test + * covers the half the compiler cannot see: the document. + */ +// __dirname, not import.meta: the api package compiles CJS (tsconfig has no +// nodenext module), and vitest resolves both — the compiler only the former. +const doc = readFileSync(join(__dirname, '../../../../docs/architecture/audit-events.md'), 'utf8'); + +/** Event rows are `| \`ns.event\` | trigger | severity | …` — the dot in the + * id keeps field-set rows (`msg`, `severity`, …) out of the match. */ +function documentedEvents(): Map { + const events = new Map(); + for (const line of doc.split('\n')) { + const id = /^\| `([a-z]+\.[a-z_]+)` +\|/.exec(line)?.[1]; + if (!id) continue; + const cells = line.split('|').map((cell) => cell.trim()); + // cells[0] is the empty string before the leading pipe. + events.set(id, cells[3] ?? ''); + } + return events; +} + +describe('audit catalogue fence (issue #201)', () => { + it('documents exactly the ids the code can emit', () => { + const documented = documentedEvents(); + const inCode = Object.keys(AUDIT_EVENTS).sort(); + expect([...documented.keys()].sort()).toEqual(inCode); + }); + + it('documents each id with the severity the code stamps', () => { + const documented = documentedEvents(); + for (const [action, { severity }] of Object.entries(AUDIT_EVENTS)) { + expect(`${action}: ${documented.get(action)}`).toBe(`${action}: ${severity}`); + } + }); +}); diff --git a/apps/api/src/audit/audit.service.ts b/apps/api/src/audit/audit.service.ts index dc602ff..3954b22 100644 --- a/apps/api/src/audit/audit.service.ts +++ b/apps/api/src/audit/audit.service.ts @@ -4,9 +4,13 @@ import { PinoLogger } from 'nestjs-pino'; import { PrismaService } from '../prisma/prisma.service'; +import { AUDIT_EVENTS, AuditAction } from './audit-actions'; + export interface AuditEvent { - /** Stable dot-namespaced id, e.g. `grant.created` — the UI translates it. */ - action: string; + /** Stable dot-namespaced id from the catalogue (issue #201, + * docs/architecture/audit-events.md) — the UI translates it, SIEM rules + * key on it. The union makes an uncatalogued emission a type error. */ + action: AuditAction; /** The acting user; null/undefined for anonymous events. */ actorId?: string | null; targetType?: string; @@ -37,7 +41,15 @@ export class AuditService { async record(event: AuditEvent): Promise { const { action, actorId, targetType, targetId, details } = event; this.logger.info( - { actor: actorId ?? null, targetType, targetId, ...details }, + // `severity` is the catalogue's routing hint for SIEM rules (#201) — + // pino's own `level` stays 30/info so log transport is unaffected. + { + severity: AUDIT_EVENTS[action].severity, + actor: actorId ?? null, + targetType, + targetId, + ...details, + }, `audit: ${action}`, ); try { diff --git a/docs/architecture/audit-events.md b/docs/architecture/audit-events.md new file mode 100644 index 0000000..5a12503 --- /dev/null +++ b/docs/architecture/audit-events.md @@ -0,0 +1,145 @@ +# Audit event catalogue + +**Catalogue version 1.0 (2026-07-31, issue #201).** + +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: ` — 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 | info | the new user | — | — | +| `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 | — | — | +| `auth.password_reset` | Password changed via reset token | notice | the user | — | — | + +### 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) | — | +| `job.triggered` | Maintenance job started manually from the System panel | info | the admin | `job` (name) | `outcome` | + +### 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` | + +### 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) | +| `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. diff --git a/docs/architecture/security.md b/docs/architecture/security.md index 783851a..9034697 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -193,6 +193,15 @@ scan docker-archive:/image.tar -o cyclonedx-json` respectively query values are masked (issue #191). Log forwarding and retention are the container runtime's job (SIEM division of labour — the application side of that contract is the stable event catalogue, issue #201). +- **Audit event catalogue** (issue #201): the versioned contract SIEM + rules are written against — every emittable event id with trigger, + severity, actor/target semantics and fields — lives in + `docs/architecture/audit-events.md`. The action set is a typed union in + code (an uncatalogued id cannot be emitted), audit stdout lines carry + the catalogue `severity`, and a CI fence (`audit-catalogue.test.ts`) + fails when document and code drift. Forwarding path: container stdout → + the operator's collector; deliberately no application-side syslog + client. - The persistent audit trail (`audit_log`, issue #86) records auth and admin events — who changed access or configuration, not who edited what; content activity stays log-only by design. diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 94f55fb..eab130d 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -113,7 +113,7 @@ chain`_ - [x] **Plugins hart abschaltbar** (`plugins.enabled = false`) · +2 AT · #200 ⟵ neu Deckt das Risiko „Codeausführung in der VS-Zone" für den Angebotsstand vollständig ab. Hash-Pinning siehe Phase 4. -- [ ] **Syslog/SIEM: Ereigniskatalog** · +3–4 AT · #201 ⟵ neu aus Roadmap +- [x] **Syslog/SIEM: Ereigniskatalog** · +3–4 AT · #201 ⟵ neu aus Roadmap Der Code-Anteil ist klein (stdout-JSON reicht meist). Wert liegt im **stabilen Ereigniskatalog**: feste Event-IDs, dokumentierte Semantik und Felder, damit die Behörde SIEM-Regeln schreiben kann. -- 2.45.2