Files
dezky/apps/operator/pages/audit.vue
T
Ronni Baslund 9435baa09d feat(audit): hash-chain tamper evidence + signed checkpoints (Phase 3)
The audit log now carries cryptographic chain-of-custody. Every chained
event references the previous event's sha256, and periodic checkpoints
sign the head with HMAC-SHA-256. An attacker who modifies a historical
row must also forge every checkpoint signature past it — which requires
the AUDIT_SIGNING_KEY, kept outside Mongo.

Schema (services/platform-api/src/schemas/):
  - audit-event.schema.ts: new `seq` (monotonic) + `chained` (Phase-3-or-
    later flag) + `prevHash` + `hash`. Compound unique index on seq with
    partial filter so pre-Phase-3 rows don't collide on null.
  - audit-counter.schema.ts: single doc `_id='audit_seq'`, incremented
    atomically by findOneAndUpdate($inc).
  - audit-checkpoint.schema.ts: { at, headSeq, headHash, signature,
    sigAlg, reason }. Reason ∈ {startup, interval, threshold, manual}.

Audit module (services/platform-api/src/audit/):
  - canonical.ts: stable JSON form + hashCanonical (sha256) +
    checkpointSignature (HMAC-SHA-256) + verifyCheckpointSignature
    (timingSafeEqual). Single source of truth for hash inputs — schema
    additions land here at the same time as the field.
  - audit.service.ts: record() now allocates seq → looks up lastHash() →
    computes hash → inserts. Per-process write mutex serializes the
    allocate+lookup so concurrent writers don't both chain off the same
    predecessor. Documented multi-instance caveat (needs Mongo replica
    set + transactions OR a distributed lock).
  - checkpoint.service.ts: scheduler triggers on startup + every 5min
    + threshold of 100 events accumulated. Skips when no new chained
    events since the last anchor.
  - verifier.service.ts: walks chain in seq order, recomputes each
    hash, validates checkpoint signatures. Returns a precise break:
    'event-hash-mismatch' (in-place modification), 'event-prev-hash-
    mismatch' (insertion/deletion), or 'checkpoint-signature-mismatch'.
  - audit.controller.ts: GET /audit/verify, GET /audit/checkpoint/latest,
    POST /audit/checkpoint (manual force).

Operator UI (apps/operator/):
  - 3 new proxies under /api/audit/{verify, checkpoint/latest, checkpoint}.
  - pages/audit.vue: new "Tamper evidence" card with "Force checkpoint"
    + "Verify chain" buttons. Header shows live head seq; result line
    shows verified count or a precise break (kind + seq + expected vs
    actual hash). Background tinted green/red on ok/broken.

Env (.env + docker-compose.yml):
  - new AUDIT_SIGNING_KEY (32-byte hex HMAC secret). Prod swaps this for
    ed25519 from an HSM/KMS; verifier code stays the same because sigAlg
    is on the checkpoint doc.

Smoke-tested all three break paths against a clean chain of 5 events:
  - normal verify: ok=true, 5/5 events verified, 1 checkpoint signed
  - modified seq=3 in Mongo directly: verify returns ok=false with
    break = { kind: 'event-hash-mismatch', seq: 3, expected, actual }
  - restored, nuked checkpoint signature: break = { kind:
    'checkpoint-signature-mismatch', headSeq: 5 }
  - operator UI's verify panel reflects all three states correctly.

Legacy data: pre-Phase-3 events stay `chained: false` and are excluded
from the chain walk. Retroactive chaining of historical entries is a
one-off migration script we can run if we ever care to.

Out of scope (Phase 4 etc.):
  - TTL + cold-storage archival to Hetzner Object Storage
  - GDPR right-to-erasure tooling
  - ed25519 / HSM signing (swap is well-defined; sigAlg field is ready)
  - Multi-instance write coordination (Mongo transaction OR distributed
    lock when we scale platform-api beyond 1 replica)
2026-05-24 20:43:54 +02:00

372 lines
13 KiB
Vue

<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:/, '')
}
// ── Tamper-evidence (Phase 3) ──────────────────────────────────────────
interface VerifyReport {
ok: boolean
totalEventsVerified: number
checkpointsChecked: number
latestCheckpointAt: string | null
latestVerifiedSeq: 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
}
const { data: latestCp, refresh: refreshCp } = useLazyFetch<CheckpointSummary>(
'/api/audit/checkpoint/latest',
{ default: () => ({ at: null, headSeq: null, headHash: null }), server: false },
)
const verifyReport = ref<VerifyReport | null>(null)
const verifying = ref(false)
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()
}
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>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>
<tr v-for="e in allEvents" :key="e._id">
<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>
</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>
<Mono dim class="note">
// sourced from /audit on platform-api · append-only · sha256 hash-chain
with HMAC-signed checkpoints every 100 events or 5 minutes · retention
+ cold-storage archival to Hetzner Object Storage is Phase 4
</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; }
.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; }
</style>