feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,202 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { AuditLogger } from '../application/audit-logger.js';
import { RateLimiter } from '../application/rate-limiter.js';
export interface SecurityRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
rateLimiter: RateLimiter;
auditLogger: AuditLogger;
}
// ── Schema definitions ─────────────────────────────────────────────────────────
const userQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(100).optional().default(20),
offset: z.coerce.number().int().min(0).optional().default(0),
role: z.enum(['customer', 'editor', 'admin']).optional(),
q: z.string().optional(),
});
const createUserBodySchema = z.object({
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['customer', 'editor', 'admin']).default('customer'),
});
const updateUserBodySchema = z.object({
role: z.enum(['customer', 'editor', 'admin']).optional(),
password: z.string().min(8).optional(),
});
const uuidParamSchema = z.object({ id: z.string().uuid() });
// ── Helpers ────────────────────────────────────────────────────────────────────
function isPgUniqueViolation(err: unknown): boolean {
return typeof err === 'object' && err !== null && (err as { code?: string }).code === '23505';
}
export async function registerSecurityRoutes(
app: FastifyInstance,
deps: SecurityRoutesDeps,
): Promise<void> {
app.get('/admin/mfa/status', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const result = await deps.pool.query<{ mfa_enrolled: boolean }>(
'SELECT mfa_enrolled FROM identity_users WHERE id = $1',
[user.id],
);
const row = result.rows[0];
return reply.send({ mfaEnrolled: row?.mfa_enrolled ?? false });
});
app.post('/admin/mfa/enroll', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
await deps.pool.query('UPDATE identity_users SET mfa_enrolled = true WHERE id = $1', [user.id]);
await deps.auditLogger.log({
actorId: user.id,
action: 'admin.mfa.enroll',
target: user.id,
metadata: {},
});
return reply.send({ mfaEnrolled: true });
});
app.post('/admin/audit/log', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(
z.object({
action: z.string().min(1).max(120),
target: z.string().min(1).max(200),
metadata: z.record(z.string(), z.unknown()).optional(),
}),
request.body,
);
await deps.auditLogger.log({
actorId: user.id,
action: input.action,
target: input.target,
metadata: input.metadata ?? {},
});
return reply.code(201).send({ ok: true });
});
app.get('/admin/audit', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { actorId, action, limit, offset } = parseJson(
z.object({
actorId: z.string().optional(),
action: z.string().optional(),
limit: z.coerce.number().int().positive().max(200).optional().default(50),
offset: z.coerce.number().int().min(0).optional().default(0),
}),
request.query ?? {},
);
const result = await deps.auditLogger.list({ actorId, action, limit, offset });
return reply.send(result);
});
app.get('/admin/rate-limit/check', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const parsed = parseJson(z.object({ key: z.string().min(1).max(120) }), request.query ?? {});
const decision = deps.rateLimiter.hit(parsed.key);
if (!decision.allowed) {
throw new AppError(429, 'RATE_LIMIT_EXCEEDED', 'Too many requests');
}
return reply.send(decision);
});
// ── Admin user management ──────────────────────────────────────────────────
app.get('/admin/users', async (request, reply) => {
const admin = await deps.authenticate(request);
requireRole(admin, 'admin');
const { limit, offset, role, q } = parseJson(userQuerySchema, request.query ?? {});
const conditions: string[] = [];
const values: unknown[] = [];
let i = 1;
if (role) { conditions.push(`role = $${i++}`); values.push(role); }
if (q) { conditions.push(`(email ILIKE $${i++})`); values.push(`%${q}%`); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const countResult = await deps.pool.query<{ count: string }>(
`SELECT COUNT(*) FROM identity_users ${where}`,
values,
);
const total = parseInt(countResult.rows[0]?.count ?? '0', 10);
const rows = await deps.pool.query(
`SELECT id, email, role, created_at FROM identity_users ${where} ORDER BY created_at DESC LIMIT $${i++} OFFSET $${i}`,
[...values, limit, offset],
);
return reply.send({
items: rows.rows.map((r) => ({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at })),
total,
});
});
app.post('/admin/users', async (request, reply) => {
const admin = await deps.authenticate(request);
requireRole(admin, 'admin');
const input = parseJson(createUserBodySchema, request.body);
const { default: argon2 } = await import('argon2');
const passwordHash = await argon2.hash(input.password);
try {
const result = await deps.pool.query(
`INSERT INTO identity_users (email, password_hash, role) VALUES ($1, $2, $3) RETURNING id, email, role, created_at`,
[input.email.toLowerCase(), passwordHash, input.role],
);
const r = result.rows[0];
return reply.code(201).send({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at });
} catch (err) {
if (isPgUniqueViolation(err)) {
throw new AppError(409, 'EMAIL_EXISTS', 'Email already registered');
}
throw err;
}
});
app.patch('/admin/users/:id', async (request, reply) => {
const admin = await deps.authenticate(request);
requireRole(admin, 'admin');
const id = (request.params as { id: string }).id;
const patch = parseJson(updateUserBodySchema, request.body);
const sets: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (patch.role) { sets.push(`role = $${idx++}`); values.push(patch.role); }
if (patch.password) {
const { default: argon2 } = await import('argon2');
sets.push(`password_hash = $${idx++}`);
values.push(await argon2.hash(patch.password));
}
if (!sets.length) {
const existing = await deps.pool.query(
`SELECT id, email, role, created_at FROM identity_users WHERE id = $1`,
[id],
);
if (!existing.rows[0]) throw new AppError(404, 'NOT_FOUND', 'User not found');
return reply.send(existing.rows[0]);
}
values.push(id);
const result = await deps.pool.query(
`UPDATE identity_users SET ${sets.join(', ')} WHERE id = $${idx} RETURNING id, email, role, created_at`,
values,
);
if (!result.rows[0]) throw new AppError(404, 'NOT_FOUND', 'User not found');
return reply.send(result.rows[0]);
});
app.delete('/admin/users/:id', async (request, reply) => {
const admin = await deps.authenticate(request);
requireRole(admin, 'admin');
const id = (request.params as { id: string }).id;
if (admin.id === id) throw new AppError(400, 'CANNOT_DELETE_SELF', 'Cannot delete your own account');
await deps.pool.query(`DELETE FROM identity_users WHERE id = $1`, [id]);
return reply.code(204).send();
});
}

View File

@@ -0,0 +1,77 @@
import type pg from 'pg';
import type { AuditEntry } from '../domain/security.js';
export class AuditLogger {
constructor(private readonly pool: pg.Pool) {}
async log(entry: AuditEntry): Promise<void> {
await this.pool.query(
`INSERT INTO security_audit_log (actor_id, action, target, metadata)
VALUES ($1, $2, $3, $4)`,
[entry.actorId, entry.action, entry.target, entry.metadata],
);
}
async list(params?: {
actorId?: string;
action?: string;
limit?: number;
offset?: number;
}): Promise<{ items: AuditEntryRow[]; total: number }> {
const conditions: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (params?.actorId) {
conditions.push(`actor_id = $${idx++}`);
values.push(params.actorId);
}
if (params?.action) {
conditions.push(`action = $${idx++}`);
values.push(params.action);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const limit = params?.limit ?? 50;
const offset = params?.offset ?? 0;
const countResult = await this.pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM security_audit_log ${where}`,
values,
);
const total = parseInt(countResult.rows[0]?.count ?? '0', 10);
const result = await this.pool.query<{
id: string;
actor_id: string | null;
action: string;
target: string;
metadata: Record<string, unknown>;
created_at: Date;
}>(
`SELECT * FROM security_audit_log ${where} ORDER BY created_at DESC LIMIT $${idx++} OFFSET $${idx}`,
[...values, limit, offset],
);
return {
items: result.rows.map((row) => ({
id: row.id,
actorId: row.actor_id,
action: row.action,
target: row.target,
metadata: row.metadata,
createdAt: row.created_at.toISOString(),
})),
total,
};
}
}
export interface AuditEntryRow {
id: string;
actorId: string | null;
action: string;
target: string;
metadata: Record<string, unknown>;
createdAt: string;
}

View File

@@ -0,0 +1,31 @@
import type { RateLimitDecision } from '../domain/security.js';
/** Sliding window rate limiter in-memory. */
export class RateLimiter {
private readonly buckets = new Map<string, number[]>();
constructor(
private readonly windowMs: number = 60_000,
private readonly maxRequests: number = 60,
) {}
hit(key: string, now: number = Date.now()): RateLimitDecision {
const cutoff = now - this.windowMs;
const stamps = this.buckets.get(key)?.filter((t) => t > cutoff) ?? [];
if (stamps.length >= this.maxRequests) {
const oldest = stamps[0] ?? now;
return {
allowed: false,
remaining: 0,
retryAfterSeconds: Math.max(1, Math.ceil((oldest + this.windowMs - now) / 1000)),
};
}
stamps.push(now);
this.buckets.set(key, stamps);
return {
allowed: true,
remaining: this.maxRequests - stamps.length,
retryAfterSeconds: 0,
};
}
}

View File

@@ -0,0 +1,12 @@
export interface RateLimitDecision {
allowed: boolean;
remaining: number;
retryAfterSeconds: number;
}
export interface AuditEntry {
actorId: string | null;
action: string;
target: string;
metadata: Record<string, unknown>;
}

View File

@@ -0,0 +1,5 @@
/** Public API of the security module. */
export { registerSecurityRoutes, type SecurityRoutesDeps } from './api/security.routes.js';
export { RateLimiter } from './application/rate-limiter.js';
export { AuditLogger } from './application/audit-logger.js';
export type { RateLimitDecision, AuditEntry } from './domain/security.js';

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { RateLimiter } from '../application/rate-limiter.js';
describe('RateLimiter', () => {
it('rejects requests above the limit with retry-after', () => {
const limiter = new RateLimiter(1000, 2);
expect(limiter.hit('k', 0).allowed).toBe(true);
expect(limiter.hit('k', 100).allowed).toBe(true);
const blocked = limiter.hit('k', 200);
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
});
it('resets after window expires', () => {
const limiter = new RateLimiter(1000, 1);
expect(limiter.hit('k', 0).allowed).toBe(true);
expect(limiter.hit('k', 100).allowed).toBe(false);
expect(limiter.hit('k', 1500).allowed).toBe(true);
});
});