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:
@@ -0,0 +1,7 @@
|
||||
import { platformApi } from '~~/server/utils/platform-api'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
// Forward filter query params verbatim — they match the platform-api DTO.
|
||||
const q = getQuery(event)
|
||||
return platformApi(event, '/audit', { query: q as Record<string, string | number | undefined> })
|
||||
})
|
||||
@@ -1,12 +1,28 @@
|
||||
// Helper: forward a request to platform-api using the signed-in operator's
|
||||
// access token. Every operator proxy route uses this — it's the only place
|
||||
// we touch the encrypted session.
|
||||
//
|
||||
// Also propagates the originating client IP via X-Forwarded-For so the
|
||||
// platform-api can record it in the audit log. Without this, the API would
|
||||
// only see the operator container's IP.
|
||||
|
||||
import type { H3Event } from 'h3'
|
||||
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||
|
||||
const BASE = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||
|
||||
function originatingIp(event: H3Event): string | undefined {
|
||||
// Traefik already injects X-Forwarded-For on the way in. Take the leftmost
|
||||
// entry (the original client), trimming any whitespace.
|
||||
const fwd = getHeader(event, 'x-forwarded-for')
|
||||
if (fwd) {
|
||||
const first = fwd.split(',')[0]?.trim()
|
||||
if (first) return first
|
||||
}
|
||||
// Direct request (no proxy header) — fall back to the socket address.
|
||||
return event.node.req.socket?.remoteAddress
|
||||
}
|
||||
|
||||
export async function platformApi<T = unknown>(
|
||||
event: H3Event,
|
||||
path: string,
|
||||
@@ -18,10 +34,14 @@ export async function platformApi<T = unknown>(
|
||||
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||
}
|
||||
|
||||
const clientIp = originatingIp(event)
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}` }
|
||||
if (clientIp) headers['x-forwarded-for'] = clientIp
|
||||
|
||||
try {
|
||||
return (await $fetch(`${BASE}${path}`, {
|
||||
method: (init.method as 'GET' | 'POST' | 'PATCH' | 'DELETE') ?? 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
headers,
|
||||
body: init.body,
|
||||
query: init.query,
|
||||
})) as T
|
||||
|
||||
Reference in New Issue
Block a user