02341d8ba5
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
113 lines
3.6 KiB
TypeScript
113 lines
3.6 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
ForbiddenException,
|
|
Get,
|
|
HttpCode,
|
|
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'
|
|
import { CreateUserDto } from './dto/create-user.dto.js'
|
|
import { UpdateUserDto } from './dto/update-user.dto.js'
|
|
import { UsersService } from './users.service.js'
|
|
|
|
// Authentik group name that grants platform-wide admin in Dezky. This is the ONLY
|
|
// place we look at the JWT's groups claim outside of /users/me — and even here it's
|
|
// just for the bootstrap: the resulting platformAdmin boolean on the User doc is
|
|
// what every other endpoint reads.
|
|
const ADMIN_BOOTSTRAP_GROUP_DEFAULT = 'dezky-platform-admins'
|
|
|
|
@Controller('users')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class UsersController {
|
|
private readonly adminBootstrapGroup: string
|
|
|
|
constructor(
|
|
private readonly users: UsersService,
|
|
private readonly actor: ActorService,
|
|
config: ConfigService,
|
|
) {
|
|
this.adminBootstrapGroup =
|
|
config.get<string>('PLATFORM_ADMIN_BOOTSTRAP_GROUP') ?? ADMIN_BOOTSTRAP_GROUP_DEFAULT
|
|
}
|
|
|
|
// The signed-in user's own profile — bootstraps the user record on first call,
|
|
// and syncs name/email/tenants/platformAdmin from the JWT on every subsequent call.
|
|
@Get('me')
|
|
async me(@CurrentUser() jwt: AuthentikJwtPayload) {
|
|
return this.users.upsertFromAuthentik({
|
|
subject: jwt.sub,
|
|
email: jwt.email ?? jwt.preferred_username ?? jwt.sub,
|
|
name: jwt.name ?? jwt.preferred_username ?? jwt.email ?? jwt.sub,
|
|
tenantSlugs: jwt.groups ?? [],
|
|
platformAdmin: jwt.groups?.includes(this.adminBootstrapGroup) ?? false,
|
|
})
|
|
}
|
|
|
|
@Post()
|
|
async create(@Body() dto: CreateUserDto, @CurrentUser() jwt: AuthentikJwtPayload) {
|
|
const actor = await this.actor.resolve(jwt)
|
|
if (!actor.platformAdmin) {
|
|
throw new ForbiddenException('Only platform admins can create users directly')
|
|
}
|
|
return this.users.create(dto)
|
|
}
|
|
|
|
@Get()
|
|
async findAll(@CurrentUser() jwt: AuthentikJwtPayload) {
|
|
const actor = await this.actor.resolve(jwt)
|
|
if (actor.platformAdmin) return this.users.findAll()
|
|
return this.users.findAllForTenants(actor.tenantIds)
|
|
}
|
|
|
|
@Get(':subject')
|
|
async findOne(@Param('subject') subject: string, @CurrentUser() jwt: AuthentikJwtPayload) {
|
|
const actor = await this.actor.resolve(jwt)
|
|
if (subject !== jwt.sub && !actor.platformAdmin) {
|
|
throw new ForbiddenException('Cannot read other users')
|
|
}
|
|
return this.users.findOneBySubject(subject)
|
|
}
|
|
|
|
@Patch(':subject')
|
|
async update(
|
|
@Param('subject') subject: string,
|
|
@Body() dto: UpdateUserDto,
|
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
|
) {
|
|
const actor = await this.actor.resolve(jwt)
|
|
if (!actor.platformAdmin) {
|
|
throw new ForbiddenException('Only platform admins can update users')
|
|
}
|
|
return this.users.update(subject, dto)
|
|
}
|
|
|
|
@Delete(':subject')
|
|
@HttpCode(204)
|
|
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, {
|
|
userId: String(actor._id),
|
|
email: actor.email,
|
|
ip: clientIp(req),
|
|
})
|
|
}
|
|
}
|