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:
+139
-41
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user