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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user