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