ai-chatbot/lib/security/access-log.ts

201 lines
5.9 KiB
TypeScript

import "server-only";
import { sql } from "drizzle-orm";
import { headers } from "next/headers";
import { db } from "@/lib/db";
import { accessIpEvent } from "@/lib/db/schema";
export type AccessLogSeverity = "info" | "watch" | "high" | "critical";
export type RecordAccessIpEventInput = {
eventType?: string | null;
ip?: string | null;
userId?: string | null;
userEmail?: string | null;
userRole?: string | null;
method?: string | null;
path?: string | null;
routeGroup?: string | null;
statusCode?: number | null;
outcome?: string | null;
severity?: AccessLogSeverity | string | null;
reasons?: string[] | null;
userAgent?: string | null;
referer?: string | null;
metadata?: Record<string, unknown> | null;
occurredAt?: Date | string | number | null;
};
const SEVERITIES = new Set(["info", "watch", "high", "critical"]);
const ACCESS_EVENT_UNAVAILABLE_CACHE_MS = 5 * 60 * 1000;
let accessEventUnavailableUntil = 0;
let accessEventMissingTableWarned = false;
function getErrorCode(error: unknown) {
const directCode =
error && typeof error === "object"
? (error as { code?: unknown }).code
: null;
if (typeof directCode === "string") return directCode;
const cause =
error && typeof error === "object"
? (error as { cause?: unknown }).cause
: null;
const causeCode =
cause && typeof cause === "object"
? (cause as { code?: unknown }).code
: null;
return typeof causeCode === "string" ? causeCode : null;
}
function getErrorMessage(error: unknown) {
const messages: string[] = [];
if (error instanceof Error) messages.push(error.message);
const cause =
error && typeof error === "object"
? (error as { cause?: unknown }).cause
: null;
if (cause instanceof Error) messages.push(cause.message);
if (!messages.length) messages.push(String(error));
return messages.join("\n");
}
function isMissingAccessIpEventTableError(error: unknown) {
if (getErrorCode(error) === "42P01") return true;
const message = getErrorMessage(error);
return (
message.includes('relation "AccessIpEvent" does not exist') ||
(message.includes("AccessIpEvent") && message.includes("does not exist"))
);
}
function markAccessEventsUnavailable(error: unknown) {
accessEventUnavailableUntil = Date.now() + ACCESS_EVENT_UNAVAILABLE_CACHE_MS;
if (accessEventMissingTableWarned) return;
accessEventMissingTableWarned = true;
console.warn(
"[AccessIpEvent] table is unavailable; access logging will be skipped temporarily",
getErrorMessage(error)
);
}
function shouldSkipAccessEventWrite() {
return (
process.env.ACCESS_LOG_ENABLED === "false" ||
Date.now() < accessEventUnavailableUntil
);
}
function clampText(value: unknown, maxLength: number) {
const normalized = String(value || "").trim();
if (!normalized) return null;
return normalized.slice(0, maxLength);
}
function normalizeIp(value: unknown) {
const normalized = clampText(value, 128);
if (!normalized) return "unknown";
return normalized.split(",")[0]?.trim().slice(0, 64) || "unknown";
}
function normalizeSeverity(value: unknown): AccessLogSeverity {
const normalized = String(value || "").trim().toLowerCase();
return SEVERITIES.has(normalized)
? (normalized as AccessLogSeverity)
: "info";
}
function normalizeReasons(value: unknown) {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((item) => clampText(item, 80))
.filter((item): item is string => Boolean(item))
)
).slice(0, 8);
}
function normalizeOccurredAt(value: unknown) {
if (!value) return new Date();
const date =
value instanceof Date
? value
: typeof value === "number"
? new Date(value)
: new Date(String(value));
return Number.isNaN(date.getTime()) ? new Date() : date;
}
async function maybePruneOldAccessEvents() {
if (Math.random() > 0.005) return;
await db.execute(sql`
DELETE FROM "AccessIpEvent"
WHERE "createdAt" < NOW() - INTERVAL '30 days'
`);
}
export async function recordAccessIpEvent(input: RecordAccessIpEventInput) {
if (shouldSkipAccessEventWrite()) return;
const ip = normalizeIp(input.ip);
await maybePruneOldAccessEvents().catch((error) => {
if (isMissingAccessIpEventTableError(error)) {
markAccessEventsUnavailable(error);
return;
}
console.warn("[AccessIpEvent] prune failed", error);
});
try {
await db.insert(accessIpEvent).values({
eventType: clampText(input.eventType, 32) || "request",
ip,
userId: clampText(input.userId, 64),
userEmail: clampText(input.userEmail, 128),
userRole: clampText(input.userRole, 32),
method: clampText(input.method, 12),
path: clampText(input.path, 256),
routeGroup: clampText(input.routeGroup, 64),
statusCode:
typeof input.statusCode === "number" && Number.isFinite(input.statusCode)
? Math.round(input.statusCode)
: null,
outcome: clampText(input.outcome, 32) || "allowed",
severity: normalizeSeverity(input.severity),
reasons: normalizeReasons(input.reasons),
userAgent: clampText(input.userAgent, 512),
referer: clampText(input.referer, 512),
metadata: input.metadata || null,
occurredAt: normalizeOccurredAt(input.occurredAt),
});
} catch (error) {
if (isMissingAccessIpEventTableError(error)) {
markAccessEventsUnavailable(error);
return;
}
throw error;
}
}
export async function recordAccessIpEventFromHeaders(
input: Omit<RecordAccessIpEventInput, "ip" | "userAgent" | "referer">
) {
const requestHeaders = await headers();
const forwardedFor = requestHeaders.get("x-forwarded-for");
const realIp = requestHeaders.get("x-real-ip");
await recordAccessIpEvent({
...input,
ip: forwardedFor || realIp,
userAgent: requestHeaders.get("user-agent"),
referer: requestHeaders.get("referer"),
});
}