feat(ADM-018): completed feature
This commit is contained in:
202
project/src/modules/security/api/security.routes.ts
Normal file
202
project/src/modules/security/api/security.routes.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
77
project/src/modules/security/application/audit-logger.ts
Normal file
77
project/src/modules/security/application/audit-logger.ts
Normal 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;
|
||||
}
|
||||
31
project/src/modules/security/application/rate-limiter.ts
Normal file
31
project/src/modules/security/application/rate-limiter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
12
project/src/modules/security/domain/security.ts
Normal file
12
project/src/modules/security/domain/security.ts
Normal 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>;
|
||||
}
|
||||
5
project/src/modules/security/index.ts
Normal file
5
project/src/modules/security/index.ts
Normal 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';
|
||||
20
project/src/modules/security/tests/rate-limiter.test.ts
Normal file
20
project/src/modules/security/tests/rate-limiter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user