Files
Ronni Baslund 02341d8ba5 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
2026-05-24 19:50:24 +02:00

114 lines
6.4 KiB
TypeScript

// Visual-only fixtures for screens we haven't wired to a real backend yet
// (infrastructure, feature flags, audit log, active incident, operator team
// extras). Real data sources are GET /tenants, /partners, /users — anything
// derivable from those should NOT live here. See OPERATOR-PLAN.md follow-ups
// for the path from each fixture to a real implementation.
import type { IconName } from '~/components/UiIcon.vue'
export type ServiceStatus = 'ok' | 'warn' | 'bad'
export interface PlatformService {
id: string
name: string
role: string
status: ServiceStatus
uptime: number // percent, 30d
p95: number // ms
err: number // percent
last: string // human duration since last incident
}
export const SERVICES: PlatformService[] = [
{ id: 'mail', name: 'Stalwart', role: 'Mail · IMAP/JMAP/SMTP', status: 'ok', uptime: 99.99, p95: 42, err: 0.002, last: '—' },
{ id: 'files', name: 'OCIS', role: 'Files · OwnCloud Infinite', status: 'ok', uptime: 99.97, p95: 88, err: 0.004, last: '11 d ago' },
{ id: 'video', name: 'Jitsi', role: 'Video meetings', status: 'ok', uptime: 99.91, p95: 124, err: 0.018, last: '4 d ago' },
{ id: 'chat', name: 'Zulip', role: 'Team chat', status: 'ok', uptime: 99.99, p95: 35, err: 0.001, last: '—' },
{ id: 'auth', name: 'Authentik', role: 'Identity · SSO · MFA', status: 'warn', uptime: 99.94, p95: 412, err: 0.052, last: 'active' },
{ id: 'db', name: 'PostgreSQL', role: 'Primary database', status: 'ok', uptime: 99.99, p95: 8, err: 0, last: '—' },
{ id: 'obj', name: 'Object storage',role: 'S3-compatible · Hetzner', status: 'ok', uptime: 99.99, p95: 22, err: 0.001, last: '—' },
{ id: 'cdn', name: 'Cloudflare', role: 'CDN · WAF', status: 'ok', uptime: 100, p95: 18, err: 0, last: '—' },
{ id: 'smtp', name: 'Outbound SMTP', role: 'Email delivery (Postmark)', status: 'ok', uptime: 99.95, p95: 280, err: 0, last: '3 d ago' },
]
export interface ActiveIncident {
id: string
title: string
severity: 'P1' | 'P2' | 'P3'
started: string
duration: string
affected: string
state: 'investigating' | 'identified' | 'monitoring'
ic: string
updates: { t: string; who: string; msg: string }[]
}
export const INCIDENT: ActiveIncident = {
id: 'INC-2026-018',
title: 'Authentik · elevated SSO login latency',
severity: 'P2',
started: '14:18',
duration: '42 min',
affected: 'Login latency p95 above 400ms · 12 tenants impacted',
state: 'investigating',
ic: 'Mikkel Nørgaard',
updates: [
{ t: '15:00', who: 'Mikkel N.', msg: 'Pod restart deployed, monitoring' },
{ t: '14:36', who: 'auto', msg: 'Page sent to on-call (Mikkel)' },
{ t: '14:22', who: 'Anne B.', msg: 'Confirmed: Postgres conn pool exhaustion on auth-db-2' },
{ t: '14:18', who: 'auto', msg: 'Alert: authentik p95 > 400ms for 5m · 12 tenants impacted' },
],
}
// Feature flags moved to a real backend at /api/flags + see types/flag.ts.
// The seed in services/platform-api/src/seed/seed.service.ts creates the
// same 10 flags this fixture used to contain.
// 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
// honest deployment state instead of a fake all-green grid.
export interface PlannedService {
id: string
name: string
role: string
note: string
}
export const PLANNED_SERVICES: PlannedService[] = [
{ id: 'jitsi', name: 'Jitsi', role: 'Video meetings', note: 'Lands with docker-compose.optional.yml (Phase 7)' },
{ id: 'zulip', name: 'Zulip', role: 'Team chat', note: 'Lands with docker-compose.optional.yml (Phase 7)' },
{ id: 'dns', name: 'simpledns.plus', role: 'DNS · authoritative', note: 'External SaaS · prod only' },
{ id: 'objstore', name: 'Hetzner Object Storage', role: 'Files · S3 backend for OCIS', note: 'External · prod only' },
{ id: 'smtp-out', name: 'Postmark', role: 'Outbound SMTP · transactional email', note: 'External SaaS · prod only' },
]
export type NotificationKind = 'security' | 'user' | 'billing' | 'integration' | 'support' | 'signin'
export type NotificationTone = 'warn' | 'info' | 'neutral' | 'ok' | 'bad'
export interface NotificationItem {
id: string
kind: NotificationKind
title: string
body: string
when: string
icon: IconName
tone: NotificationTone
unread: boolean
}
// Seed list mirrors the design screenshot. The real source will be an event
// stream / Mongo collection later; for now this is the shell other features
// can plug into. See the "notifications backend" follow-up in NEXT-STEPS.md.
export const NOTIFICATIONS: NotificationItem[] = [
{ id: 'n_2821', kind: 'security', icon: 'shield', tone: 'warn', when: '2 min ago', unread: true, title: 'DMARC policy weak on baslund.dk', body: 'Set the policy to at least quarantine to reduce spoofing.' },
{ id: 'n_2820', kind: 'user', icon: 'users', tone: 'info', when: '14 min ago', unread: true, title: 'Mikkel accepted your invitation', body: 'mikkel@dezky.com joined as Admin.' },
{ id: 'n_2819', kind: 'billing', icon: 'card', tone: 'neutral', when: '1 h ago', unread: false, title: 'Invoice INV-2026-005 paid', body: '1.940,00 DKK · Visa •••• 4242' },
{ id: 'n_2818', kind: 'integration', icon: 'plug', tone: 'neutral', when: '3 h ago', unread: false, title: 'Notion SAML connection live', body: 'Connected via Authentik. 11 users provisioned.' },
{ id: 'n_2817', kind: 'support', icon: 'help', tone: 'neutral', when: 'Yesterday', unread: false, title: 'Sofie replied to your ticket TKT-2832', body: 'Update on missing mobile recordings.' },
{ id: 'n_2816', kind: 'signin', icon: 'bell', tone: 'bad', when: '2 d ago', unread: false, title: 'Failed sign-in attempts on oliver@', body: '3 attempts from 203.0.113.4 — IP added to watchlist.' },
]