diff --git a/src/lib/kyc-status-log.ts b/src/lib/kyc-status-log.ts index 475e176..2952443 100644 --- a/src/lib/kyc-status-log.ts +++ b/src/lib/kyc-status-log.ts @@ -5,6 +5,9 @@ import { truncateWalletAddress } from "./kyc"; /** The administrative action that produced a KYC status change. */ export type KYCStatusChangeAction = "approve" | "reject" | "revoke"; +/** The stable category of a KYC review failure. */ +export type KYCReviewFailureCategory = "unauthorized" | "user_not_found" | "invalid_state" | "database_error"; + export interface KYCStatusChangeInput { /** Wallet address of the user whose KYC status changed. */ wallet: string; @@ -51,3 +54,32 @@ export function logKYCStatusChange(logger: AppLogger, input: KYCStatusChangeInpu ...(input.reason ? { reason: input.reason } : {}), }); } + +export interface KYCReviewFailureInput { + /** Internal id of the user being reviewed, if known. */ + userId?: string; + /** Internal id of the reviewing admin, if known. */ + reviewerId?: string; + /** Which admin action failed. */ + action: KYCStatusChangeAction; + /** The stable category of the failure. */ + failureCategory: KYCReviewFailureCategory; + /** A correlation identifier for the request, if available. */ + correlationId?: string; + /** Any error message or code. */ + errorDetails?: string; +} + +/** + * Audit log for failed KYC review decisions. + */ +export function logKYCReviewFailure(logger: AppLogger, input: KYCReviewFailureInput): void { + logger.error("KYC review failed", { + ...(input.userId ? { user_id: input.userId } : {}), + ...(input.reviewerId ? { reviewer_id: input.reviewerId } : {}), + action: input.action, + failure_category: input.failureCategory, + ...(input.correlationId ? { correlation_id: input.correlationId } : {}), + ...(input.errorDetails ? { error_details: input.errorDetails } : {}), + }); +} diff --git a/src/routes/admin/approve-kyc.ts b/src/routes/admin/approve-kyc.ts index 2b9f3af..543dc90 100644 --- a/src/routes/admin/approve-kyc.ts +++ b/src/routes/admin/approve-kyc.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { DataSource } from "typeorm"; import { User } from "@/models/User.model"; import { KYCStatus } from "@/types/enums"; -import { logKYCStatusChange } from "@/lib/kyc-status-log"; +import { logKYCStatusChange, logKYCReviewFailure } from "@/lib/kyc-status-log"; import { logger } from "@/observability/logger"; interface ApproveKYCBody { @@ -13,7 +13,16 @@ interface ApproveKYCBody { export async function approveKYC(req: Request, res: Response, dataSource: DataSource) { try { const adminKey = req.headers["x-admin-key"]; + const correlationId = req.headers["x-request-id"] as string | undefined; + if (adminKey !== process.env.ADMIN_API_KEY) { + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "approve", + failureCategory: "unauthorized", + correlationId, + }); return res.status(401).json({ error: "Unauthorized" }); } @@ -22,6 +31,13 @@ export async function approveKYC(req: Request, const userRepo = dataSource.getRepository(User); const user = await userRepo.findOneBy({ id: userId }); if (!user) { + logKYCReviewFailure(logger, { + userId, + reviewerId, + action: "approve", + failureCategory: "user_not_found", + correlationId, + }); return res.status(404).json({ error: "User not found" }); } @@ -45,6 +61,16 @@ export async function approveKYC(req: Request, return res.json({ success: true }); } catch (err: unknown) { + const correlationId = req.headers["x-request-id"] as string | undefined; + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "approve", + failureCategory: "database_error", + correlationId, + errorDetails: err instanceof Error ? err.message : String(err), + }); + const appErr = err as { status?: number; code?: string; message?: string }; return res.status(appErr.status ?? 500).json({ error: { diff --git a/src/routes/admin/reject-kyc.ts b/src/routes/admin/reject-kyc.ts index 75ac4a1..5abe1af 100644 --- a/src/routes/admin/reject-kyc.ts +++ b/src/routes/admin/reject-kyc.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { DataSource } from "typeorm"; import { User } from "@/models/User.model"; import { KYCStatus } from "@/types/enums"; -import { logKYCStatusChange } from "@/lib/kyc-status-log"; +import { logKYCStatusChange, logKYCReviewFailure } from "@/lib/kyc-status-log"; import { logger } from "@/observability/logger"; interface RejectKYCBody { @@ -14,7 +14,16 @@ interface RejectKYCBody { export async function rejectKYC(req: Request, res: Response, dataSource: DataSource) { try { const adminKey = req.headers["x-admin-key"]; + const correlationId = req.headers["x-request-id"] as string | undefined; + if (adminKey !== process.env.ADMIN_API_KEY) { + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "reject", + failureCategory: "unauthorized", + correlationId, + }); return res.status(401).json({ error: "Unauthorized" }); } @@ -23,6 +32,13 @@ export async function rejectKYC(req: Request, r const userRepo = dataSource.getRepository(User); const user = await userRepo.findOneBy({ id: userId }); if (!user) { + logKYCReviewFailure(logger, { + userId, + reviewerId, + action: "reject", + failureCategory: "user_not_found", + correlationId, + }); return res.status(404).json({ error: "User not found" }); } @@ -47,6 +63,16 @@ export async function rejectKYC(req: Request, r return res.json({ success: true }); } catch (err: unknown) { + const correlationId = req.headers["x-request-id"] as string | undefined; + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "reject", + failureCategory: "database_error", + correlationId, + errorDetails: err instanceof Error ? err.message : String(err), + }); + const appErr = err as { status?: number; code?: string; message?: string }; return res.status(appErr.status ?? 500).json({ error: { diff --git a/src/routes/admin/revoke-kyc.ts b/src/routes/admin/revoke-kyc.ts index 84716ad..17ecfff 100644 --- a/src/routes/admin/revoke-kyc.ts +++ b/src/routes/admin/revoke-kyc.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { DataSource } from "typeorm"; import { User } from "@/models/User.model"; import { KYCStatus } from "@/types/enums"; -import { logKYCStatusChange } from "@/lib/kyc-status-log"; +import { logKYCStatusChange, logKYCReviewFailure } from "@/lib/kyc-status-log"; import { logger } from "@/observability/logger"; interface RevokeKYCBody { @@ -23,7 +23,16 @@ interface RevokeKYCBody { export async function revokeKYC(req: Request, res: Response, dataSource: DataSource) { try { const adminKey = req.headers["x-admin-key"]; + const correlationId = req.headers["x-request-id"] as string | undefined; + if (adminKey !== process.env.ADMIN_API_KEY) { + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "revoke", + failureCategory: "unauthorized", + correlationId, + }); return res.status(401).json({ error: "Unauthorized" }); } @@ -32,11 +41,26 @@ export async function revokeKYC(req: Request, r const userRepo = dataSource.getRepository(User); const user = await userRepo.findOneBy({ id: userId }); if (!user) { + logKYCReviewFailure(logger, { + userId, + reviewerId, + action: "revoke", + failureCategory: "user_not_found", + correlationId, + }); return res.status(404).json({ error: "User not found" }); } const previousStatus = user.kycStatus; if (previousStatus !== KYCStatus.APPROVED) { + logKYCReviewFailure(logger, { + userId, + reviewerId, + action: "revoke", + failureCategory: "invalid_state", + correlationId, + errorDetails: `Cannot revoke KYC for a user whose status is ${previousStatus}.`, + }); return res.status(409).json({ error: { code: "KYC_NOT_APPROVED", @@ -63,6 +87,16 @@ export async function revokeKYC(req: Request, r return res.json({ success: true }); } catch (err: unknown) { + const correlationId = req.headers["x-request-id"] as string | undefined; + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "revoke", + failureCategory: "database_error", + correlationId, + errorDetails: err instanceof Error ? err.message : String(err), + }); + const appErr = err as { status?: number; code?: string; message?: string }; return res.status(appErr.status ?? 500).json({ error: {