Files
dezky/apps/operator/pages/audit.vue
T
Ronni Baslund 4d9e906ec1 feat(audit): cold-storage archival to S3 (Phase 4)
Final piece of the audit work. Events older than the hot retention window
move to S3-compatible object storage with signed manifests. Production uses
Hetzner Object Storage; dev uses a MinIO container with the same API.

Infra (infrastructure/docker-compose):
  - New `minio` service exposing the S3 API at minio:9000 + admin console at
    minio.dezky.local. Healthchecked. Bucket-init sidecar runs `mc mb` once
    to create `dezky-audit`; safe to re-run.
  - .env adds MINIO_ROOT_USER + MINIO_ROOT_PASSWORD.
  - platform-api env: AUDIT_COLD_{ENDPOINT,REGION,BUCKET,ACCESS_KEY,SECRET_KEY}
    + AUDIT_HOT_RETENTION_DAYS=90 + ARCHIVE_ENABLED=false (dormant in dev;
    operator UI's "Run archive now" bypasses this gate). AUDIT_COLD_SSE
    opts into SSE-S3 — left unset in dev because MinIO without a KMS rejects
    AES256 PUTs with "KMS is not configured".

Platform-api (services/platform-api/src/cold/):
  - cold-storage.client.ts: thin @aws-sdk/client-s3 wrapper — put/head/list.
    forcePathStyle=true so MinIO and Hetzner both work; same code, env-swap.
  - archive.service.ts: runOnce() selects chained events with at < cutoff →
    serializes to JSONL → gzip → sha256s → uploads JSONL + signed manifest
    → HEAD-confirms both objects exist → records an ArchiveBatch doc → only
    then deletes from hot Mongo. Crash-safe: a failed upload leaves events
    in hot. Manifest uses the Phase 3 AUDIT_SIGNING_KEY (HMAC-SHA-256), so
    archives + checkpoints share trust chain. Bypassable via { override:
    true } for the operator's UI force-run.
  - archive.worker.ts: hourly tick guarded by configured run-hour-UTC
    (default 03:00) + day-guard so the same UTC day doesn't archive twice.
    Disabled until ARCHIVE_ENABLED=true.
  - archive-batch.schema.ts: { archivedAt, startSeq, endSeq, eventCount,
    manifestSha256, jsonlKey, manifestKey, bytesUncompressed }. The
    manifest sha256 stored in Mongo lets us detect manifest tampering
    without downloading the actual manifest.

Audit module additions:
  - audit.controller.ts: GET /audit/archives, POST /audit/archive/run,
    /audit/verify now reports { oldestHotSeq, highestArchivedSeq } so the
    UI shows the tier boundary.

Operator UI (apps/operator):
  - 2 new proxies: /api/audit/archives + /api/audit/archive/run (force
    override=true). Both behind operator auth via the existing platformApi
    helper.
  - audit.vue: new "Cold storage" card with batch table (archived-at, seq
    range, event count, size, truncated manifest sha256), "Run archive
    now" button + per-run result line.

Smoke-tested end-to-end:
  - 7 chained events in hot. /api/audit/archive/run → ok=true, batchId
    returned. JSONL + manifest both exist in MinIO (verified via mc ls +
    mc cat). Mongo's chained set went 7 → 0. Verify reports
    highestArchivedSeq=1446 (since we burn-allocate seqs on Authentik
    dup-key rejections). Operator /audit panel shows the batch with
    manifest hash 1d8263…
  - First attempt with SSE-S3 enabled failed cleanly (MinIO KMS not
    configured) — archive service correctly left events in hot Mongo.
    Made SSE opt-in via AUDIT_COLD_SSE=true; prod turns it on.

Out of scope (each could be its own session):
  - Restore-to-hot endpoint (today: download from S3 + offline query)
  - Client-side encryption (today: SSE-S3 in prod, none in dev)
  - Multi-region replication
  - Soft TTL safety net (defense-in-depth on top of app-managed deletion)

This completes the four-phase audit log work:
  1. platform-api as audit hub
  2. External system ingest (Authentik / Stalwart / OCIS)
  3. Hash-chain + signed checkpoints (tamper evidence)
  4. Cold-storage archival (retention without unbounded Mongo growth)
2026-05-24 21:03:41 +02:00

483 lines
17 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:/, '')
}
// ── 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>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>
<!-- 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; }
.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>