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:
Ronni Baslund
2026-05-24 19:50:24 +02:00
parent 5407c04682
commit 02341d8ba5
26 changed files with 864 additions and 128 deletions
+5 -26
View File
@@ -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
+139 -41
View File
@@ -1,26 +1,75 @@
<script setup lang="ts">
import { OP_AUDIT, type AuditEntry } from '~/data/fixtures'
import type { AuditEvent } from '~/types/audit'
// Local UI state for filters. Drives the params we pass to /api/audit.
const search = ref('')
const actionFilter = ref('') // exact or prefix (e.g. 'tenant')
const outcomeFilter = ref<'' | 'success' | 'failure'>('')
const tenantFilter = ref('')
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return OP_AUDIT
return OP_AUDIT.filter((a) => {
return (
a.action.toLowerCase().includes(q) ||
a.actor.toLowerCase().includes(q) ||
a.target.toLowerCase().includes(q) ||
a.tenant.toLowerCase().includes(q)
)
})
const params = computed<Record<string, string>>(() => {
const p: Record<string, string> = {}
if (search.value.trim()) p.q = search.value.trim()
if (actionFilter.value) p.action = actionFilter.value
if (outcomeFilter.value) p.outcome = outcomeFilter.value
if (tenantFilter.value.trim()) p.tenantSlug = tenantFilter.value.trim()
return p
})
function tone(a: AuditEntry): 'info' | 'warn' | 'bad' {
return a.tone
const { data: events, pending, refresh } = await useFetch<AuditEvent[]>('/api/audit', {
default: () => [],
query: params,
})
// Pagination via the `before` cursor — request the next page using the
// timestamp of the last event we have.
const olderPages = ref<AuditEvent[]>([])
const reachedEnd = ref(false)
const allEvents = computed(() => [...(events.value ?? []), ...olderPages.value])
// Reset older-page state whenever the live filter set changes; otherwise
// older results from a previous filter combination linger.
watch(params, () => {
olderPages.value = []
reachedEnd.value = false
})
async function loadMore() {
const last = allEvents.value[allEvents.value.length - 1]
if (!last) return
const next = await $fetch<AuditEvent[]>('/api/audit', {
query: { ...params.value, before: last.at },
})
if (!next.length) {
reachedEnd.value = true
return
}
olderPages.value.push(...next)
}
function label(a: AuditEntry) {
return a.tone === 'bad' ? 'fail' : a.tone === 'warn' ? 'warn' : 'ok'
// Quick-pick filter chips that map to common queries.
const QUICK_ACTIONS = [
{ label: 'All actions', value: '' },
{ label: 'Tenants', value: 'tenant' },
{ label: 'Partners', value: 'partner' },
{ label: 'Flags', value: 'flag' },
{ label: 'Users', value: 'user' },
]
function fmtAbs(iso: string) {
const d = new Date(iso)
return d.toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'medium' })
}
function tone(e: AuditEvent): 'info' | 'warn' | 'bad' | 'ok' {
if (e.outcome === 'failure') return 'bad'
if (e.action.includes('suspended') || e.action.includes('killed') || e.action.includes('deleted') || e.action.includes('terminated')) return 'warn'
return 'info'
}
function shortIp(ip?: string) {
if (!ip) return '—'
// Strip v4-in-v6 prefix that node sockets sometimes report (::ffff:1.2.3.4 → 1.2.3.4)
return ip.replace(/^::ffff:/, '')
}
</script>
@@ -29,27 +78,44 @@ function label(a: AuditEntry) {
<PageHeader
eyebrow="Compliance"
title="Global audit log"
subtitle="Every operator action, every system event — across all tenants, immutable."
/>
:subtitle="`${allEvents.length} event${allEvents.length === 1 ? '' : 's'} · every privileged action recorded by platform-api`"
>
<template #actions>
<UiButton variant="secondary" :disabled="pending" @click="refresh()">
<template #leading><UiIcon name="chevDown" :size="13" /></template>
Refresh
</UiButton>
</template>
</PageHeader>
<div class="stage">
<div class="toolbar">
<div class="search">
<UiIcon name="search" :size="13" />
<input v-model="search" placeholder="action.type, actor, target…" type="text" />
<input v-model="search" placeholder="action, actor, target, tenant…" type="text" />
</div>
<div class="streaming">
<StatusDot color="var(--ok)" :size="6" />
<Mono dim>streaming · mock</Mono>
<div class="chips">
<button
v-for="q in QUICK_ACTIONS"
:key="q.label"
:class="['chip', { on: actionFilter === q.value }]"
type="button"
@click="actionFilter = q.value"
>{{ q.label }}</button>
</div>
<UiButton variant="secondary" disabled>
<template #leading><UiIcon name="external" :size="13" /></template>
Export CSV
</UiButton>
<div class="chips">
<button :class="['chip', { on: outcomeFilter === '' }]" type="button" @click="outcomeFilter = ''">Any</button>
<button :class="['chip', { on: outcomeFilter === 'success' }]" type="button" @click="outcomeFilter = 'success'">Success</button>
<button :class="['chip', { on: outcomeFilter === 'failure' }]" type="button" @click="outcomeFilter = 'failure'">Failure</button>
</div>
<Mono dim class="streaming">
<StatusDot color="var(--ok)" :size="6" :glow="false" />
live · backed by Mongo
</Mono>
</div>
<Card :pad="0">
<table v-if="filtered.length">
<table v-if="allEvents.length">
<thead>
<tr>
<th>Time</th>
@@ -62,31 +128,46 @@ function label(a: AuditEntry) {
</tr>
</thead>
<tbody>
<tr v-for="a in filtered" :key="a.id">
<td><Mono>{{ a.when }}</Mono></td>
<tr v-for="e in allEvents" :key="e._id">
<td><Mono>{{ fmtAbs(e.at) }}</Mono></td>
<td class="actor">
<div v-if="a.actor === 'system'" class="sys">sys</div>
<Avatar v-else :name="a.actor" :size="22" />
<div v-if="e.actorType === 'system'" class="sys">sys</div>
<Avatar v-else :name="e.actorEmail || '?'" :size="22" />
<div>
<div class="name">{{ a.actor }}</div>
<Mono dim>{{ a.role }}</Mono>
<div class="name">{{ e.actorEmail || 'system' }}</div>
<Mono dim>{{ e.source }}</Mono>
</div>
</td>
<td><Mono class="action">{{ a.action }}</Mono></td>
<td><span class="target">{{ a.target }}</span></td>
<td><Mono class="action">{{ e.action }}</Mono></td>
<td><span class="target">{{ e.resourceName || e.resourceId || '—' }}</span></td>
<td>
<Mono v-if="a.tenant !== '—'">{{ a.tenant }}</Mono>
<Mono v-if="e.tenantSlug">{{ e.tenantSlug }}</Mono>
<Mono v-else dim></Mono>
</td>
<td><Mono dim>{{ a.ip }}</Mono></td>
<td class="r"><Badge :tone="tone(a)" dot>{{ label(a) }}</Badge></td>
<td><Mono dim>{{ shortIp(e.actorIp) }}</Mono></td>
<td class="r">
<Badge :tone="tone(e)" dot>
{{ e.outcome === 'failure' ? 'fail' : e.outcome === 'success' ? 'ok' : '—' }}
</Badge>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty"><Mono dim>// no matching entries</Mono></div>
<div v-else class="empty"><Mono dim>// no events match the current filters</Mono></div>
</Card>
<Mono dim class="note">// retention 7 years · write-once · mock fixtures — replace with real append-only audit collection</Mono>
<div v-if="allEvents.length" class="footer">
<UiButton v-if="!reachedEnd" variant="secondary" :disabled="pending" @click="loadMore">
Load older events
</UiButton>
<Mono v-else dim>// reached the start of the log</Mono>
</div>
<Mono dim class="note">
// sourced from /audit on platform-api · append-only · hash-chain tamper
evidence + external system ingest (Authentik / OCIS / Stalwart) are
planned follow-ups (see docs/NEXT-STEPS.md)
</Mono>
</div>
</div>
</template>
@@ -115,7 +196,23 @@ function label(a: AuditEntry) {
font-size: 12px;
color: var(--text);
}
.streaming { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
appearance: none;
padding: 6px 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
border-radius: 999px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.chip:hover { color: var(--text); }
.chip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
.streaming { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; }
table { width: 100%; border-collapse: collapse; }
th {
@@ -148,5 +245,6 @@ td.actor { display: flex; align-items: center; gap: 10px; }
}
.empty { padding: 40px 20px; text-align: center; }
.footer { display: flex; justify-content: center; padding: 4px 0; }
.note { display: block; padding: 4px 4px 0 4px; }
</style>
+24 -9
View File
@@ -2,18 +2,27 @@
import type { Tenant } from '~/types/tenant'
import type { Partner } from '~/types/partner'
import type { PlatformUser } from '~/types/user'
import { SERVICES, INCIDENT, OP_AUDIT } from '~/data/fixtures'
import type { AuditEvent } from '~/types/audit'
import { SERVICES, INCIDENT } from '~/data/fixtures'
const { data: tenants, pending: tp, refresh: rT } = await useFetch<Tenant[]>('/api/tenants', { default: () => [] })
const { data: partners, pending: pp, refresh: rP } = await useFetch<Partner[]>('/api/partners', { default: () => [] })
const { data: users, pending: up, refresh: rU } = await useFetch<PlatformUser[]>('/api/users', { default: () => [] })
const { data: auditEvents, refresh: rA } = await useFetch<AuditEvent[]>('/api/audit', {
default: () => [],
query: { limit: 8 },
})
function fmtClock(iso: string) {
return new Date(iso).toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })
}
const { open: openIncident } = useIncidentModal()
const pending = computed(() => tp.value || pp.value || up.value)
async function refresh() {
await Promise.all([rT(), rP(), rU()])
await Promise.all([rT(), rP(), rU(), rA()])
}
const degradedCount = computed(() => SERVICES.filter((s) => s.status !== 'ok').length)
@@ -107,22 +116,24 @@ function fmtDate(d: string) {
</div>
<div class="streaming">
<StatusDot color="var(--ok)" :size="6" />
<Mono dim>streaming · mock</Mono>
<Mono dim>live · {{ auditEvents.length }} recent</Mono>
</div>
</div>
<div>
<div v-for="a in OP_AUDIT.slice(0, 8)" :key="a.id" class="row">
<Mono dim>{{ a.when }}</Mono>
<NuxtLink v-for="a in auditEvents" :key="a._id" class="row" to="/audit">
<Mono dim>{{ fmtClock(a.at) }}</Mono>
<div class="entry">
<div class="line">
<span class="actor">{{ a.actor }}</span>
<span class="actor">{{ a.actorEmail || 'system' }}</span>
<Mono dim>{{ a.action }}</Mono>
<span class="arrow"></span>
<span class="target">{{ a.target }}</span>
<span class="target">{{ a.resourceName || a.resourceId || '—' }}</span>
</div>
<div v-if="a.tenant !== '—'" class="tenant"><Mono dim>tenant: {{ a.tenant }}</Mono></div>
<div v-if="a.tenantSlug" class="tenant"><Mono dim>tenant: {{ a.tenantSlug }}</Mono></div>
</div>
<Badge :tone="a.tone === 'bad' ? 'bad' : a.tone === 'warn' ? 'warn' : 'info'" dot>{{ a.tone }}</Badge>
<Badge :tone="a.outcome === 'failure' ? 'bad' : 'info'" dot>{{ a.outcome === 'failure' ? 'fail' : 'ok' }}</Badge></NuxtLink>
<div v-if="!auditEvents.length" class="row empty-row">
<Mono dim>// no audit events yet — perform an action in operator and reload</Mono>
</div>
</div>
</Card>
@@ -297,8 +308,12 @@ function fmtDate(d: string) {
padding: 10px 20px;
border-bottom: 1px solid var(--border);
font-size: 12px;
text-decoration: none;
color: inherit;
}
.row:hover { background: var(--surface); }
.row:last-child { border-bottom: none; }
.row.empty-row { grid-template-columns: 1fr; }
.entry { min-width: 0; }
.line { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
.actor { font-weight: 500; }
@@ -0,0 +1,7 @@
import { platformApi } from '~~/server/utils/platform-api'
export default defineEventHandler((event) => {
// Forward filter query params verbatim — they match the platform-api DTO.
const q = getQuery(event)
return platformApi(event, '/audit', { query: q as Record<string, string | number | undefined> })
})
+21 -1
View File
@@ -1,12 +1,28 @@
// Helper: forward a request to platform-api using the signed-in operator's
// access token. Every operator proxy route uses this — it's the only place
// we touch the encrypted session.
//
// Also propagates the originating client IP via X-Forwarded-For so the
// platform-api can record it in the audit log. Without this, the API would
// only see the operator container's IP.
import type { H3Event } from 'h3'
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
const BASE = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
function originatingIp(event: H3Event): string | undefined {
// Traefik already injects X-Forwarded-For on the way in. Take the leftmost
// entry (the original client), trimming any whitespace.
const fwd = getHeader(event, 'x-forwarded-for')
if (fwd) {
const first = fwd.split(',')[0]?.trim()
if (first) return first
}
// Direct request (no proxy header) — fall back to the socket address.
return event.node.req.socket?.remoteAddress
}
export async function platformApi<T = unknown>(
event: H3Event,
path: string,
@@ -18,10 +34,14 @@ export async function platformApi<T = unknown>(
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const clientIp = originatingIp(event)
const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}` }
if (clientIp) headers['x-forwarded-for'] = clientIp
try {
return (await $fetch(`${BASE}${path}`, {
method: (init.method as 'GET' | 'POST' | 'PATCH' | 'DELETE') ?? 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
headers,
body: init.body,
query: init.query,
})) as T
+24
View File
@@ -0,0 +1,24 @@
// Shape returned by /api/audit — matches AuditEvent on platform-api.
export type AuditOutcome = 'success' | 'failure'
export type AuditSource = 'platform-api' | 'operator-ui' | 'portal' | 'authentik' | 'ocis' | 'stalwart'
export type AuditResourceType = 'tenant' | 'partner' | 'user' | 'flag' | 'subscription' | 'system'
export interface AuditEvent {
_id: string
at: string // ISO timestamp
actorType: 'user' | 'system'
actorId?: string
actorEmail?: string
actorIp?: string
action: string
outcome: AuditOutcome
resourceType?: AuditResourceType
resourceId?: string
resourceName?: string
tenantSlug?: string
partnerSlug?: string
source: AuditSource
metadata?: Record<string, unknown>
recordedAt?: string
}