From 7c07749580292a8fe200fad20f0b010d71dacb4d Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 21 Feb 2026 05:09:14 +0000 Subject: [PATCH] feat: add audit logging repository, service, and query API Implements IAuditLogRepository with Prisma, AuditLogService with configurable retention policy and purge, and REST routes for querying/filtering audit logs at /api/v1/audit-logs. Co-Authored-By: Claude Opus 4.6 --- .../src/repositories/audit-log.repository.ts | 70 ++++++++ src/mcpd/src/repositories/index.ts | 3 +- src/mcpd/src/repositories/interfaces.ts | 21 ++- src/mcpd/src/routes/audit-logs.ts | 39 +++++ src/mcpd/src/routes/index.ts | 1 + src/mcpd/src/services/audit-log.service.ts | 73 +++++++++ src/mcpd/src/services/index.ts | 2 + src/mcpd/tests/audit-log-routes.test.ts | 111 +++++++++++++ src/mcpd/tests/audit-log-service.test.ts | 151 ++++++++++++++++++ 9 files changed, 469 insertions(+), 2 deletions(-) create mode 100644 src/mcpd/src/repositories/audit-log.repository.ts create mode 100644 src/mcpd/src/routes/audit-logs.ts create mode 100644 src/mcpd/src/services/audit-log.service.ts create mode 100644 src/mcpd/tests/audit-log-routes.test.ts create mode 100644 src/mcpd/tests/audit-log-service.test.ts diff --git a/src/mcpd/src/repositories/audit-log.repository.ts b/src/mcpd/src/repositories/audit-log.repository.ts new file mode 100644 index 0000000..de7d408 --- /dev/null +++ b/src/mcpd/src/repositories/audit-log.repository.ts @@ -0,0 +1,70 @@ +import type { PrismaClient, AuditLog, Prisma } from '@prisma/client'; +import type { IAuditLogRepository, AuditLogFilter } from './interfaces.js'; + +export class AuditLogRepository implements IAuditLogRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(filter?: AuditLogFilter): Promise { + const where = buildWhere(filter); + return this.prisma.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: filter?.limit ?? 100, + skip: filter?.offset ?? 0, + }); + } + + async findById(id: string): Promise { + return this.prisma.auditLog.findUnique({ where: { id } }); + } + + async create(data: { + userId: string; + action: string; + resource: string; + resourceId?: string; + details?: Record; + }): Promise { + const createData: Prisma.AuditLogUncheckedCreateInput = { + userId: data.userId, + action: data.action, + resource: data.resource, + details: (data.details ?? {}) as Prisma.InputJsonValue, + }; + if (data.resourceId !== undefined) { + createData.resourceId = data.resourceId; + } + return this.prisma.auditLog.create({ data: createData }); + } + + async count(filter?: AuditLogFilter): Promise { + const where = buildWhere(filter); + return this.prisma.auditLog.count({ where }); + } + + async deleteOlderThan(date: Date): Promise { + const result = await this.prisma.auditLog.deleteMany({ + where: { createdAt: { lt: date } }, + }); + return result.count; + } +} + +function buildWhere(filter?: AuditLogFilter): Prisma.AuditLogWhereInput { + const where: Prisma.AuditLogWhereInput = {}; + if (!filter) return where; + + if (filter.userId !== undefined) where.userId = filter.userId; + if (filter.action !== undefined) where.action = filter.action; + if (filter.resource !== undefined) where.resource = filter.resource; + if (filter.resourceId !== undefined) where.resourceId = filter.resourceId; + + if (filter.since !== undefined || filter.until !== undefined) { + const createdAt: Prisma.DateTimeFilter = {}; + if (filter.since !== undefined) createdAt.gte = filter.since; + if (filter.until !== undefined) createdAt.lte = filter.until; + where.createdAt = createdAt; + } + + return where; +} diff --git a/src/mcpd/src/repositories/index.ts b/src/mcpd/src/repositories/index.ts index 8e42467..98da343 100644 --- a/src/mcpd/src/repositories/index.ts +++ b/src/mcpd/src/repositories/index.ts @@ -1,6 +1,7 @@ -export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository } from './interfaces.js'; +export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js'; export { McpServerRepository } from './mcp-server.repository.js'; export { McpProfileRepository } from './mcp-profile.repository.js'; export type { IProjectRepository } from './project.repository.js'; export { ProjectRepository } from './project.repository.js'; export { McpInstanceRepository } from './mcp-instance.repository.js'; +export { AuditLogRepository } from './audit-log.repository.js'; diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts index 313ce72..00d2d20 100644 --- a/src/mcpd/src/repositories/interfaces.ts +++ b/src/mcpd/src/repositories/interfaces.ts @@ -1,4 +1,4 @@ -import type { McpServer, McpProfile, McpInstance, InstanceStatus } from '@prisma/client'; +import type { McpServer, McpProfile, McpInstance, AuditLog, InstanceStatus } from '@prisma/client'; import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js'; import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js'; @@ -28,3 +28,22 @@ export interface IMcpProfileRepository { update(id: string, data: UpdateMcpProfileInput): Promise; delete(id: string): Promise; } + +export interface AuditLogFilter { + userId?: string; + action?: string; + resource?: string; + resourceId?: string; + since?: Date; + until?: Date; + limit?: number; + offset?: number; +} + +export interface IAuditLogRepository { + findAll(filter?: AuditLogFilter): Promise; + findById(id: string): Promise; + create(data: { userId: string; action: string; resource: string; resourceId?: string; details?: Record }): Promise; + count(filter?: AuditLogFilter): Promise; + deleteOlderThan(date: Date): Promise; +} diff --git a/src/mcpd/src/routes/audit-logs.ts b/src/mcpd/src/routes/audit-logs.ts new file mode 100644 index 0000000..15cf838 --- /dev/null +++ b/src/mcpd/src/routes/audit-logs.ts @@ -0,0 +1,39 @@ +import type { FastifyInstance } from 'fastify'; +import type { AuditLogService } from '../services/audit-log.service.js'; + +interface AuditLogQuery { + userId?: string; + action?: string; + resource?: string; + resourceId?: string; + since?: string; + until?: string; + limit?: string; + offset?: string; +} + +export function registerAuditLogRoutes(app: FastifyInstance, service: AuditLogService): void { + app.get<{ Querystring: AuditLogQuery }>('/api/v1/audit-logs', async (request) => { + const q = request.query; + const params: Record = {}; + if (q.userId !== undefined) params.userId = q.userId; + if (q.action !== undefined) params.action = q.action; + if (q.resource !== undefined) params.resource = q.resource; + if (q.resourceId !== undefined) params.resourceId = q.resourceId; + if (q.since !== undefined) params.since = q.since; + if (q.until !== undefined) params.until = q.until; + if (q.limit !== undefined) params.limit = parseInt(q.limit, 10); + if (q.offset !== undefined) params.offset = parseInt(q.offset, 10); + return service.list(params); + }); + + app.get<{ Params: { id: string } }>('/api/v1/audit-logs/:id', async (request) => { + return service.getById(request.params.id); + }); + + app.post('/api/v1/audit-logs/purge', async (_request, reply) => { + const deleted = await service.purgeExpired(); + reply.code(200); + return { deleted }; + }); +} diff --git a/src/mcpd/src/routes/index.ts b/src/mcpd/src/routes/index.ts index 90bf7c7..26ed752 100644 --- a/src/mcpd/src/routes/index.ts +++ b/src/mcpd/src/routes/index.ts @@ -4,3 +4,4 @@ export { registerMcpServerRoutes } from './mcp-servers.js'; export { registerMcpProfileRoutes } from './mcp-profiles.js'; export { registerProjectRoutes } from './projects.js'; export { registerInstanceRoutes } from './instances.js'; +export { registerAuditLogRoutes } from './audit-logs.js'; diff --git a/src/mcpd/src/services/audit-log.service.ts b/src/mcpd/src/services/audit-log.service.ts new file mode 100644 index 0000000..21e8c1a --- /dev/null +++ b/src/mcpd/src/services/audit-log.service.ts @@ -0,0 +1,73 @@ +import type { AuditLog } from '@prisma/client'; +import type { IAuditLogRepository, AuditLogFilter } from '../repositories/interfaces.js'; +import { NotFoundError } from './mcp-server.service.js'; + +export interface AuditLogQueryParams { + userId?: string; + action?: string; + resource?: string; + resourceId?: string; + since?: string; + until?: string; + limit?: number; + offset?: number; +} + +/** Default retention: 90 days */ +const DEFAULT_RETENTION_DAYS = 90; + +export class AuditLogService { + constructor( + private readonly repo: IAuditLogRepository, + private readonly retentionDays: number = DEFAULT_RETENTION_DAYS, + ) {} + + async list(params?: AuditLogQueryParams): Promise<{ logs: AuditLog[]; total: number }> { + const filter = this.buildFilter(params); + const [logs, total] = await Promise.all([ + this.repo.findAll(filter), + this.repo.count(filter), + ]); + return { logs, total }; + } + + async getById(id: string): Promise { + const log = await this.repo.findById(id); + if (!log) { + throw new NotFoundError(`Audit log '${id}' not found`); + } + return log; + } + + async create(data: { + userId: string; + action: string; + resource: string; + resourceId?: string; + details?: Record; + }): Promise { + return this.repo.create(data); + } + + async purgeExpired(): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - this.retentionDays); + return this.repo.deleteOlderThan(cutoff); + } + + private buildFilter(params?: AuditLogQueryParams): AuditLogFilter | undefined { + if (!params) return undefined; + const filter: AuditLogFilter = {}; + + if (params.userId !== undefined) filter.userId = params.userId; + if (params.action !== undefined) filter.action = params.action; + if (params.resource !== undefined) filter.resource = params.resource; + if (params.resourceId !== undefined) filter.resourceId = params.resourceId; + if (params.since !== undefined) filter.since = new Date(params.since); + if (params.until !== undefined) filter.until = new Date(params.until); + if (params.limit !== undefined) filter.limit = params.limit; + if (params.offset !== undefined) filter.offset = params.offset; + + return filter; + } +} diff --git a/src/mcpd/src/services/index.ts b/src/mcpd/src/services/index.ts index 7c6a457..b9f94c1 100644 --- a/src/mcpd/src/services/index.ts +++ b/src/mcpd/src/services/index.ts @@ -7,3 +7,5 @@ export type { McpConfig, McpConfigServer, ProfileWithServer } from './mcp-config export type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs } from './orchestrator.js'; export { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js'; export { DockerContainerManager } from './docker/container-manager.js'; +export { AuditLogService } from './audit-log.service.js'; +export type { AuditLogQueryParams } from './audit-log.service.js'; diff --git a/src/mcpd/tests/audit-log-routes.test.ts b/src/mcpd/tests/audit-log-routes.test.ts new file mode 100644 index 0000000..8ef0c0c --- /dev/null +++ b/src/mcpd/tests/audit-log-routes.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { registerAuditLogRoutes } from '../src/routes/audit-logs.js'; +import { AuditLogService } from '../src/services/audit-log.service.js'; +import type { IAuditLogRepository } from '../src/repositories/interfaces.js'; +import { errorHandler } from '../src/middleware/error-handler.js'; + +function makeLog(id: string) { + return { + id, + userId: 'user-1', + action: 'CREATE', + resource: 'servers', + resourceId: null, + details: {}, + createdAt: new Date('2025-01-15T00:00:00Z'), + }; +} + +function mockRepo(): IAuditLogRepository { + return { + findAll: vi.fn(async () => [makeLog('log-1'), makeLog('log-2')]), + findById: vi.fn(async () => null), + create: vi.fn(async () => makeLog('new-log')), + count: vi.fn(async () => 2), + deleteOlderThan: vi.fn(async () => 3), + }; +} + +describe('Audit Log Routes', () => { + let app: FastifyInstance; + let repo: ReturnType; + let service: AuditLogService; + + beforeEach(async () => { + app = Fastify(); + app.setErrorHandler(errorHandler); + repo = mockRepo(); + service = new AuditLogService(repo, 90); + registerAuditLogRoutes(app, service); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('GET /api/v1/audit-logs', () => { + it('returns paginated audit logs', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/audit-logs' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.logs).toHaveLength(2); + expect(body.total).toBe(2); + }); + + it('passes query filters to service', async () => { + await app.inject({ + method: 'GET', + url: '/api/v1/audit-logs?userId=user-1&action=CREATE&resource=servers&limit=10&offset=5', + }); + + expect(repo.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + action: 'CREATE', + resource: 'servers', + limit: 10, + offset: 5, + }), + ); + }); + + it('passes date range filters', async () => { + await app.inject({ + method: 'GET', + url: '/api/v1/audit-logs?since=2025-01-01T00:00:00Z&until=2025-12-31T23:59:59Z', + }); + + expect(repo.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + since: expect.any(Date), + until: expect.any(Date), + }), + ); + }); + }); + + describe('GET /api/v1/audit-logs/:id', () => { + it('returns a specific log entry', async () => { + vi.mocked(repo.findById).mockResolvedValue(makeLog('log-1')); + const res = await app.inject({ method: 'GET', url: '/api/v1/audit-logs/log-1' }); + expect(res.statusCode).toBe(200); + expect(res.json().id).toBe('log-1'); + }); + + it('returns 404 for missing log', async () => { + const res = await app.inject({ method: 'GET', url: '/api/v1/audit-logs/nonexistent' }); + expect(res.statusCode).toBe(404); + }); + }); + + describe('POST /api/v1/audit-logs/purge', () => { + it('purges expired logs and returns count', async () => { + const res = await app.inject({ method: 'POST', url: '/api/v1/audit-logs/purge' }); + expect(res.statusCode).toBe(200); + expect(res.json().deleted).toBe(3); + }); + }); +}); diff --git a/src/mcpd/tests/audit-log-service.test.ts b/src/mcpd/tests/audit-log-service.test.ts new file mode 100644 index 0000000..3c661be --- /dev/null +++ b/src/mcpd/tests/audit-log-service.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AuditLogService } from '../src/services/audit-log.service.js'; +import type { IAuditLogRepository } from '../src/repositories/interfaces.js'; +import { NotFoundError } from '../src/services/mcp-server.service.js'; + +function makeLog(overrides: Partial<{ id: string; userId: string; action: string; resource: string; resourceId: string | null; createdAt: Date }> = {}) { + return { + id: overrides.id ?? 'log-1', + userId: overrides.userId ?? 'user-1', + action: overrides.action ?? 'CREATE', + resource: overrides.resource ?? 'servers', + resourceId: overrides.resourceId ?? null, + details: {}, + createdAt: overrides.createdAt ?? new Date('2025-01-15T00:00:00Z'), + }; +} + +function mockRepo(): IAuditLogRepository { + return { + findAll: vi.fn(async () => [makeLog()]), + findById: vi.fn(async () => null), + create: vi.fn(async (data) => ({ + id: 'new-log', + userId: data.userId, + action: data.action, + resource: data.resource, + resourceId: data.resourceId ?? null, + details: data.details ?? {}, + createdAt: new Date(), + })), + count: vi.fn(async () => 1), + deleteOlderThan: vi.fn(async () => 5), + }; +} + +describe('AuditLogService', () => { + let repo: ReturnType; + let service: AuditLogService; + + beforeEach(() => { + repo = mockRepo(); + service = new AuditLogService(repo, 90); + }); + + describe('list', () => { + it('returns logs with total count', async () => { + const result = await service.list(); + expect(result.logs).toHaveLength(1); + expect(result.total).toBe(1); + expect(repo.findAll).toHaveBeenCalled(); + expect(repo.count).toHaveBeenCalled(); + }); + + it('passes filter params to repository', async () => { + await service.list({ + userId: 'user-1', + action: 'CREATE', + resource: 'servers', + limit: 50, + offset: 10, + }); + + expect(repo.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + action: 'CREATE', + resource: 'servers', + limit: 50, + offset: 10, + }), + ); + }); + + it('parses date strings in since/until', async () => { + await service.list({ + since: '2025-01-01T00:00:00Z', + until: '2025-12-31T23:59:59Z', + }); + + expect(repo.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + since: expect.any(Date), + until: expect.any(Date), + }), + ); + }); + }); + + describe('getById', () => { + it('returns a log entry', async () => { + vi.mocked(repo.findById).mockResolvedValue(makeLog({ id: 'log-1' })); + const log = await service.getById('log-1'); + expect(log.id).toBe('log-1'); + }); + + it('throws NotFoundError when log does not exist', async () => { + await expect(service.getById('nonexistent')).rejects.toThrow(NotFoundError); + }); + }); + + describe('create', () => { + it('creates an audit log entry', async () => { + const log = await service.create({ + userId: 'user-1', + action: 'CREATE', + resource: 'servers', + details: { method: 'POST' }, + }); + + expect(log.id).toBe('new-log'); + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + action: 'CREATE', + resource: 'servers', + }), + ); + }); + + it('creates log with optional resourceId', async () => { + await service.create({ + userId: 'user-1', + action: 'DELETE', + resource: 'servers', + resourceId: 'srv-1', + }); + + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ resourceId: 'srv-1' }), + ); + }); + }); + + describe('purgeExpired', () => { + it('deletes logs older than retention period', async () => { + const deleted = await service.purgeExpired(); + expect(deleted).toBe(5); + expect(repo.deleteOlderThan).toHaveBeenCalledWith(expect.any(Date)); + }); + + it('uses configured retention days', async () => { + const customService = new AuditLogService(repo, 30); + await customService.purgeExpired(); + + const cutoff = vi.mocked(repo.deleteOlderThan).mock.calls[0]?.[0] as Date; + const now = new Date(); + const daysDiff = Math.round((now.getTime() - cutoff.getTime()) / (1000 * 60 * 60 * 24)); + expect(daysDiff).toBe(30); + }); + }); +});