feat: error audit middleware, impersonation attribution, account lockout logging
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m13s
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:
@@ -126,6 +126,11 @@ const ACTION_TYPES = [
|
|||||||
'USER_CHANGE_PASSWORD',
|
'USER_CHANGE_PASSWORD',
|
||||||
'USER_COMPLETE_ONBOARDING',
|
'USER_COMPLETE_ONBOARDING',
|
||||||
'SPECIAL_AWARD_SUBMIT_VOTE',
|
'SPECIAL_AWARD_SUBMIT_VOTE',
|
||||||
|
// Security events
|
||||||
|
'ACCOUNT_LOCKED',
|
||||||
|
'ACCESS_DENIED_FORBIDDEN',
|
||||||
|
'ACCESS_DENIED_UNAUTHORIZED',
|
||||||
|
'ACCESS_DENIED_NOT_FOUND',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Entity type options
|
// Entity type options
|
||||||
@@ -210,6 +215,11 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
|
|||||||
USER_SET_PASSWORD: 'outline',
|
USER_SET_PASSWORD: 'outline',
|
||||||
USER_CHANGE_PASSWORD: 'outline',
|
USER_CHANGE_PASSWORD: 'outline',
|
||||||
USER_COMPLETE_ONBOARDING: 'default',
|
USER_COMPLETE_ONBOARDING: 'default',
|
||||||
|
// Security events
|
||||||
|
ACCOUNT_LOCKED: 'destructive',
|
||||||
|
ACCESS_DENIED_FORBIDDEN: 'destructive',
|
||||||
|
ACCESS_DENIED_UNAUTHORIZED: 'destructive',
|
||||||
|
ACCESS_DENIED_NOT_FOUND: 'secondary',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
// Track failed attempt (don't reveal whether user exists)
|
// Track failed attempt (don't reveal whether user exists)
|
||||||
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
|
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
|
||||||
current.count++
|
current.count++
|
||||||
if (current.count >= MAX_LOGIN_ATTEMPTS) {
|
const wasLocked = current.count >= MAX_LOGIN_ATTEMPTS
|
||||||
|
if (wasLocked) {
|
||||||
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
|
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
|
||||||
current.count = 0
|
current.count = 0
|
||||||
}
|
}
|
||||||
@@ -171,6 +172,22 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
},
|
},
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
|
// Log account lockout as a distinct security event
|
||||||
|
if (wasLocked) {
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: null,
|
||||||
|
action: 'ACCOUNT_LOCKED',
|
||||||
|
entityType: 'User',
|
||||||
|
detailsJson: {
|
||||||
|
email,
|
||||||
|
reason: 'max_failed_attempts',
|
||||||
|
lockoutDurationMinutes: LOCKOUT_DURATION_MS / 60000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +202,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
// Track failed attempt
|
// Track failed attempt
|
||||||
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
|
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
|
||||||
current.count++
|
current.count++
|
||||||
if (current.count >= MAX_LOGIN_ATTEMPTS) {
|
const wasLocked = current.count >= MAX_LOGIN_ATTEMPTS
|
||||||
|
if (wasLocked) {
|
||||||
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
|
current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS
|
||||||
current.count = 0
|
current.count = 0
|
||||||
}
|
}
|
||||||
@@ -202,6 +220,23 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
},
|
},
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
|
// Log account lockout as a distinct security event
|
||||||
|
if (wasLocked) {
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
action: 'ACCOUNT_LOCKED',
|
||||||
|
entityType: 'User',
|
||||||
|
entityId: user.id,
|
||||||
|
detailsJson: {
|
||||||
|
email,
|
||||||
|
reason: 'max_failed_attempts',
|
||||||
|
lockoutDurationMinutes: LOCKOUT_DURATION_MS / 60000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,16 @@ export const exportRouter = router({
|
|||||||
orderBy: [{ project: { title: 'asc' } }, { user: { name: 'asc' } }],
|
orderBy: [{ project: { title: 'asc' } }, { user: { name: 'asc' } }],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'EXPORT',
|
||||||
|
entityType: 'Assignment',
|
||||||
|
detailsJson: { roundId: input.roundId, count: assignments.length, exportType: 'assignments' },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
const data = assignments.map((a) => ({
|
const data = assignments.map((a) => ({
|
||||||
projectTitle: a.project.title,
|
projectTitle: a.project.title,
|
||||||
teamName: a.project.teamName,
|
teamName: a.project.teamName,
|
||||||
@@ -398,6 +408,20 @@ export const exportRouter = router({
|
|||||||
take: 10000, // Limit export to 10k records
|
take: 10000, // Limit export to 10k records
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'EXPORT',
|
||||||
|
entityType: 'AuditLog',
|
||||||
|
detailsJson: {
|
||||||
|
count: logs.length,
|
||||||
|
exportType: 'auditLogs',
|
||||||
|
filters: { userId, action, entityType, startDate: startDate?.toISOString(), endDate: endDate?.toISOString() },
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
const data = logs.map((log) => ({
|
const data = logs.map((log) => ({
|
||||||
timestamp: log.timestamp.toISOString(),
|
timestamp: log.timestamp.toISOString(),
|
||||||
userName: log.user?.name ?? 'System',
|
userName: log.user?.name ?? 'System',
|
||||||
|
|||||||
@@ -145,8 +145,34 @@ function sanitizeInput(value: unknown, depth = 0): unknown {
|
|||||||
return String(value)
|
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.
|
* 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.
|
* Captures: procedure path, sanitized input, user role, IP, user agent.
|
||||||
* Failures are silently caught — audit logging never breaks the calling operation.
|
* 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
|
const user = ctx.session?.user
|
||||||
if (!user?.id) return result
|
if (!user?.id) return result
|
||||||
|
|
||||||
// Skip SUPER_ADMIN — they have their own manual audit trail
|
// Skip SUPER_ADMIN (unless impersonating — then user.role is the target's role)
|
||||||
if (user.role === 'SUPER_ADMIN') return result
|
const impersonation = user.impersonating as { originalId: string; originalRole: string; originalEmail: string } | undefined
|
||||||
|
if (user.role === 'SUPER_ADMIN' && !impersonation) return result
|
||||||
|
|
||||||
try {
|
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
|
// Get and sanitize the raw input
|
||||||
let sanitizedInput: unknown = undefined
|
let sanitizedInput: unknown = undefined
|
||||||
try {
|
try {
|
||||||
@@ -183,30 +202,32 @@ const withMutationAudit = middleware(async ({ ctx, next, path, type, getRawInput
|
|||||||
// getRawInput can fail if input was already consumed; ignore
|
// getRawInput can fail if input was already consumed; ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capitalize first letter of router name for entityType
|
const { procedureName, action, entityType, entityId } = extractAuditFields(path, sanitizedInput)
|
||||||
const entityType = routerName.charAt(0).toUpperCase() + routerName.slice(1)
|
|
||||||
|
|
||||||
// Try to extract entityId from common input patterns
|
// Build details payload — include impersonation info when active
|
||||||
const inputObj = (typeof sanitizedInput === 'object' && sanitizedInput !== null)
|
const details: Record<string, unknown> = {
|
||||||
? 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
|
|
||||||
|
|
||||||
await ctx.prisma.auditLog.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
action,
|
|
||||||
entityType,
|
|
||||||
entityId: entityId ? String(entityId) : undefined,
|
|
||||||
detailsJson: {
|
|
||||||
procedure: path,
|
procedure: path,
|
||||||
procedureName,
|
procedureName,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
roles: user.roles,
|
roles: user.roles,
|
||||||
input: sanitizedInput,
|
input: sanitizedInput,
|
||||||
} as Prisma.InputJsonValue,
|
}
|
||||||
|
if (impersonation) {
|
||||||
|
details.impersonatedBy = {
|
||||||
|
adminId: impersonation.originalId,
|
||||||
|
adminEmail: impersonation.originalEmail,
|
||||||
|
adminRole: impersonation.originalRole,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
// 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: details as Prisma.InputJsonValue,
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
},
|
},
|
||||||
@@ -219,62 +240,136 @@ const withMutationAudit = middleware(async ({ ctx, next, path, type, getRawInput
|
|||||||
return result
|
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
|
// Procedure Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protected procedure - requires authenticated user
|
* Protected procedure - requires authenticated user.
|
||||||
* Mutations are automatically audit-logged for non-SUPER_ADMIN users.
|
* 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
|
* Admin procedure - requires SUPER_ADMIN or PROGRAM_ADMIN role.
|
||||||
* PROGRAM_ADMIN mutations are audit-logged; SUPER_ADMIN mutations are skipped.
|
* PROGRAM_ADMIN mutations are audit-logged; SUPER_ADMIN mutations skipped.
|
||||||
|
* Errors tracked for all users.
|
||||||
*/
|
*/
|
||||||
export const adminProcedure = t.procedure
|
export const adminProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
|
||||||
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Super admin procedure - requires SUPER_ADMIN role
|
* Super admin procedure - requires SUPER_ADMIN role.
|
||||||
* No automatic mutation audit (super admins have manual audit trail).
|
* 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
|
* Jury procedure - requires JURY_MEMBER role.
|
||||||
* All mutations are automatically audit-logged.
|
* 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 procedure - requires MENTOR role (or admin).
|
||||||
* MENTOR and PROGRAM_ADMIN mutations are audit-logged.
|
* MENTOR and PROGRAM_ADMIN mutations are audit-logged, errors tracked.
|
||||||
*/
|
*/
|
||||||
export const mentorProcedure = t.procedure
|
export const mentorProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR'))
|
||||||
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Observer procedure - requires OBSERVER role (read-only access)
|
* Observer procedure - requires OBSERVER role (read-only access).
|
||||||
* Mutations (if any) are audit-logged for OBSERVER and PROGRAM_ADMIN.
|
* Mutations (if any) audit-logged, errors tracked.
|
||||||
*/
|
*/
|
||||||
export const observerProcedure = t.procedure
|
export const observerProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
|
||||||
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Award master procedure - requires AWARD_MASTER role (or admin)
|
* Award master procedure - requires AWARD_MASTER role (or admin).
|
||||||
* AWARD_MASTER and PROGRAM_ADMIN mutations are audit-logged.
|
* AWARD_MASTER and PROGRAM_ADMIN mutations audit-logged, errors tracked.
|
||||||
*/
|
*/
|
||||||
export const awardMasterProcedure = t.procedure
|
export const awardMasterProcedure = t.procedure
|
||||||
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
|
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
|
||||||
|
.use(withErrorAudit)
|
||||||
.use(withMutationAudit)
|
.use(withMutationAudit)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audience procedure - requires any authenticated user
|
* Audience procedure - requires any authenticated user.
|
||||||
* All mutations are automatically audit-logged.
|
* 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user