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:
Ronni Baslund
2026-05-24 19:50:24 +02:00
parent 5407c04682
commit 02341d8ba5
26 changed files with 864 additions and 128 deletions
@@ -7,8 +7,10 @@ import {
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common'
import { clientIp } from '../auth/client-ip.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import { OperatorGuard } from '../auth/operator.guard.js'
@@ -43,8 +45,12 @@ export class FlagsController {
@Post()
@UseGuards(OperatorGuard)
create(@Body() dto: CreateFlagDto, @CurrentUser() jwt: AuthentikJwtPayload) {
return this.flags.create(dto, { email: jwt.email })
create(
@Body() dto: CreateFlagDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
return this.flags.create(dto, { email: jwt.email, ip: clientIp(req) })
}
@Patch(':key')
@@ -53,14 +59,19 @@ export class FlagsController {
@Param('key') key: string,
@Body() dto: UpdateFlagDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
return this.flags.update(key, dto, { email: jwt.email })
return this.flags.update(key, dto, { email: jwt.email, ip: clientIp(req) })
}
@Delete(':key')
@HttpCode(204)
@UseGuards(OperatorGuard)
async remove(@Param('key') key: string) {
await this.flags.remove(key)
async remove(
@Param('key') key: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
await this.flags.remove(key, { email: jwt.email, ip: clientIp(req) })
}
}
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { Flag, FlagSchema } from '../schemas/flag.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
@@ -9,6 +10,7 @@ import { FlagsService } from './flags.service.js'
@Module({
imports: [
AuthModule,
AuditModule,
MongooseModule.forFeature([
{ name: Flag.name, schema: FlagSchema },
{ name: Tenant.name, schema: TenantSchema },
@@ -7,6 +7,7 @@ import {
import { InjectModel } from '@nestjs/mongoose'
import { createHash } from 'node:crypto'
import { Model } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { Flag, FlagDocument } from '../schemas/flag.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import type { CreateFlagDto } from './dto/create-flag.dto.js'
@@ -25,10 +26,10 @@ export interface EvalContext {
env: 'prod' | 'staging' | 'dev'
}
export interface ActorRef {
userId?: string
email?: string
}
// Existing ActorRef was a local subset of AuditActor — collapse to the shared
// type so the controller passes one shape into both flag history and the
// platform audit log.
export type ActorRef = AuditActor
@Injectable()
export class FlagsService {
@@ -37,6 +38,7 @@ export class FlagsService {
constructor(
@InjectModel(Flag.name) private readonly flagModel: Model<FlagDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
private readonly audit: AuditService,
) {}
async create(dto: CreateFlagDto, actor?: ActorRef): Promise<FlagDocument> {
@@ -56,6 +58,16 @@ export class FlagsService {
},
],
})
void this.audit.record(
{
action: 'flag.created',
resourceType: 'flag',
resourceId: String(doc._id),
resourceName: doc.key,
metadata: { state: doc.state, pct: doc.pct },
},
actor,
)
return doc
}
@@ -109,12 +121,38 @@ export class FlagsService {
}
await flag.save()
// Kill-switch (state→off + pct→0 from a non-off state) gets its own action
// verb so the operator can search the timeline for "what got killed?". The
// FlagDetail UI's kill-switch button always passes note='kill-switch'.
const isKillSwitch = dto.state === 'off' && dto.note === 'kill-switch'
void this.audit.record(
{
action: isKillSwitch ? 'flag.killed' : 'flag.updated',
resourceType: 'flag',
resourceId: String(flag._id),
resourceName: flag.key,
metadata: { changes: actions, note: dto.note },
},
actor,
)
return flag
}
async remove(key: string): Promise<void> {
async remove(key: string, actor?: ActorRef): Promise<void> {
const flag = await this.flagModel.findOne({ key }).exec()
const result = await this.flagModel.deleteOne({ key }).exec()
if (result.deletedCount === 0) throw new NotFoundException(`Flag "${key}" not found`)
void this.audit.record(
{
action: 'flag.deleted',
resourceType: 'flag',
resourceId: flag ? String(flag._id) : undefined,
resourceName: key,
},
actor,
)
}
// ── Evaluation ───────────────────────────────────────────────────────────