All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
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 }
|
|
}),
|
|
})
|