Files
dezky/apps/operator/pages/audit.vue
T
Ronni Baslund d3376d7f4a feat(operator): expandable audit row reveals event metadata
Rows with a non-empty metadata object now show a chevron and become
clickable. Clicking expands a detail row underneath that lists every
metadata field — partner.updated/tenant.updated render their 'changes'
array as a row of mono chips, everything else falls back to a generic
key/value layout. Toggling is per-row, so the operator can open
several at once when comparing actions.
2026-05-24 22:17:04 +02:00

564 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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 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
})
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)
}
// 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:/, '')
}
// Row expansion — reveals metadata.changes + any other metadata keys for the
// row. Kept as a plain Set so toggling stays O(1) and the operator can have
// multiple rows open at once.
const expanded = ref(new Set<string>())
function toggleRow(id: string) {
if (expanded.value.has(id)) expanded.value.delete(id)
else expanded.value.add(id)
// Force reactivity — Set mutations aren't tracked by Vue directly.
expanded.value = new Set(expanded.value)
}
function hasDetails(e: AuditEvent): boolean {
return !!e.metadata && Object.keys(e.metadata).length > 0
}
function formatMetaValue(v: unknown): string {
if (v === null || v === undefined) return '—'
if (Array.isArray(v)) return v.join(', ')
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}
// ── Tamper-evidence (Phase 3) + cold-storage archives (Phase 4) ────────
interface VerifyReport {
ok: boolean
totalEventsVerified: number
checkpointsChecked: number
latestCheckpointAt: string | null
latestVerifiedSeq: number | null
// Phase 4 additions — included in /audit/verify response.
oldestHotSeq: number | null
highestArchivedSeq: number | null
break?:
| { kind: 'event-hash-mismatch'; seq: number; expected: string; actual: string }
| { kind: 'event-prev-hash-mismatch'; seq: number; expected: string; actual: string }
| { kind: 'checkpoint-signature-mismatch'; headSeq: number }
}
interface CheckpointSummary {
at: string | null
headSeq: number | null
headHash: string | null
reason?: string
}
interface ArchiveBatch {
_id: string
archivedAt: string
startSeq: number
endSeq: number
eventCount: number
manifestSha256: string
jsonlKey: string
manifestKey: string
bytesUncompressed: number
}
const { data: latestCp, refresh: refreshCp } = useLazyFetch<CheckpointSummary>(
'/api/audit/checkpoint/latest',
{ default: () => ({ at: null, headSeq: null, headHash: null }), server: false },
)
const { data: archives, refresh: refreshArchives } = useLazyFetch<ArchiveBatch[]>(
'/api/audit/archives',
{ default: () => [], server: false },
)
const verifyReport = ref<VerifyReport | null>(null)
const verifying = ref(false)
const archiving = ref(false)
const archiveResult = ref<{ ok: boolean; reason?: string; eventCount?: number; startSeq?: number; endSeq?: number } | null>(null)
async function runVerify() {
verifying.value = true
try {
verifyReport.value = await $fetch<VerifyReport>('/api/audit/verify')
await refreshCp()
} finally {
verifying.value = false
}
}
async function forceCheckpoint() {
await $fetch('/api/audit/checkpoint', { method: 'POST' })
await refreshCp()
}
async function forceArchive() {
archiving.value = true
archiveResult.value = null
try {
archiveResult.value = await $fetch('/api/audit/archive/run', { method: 'POST' })
await Promise.all([refreshArchives(), refresh()])
} finally {
archiving.value = false
}
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
function fmtRelative(iso: string | null | undefined): string {
if (!iso) return 'never'
const ms = Date.now() - new Date(iso).getTime()
if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`
return new Date(iso).toLocaleDateString('da-DK')
}
</script>
<template>
<div>
<PageHeader
eyebrow="Compliance"
title="Global audit log"
: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, actor, target, tenant…" type="text" />
</div>
<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>
<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="allEvents.length">
<thead>
<tr>
<th class="caret-col"></th>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Target</th>
<th>Tenant</th>
<th>IP</th>
<th class="r">Result</th>
</tr>
</thead>
<tbody>
<template v-for="e in allEvents" :key="e._id">
<tr
:class="['row', { expandable: hasDetails(e), open: expanded.has(e._id) }]"
@click="hasDetails(e) && toggleRow(e._id)"
>
<td class="caret-col">
<UiIcon
v-if="hasDetails(e)"
:name="expanded.has(e._id) ? 'chevDown' : 'chevRight'"
:size="12"
/>
</td>
<td><Mono>{{ fmtAbs(e.at) }}</Mono></td>
<td class="actor">
<div v-if="e.actorType === 'system'" class="sys">sys</div>
<Avatar v-else :name="e.actorEmail || '?'" :size="22" />
<div>
<div class="name">{{ e.actorEmail || 'system' }}</div>
<Mono dim>{{ e.source }}</Mono>
</div>
</td>
<td><Mono class="action">{{ e.action }}</Mono></td>
<td><span class="target">{{ e.resourceName || e.resourceId || '—' }}</span></td>
<td>
<Mono v-if="e.tenantSlug">{{ e.tenantSlug }}</Mono>
<Mono v-else dim></Mono>
</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>
<tr v-if="expanded.has(e._id) && hasDetails(e)" class="detail-row">
<td></td>
<td colspan="7">
<dl class="meta">
<template v-for="(value, key) in e.metadata" :key="key">
<dt><Mono dim>{{ key }}</Mono></dt>
<dd>
<span v-if="key === 'changes' && Array.isArray(value)" class="changes">
<Mono v-for="field in value as string[]" :key="field" class="change-chip">
{{ field }}
</Mono>
</span>
<Mono v-else>{{ formatMetaValue(value) }}</Mono>
</dd>
</template>
</dl>
</td>
</tr>
</template>
</tbody>
</table>
<div v-else class="empty"><Mono dim>// no events match the current filters</Mono></div>
</Card>
<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>
<!-- Tamper-evidence panel Phase 3 -->
<Card :pad="0" class="verify-card">
<div class="verify-head">
<div>
<Eyebrow>Tamper evidence</Eyebrow>
<div class="cap">
Hash chain · {{ latestCp?.headSeq != null ? `signed through seq ${latestCp.headSeq}` : 'no checkpoints yet' }}
</div>
</div>
<div class="verify-actions">
<UiButton variant="secondary" :disabled="verifying" @click="forceCheckpoint">
Force checkpoint
</UiButton>
<UiButton variant="primary" :disabled="verifying" @click="runVerify">
{{ verifying ? 'Verifying' : 'Verify chain' }}
</UiButton>
</div>
</div>
<div class="verify-meta">
<div class="kv"><Eyebrow>Last signed checkpoint</Eyebrow><Mono>{{ fmtRelative(latestCp?.at ?? null) }}</Mono></div>
<div v-if="latestCp?.headHash" class="kv"><Eyebrow>Head hash</Eyebrow><Mono dim>{{ latestCp.headHash.slice(0, 16) }}</Mono></div>
<div v-if="latestCp?.reason" class="kv"><Eyebrow>Reason</Eyebrow><Mono dim>{{ latestCp.reason }}</Mono></div>
</div>
<!-- Verify result -->
<div v-if="verifyReport" class="verify-result" :data-ok="verifyReport.ok">
<div v-if="verifyReport.ok" class="result-line">
<Badge tone="ok" dot>verified</Badge>
<Mono>{{ verifyReport.totalEventsVerified }} event(s) · {{ verifyReport.checkpointsChecked }} checkpoint(s) · last seq {{ verifyReport.latestVerifiedSeq ?? '—' }}</Mono>
</div>
<div v-else class="result-line">
<Badge tone="bad" dot>BROKEN</Badge>
<Mono v-if="verifyReport.break?.kind === 'event-hash-mismatch'">
event hash mismatch at seq {{ verifyReport.break.seq }} · stored {{ verifyReport.break.actual.slice(0, 16) }} expected {{ verifyReport.break.expected.slice(0, 16) }}
</Mono>
<Mono v-else-if="verifyReport.break?.kind === 'event-prev-hash-mismatch'">
chain link broken at seq {{ verifyReport.break.seq }} · prevHash mismatch
</Mono>
<Mono v-else-if="verifyReport.break?.kind === 'checkpoint-signature-mismatch'">
checkpoint signature mismatch at head seq {{ verifyReport.break.headSeq }}
</Mono>
</div>
</div>
</Card>
<!-- Cold-storage archives Phase 4 -->
<Card :pad="0" class="archive-card">
<div class="archive-head">
<div>
<Eyebrow>Cold storage</Eyebrow>
<div class="cap">
{{
archives?.length
? `archived through seq ${archives[0].endSeq} · ${archives.length} batch${archives.length === 1 ? '' : 'es'}`
: 'no archives yet · 90-day hot retention'
}}
</div>
</div>
<UiButton variant="secondary" :disabled="archiving" @click="forceArchive">
{{ archiving ? 'Archiving' : 'Run archive now' }}
</UiButton>
</div>
<div v-if="archiveResult" class="archive-result" :data-ok="archiveResult.ok">
<Badge v-if="archiveResult.ok && archiveResult.eventCount" tone="ok" dot>archived</Badge>
<Badge v-else-if="archiveResult.ok" tone="info" dot>no-op</Badge>
<Badge v-else tone="bad" dot>failed</Badge>
<Mono v-if="archiveResult.ok && archiveResult.eventCount">
{{ archiveResult.eventCount }} event(s) · seq {{ archiveResult.startSeq }}{{ archiveResult.endSeq }}
</Mono>
<Mono v-else dim>{{ archiveResult.reason || '—' }}</Mono>
</div>
<table v-if="archives?.length">
<thead>
<tr>
<th>Archived</th>
<th>Seq range</th>
<th>Events</th>
<th>Size</th>
<th>Manifest sha256</th>
</tr>
</thead>
<tbody>
<tr v-for="b in archives" :key="b._id">
<td><Mono>{{ fmtAbs(b.archivedAt) }}</Mono></td>
<td><Mono>{{ b.startSeq }}{{ b.endSeq }}</Mono></td>
<td><Mono>{{ b.eventCount }}</Mono></td>
<td><Mono dim>{{ fmtBytes(b.bytesUncompressed) }}</Mono></td>
<td><Mono dim>{{ b.manifestSha256.slice(0, 16) }}</Mono></td>
</tr>
</tbody>
</table>
<div v-else class="empty"><Mono dim>// no archive batches yet — events stay in hot Mongo for {{ '90' }} days, then move to S3 (MinIO in dev / Hetzner in prod)</Mono></div>
</Card>
<Mono dim class="note">
// hot tier: Mongo · cold tier: S3-compatible object storage ·
sha256 hash-chain with HMAC-signed checkpoints + signed archive
manifests · retention 90 days hot, indefinite cold · production
encryption at rest is SSE-S3
</Mono>
</div>
</div>
</template>
<style scoped>
.stage { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 16px; }
.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.search {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-mute);
min-width: 320px;
}
.search input {
background: transparent;
border: 0;
outline: 0;
flex: 1;
font-family: inherit;
font-size: 12px;
color: var(--text);
}
.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 {
text-align: left;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
padding: 12px 16px;
font-weight: 500;
border-bottom: 1px solid var(--border);
}
th.r, td.r { text-align: right; }
td { padding: 10px 16px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; }
td.actor { display: flex; align-items: center; gap: 10px; }
/* Expandable row: rows with metadata show a caret + become clickable. */
.caret-col { width: 28px; padding-left: 12px; padding-right: 0; color: var(--text-mute); }
tr.row.expandable { cursor: pointer; }
tr.row.expandable:hover { background: var(--surface); }
tr.row.open .caret-col { color: var(--text); }
tr.detail-row td {
background: var(--surface);
border-top: 0;
padding: 10px 16px 14px 16px;
}
.meta {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 14px;
align-items: baseline;
}
.meta dt { font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; }
.meta dd { margin: 0; font-size: 12px; color: var(--text-dim); }
.changes { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.change-chip {
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg);
font-size: 11px;
color: var(--text);
}
.name { font-size: 12px; font-weight: 500; }
.action { font-weight: 500; }
.target { font-size: 12px; color: var(--text-dim); }
.sys {
width: 22px; height: 22px;
border-radius: 4px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-family: var(--font-mono);
}
.empty { padding: 40px 20px; text-align: center; }
.footer { display: flex; justify-content: center; padding: 4px 0; }
.note { display: block; padding: 4px 4px 0 4px; }
/* Tamper-evidence panel */
.verify-card { margin-top: 8px; }
.verify-head {
padding: 14px 18px;
display: flex; justify-content: space-between; align-items: center;
gap: 16px;
border-bottom: 1px solid var(--border);
}
.verify-head .cap { font-family: var(--font-display); font-weight: 600; font-size: 15px; margin-top: 2px; }
.verify-actions { display: flex; gap: 8px; flex-shrink: 0; }
.verify-meta {
padding: 12px 18px;
display: flex; gap: 24px; flex-wrap: wrap;
}
.verify-meta .kv { display: flex; flex-direction: column; gap: 4px; }
.verify-result {
padding: 12px 18px;
border-top: 1px solid var(--border);
}
.verify-result[data-ok="true"] { background: rgba(31, 138, 91, 0.05); }
.verify-result[data-ok="false"] { background: rgba(240, 88, 88, 0.06); }
.result-line { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* Cold-storage archives panel */
.archive-card { margin-top: 8px; }
.archive-head {
padding: 14px 18px;
display: flex; justify-content: space-between; align-items: center;
gap: 16px;
border-bottom: 1px solid var(--border);
}
.archive-head .cap { font-family: var(--font-display); font-weight: 600; font-size: 15px; margin-top: 2px; }
.archive-result {
padding: 10px 18px;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
border-bottom: 1px solid var(--border);
}
.archive-result[data-ok="true"] { background: rgba(31, 138, 91, 0.05); }
.archive-result[data-ok="false"] { background: rgba(240, 88, 88, 0.06); }
.archive-card table { width: 100%; border-collapse: collapse; }
.archive-card th, .archive-card td { padding: 10px 18px; font-size: 12px; text-align: left; }
.archive-card th { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); font-weight: 500; border-bottom: 1px solid var(--border); }
.archive-card td { border-top: 1px solid var(--border); }
.archive-card .empty { padding: 16px 18px; }
</style>