feat(F-048): completed feature

This commit is contained in:
chattie
2026-08-19 07:17:14 +02:00
parent 8ee1938af9
commit 835ab66eda
187 changed files with 12361 additions and 1065 deletions

View File

@@ -1,9 +1,11 @@
import type { FastifyInstance } from 'fastify';
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
import { parseJson } from '../../../shared/http-input.js';
import { AuditLogger } from '../application/audit-logger.js';
import { RateLimiter } from '../application/rate-limiter.js';
@@ -31,7 +33,6 @@ 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 {
@@ -42,7 +43,12 @@ export async function registerSecurityRoutes(
app: FastifyInstance,
deps: SecurityRoutesDeps,
): Promise<void> {
app.get('/admin/mfa/status', async (request, reply) => {
const mfaStatusSchema: FastifySchema = {
tags: ['Admin'],
summary: 'MFA status',
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/admin/mfa/status', { schema: mfaStatusSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const result = await deps.pool.query<{ mfa_enrolled: boolean }>(
@@ -53,7 +59,12 @@ export async function registerSecurityRoutes(
return reply.send({ mfaEnrolled: row?.mfa_enrolled ?? false });
});
app.post('/admin/mfa/enroll', async (request, reply) => {
const enrollMfaSchema: FastifySchema = {
tags: ['Admin'],
summary: 'Enroll MFA',
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
};
app.post('/admin/mfa/enroll', { schema: enrollMfaSchema }, 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]);
@@ -66,7 +77,13 @@ export async function registerSecurityRoutes(
return reply.send({ mfaEnrolled: true });
});
app.post('/admin/audit/log', async (request, reply) => {
const auditLogSchema: FastifySchema = {
tags: ['Admin'],
summary: 'Write audit log',
body: { type: 'object' },
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
};
app.post('/admin/audit/log', { schema: auditLogSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(
@@ -86,7 +103,21 @@ export async function registerSecurityRoutes(
return reply.code(201).send({ ok: true });
});
app.get('/admin/audit', async (request, reply) => {
const getAuditSchema: FastifySchema = {
tags: ['Admin'],
summary: 'Get audit log',
querystring: {
type: 'object',
properties: {
actorId: { type: 'string' },
action: { type: 'string' },
limit: { type: 'integer', default: 50 },
offset: { type: 'integer', default: 0 },
},
},
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/admin/audit', { schema: getAuditSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { actorId, action, limit, offset } = parseJson(
@@ -102,7 +133,13 @@ export async function registerSecurityRoutes(
return reply.send(result);
});
app.get('/admin/rate-limit/check', async (request, reply) => {
const rateLimitCheckSchema: FastifySchema = {
tags: ['Admin'],
summary: 'Check rate limit',
querystring: { type: 'object', required: ['key'], properties: { key: { type: 'string' } } },
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/admin/rate-limit/check', { schema: rateLimitCheckSchema }, 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 ?? {});
@@ -114,15 +151,35 @@ export async function registerSecurityRoutes(
});
// ── Admin user management ──────────────────────────────────────────────────
app.get('/admin/users', async (request, reply) => {
const listAdminUsersSchema: FastifySchema = {
tags: ['Admin'],
summary: 'List users (admin)',
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', default: 20 },
offset: { type: 'integer', default: 0 },
role: { type: 'string', enum: ['customer', 'editor', 'admin'] },
q: { type: 'string' },
},
},
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/admin/users', { schema: listAdminUsersSchema }, 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}%`); }
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}`,
@@ -134,12 +191,23 @@ export async function registerSecurityRoutes(
[...values, limit, offset],
);
return reply.send({
items: rows.rows.map((r) => ({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at })),
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 createAdminUserSchema: FastifySchema = {
tags: ['Admin'],
summary: 'Create user (admin)',
body: { type: 'object' },
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
};
app.post('/admin/users', { schema: createAdminUserSchema }, async (request, reply) => {
const admin = await deps.authenticate(request);
requireRole(admin, 'admin');
const input = parseJson(createUserBodySchema, request.body);
@@ -151,7 +219,9 @@ export async function registerSecurityRoutes(
[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 });
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');
@@ -160,7 +230,18 @@ export async function registerSecurityRoutes(
}
});
app.patch('/admin/users/:id', async (request, reply) => {
const patchAdminUserSchema: FastifySchema = {
tags: ['Admin'],
summary: 'Update user (admin)',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: { type: 'object' },
response: { 401: errorSchema, 403: errorSchema },
};
app.patch('/admin/users/:id', { schema: patchAdminUserSchema }, async (request, reply) => {
const admin = await deps.authenticate(request);
requireRole(admin, 'admin');
const id = (request.params as { id: string }).id;
@@ -168,7 +249,10 @@ export async function registerSecurityRoutes(
const sets: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (patch.role) { sets.push(`role = $${idx++}`); values.push(patch.role); }
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++}`);
@@ -191,11 +275,22 @@ export async function registerSecurityRoutes(
return reply.send(result.rows[0]);
});
app.delete('/admin/users/:id', async (request, reply) => {
const deleteAdminUserSchema: FastifySchema = {
tags: ['Admin'],
summary: 'Delete user (admin)',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema },
};
app.delete('/admin/users/:id', { schema: deleteAdminUserSchema }, 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');
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();
});