feat(F-048): completed feature
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user