feat(audit): platform-api audit log + operator UI wired to real events
Phase 1 of the audit work — capture everything we control today, ingest from
external systems (Authentik / OCIS / Stalwart) in a later phase. The mock
OP_AUDIT fixture is gone; both the /audit page and Overview's activity card
now show real events recorded by AuditService.record() in platform-api.
Schema (services/platform-api/src/schemas/audit-event.schema.ts):
AuditEvent { at, actorType, actorId, actorEmail, actorIp, action, outcome,
resourceType, resourceId, resourceName, tenantSlug, partnerSlug, source,
metadata, prevHash, hash }
Indexes: {at:-1}, {tenantSlug,at:-1}, {actorId,at:-1}, {action,at:-1}.
prevHash/hash are nullable now; hash-chain tamper evidence is a later phase.
AuditService:
- record() — best-effort write, swallows errors so the underlying mutation
that succeeded isn't failed by a downstream log issue. Surfaces failures
via Logger.
- list() — filters: since/until/before, action (exact OR prefix match
via leading-anchor regex), tenantSlug, partnerSlug, actorEmail, outcome,
free-text q across action/resourceName/actorEmail/tenantSlug, limit
(default 100, max 500). Cursor pagination via `before`.
- No UPDATE/DELETE surface — entries are append-only by construction.
AuditController: GET /audit, behind JwtAuthGuard + OperatorGuard. No mutations
exposed; entries written internally by other modules.
X-Forwarded-For threading:
- apps/operator/server/utils/platform-api.ts forwards the originating
client IP to platform-api so audit entries carry a real address.
- services/platform-api/src/auth/client-ip.ts extracts leftmost
X-Forwarded-For, falls back to socket.remoteAddress.
Instrumented mutations (every one threads actor + IP through):
Tenants: create, update, softDelete, setStatus(suspend/resume)
Partners: create, update, terminate
Flags: create, update (incl. flag.killed verb when state=off+note=kill-switch),
remove
Users: deactivate
Each controller resolves the User doc via ActorService, extracts IP via
clientIp(req), and passes { userId, email, ip } as AuditActor to the service.
FlagsService's local ActorRef collapses to AuditActor so flag history and the
audit log share one shape.
Operator UI:
- /api/audit proxy that forwards query params verbatim
- types/audit.ts
- pages/audit.vue: real list with quick-pick action chips (All/Tenants/
Partners/Flags/Users), outcome filter, free-text search, "Load older
events" cursor pagination
- pages/index.vue: Overview activity card swaps mock OP_AUDIT for the
same /api/audit endpoint, rows link into /audit
- data/fixtures.ts: OP_AUDIT / AuditEntry / AuditTone exports removed
Verified end-to-end: suspended + resumed acme, flipped oci_versioning through
rollout → kill → on, then /audit returned all 5 events with the right action
verbs (tenant.suspended, tenant.resumed, flag.updated, flag.killed,
flag.updated), actor admin@dezky.local, IP 192.168.65.1. Filters (action
prefix + free-text q) narrow correctly.
Out of scope for this commit (each gets its own conversation):
- Authentik / OCIS / Stalwart ingest adapters (Phase 2)
- Hash-chain tamper evidence (Phase 3)
- TTL + cold-storage archival to Hetzner Object Storage (Phase 4)
- GDPR right-to-erasure tooling
This commit is contained in:
@@ -63,32 +63,11 @@ export const INCIDENT: ActiveIncident = {
|
||||
// The seed in services/platform-api/src/seed/seed.service.ts creates the
|
||||
// same 10 flags this fixture used to contain.
|
||||
|
||||
export type AuditTone = 'info' | 'warn' | 'bad'
|
||||
export interface AuditEntry {
|
||||
id: string
|
||||
when: string
|
||||
actor: string
|
||||
role: string
|
||||
action: string
|
||||
target: string
|
||||
tenant: string
|
||||
ip: string
|
||||
tone: AuditTone
|
||||
}
|
||||
|
||||
export const OP_AUDIT: AuditEntry[] = [
|
||||
{ id: 'op_8821', when: '15:02:11', actor: 'Anne Baslund', role: 'platform admin', action: 'feature_flag.rollout', target: 'jmap_native_v2 · 50%', tenant: '—', ip: '10.0.4.18', tone: 'info' },
|
||||
{ id: 'op_8820', when: '14:58:42', actor: 'Mikkel Nørgaard', role: 'engineer', action: 'service.pod_restart', target: 'authentik-worker-3', tenant: '—', ip: '10.0.4.21', tone: 'warn' },
|
||||
{ id: 'op_8819', when: '14:48:02', actor: 'Sofie Lindberg', role: 'ops', action: 'tenant.impersonate', target: 'oliver@bygherre.dk', tenant: 'Bygherre Cloud', ip: '10.0.4.04', tone: 'info' },
|
||||
{ id: 'op_8818', when: '14:36:00', actor: 'system', role: 'auto', action: 'oncall.paged', target: 'Mikkel Nørgaard', tenant: '—', ip: '—', tone: 'warn' },
|
||||
{ id: 'op_8817', when: '14:18:00', actor: 'system', role: 'auto', action: 'alert.triggered', target: 'authentik p95 > 400ms', tenant: '—', ip: '—', tone: 'bad' },
|
||||
{ id: 'op_8816', when: '13:21:55', actor: 'Anne Baslund', role: 'platform admin', action: 'tenant.refund_issued', target: 'INV-0480 · 980 DKK', tenant: 'Vester Foods', ip: '10.0.4.18', tone: 'info' },
|
||||
{ id: 'op_8815', when: '12:09:30', actor: 'Sofie Lindberg', role: 'ops', action: 'tenant.suspended', target: 'København Kalkulator', tenant: 'København Kalkulator', ip: '10.0.4.04', tone: 'warn' },
|
||||
{ id: 'op_8814', when: '11:44:00', actor: 'Anne Baslund', role: 'platform admin', action: 'partner.created', target: 'Klaussen Digital · invited', tenant: '—', ip: '10.0.4.18', tone: 'info' },
|
||||
{ id: 'op_8813', when: '10:55:41', actor: 'system', role: 'auto', action: 'invoice.past_due', target: 'INV-0522 · 2.940 DKK · 21 d', tenant: 'Bygherre Cloud', ip: '—', tone: 'bad' },
|
||||
{ id: 'op_8812', when: '10:12:08', actor: 'Mikkel Nørgaard', role: 'engineer', action: 'feature_flag.created', target: 'beta_ai_summaries', tenant: '—', ip: '10.0.4.21', tone: 'info' },
|
||||
{ id: 'op_8811', when: '09:30:00', actor: 'Anne Baslund', role: 'platform admin', action: 'tos.published', target: 'v2026.05 · all tenants', tenant: '—', ip: '10.0.4.18', tone: 'info' },
|
||||
]
|
||||
// Audit log moved to a real backend at /api/audit + see types/audit.ts.
|
||||
// AuditService.record() in services/platform-api/src/audit/ writes an entry on
|
||||
// every privileged mutation. Incident timeline still references on-call
|
||||
// historically (see INCIDENT.updates above) — those are story content for
|
||||
// the mock incident, not entries in the audit collection.
|
||||
|
||||
// Services in the design that haven't been deployed yet. Surfaced as a
|
||||
// separate "Planned" section on the Infrastructure page so the operator sees
|
||||
|
||||
Reference in New Issue
Block a user