Files
MOPC-Portal/src/server/routers/audit.ts

340 lines
10 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { router, adminProcedure, superAdminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const auditRouter = router({
/**
* List audit logs with filtering and pagination
*/
list: adminProcedure
.input(
z.object({
userId: z.string().optional(),
action: z.string().optional(),
entityType: z.string().optional(),
entityId: z.string().optional(),
startDate: z.date().optional(),
endDate: z.date().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
const { userId, action, entityType, entityId, startDate, endDate, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {}
if (userId) where.userId = userId
if (action) where.action = action
if (entityType) where.entityType = entityType
if (entityId) where.entityId = entityId
if (startDate || endDate) {
where.timestamp = {}
if (startDate) (where.timestamp as Record<string, Date>).gte = startDate
if (endDate) (where.timestamp as Record<string, Date>).lte = endDate
}
const [logs, total] = await Promise.all([
ctx.prisma.auditLog.findMany({
where,
skip,
take: perPage,
orderBy: { timestamp: 'desc' },
include: {
user: { select: { name: true, email: true } },
},
}),
ctx.prisma.auditLog.count({ where }),
])
return {
logs,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
}
}),
/**
* Get audit logs for a specific entity
*/
getByEntity: adminProcedure
.input(
z.object({
entityType: z.string(),
entityId: z.string(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.auditLog.findMany({
where: {
entityType: input.entityType,
entityId: input.entityId,
},
orderBy: { timestamp: 'desc' },
include: {
user: { select: { name: true, email: true } },
},
})
}),
/**
* Get audit logs for a specific user
*/
getByUser: adminProcedure
.input(
z.object({
userId: z.string(),
limit: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.auditLog.findMany({
where: { userId: input.userId },
orderBy: { timestamp: 'desc' },
take: input.limit,
})
}),
/**
* Get recent activity summary
*/
getRecentActivity: adminProcedure
.input(z.object({ limit: z.number().int().min(1).max(50).default(20) }))
.query(async ({ ctx, input }) => {
return ctx.prisma.auditLog.findMany({
orderBy: { timestamp: 'desc' },
take: input.limit,
include: {
user: { select: { name: true, email: true } },
},
})
}),
/**
* Get action statistics
*/
getStats: adminProcedure
.input(
z.object({
startDate: z.date().optional(),
endDate: z.date().optional(),
})
)
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.startDate || input.endDate) {
where.timestamp = {}
if (input.startDate) (where.timestamp as Record<string, Date>).gte = input.startDate
if (input.endDate) (where.timestamp as Record<string, Date>).lte = input.endDate
}
const [byAction, byEntity, byUser] = await Promise.all([
ctx.prisma.auditLog.groupBy({
by: ['action'],
where,
_count: true,
orderBy: { _count: { action: 'desc' } },
}),
ctx.prisma.auditLog.groupBy({
by: ['entityType'],
where,
_count: true,
orderBy: { _count: { entityType: 'desc' } },
}),
ctx.prisma.auditLog.groupBy({
by: ['userId'],
where,
_count: true,
orderBy: { _count: { userId: 'desc' } },
take: 10,
}),
])
// Get user names for top users
const userIds = byUser
.map((u) => u.userId)
.filter((id): id is string => id !== null)
const users = await ctx.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
})
const userMap = new Map(users.map((u) => [u.id, u]))
return {
byAction: byAction.map((a) => ({
action: a.action,
count: a._count,
})),
byEntity: byEntity.map((e) => ({
entityType: e.entityType,
count: e._count,
})),
byUser: byUser.map((u) => ({
userId: u.userId,
user: u.userId ? userMap.get(u.userId) : null,
count: u._count,
})),
}
}),
// =========================================================================
// Anomaly Detection & Session Tracking (F14)
// =========================================================================
/**
* Detect anomalous activity patterns within a time window
*/
getAnomalies: adminProcedure
.input(
z.object({
timeWindowMinutes: z.number().int().min(1).max(1440).default(60),
})
)
.query(async ({ ctx, input }) => {
// Load anomaly rules from settings
const rulesSetting = await ctx.prisma.systemSettings.findUnique({
where: { key: 'audit_anomaly_rules' },
})
const rules = rulesSetting?.value
? JSON.parse(rulesSetting.value) as {
rapid_changes_per_minute?: number
bulk_operations_threshold?: number
}
: { rapid_changes_per_minute: 30, bulk_operations_threshold: 50 }
const rapidThreshold = rules.rapid_changes_per_minute || 30
const bulkThreshold = rules.bulk_operations_threshold || 50
const windowStart = new Date()
windowStart.setMinutes(windowStart.getMinutes() - input.timeWindowMinutes)
// Get action counts per user in the time window
const userActivity = await ctx.prisma.auditLog.groupBy({
by: ['userId'],
where: {
timestamp: { gte: windowStart },
userId: { not: null },
},
_count: true,
})
// Filter for users exceeding thresholds
const suspiciousUserIds = userActivity
.filter((u) => u._count >= bulkThreshold)
.map((u) => u.userId)
.filter((id): id is string => id !== null)
// Get user details
const users = suspiciousUserIds.length > 0
? await ctx.prisma.user.findMany({
where: { id: { in: suspiciousUserIds } },
select: { id: true, name: true, email: true, role: true },
})
: []
const userMap = new Map(users.map((u) => [u.id, u]))
const anomalies = userActivity
.filter((u) => u._count >= bulkThreshold)
.map((u) => ({
userId: u.userId,
user: u.userId ? userMap.get(u.userId) || null : null,
actionCount: u._count,
timeWindowMinutes: input.timeWindowMinutes,
actionsPerMinute: u._count / input.timeWindowMinutes,
isRapid: (u._count / input.timeWindowMinutes) >= rapidThreshold,
isBulk: u._count >= bulkThreshold,
}))
.sort((a, b) => b.actionCount - a.actionCount)
return {
anomalies,
thresholds: {
rapidChangesPerMinute: rapidThreshold,
bulkOperationsThreshold: bulkThreshold,
},
timeWindow: {
start: windowStart,
end: new Date(),
minutes: input.timeWindowMinutes,
},
}
}),
/**
* Get all audit logs for a specific session
*/
getSessionTimeline: adminProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const logs = await ctx.prisma.auditLog.findMany({
where: { sessionId: input.sessionId },
orderBy: { timestamp: 'asc' },
include: {
user: { select: { id: true, name: true, email: true } },
},
})
return logs
}),
/**
* Get current audit retention configuration
*/
getRetentionConfig: adminProcedure.query(async ({ ctx }) => {
const setting = await ctx.prisma.systemSettings.findUnique({
where: { key: 'audit_retention_days' },
})
return {
retentionDays: setting?.value ? parseInt(setting.value, 10) : 365,
}
}),
/**
* Update audit retention configuration (super admin only)
*/
updateRetentionConfig: superAdminProcedure
.input(z.object({ retentionDays: z.number().int().min(30) }))
.mutation(async ({ ctx, input }) => {
const setting = await ctx.prisma.systemSettings.upsert({
where: { key: 'audit_retention_days' },
update: {
value: input.retentionDays.toString(),
updatedBy: ctx.user.id,
},
create: {
key: 'audit_retention_days',
value: input.retentionDays.toString(),
category: 'AUDIT_CONFIG',
updatedBy: ctx.user.id,
},
})
// Audit log
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_RETENTION_CONFIG',
entityType: 'SystemSettings',
entityId: setting.id,
detailsJson: { retentionDays: input.retentionDays },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return { retentionDays: input.retentionDays }
}),
})