diff --git a/apps/operator/data/fixtures.ts b/apps/operator/data/fixtures.ts index 6c242bf..02e4e64 100644 --- a/apps/operator/data/fixtures.ts +++ b/apps/operator/data/fixtures.ts @@ -63,32 +63,11 @@ export const INCIDENT: ActiveIncident = { // The seed in services/platform-api/src/seed/seed.service.ts creates the // same 10 flags this fixture used to contain. -export type AuditTone = 'info' | 'warn' | 'bad' -export interface AuditEntry { - id: string - when: string - actor: string - role: string - action: string - target: string - tenant: string - ip: string - tone: AuditTone -} - -export const OP_AUDIT: AuditEntry[] = [ - { id: 'op_8821', when: '15:02:11', actor: 'Anne Baslund', role: 'platform admin', action: 'feature_flag.rollout', target: 'jmap_native_v2 · 50%', tenant: '—', ip: '10.0.4.18', tone: 'info' }, - { id: 'op_8820', when: '14:58:42', actor: 'Mikkel Nørgaard', role: 'engineer', action: 'service.pod_restart', target: 'authentik-worker-3', tenant: '—', ip: '10.0.4.21', tone: 'warn' }, - { id: 'op_8819', when: '14:48:02', actor: 'Sofie Lindberg', role: 'ops', action: 'tenant.impersonate', target: 'oliver@bygherre.dk', tenant: 'Bygherre Cloud', ip: '10.0.4.04', tone: 'info' }, - { id: 'op_8818', when: '14:36:00', actor: 'system', role: 'auto', action: 'oncall.paged', target: 'Mikkel Nørgaard', tenant: '—', ip: '—', tone: 'warn' }, - { id: 'op_8817', when: '14:18:00', actor: 'system', role: 'auto', action: 'alert.triggered', target: 'authentik p95 > 400ms', tenant: '—', ip: '—', tone: 'bad' }, - { id: 'op_8816', when: '13:21:55', actor: 'Anne Baslund', role: 'platform admin', action: 'tenant.refund_issued', target: 'INV-0480 · 980 DKK', tenant: 'Vester Foods', ip: '10.0.4.18', tone: 'info' }, - { id: 'op_8815', when: '12:09:30', actor: 'Sofie Lindberg', role: 'ops', action: 'tenant.suspended', target: 'København Kalkulator', tenant: 'København Kalkulator', ip: '10.0.4.04', tone: 'warn' }, - { id: 'op_8814', when: '11:44:00', actor: 'Anne Baslund', role: 'platform admin', action: 'partner.created', target: 'Klaussen Digital · invited', tenant: '—', ip: '10.0.4.18', tone: 'info' }, - { id: 'op_8813', when: '10:55:41', actor: 'system', role: 'auto', action: 'invoice.past_due', target: 'INV-0522 · 2.940 DKK · 21 d', tenant: 'Bygherre Cloud', ip: '—', tone: 'bad' }, - { id: 'op_8812', when: '10:12:08', actor: 'Mikkel Nørgaard', role: 'engineer', action: 'feature_flag.created', target: 'beta_ai_summaries', tenant: '—', ip: '10.0.4.21', tone: 'info' }, - { id: 'op_8811', when: '09:30:00', actor: 'Anne Baslund', role: 'platform admin', action: 'tos.published', target: 'v2026.05 · all tenants', tenant: '—', ip: '10.0.4.18', tone: 'info' }, -] +// Audit log moved to a real backend at /api/audit + see types/audit.ts. +// AuditService.record() in services/platform-api/src/audit/ writes an entry on +// every privileged mutation. Incident timeline still references on-call +// historically (see INCIDENT.updates above) — those are story content for +// the mock incident, not entries in the audit collection. // Services in the design that haven't been deployed yet. Surfaced as a // separate "Planned" section on the Infrastructure page so the operator sees diff --git a/apps/operator/pages/audit.vue b/apps/operator/pages/audit.vue index 0024039..b74663e 100644 --- a/apps/operator/pages/audit.vue +++ b/apps/operator/pages/audit.vue @@ -1,26 +1,75 @@ @@ -29,27 +78,44 @@ function label(a: AuditEntry) { + :subtitle="`${allEvents.length} event${allEvents.length === 1 ? '' : 's'} · every privileged action recorded by platform-api`" + > + +
-
- - streaming · mock +
+
- - - Export CSV - +
+ + + +
+ + + live · backed by Mongo +
- +
@@ -62,31 +128,46 @@ function label(a: AuditEntry) { - - + + - - + + - - + +
Time
{{ a.when }}
{{ fmtAbs(e.at) }} -
sys
- +
sys
+
-
{{ a.actor }}
- {{ a.role }} +
{{ e.actorEmail || 'system' }}
+ {{ e.source }}
{{ a.action }}{{ a.target }}{{ e.action }}{{ e.resourceName || e.resourceId || '—' }} - {{ a.tenant }} + {{ e.tenantSlug }} {{ a.ip }}{{ label(a) }}{{ shortIp(e.actorIp) }} + + {{ e.outcome === 'failure' ? 'fail' : e.outcome === 'success' ? 'ok' : '—' }} + +
-
// no matching entries
+
// no events match the current filters
- // retention 7 years · write-once · mock fixtures — replace with real append-only audit collection + + + + // sourced from /audit on platform-api · append-only · hash-chain tamper + evidence + external system ingest (Authentik / OCIS / Stalwart) are + planned follow-ups (see docs/NEXT-STEPS.md) +
@@ -115,7 +196,23 @@ function label(a: AuditEntry) { font-size: 12px; color: var(--text); } -.streaming { display: flex; align-items: center; gap: 8px; margin-left: auto; } + +.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 { @@ -148,5 +245,6 @@ td.actor { display: flex; align-items: center; gap: 10px; } } .empty { padding: 40px 20px; text-align: center; } +.footer { display: flex; justify-content: center; padding: 4px 0; } .note { display: block; padding: 4px 4px 0 4px; } diff --git a/apps/operator/pages/index.vue b/apps/operator/pages/index.vue index ae160fa..83ee458 100644 --- a/apps/operator/pages/index.vue +++ b/apps/operator/pages/index.vue @@ -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('/api/tenants', { default: () => [] }) const { data: partners, pending: pp, refresh: rP } = await useFetch('/api/partners', { default: () => [] }) const { data: users, pending: up, refresh: rU } = await useFetch('/api/users', { default: () => [] }) +const { data: auditEvents, refresh: rA } = await useFetch('/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) {
- streaming · mock + live · {{ auditEvents.length }} recent
-
- {{ a.when }} + + {{ fmtClock(a.at) }}
- {{ a.actor }} + {{ a.actorEmail || 'system' }} {{ a.action }} - {{ a.target }} + {{ a.resourceName || a.resourceId || '—' }}
-
tenant: {{ a.tenant }}
+
tenant: {{ a.tenantSlug }}
- {{ a.tone }} + {{ a.outcome === 'failure' ? 'fail' : 'ok' }}
+
+ // no audit events yet — perform an action in operator and reload
@@ -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; } diff --git a/apps/operator/server/api/audit/index.get.ts b/apps/operator/server/api/audit/index.get.ts new file mode 100644 index 0000000..c5ce93a --- /dev/null +++ b/apps/operator/server/api/audit/index.get.ts @@ -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 }) +}) diff --git a/apps/operator/server/utils/platform-api.ts b/apps/operator/server/utils/platform-api.ts index 9df67dc..208f7c8 100644 --- a/apps/operator/server/utils/platform-api.ts +++ b/apps/operator/server/utils/platform-api.ts @@ -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( event: H3Event, path: string, @@ -18,10 +34,14 @@ export async function platformApi( throw createError({ statusCode: 401, statusMessage: 'Not signed in' }) } + const clientIp = originatingIp(event) + const headers: Record = { 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 diff --git a/apps/operator/types/audit.ts b/apps/operator/types/audit.ts new file mode 100644 index 0000000..d54659e --- /dev/null +++ b/apps/operator/types/audit.ts @@ -0,0 +1,24 @@ +// Shape returned by /api/audit — matches AuditEvent on platform-api. + +export type AuditOutcome = 'success' | 'failure' +export type AuditSource = 'platform-api' | 'operator-ui' | 'portal' | 'authentik' | 'ocis' | 'stalwart' +export type AuditResourceType = 'tenant' | 'partner' | 'user' | 'flag' | 'subscription' | 'system' + +export interface AuditEvent { + _id: string + at: string // ISO timestamp + actorType: 'user' | 'system' + actorId?: string + actorEmail?: string + actorIp?: string + action: string + outcome: AuditOutcome + resourceType?: AuditResourceType + resourceId?: string + resourceName?: string + tenantSlug?: string + partnerSlug?: string + source: AuditSource + metadata?: Record + recordedAt?: string +} diff --git a/services/platform-api/src/app.module.ts b/services/platform-api/src/app.module.ts index 4af9e1f..97c49e5 100644 --- a/services/platform-api/src/app.module.ts +++ b/services/platform-api/src/app.module.ts @@ -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, diff --git a/services/platform-api/src/audit/audit.controller.ts b/services/platform-api/src/audit/audit.controller.ts new file mode 100644 index 0000000..15fef85 --- /dev/null +++ b/services/platform-api/src/audit/audit.controller.ts @@ -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, + }) + } +} diff --git a/services/platform-api/src/audit/audit.module.ts b/services/platform-api/src/audit/audit.module.ts new file mode 100644 index 0000000..b087e8e --- /dev/null +++ b/services/platform-api/src/audit/audit.module.ts @@ -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 {} diff --git a/services/platform-api/src/audit/audit.service.ts b/services/platform-api/src/audit/audit.service.ts new file mode 100644 index 0000000..7698225 --- /dev/null +++ b/services/platform-api/src/audit/audit.service.ts @@ -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 +} + +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, + ) {} + + // 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 { + 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 { + const q: FilterQuery = {} + + if (filters.since || filters.until || filters.before) { + const at: Record = {} + 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)) +} diff --git a/services/platform-api/src/audit/dto/list-audit.dto.ts b/services/platform-api/src/audit/dto/list-audit.dto.ts new file mode 100644 index 0000000..834a940 --- /dev/null +++ b/services/platform-api/src/audit/dto/list-audit.dto.ts @@ -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 +} diff --git a/services/platform-api/src/auth/client-ip.ts b/services/platform-api/src/auth/client-ip.ts new file mode 100644 index 0000000..fd95201 --- /dev/null +++ b/services/platform-api/src/auth/client-ip.ts @@ -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; 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 +} diff --git a/services/platform-api/src/auth/current-request.decorator.ts b/services/platform-api/src/auth/current-request.decorator.ts new file mode 100644 index 0000000..3ac1cc9 --- /dev/null +++ b/services/platform-api/src/auth/current-request.decorator.ts @@ -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(), +) diff --git a/services/platform-api/src/flags/flags.controller.ts b/services/platform-api/src/flags/flags.controller.ts index c6b85e4..955fe56 100644 --- a/services/platform-api/src/flags/flags.controller.ts +++ b/services/platform-api/src/flags/flags.controller.ts @@ -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[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[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[0], + ) { + await this.flags.remove(key, { email: jwt.email, ip: clientIp(req) }) } } diff --git a/services/platform-api/src/flags/flags.module.ts b/services/platform-api/src/flags/flags.module.ts index 5f5ec48..bb27298 100644 --- a/services/platform-api/src/flags/flags.module.ts +++ b/services/platform-api/src/flags/flags.module.ts @@ -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 }, diff --git a/services/platform-api/src/flags/flags.service.ts b/services/platform-api/src/flags/flags.service.ts index 466bd85..8b377fe 100644 --- a/services/platform-api/src/flags/flags.service.ts +++ b/services/platform-api/src/flags/flags.service.ts @@ -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, @InjectModel(Tenant.name) private readonly tenantModel: Model, + private readonly audit: AuditService, ) {} async create(dto: CreateFlagDto, actor?: ActorRef): Promise { @@ -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 { + async remove(key: string, actor?: ActorRef): Promise { + 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 ─────────────────────────────────────────────────────────── diff --git a/services/platform-api/src/partners/partners.controller.ts b/services/platform-api/src/partners/partners.controller.ts index 385657f..b327b0e 100644 --- a/services/platform-api/src/partners/partners.controller.ts +++ b/services/platform-api/src/partners/partners.controller.ts @@ -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[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[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[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[0], + ) { + const user = await this.actorService.resolve(jwt) + await this.partners.terminate(slug, auditActor(user, req)) } } diff --git a/services/platform-api/src/partners/partners.module.ts b/services/platform-api/src/partners/partners.module.ts index 8ff436b..90004d5 100644 --- a/services/platform-api/src/partners/partners.module.ts +++ b/services/platform-api/src/partners/partners.module.ts @@ -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], diff --git a/services/platform-api/src/partners/partners.service.ts b/services/platform-api/src/partners/partners.service.ts index c648788..0695706 100644 --- a/services/platform-api/src/partners/partners.service.ts +++ b/services/platform-api/src/partners/partners.service.ts @@ -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, @InjectModel(Tenant.name) private readonly tenantModel: Model, + private readonly audit: AuditService, ) {} - async create(dto: CreatePartnerDto): Promise { + async create(dto: CreatePartnerDto, actor?: AuditActor): Promise { 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 { @@ -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 { + async update(slug: string, dto: UpdatePartnerDto, actor?: AuditActor): Promise { 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) }, + }, + 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 { - const result = await this.partnerModel - .updateOne({ slug }, { status: 'terminated' }) + async terminate(slug: string, actor?: AuditActor): Promise { + 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, + ) } } diff --git a/services/platform-api/src/schemas/audit-event.schema.ts b/services/platform-api/src/schemas/audit-event.schema.ts new file mode 100644 index 0000000..c55dddc --- /dev/null +++ b/services/platform-api/src/schemas/audit-event.schema.ts @@ -0,0 +1,110 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { HydratedDocument, Types } from 'mongoose' + +export type AuditEventDocument = HydratedDocument + +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 + + // 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 }) diff --git a/services/platform-api/src/tenants/tenants.controller.ts b/services/platform-api/src/tenants/tenants.controller.ts index 1e48978..9fd1eec 100644 --- a/services/platform-api/src/tenants/tenants.controller.ts +++ b/services/platform-api/src/tenants/tenants.controller.ts @@ -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[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[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[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[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[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[0]) { + const user = await this.actor.resolve(jwt) + return this.tenants.setStatus(slug, 'active', auditActor(user, req)) } } diff --git a/services/platform-api/src/tenants/tenants.module.ts b/services/platform-api/src/tenants/tenants.module.ts index e393c07..5c40a67 100644 --- a/services/platform-api/src/tenants/tenants.module.ts +++ b/services/platform-api/src/tenants/tenants.module.ts @@ -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], diff --git a/services/platform-api/src/tenants/tenants.service.ts b/services/platform-api/src/tenants/tenants.service.ts index f977fc8..d45e680 100644 --- a/services/platform-api/src/tenants/tenants.service.ts +++ b/services/platform-api/src/tenants/tenants.service.ts @@ -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, @InjectModel(User.name) private readonly userModel: Model, private readonly provisioning: ProvisioningService, + private readonly audit: AuditService, ) {} async listUsersForTenant(slug: string): Promise { @@ -23,10 +25,21 @@ export class TenantsService { .exec() } - async create(dto: CreateTenantDto): Promise { + async create(dto: CreateTenantDto, actor?: AuditActor): Promise { 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 { + async update(slug: string, dto: UpdateTenantDto, actor?: AuditActor): Promise { // 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 { + async softDelete(slug: string, actor?: AuditActor): Promise { 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 { + async setStatus( + slug: string, + status: 'active' | 'suspended', + actor?: AuditActor, + ): Promise { 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 } } diff --git a/services/platform-api/src/users/users.controller.ts b/services/platform-api/src/users/users.controller.ts index d3e475d..20da87f 100644 --- a/services/platform-api/src/users/users.controller.ts +++ b/services/platform-api/src/users/users.controller.ts @@ -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[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), + }) } } diff --git a/services/platform-api/src/users/users.module.ts b/services/platform-api/src/users/users.module.ts index 2d53efb..a1bdb00 100644 --- a/services/platform-api/src/users/users.module.ts +++ b/services/platform-api/src/users/users.module.ts @@ -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], diff --git a/services/platform-api/src/users/users.service.ts b/services/platform-api/src/users/users.service.ts index 8944a45..3130148 100644 --- a/services/platform-api/src/users/users.service.ts +++ b/services/platform-api/src/users/users.service.ts @@ -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, @InjectModel(Tenant.name) private readonly tenantModel: Model, + private readonly audit: AuditService, ) {} async create(dto: CreateUserDto): Promise { @@ -54,11 +56,20 @@ export class UsersService { return user } - async deactivate(subject: string): Promise { - const result = await this.userModel - .updateOne({ authentikSubjectId: subject }, { active: false }) + async deactivate(subject: string, actor?: AuditActor): Promise { + 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