#201: stable audit event catalogue for syslog/SIEM export #261

Merged
fable-5 merged 1 commits from feat/201-audit-event-catalogue into main 2026-07-31 05:07:50 +02:00
6 changed files with 278 additions and 4 deletions

View File

@ -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<string, { severity: AuditSeverity }>;
/** 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;

View File

@ -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<string, string> {
const events = new Map<string, string>();
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}`);
}
});
});

View File

@ -4,9 +4,13 @@ import { PinoLogger } from 'nestjs-pino';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AUDIT_EVENTS, AuditAction } from './audit-actions';
export interface AuditEvent { export interface AuditEvent {
/** Stable dot-namespaced id, e.g. `grant.created` — the UI translates it. */ /** Stable dot-namespaced id from the catalogue (issue #201,
action: string; * 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. */ /** The acting user; null/undefined for anonymous events. */
actorId?: string | null; actorId?: string | null;
targetType?: string; targetType?: string;
@ -37,7 +41,15 @@ export class AuditService {
async record(event: AuditEvent): Promise<void> { async record(event: AuditEvent): Promise<void> {
const { action, actorId, targetType, targetId, details } = event; const { action, actorId, targetType, targetId, details } = event;
this.logger.info( 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}`, `audit: ${action}`,
); );
try { try {

View File

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

View File

@ -193,6 +193,15 @@ scan docker-archive:/image.tar -o cyclonedx-json` respectively
query values are masked (issue #191). Log forwarding and retention are query values are masked (issue #191). Log forwarding and retention are
the container runtime's job (SIEM division of labour — the application the container runtime's job (SIEM division of labour — the application
side of that contract is the stable event catalogue, issue #201). 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 - The persistent audit trail (`audit_log`, issue #86) records auth and
admin events — who changed access or configuration, not who edited admin events — who changed access or configuration, not who edited
what; content activity stays log-only by design. what; content activity stays log-only by design.

View File

@ -113,7 +113,7 @@ chain`_
- [x] **Plugins hart abschaltbar** (`plugins.enabled = false`) · +2 AT · #200 ⟵ neu - [x] **Plugins hart abschaltbar** (`plugins.enabled = false`) · +2 AT · #200 ⟵ neu
Deckt das Risiko „Codeausführung in der VS-Zone" für den Deckt das Risiko „Codeausführung in der VS-Zone" für den
Angebotsstand vollständig ab. Hash-Pinning siehe Phase 4. Angebotsstand vollständig ab. Hash-Pinning siehe Phase 4.
- [ ] **Syslog/SIEM: Ereigniskatalog** · +34 AT · #201 ⟵ neu aus Roadmap - [x] **Syslog/SIEM: Ereigniskatalog** · +34 AT · #201 ⟵ neu aus Roadmap
Der Code-Anteil ist klein (stdout-JSON reicht meist). Wert liegt im Der Code-Anteil ist klein (stdout-JSON reicht meist). Wert liegt im
**stabilen Ereigniskatalog**: feste Event-IDs, dokumentierte Semantik **stabilen Ereigniskatalog**: feste Event-IDs, dokumentierte Semantik
und Felder, damit die Behörde SIEM-Regeln schreiben kann. und Felder, damit die Behörde SIEM-Regeln schreiben kann.