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
+2
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from './audit/audit.module.js'
import { AuthModule } from './auth/auth.module.js'
import { FlagsModule } from './flags/flags.module.js'
import { HealthModule } from './health/health.module.js'
@@ -17,6 +18,7 @@ import { UsersModule } from './users/users.module.js'
process.env.MONGODB_URI ?? 'mongodb://localhost:27017/dezky',
),
AuthModule,
AuditModule,
HealthModule,
TenantsModule,
PartnersModule,
@@ -0,0 +1,30 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import { OperatorGuard } from '../auth/operator.guard.js'
import { ListAuditDto } from './dto/list-audit.dto.js'
import { AuditService } from './audit.service.js'
// Read-only. There is intentionally no POST/PATCH/DELETE — entries are
// written by AuditService.record(), called from every mutation in other
// modules. Operator-only because the trail is sensitive.
@Controller('audit')
@UseGuards(JwtAuthGuard, OperatorGuard)
export class AuditController {
constructor(private readonly audit: AuditService) {}
@Get()
list(@Query() q: ListAuditDto) {
return this.audit.list({
since: q.since ? new Date(q.since) : undefined,
until: q.until ? new Date(q.until) : undefined,
before: q.before ? new Date(q.before) : undefined,
action: q.action,
tenantSlug: q.tenantSlug,
partnerSlug: q.partnerSlug,
actorEmail: q.actorEmail,
outcome: q.outcome,
q: q.q,
limit: q.limit,
})
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuthModule } from '../auth/auth.module.js'
import { AuditEvent, AuditEventSchema } from '../schemas/audit-event.schema.js'
import { AuditController } from './audit.controller.js'
import { AuditService } from './audit.service.js'
@Module({
imports: [
AuthModule,
MongooseModule.forFeature([{ name: AuditEvent.name, schema: AuditEventSchema }]),
],
controllers: [AuditController],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
@@ -0,0 +1,145 @@
// Single entry-point for writing the platform audit trail. Every mutation in
// the API layer threads through `record()` so the operator's /audit timeline
// reflects reality. The service is intentionally minimal — no UPDATE/DELETE
// methods exist, and `list` is read-only.
//
// What's NOT here yet (tracked as Phase 2+ in the audit plan):
// - Ingest adapters for Authentik / OCIS / Stalwart
// - Hash-chain tamper evidence (schema fields exist; computation lands later)
// - TTL / cold-storage archival
// - GDPR right-to-erasure (delete events for tenant X)
import { Injectable, Logger } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import type { FilterQuery, Model, Types } from 'mongoose'
import {
AuditEvent,
type AuditEventDocument,
type AuditOutcome,
type AuditResourceType,
type AuditSource,
} from '../schemas/audit-event.schema.js'
export interface AuditActor {
userId?: Types.ObjectId | string
email?: string
ip?: string
}
// What every caller hands to record(). Required: action + outcome. Everything
// else is contextual.
export interface AuditRecordInput {
action: string
outcome?: AuditOutcome
resourceType?: AuditResourceType
resourceId?: string
resourceName?: string
tenantSlug?: string
partnerSlug?: string
source?: AuditSource
metadata?: Record<string, unknown>
}
export interface AuditListFilters {
since?: Date
until?: Date
before?: Date
action?: string // exact or prefix (handled below)
tenantSlug?: string
partnerSlug?: string
actorEmail?: string
outcome?: AuditOutcome
q?: string
limit?: number
}
const DEFAULT_LIMIT = 100
const MAX_LIMIT = 500
@Injectable()
export class AuditService {
private readonly logger = new Logger(AuditService.name)
constructor(
@InjectModel(AuditEvent.name) private readonly model: Model<AuditEventDocument>,
) {}
// Best-effort. We deliberately swallow write failures rather than failing
// the operator's actual request — losing an audit entry is bad, but failing
// the underlying mutation (which already succeeded) is worse. Write failures
// are surfaced via the logger so they show up in container logs.
async record(input: AuditRecordInput, actor?: AuditActor): Promise<void> {
try {
await this.model.create({
at: new Date(),
actorType: actor?.userId || actor?.email ? 'user' : 'system',
actorId: actor?.userId,
actorEmail: actor?.email,
actorIp: actor?.ip,
action: input.action,
outcome: input.outcome ?? 'success',
resourceType: input.resourceType,
resourceId: input.resourceId,
resourceName: input.resourceName,
tenantSlug: input.tenantSlug,
partnerSlug: input.partnerSlug,
source: input.source ?? 'platform-api',
metadata: input.metadata,
})
} catch (err) {
this.logger.error(
`audit write failed for action=${input.action} resource=${input.resourceId}: ${
err instanceof Error ? err.message : String(err)
}`,
)
}
}
async list(filters: AuditListFilters): Promise<AuditEventDocument[]> {
const q: FilterQuery<AuditEventDocument> = {}
if (filters.since || filters.until || filters.before) {
const at: Record<string, Date> = {}
if (filters.since) at.$gte = filters.since
if (filters.until) at.$lte = filters.until
// `before` overrides `until` if both somehow get passed; the timeline UI
// uses `before` for paging through older history.
if (filters.before) at.$lt = filters.before
q.at = at
}
if (filters.action) {
// Allow prefix match: 'tenant' → everything under 'tenant.*'. If the
// caller passes a full dotted verb we still match it exactly.
q.action = filters.action.includes('.')
? filters.action
: { $regex: `^${escapeRegex(filters.action)}(\\.|$)` }
}
if (filters.tenantSlug) q.tenantSlug = filters.tenantSlug
if (filters.partnerSlug) q.partnerSlug = filters.partnerSlug
if (filters.actorEmail) q.actorEmail = filters.actorEmail
if (filters.outcome) q.outcome = filters.outcome
if (filters.q) {
// Free-text across the human-displayed fields. Case-insensitive contains.
const re = new RegExp(escapeRegex(filters.q), 'i')
q.$or = [
{ action: re },
{ resourceName: re },
{ actorEmail: re },
{ tenantSlug: re },
]
}
const limit = clamp(filters.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT)
return this.model.find(q).sort({ at: -1, _id: -1 }).limit(limit).exec()
}
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n))
}
@@ -0,0 +1,43 @@
import { Transform } from 'class-transformer'
import { IsDateString, IsEnum, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'
// Query params for GET /audit. All optional — empty returns the most recent
// page. Pagination is cursor-based via `before`, which is the ISO timestamp of
// the last event in the previous page.
export class ListAuditDto {
@IsOptional() @IsDateString()
since?: string
@IsOptional() @IsDateString()
until?: string
// ISO timestamp from the last event in the previous page. Mongo query is
// `{ at: { $lt: before } }` so the next page picks up immediately after.
@IsOptional() @IsDateString()
before?: string
// dotted.snake_case verb or prefix (e.g. 'tenant' matches everything under
// 'tenant.*'). Server uses a prefix match against the indexed `action` field.
@IsOptional() @IsString() @MaxLength(80)
action?: string
@IsOptional() @IsString() @MaxLength(64)
tenantSlug?: string
@IsOptional() @IsString() @MaxLength(64)
partnerSlug?: string
@IsOptional() @IsString() @MaxLength(120)
actorEmail?: string
@IsOptional() @IsEnum(['success', 'failure'])
outcome?: 'success' | 'failure'
@IsOptional() @IsString() @MaxLength(80)
q?: string // free-text on action / resourceName / actorEmail
@IsOptional()
@Transform(({ value }) => (value === undefined ? undefined : Number(value)))
@IsInt() @Min(1) @Max(500)
limit?: number
}
@@ -0,0 +1,19 @@
// Extract the originating client IP from a Fastify/Express request. The Nuxt
// proxies in front of platform-api set x-forwarded-for to the real user's IP
// (see apps/operator/server/utils/platform-api.ts). Without this helper we'd
// only ever see the proxy container's address in the audit log.
//
// We take the leftmost entry of x-forwarded-for. That's the address closest
// to the real user; everything to the right is intermediate proxies. If the
// header is missing (direct request, no proxy), fall back to the socket
// address.
export function clientIp(req: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string } }): string | undefined {
const raw = req.headers['x-forwarded-for']
const header = Array.isArray(raw) ? raw[0] : (raw as string | undefined)
if (header) {
const first = header.split(',')[0]?.trim()
if (first) return first
}
return req.ip ?? req.socket?.remoteAddress
}
@@ -0,0 +1,8 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common'
// Tiny escape hatch for handlers that need to read request-level metadata
// the JWT doesn't carry — primarily the client IP for audit-log entries.
// Use `clientIp(req)` from ./client-ip.ts to extract the originating address.
export const CurrentRequest = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => ctx.switchToHttp().getRequest(),
)
@@ -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 ───────────────────────────────────────────────────────────
@@ -7,14 +7,31 @@ import {
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common'
import { ActorService } from '../auth/actor.service.js'
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'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import type { AuditActor } from '../audit/audit.service.js'
import { CreatePartnerDto } from './dto/create-partner.dto.js'
import { UpdatePartnerDto } from './dto/update-partner.dto.js'
import { PartnersService } from './partners.service.js'
function auditActor(
user: { _id: unknown; email: string },
req: Parameters<typeof clientIp>[0],
): AuditActor {
return {
userId: String(user._id),
email: user.email,
ip: clientIp(req),
}
}
// Partners are operator-managed only. Every endpoint requires an
// operator-scoped token (aud === 'dezky-operator') plus platformAdmin on the
// resolved user. A self-serve partner portal (partner.dezky.local) is a
@@ -23,11 +40,19 @@ import { PartnersService } from './partners.service.js'
@Controller('partners')
@UseGuards(JwtAuthGuard, OperatorGuard)
export class PartnersController {
constructor(private readonly partners: PartnersService) {}
constructor(
private readonly partners: PartnersService,
private readonly actorService: ActorService,
) {}
@Post()
create(@Body() dto: CreatePartnerDto) {
return this.partners.create(dto)
async create(
@Body() dto: CreatePartnerDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const user = await this.actorService.resolve(jwt)
return this.partners.create(dto, auditActor(user, req))
}
@Get()
@@ -48,13 +73,24 @@ export class PartnersController {
}
@Patch(':slug')
update(@Param('slug') slug: string, @Body() dto: UpdatePartnerDto) {
return this.partners.update(slug, dto)
async update(
@Param('slug') slug: string,
@Body() dto: UpdatePartnerDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const user = await this.actorService.resolve(jwt)
return this.partners.update(slug, dto, auditActor(user, req))
}
@Delete(':slug')
@HttpCode(204)
async terminate(@Param('slug') slug: string) {
await this.partners.terminate(slug)
async terminate(
@Param('slug') slug: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const user = await this.actorService.resolve(jwt)
await this.partners.terminate(slug, auditActor(user, 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 { Partner, PartnerSchema } from '../schemas/partner.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
@@ -13,6 +14,7 @@ import { PartnersService } from './partners.service.js'
{ name: Tenant.name, schema: TenantSchema },
]),
AuthModule,
AuditModule,
],
controllers: [PartnersController],
providers: [PartnersService],
@@ -1,6 +1,7 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import type { CreatePartnerDto } from './dto/create-partner.dto.js'
@@ -23,15 +24,28 @@ export class PartnersService {
constructor(
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
private readonly audit: AuditService,
) {}
async create(dto: CreatePartnerDto): Promise<PartnerDocument> {
async create(dto: CreatePartnerDto, actor?: AuditActor): Promise<PartnerDocument> {
const exists = await this.partnerModel.exists({ slug: dto.slug })
if (exists) throw new ConflictException(`Partner "${dto.slug}" already exists`)
return this.partnerModel.create({
const partner = await this.partnerModel.create({
...dto,
status: dto.status ?? 'in-negotiation',
})
void this.audit.record(
{
action: 'partner.created',
resourceType: 'partner',
resourceId: String(partner._id),
resourceName: partner.name,
partnerSlug: partner.slug,
metadata: { domain: partner.domain, marginPct: partner.marginPct },
},
actor,
)
return partner
}
async findAll(): Promise<PartnerDocument[]> {
@@ -66,22 +80,43 @@ export class PartnersService {
return this.tenantModel.find({ partnerId: partner._id }).sort({ createdAt: -1 }).exec()
}
async update(slug: string, dto: UpdatePartnerDto): Promise<PartnerDocument> {
async update(slug: string, dto: UpdatePartnerDto, actor?: AuditActor): Promise<PartnerDocument> {
const partner = await this.partnerModel
.findOneAndUpdate({ slug }, dto, { new: true, runValidators: true })
.exec()
if (!partner) throw new NotFoundException(`Partner "${slug}" not found`)
void this.audit.record(
{
action: 'partner.updated',
resourceType: 'partner',
resourceId: String(partner._id),
resourceName: partner.name,
partnerSlug: partner.slug,
metadata: { changes: Object.keys(dto as Record<string, unknown>) },
},
actor,
)
return partner
}
// Soft-terminate. We never hard-delete a partner — there may be customer
// tenants pointing at it and we want the historical reference to survive.
async terminate(slug: string): Promise<void> {
const result = await this.partnerModel
.updateOne({ slug }, { status: 'terminated' })
async terminate(slug: string, actor?: AuditActor): Promise<void> {
const partner = await this.partnerModel
.findOneAndUpdate({ slug }, { status: 'terminated' }, { new: true })
.exec()
if (result.matchedCount === 0) {
if (!partner) {
throw new NotFoundException(`Partner "${slug}" not found`)
}
void this.audit.record(
{
action: 'partner.terminated',
resourceType: 'partner',
resourceId: String(partner._id),
resourceName: partner.name,
partnerSlug: partner.slug,
},
actor,
)
}
}
@@ -0,0 +1,110 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type AuditEventDocument = HydratedDocument<AuditEvent>
export type AuditActorType = 'user' | 'system'
export type AuditOutcome = 'success' | 'failure'
export type AuditResourceType =
| 'tenant'
| 'partner'
| 'user'
| 'flag'
| 'subscription'
| 'system'
export type AuditSource =
| 'platform-api'
| 'operator-ui'
| 'portal'
| 'authentik'
| 'ocis'
| 'stalwart'
// Append-only audit trail. Records are written by AuditService.record (called
// from every mutation in the API layer). The schema has no UPDATE/DELETE
// surface — older entries can only be removed by Mongo TTL / archive jobs,
// not application code. See docs/FEATURE-FLAGS.md ... no wait, see the audit
// plan when it lands. The hash fields are nullable placeholders for the
// hash-chain we add in a later phase.
@Schema({ collection: 'audit_events', timestamps: { createdAt: 'recordedAt', updatedAt: false } })
export class AuditEvent {
// Wall-clock time of the event itself. Distinct from recordedAt (timestamps)
// because a future ingestion adapter (Authentik / OCIS / Stalwart) may
// backfill events whose real time is older than when we recorded them.
@Prop({ required: true, default: () => new Date(), index: true })
at!: Date
@Prop({ enum: ['user', 'system'], default: 'user', index: true })
actorType!: AuditActorType
@Prop({ type: Types.ObjectId, ref: 'User', index: true })
actorId?: Types.ObjectId
@Prop()
actorEmail?: string
// Originating IP — forwarded via X-Forwarded-For from the Nuxt proxies.
// May be undefined for system-triggered events (seed, scheduled jobs).
@Prop()
actorIp?: string
// dotted.snake_case verb. Examples:
// tenant.created, tenant.suspended, tenant.resumed
// partner.created, partner.terminated
// flag.created, flag.state_changed, flag.killed
// user.deactivated
// Future ingestion adapters add: authentik.login, ocis.share_created, ...
@Prop({ required: true, index: true })
action!: string
@Prop({ enum: ['success', 'failure'], default: 'success', index: true })
outcome!: AuditOutcome
@Prop({ enum: ['tenant', 'partner', 'user', 'flag', 'subscription', 'system'] })
resourceType?: AuditResourceType
// Free-form (slug, ObjectId-as-string, external id, etc.) since some
// resources don't have an ObjectId we can ref directly.
@Prop()
resourceId?: string
// Human-friendly label shown in the operator UI (saves a join).
@Prop()
resourceName?: string
// Bubbled-up tenant/partner refs so per-tenant/per-partner timelines
// are an index scan, not a join.
@Prop({ index: true })
tenantSlug?: string
@Prop({ index: true })
partnerSlug?: string
@Prop({
enum: ['platform-api', 'operator-ui', 'portal', 'authentik', 'ocis', 'stalwart'],
default: 'platform-api',
index: true,
})
source!: AuditSource
// Free-form additional context. Keep small — no large blobs. Common keys:
// before, after: the diff for an update
// reason: operator-supplied note (impersonation reason, kill-switch reason)
// error: failure message when outcome === 'failure'
@Prop({ type: Object })
metadata?: Record<string, unknown>
// Tamper-evidence prep. Populated by a later phase (hash-chain + signing).
@Prop()
prevHash?: string
@Prop()
hash?: string
}
export const AuditEventSchema = SchemaFactory.createForClass(AuditEvent)
// Compound indexes for the common query shapes — see audit.service.ts.
AuditEventSchema.index({ at: -1 })
AuditEventSchema.index({ tenantSlug: 1, at: -1 })
AuditEventSchema.index({ actorId: 1, at: -1 })
AuditEventSchema.index({ action: 1, at: -1 })
@@ -8,17 +8,33 @@ import {
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common'
import { ActorService } from '../auth/actor.service.js'
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'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import type { AuditActor } from '../audit/audit.service.js'
import { CreateTenantDto } from './dto/create-tenant.dto.js'
import { UpdateTenantDto } from './dto/update-tenant.dto.js'
import { TenantsService } from './tenants.service.js'
// Build the audit actor from a resolved User doc + the originating request.
// Threaded into every mutation so the audit log records who did it from where.
function auditActor(
user: { _id: unknown; email: string },
req: Parameters<typeof clientIp>[0],
): AuditActor {
return {
userId: String(user._id),
email: user.email,
ip: clientIp(req),
}
}
@Controller('tenants')
@UseGuards(JwtAuthGuard)
export class TenantsController {
@@ -28,12 +44,12 @@ export class TenantsController {
) {}
@Post()
async create(@Body() dto: CreateTenantDto, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
async create(@Body() dto: CreateTenantDto, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters<typeof clientIp>[0]) {
const user = await this.actor.resolve(jwt)
if (!user.platformAdmin) {
throw new ForbiddenException('Only platform admins can create tenants')
}
return this.tenants.create(dto)
return this.tenants.create(dto, auditActor(user, req))
}
@Get()
@@ -68,23 +84,24 @@ export class TenantsController {
@Param('slug') slug: string,
@Body() dto: UpdateTenantDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const actor = await this.actor.resolve(jwt)
const user = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return this.tenants.update(slug, dto)
return this.tenants.update(slug, dto, auditActor(user, req))
}
@Delete(':slug')
@HttpCode(204)
async remove(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
async remove(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters<typeof clientIp>[0]) {
const user = await this.actor.resolve(jwt)
if (!user.platformAdmin) {
throw new ForbiddenException('Only platform admins can delete tenants')
}
await this.tenants.softDelete(slug)
await this.tenants.softDelete(slug, auditActor(user, req))
}
// Manually re-run provisioning. Useful when an integration was down at create
@@ -104,13 +121,15 @@ export class TenantsController {
// OperatorGuard requires audience='dezky-operator' AND platformAdmin=true.
@Post(':slug/suspend')
@UseGuards(OperatorGuard)
suspend(@Param('slug') slug: string) {
return this.tenants.setStatus(slug, 'suspended')
async suspend(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters<typeof clientIp>[0]) {
const user = await this.actor.resolve(jwt)
return this.tenants.setStatus(slug, 'suspended', auditActor(user, req))
}
@Post(':slug/resume')
@UseGuards(OperatorGuard)
resume(@Param('slug') slug: string) {
return this.tenants.setStatus(slug, 'active')
async resume(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters<typeof clientIp>[0]) {
const user = await this.actor.resolve(jwt)
return this.tenants.setStatus(slug, 'active', auditActor(user, 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 { IntegrationsModule } from '../integrations/integrations.module.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
@@ -15,6 +16,7 @@ import { TenantsService } from './tenants.service.js'
{ name: User.name, schema: UserSchema },
]),
AuthModule,
AuditModule,
IntegrationsModule,
],
controllers: [TenantsController],
@@ -1,6 +1,7 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
@@ -13,6 +14,7 @@ export class TenantsService {
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
private readonly provisioning: ProvisioningService,
private readonly audit: AuditService,
) {}
async listUsersForTenant(slug: string): Promise<UserDocument[]> {
@@ -23,10 +25,21 @@ export class TenantsService {
.exec()
}
async create(dto: CreateTenantDto): Promise<TenantDocument> {
async create(dto: CreateTenantDto, actor?: AuditActor): Promise<TenantDocument> {
const exists = await this.tenantModel.exists({ slug: dto.slug })
if (exists) throw new ConflictException(`Tenant with slug "${dto.slug}" already exists`)
const tenant = await this.tenantModel.create({ ...dto, status: 'pending' })
void this.audit.record(
{
action: 'tenant.created',
resourceType: 'tenant',
resourceId: String(tenant._id),
resourceName: tenant.name,
tenantSlug: tenant.slug,
metadata: { plan: tenant.plan, domains: tenant.domains },
},
actor,
)
// Provision external resources best-effort. Errors are recorded on the doc;
// the caller can re-POST or call /tenants/:slug/reconcile to retry.
return this.provisioning.reconcile(tenant)
@@ -61,7 +74,7 @@ export class TenantsService {
return tenant
}
async update(slug: string, dto: UpdateTenantDto): Promise<TenantDocument> {
async update(slug: string, dto: UpdateTenantDto, actor?: AuditActor): Promise<TenantDocument> {
// Build $set / $unset explicitly. Doing `findOneAndUpdate({slug}, dto, ...)`
// with a class-transformer instance leaks undefined slots into the update,
// and Mongoose doesn't always cast string→ObjectId for ref fields when
@@ -85,21 +98,59 @@ export class TenantsService {
.findOneAndUpdate({ slug }, update, { new: true, runValidators: true })
.exec()
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
void this.audit.record(
{
action: 'tenant.updated',
resourceType: 'tenant',
resourceId: String(tenant._id),
resourceName: tenant.name,
tenantSlug: tenant.slug,
metadata: {
changes: Object.keys(set).concat(Object.keys(unset).map((k) => `${k}:cleared`)),
},
},
actor,
)
return tenant
}
async softDelete(slug: string): Promise<void> {
async softDelete(slug: string, actor?: AuditActor): Promise<void> {
const result = await this.tenantModel
.updateOne({ slug }, { status: 'deleted' })
.findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true })
.exec()
if (result.matchedCount === 0) throw new NotFoundException(`Tenant "${slug}" not found`)
if (!result) throw new NotFoundException(`Tenant "${slug}" not found`)
void this.audit.record(
{
action: 'tenant.deleted',
resourceType: 'tenant',
resourceId: String(result._id),
resourceName: result.name,
tenantSlug: result.slug,
},
actor,
)
}
async setStatus(slug: string, status: 'active' | 'suspended'): Promise<TenantDocument> {
async setStatus(
slug: string,
status: 'active' | 'suspended',
actor?: AuditActor,
): Promise<TenantDocument> {
const tenant = await this.tenantModel
.findOneAndUpdate({ slug }, { status }, { new: true })
.exec()
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
void this.audit.record(
{
action: status === 'suspended' ? 'tenant.suspended' : 'tenant.resumed',
resourceType: 'tenant',
resourceId: String(tenant._id),
resourceName: tenant.name,
tenantSlug: tenant.slug,
},
actor,
)
return tenant
}
}
@@ -8,10 +8,12 @@ import {
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { ActorService } from '../auth/actor.service.js'
import { clientIp } from '../auth/client-ip.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
@@ -92,11 +94,19 @@ export class UsersController {
@Delete(':subject')
@HttpCode(204)
async deactivate(@Param('subject') subject: string, @CurrentUser() jwt: AuthentikJwtPayload) {
async deactivate(
@Param('subject') subject: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const actor = await this.actor.resolve(jwt)
if (!actor.platformAdmin) {
throw new ForbiddenException('Only platform admins can deactivate users')
}
await this.users.deactivate(subject)
await this.users.deactivate(subject, {
userId: String(actor._id),
email: actor.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 { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
@@ -14,6 +15,7 @@ import { UsersService } from './users.service.js'
{ name: Tenant.name, schema: TenantSchema },
]),
AuthModule,
AuditModule,
TenantsModule,
],
controllers: [UsersController],
@@ -1,6 +1,7 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import type { CreateUserDto } from './dto/create-user.dto.js'
@@ -11,6 +12,7 @@ export class UsersService {
constructor(
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
private readonly audit: AuditService,
) {}
async create(dto: CreateUserDto): Promise<UserDocument> {
@@ -54,11 +56,20 @@ export class UsersService {
return user
}
async deactivate(subject: string): Promise<void> {
const result = await this.userModel
.updateOne({ authentikSubjectId: subject }, { active: false })
async deactivate(subject: string, actor?: AuditActor): Promise<void> {
const user = await this.userModel
.findOneAndUpdate({ authentikSubjectId: subject }, { active: false }, { new: true })
.exec()
if (result.matchedCount === 0) throw new NotFoundException(`User ${subject} not found`)
if (!user) throw new NotFoundException(`User ${subject} not found`)
void this.audit.record(
{
action: 'user.deactivated',
resourceType: 'user',
resourceId: String(user._id),
resourceName: user.email,
},
actor,
)
}
// Called on every authenticated request from /users/me. The JWT's groups claim