feat: error audit middleware, impersonation attribution, account lockout logging
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m13s

- Add withErrorAudit middleware tracking FORBIDDEN/UNAUTHORIZED/NOT_FOUND per user
- Fix impersonation attribution: log real admin ID, prefix IMPERSONATED_ on actions
- Add ACCOUNT_LOCKED audit events on login lockout (distinct from LOGIN_FAILED)
- Audit export of assignments and audit logs (meta-audit gap)
- Update audit page UI with new security event types and colors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 18:28:56 +01:00
parent c8c26beed2
commit 13f125af28
4 changed files with 214 additions and 50 deletions

View File

@@ -145,8 +145,34 @@ function sanitizeInput(value: unknown, depth = 0): unknown {
return String(value)
}
/**
* Extract common fields from a sanitized input object for audit logging.
*/
function extractAuditFields(path: string, sanitizedInput: unknown) {
const dotIndex = path.indexOf('.')
const routerName = dotIndex > 0 ? path.slice(0, dotIndex) : path
const procedureName = dotIndex > 0 ? path.slice(dotIndex + 1) : path
// Convert procedure path to readable action (e.g., "evaluation.submit" → "EVALUATION_SUBMIT")
const action = path.replace(/\./g, '_').replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()
// Capitalize first letter of router name for entityType
const entityType = routerName.charAt(0).toUpperCase() + routerName.slice(1)
// Try to extract entityId from common input patterns
const inputObj = (typeof sanitizedInput === 'object' && sanitizedInput !== null)
? sanitizedInput as Record<string, unknown>
: undefined
const entityId = inputObj?.id ?? inputObj?.userId ?? inputObj?.projectId ??
inputObj?.roundId ?? inputObj?.competitionId ?? inputObj?.editionId ??
inputObj?.targetUserId ?? inputObj?.sessionId ?? inputObj?.awardId
return { routerName, procedureName, action, entityType, entityId }
}
/**
* Middleware that automatically logs all successful mutations for non-SUPER_ADMIN users.
* During impersonation, logs both the target user ID and the real admin ID.
* Captures: procedure path, sanitized input, user role, IP, user agent.
* Failures are silently caught — audit logging never breaks the calling operation.
*/
@@ -160,18 +186,11 @@ const withMutationAudit = middleware(async ({ ctx, next, path, type, getRawInput
const user = ctx.session?.user
if (!user?.id) return result
// Skip SUPER_ADMIN — they have their own manual audit trail
if (user.role === 'SUPER_ADMIN') return result
// Skip SUPER_ADMIN (unless impersonating — then user.role is the target's role)
const impersonation = user.impersonating as { originalId: string; originalRole: string; originalEmail: string } | undefined
if (user.role === 'SUPER_ADMIN' && !impersonation) return result
try {
// Extract router name and procedure name from path (e.g., "evaluation.submit")
const dotIndex = path.indexOf('.')
const routerName = dotIndex > 0 ? path.slice(0, dotIndex) : path
const procedureName = dotIndex > 0 ? path.slice(dotIndex + 1) : path
// Convert procedure path to readable action (e.g., "evaluation.submit" → "EVALUATION_SUBMIT")
const action = path.replace(/\./g, '_').replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase()
// Get and sanitize the raw input
let sanitizedInput: unknown = undefined
try {
@@ -183,30 +202,32 @@ const withMutationAudit = middleware(async ({ ctx, next, path, type, getRawInput
// getRawInput can fail if input was already consumed; ignore
}
// Capitalize first letter of router name for entityType
const entityType = routerName.charAt(0).toUpperCase() + routerName.slice(1)
const { procedureName, action, entityType, entityId } = extractAuditFields(path, sanitizedInput)
// Try to extract entityId from common input patterns
const inputObj = (typeof sanitizedInput === 'object' && sanitizedInput !== null)
? sanitizedInput as Record<string, unknown>
: undefined
const entityId = inputObj?.id ?? inputObj?.userId ?? inputObj?.projectId ??
inputObj?.roundId ?? inputObj?.competitionId ?? inputObj?.editionId ??
inputObj?.targetUserId ?? inputObj?.sessionId ?? inputObj?.awardId
// Build details payload — include impersonation info when active
const details: Record<string, unknown> = {
procedure: path,
procedureName,
role: user.role,
roles: user.roles,
input: sanitizedInput,
}
if (impersonation) {
details.impersonatedBy = {
adminId: impersonation.originalId,
adminEmail: impersonation.originalEmail,
adminRole: impersonation.originalRole,
}
}
await ctx.prisma.auditLog.create({
data: {
userId: user.id,
action,
// During impersonation, log as the real admin with target info in details
userId: impersonation ? impersonation.originalId : user.id,
action: impersonation ? `IMPERSONATED_${action}` : action,
entityType,
entityId: entityId ? String(entityId) : undefined,
detailsJson: {
procedure: path,
procedureName,
role: user.role,
roles: user.roles,
input: sanitizedInput,
} as Prisma.InputJsonValue,
detailsJson: details as Prisma.InputJsonValue,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
@@ -219,62 +240,136 @@ const withMutationAudit = middleware(async ({ ctx, next, path, type, getRawInput
return result
})
/**
* Middleware that logs failed operations (FORBIDDEN, UNAUTHORIZED, NOT_FOUND errors).
* Tracks permission denials and access violations per user for security monitoring.
* Applied to all authenticated procedures.
*/
const withErrorAudit = middleware(async ({ ctx, next, path, type, getRawInput }) => {
try {
return await next()
} catch (error) {
// Only log TRPCErrors that indicate security/access issues
if (error instanceof TRPCError) {
const securityCodes = ['FORBIDDEN', 'UNAUTHORIZED', 'NOT_FOUND'] as const
if (securityCodes.includes(error.code as typeof securityCodes[number])) {
try {
let sanitizedInput: unknown = undefined
try {
const rawInput = await getRawInput()
if (rawInput !== undefined) {
sanitizedInput = sanitizeInput(rawInput)
}
} catch { /* ignore */ }
const { procedureName, entityType, entityId } = extractAuditFields(path, sanitizedInput)
const user = ctx.session?.user
const impersonation = user?.impersonating as { originalId: string; originalEmail: string } | undefined
await ctx.prisma.auditLog.create({
data: {
userId: impersonation?.originalId ?? user?.id ?? null,
action: `ACCESS_DENIED_${error.code}`,
entityType,
entityId: entityId ? String(entityId) : undefined,
detailsJson: {
procedure: path,
procedureName,
type,
errorCode: error.code,
errorMessage: error.message,
role: user?.role,
input: sanitizedInput,
...(impersonation ? { impersonatedUserId: user?.id } : {}),
} as Prisma.InputJsonValue,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
} catch (auditError) {
console.error('[ErrorAudit] Failed to log:', path, auditError)
}
}
}
// Always re-throw the original error
throw error
}
})
// =============================================================================
// Procedure Types
// =============================================================================
/**
* Protected procedure - requires authenticated user
* Mutations are automatically audit-logged for non-SUPER_ADMIN users.
* Protected procedure - requires authenticated user.
* Mutations auto-audited, errors (FORBIDDEN/UNAUTHORIZED/NOT_FOUND) tracked.
*/
export const protectedProcedure = t.procedure.use(isAuthenticated).use(withMutationAudit)
export const protectedProcedure = t.procedure
.use(isAuthenticated)
.use(withErrorAudit)
.use(withMutationAudit)
/**
* Admin procedure - requires SUPER_ADMIN or PROGRAM_ADMIN role
* PROGRAM_ADMIN mutations are audit-logged; SUPER_ADMIN mutations are skipped.
* Admin procedure - requires SUPER_ADMIN or PROGRAM_ADMIN role.
* PROGRAM_ADMIN mutations are audit-logged; SUPER_ADMIN mutations skipped.
* Errors tracked for all users.
*/
export const adminProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
.use(withErrorAudit)
.use(withMutationAudit)
/**
* Super admin procedure - requires SUPER_ADMIN role
* No automatic mutation audit (super admins have manual audit trail).
* Super admin procedure - requires SUPER_ADMIN role.
* No automatic mutation audit. Errors still tracked.
*/
export const superAdminProcedure = t.procedure.use(hasRole('SUPER_ADMIN'))
export const superAdminProcedure = t.procedure
.use(hasRole('SUPER_ADMIN'))
.use(withErrorAudit)
/**
* Jury procedure - requires JURY_MEMBER role
* All mutations are automatically audit-logged.
* Jury procedure - requires JURY_MEMBER role.
* All mutations auto-audited, errors tracked.
*/
export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER')).use(withMutationAudit)
export const juryProcedure = t.procedure
.use(hasRole('JURY_MEMBER'))
.use(withErrorAudit)
.use(withMutationAudit)
/**
* Mentor procedure - requires MENTOR role (or admin)
* MENTOR and PROGRAM_ADMIN mutations are audit-logged.
* Mentor procedure - requires MENTOR role (or admin).
* MENTOR and PROGRAM_ADMIN mutations are audit-logged, errors tracked.
*/
export const mentorProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR'))
.use(withErrorAudit)
.use(withMutationAudit)
/**
* Observer procedure - requires OBSERVER role (read-only access)
* Mutations (if any) are audit-logged for OBSERVER and PROGRAM_ADMIN.
* Observer procedure - requires OBSERVER role (read-only access).
* Mutations (if any) audit-logged, errors tracked.
*/
export const observerProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
.use(withErrorAudit)
.use(withMutationAudit)
/**
* Award master procedure - requires AWARD_MASTER role (or admin)
* AWARD_MASTER and PROGRAM_ADMIN mutations are audit-logged.
* Award master procedure - requires AWARD_MASTER role (or admin).
* AWARD_MASTER and PROGRAM_ADMIN mutations audit-logged, errors tracked.
*/
export const awardMasterProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
.use(withErrorAudit)
.use(withMutationAudit)
/**
* Audience procedure - requires any authenticated user
* All mutations are automatically audit-logged.
* Audience procedure - requires any authenticated user.
* All mutations auto-audited, errors tracked.
*/
export const audienceProcedure = t.procedure.use(isAuthenticated).use(withMutationAudit)
export const audienceProcedure = t.procedure
.use(isAuthenticated)
.use(withErrorAudit)
.use(withMutationAudit)