Basic request log working

This commit is contained in:
Owen
2025-10-22 12:23:48 -07:00
parent fdd4d5244f
commit f748c5dbe4
16 changed files with 793 additions and 52 deletions

View File

@@ -24,24 +24,8 @@ import { fromError } from "zod-validation-error";
import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response";
import logger from "@server/logger";
import { queryAccessAuditLogsParams, queryAccessAuditLogsQuery, querySites } from "./queryActionAuditLog";
function generateCSV(data: any[]): string {
if (data.length === 0) {
return "orgId,action,actorType,timestamp,actor\n";
}
const headers = Object.keys(data[0]).join(",");
const rows = data.map(row =>
Object.values(row).map(value =>
typeof value === 'string' && value.includes(',')
? `"${value.replace(/"/g, '""')}"`
: value
).join(",")
);
return [headers, ...rows].join("\n");
}
import { queryActionAuditLogsParams, queryActionAuditLogsQuery, querySites } from "./queryActionAuditLog";
import { generateCSV } from "@server/routers/auditLogs/generateCSV";
registry.registerPath({
method: "get",
@@ -49,19 +33,19 @@ registry.registerPath({
description: "Export the action audit log for an organization as CSV",
tags: [OpenAPITags.Org],
request: {
query: queryAccessAuditLogsQuery,
params: queryAccessAuditLogsParams
query: queryActionAuditLogsQuery,
params: queryActionAuditLogsParams
},
responses: {}
});
export async function exportAccessAuditLogs(
export async function exportActionAuditLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
@@ -72,7 +56,7 @@ export async function exportAccessAuditLogs(
}
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
const parsedParams = queryAccessAuditLogsParams.safeParse(req.params);
const parsedParams = queryActionAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
@@ -90,7 +74,7 @@ export async function exportAccessAuditLogs(
const csvData = generateCSV(log);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="audit-logs-${orgId}-${Date.now()}.csv"`);
res.setHeader('Content-Disposition', `attachment; filename="action-audit-logs-${orgId}-${Date.now()}.csv"`);
return res.send(csvData);
} catch (error) {

View File

@@ -25,7 +25,7 @@ import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response";
import logger from "@server/logger";
export const queryAccessAuditLogsQuery = z.object({
export const queryActionAuditLogsQuery = z.object({
// iso string just validate its a parseable date
timeStart: z
.string()
@@ -55,7 +55,7 @@ export const queryAccessAuditLogsQuery = z.object({
.pipe(z.number().int().nonnegative())
});
export const queryAccessAuditLogsParams = z.object({
export const queryActionAuditLogsParams = z.object({
orgId: z.string()
});
@@ -100,19 +100,19 @@ registry.registerPath({
description: "Query the action audit log for an organization",
tags: [OpenAPITags.Org],
request: {
query: queryAccessAuditLogsQuery,
params: queryAccessAuditLogsParams
query: queryActionAuditLogsQuery,
params: queryActionAuditLogsParams
},
responses: {}
});
export async function queryAccessAuditLogs(
export async function queryActionAuditLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
@@ -123,7 +123,7 @@ export async function queryAccessAuditLogs(
}
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
const parsedParams = queryAccessAuditLogsParams.safeParse(req.params);
const parsedParams = queryActionAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(

View File

@@ -348,11 +348,11 @@ authenticated.post(
authenticated.get(
"/org/:orgId/logs/action",
logs.queryAccessAuditLogs
logs.queryActionAuditLogs
)
authenticated.get(
"/org/:orgId/logs/action/export",
logs.exportAccessAuditLogs
logs.exportActionAuditLogs
)

View File

@@ -0,0 +1,73 @@
import { db, requestAuditLog } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response";
import logger from "@server/logger";
import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, querySites } from "./queryRequstAuditLog";
import { generateCSV } from "./generateCSV";
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/request",
description: "Query the request audit log for an organization",
tags: [OpenAPITags.Org],
request: {
query: queryAccessAuditLogsQuery,
params: queryRequestAuditLogsParams
},
responses: {}
});
export async function exportRequestAuditLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
const baseQuery = querySites(timeStart, timeEnd, orgId);
const log = await baseQuery.limit(limit).offset(offset);
const csvData = generateCSV(log);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${orgId}-${Date.now()}.csv"`);
return res.send(csvData);
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,16 @@
export function generateCSV(data: any[]): string {
if (data.length === 0) {
return "orgId,action,actorType,timestamp,actor\n";
}
const headers = Object.keys(data[0]).join(",");
const rows = data.map(row =>
Object.values(row).map(value =>
typeof value === 'string' && value.includes(',')
? `"${value.replace(/"/g, '""')}"`
: value
).join(",")
);
return [headers, ...rows].join("\n");
}

View File

@@ -0,0 +1,2 @@
export * from "./queryRequstAuditLog";
export * from "./exportRequstAuditLog";

View File

@@ -0,0 +1,165 @@
import { db, requestAuditLog } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response";
import logger from "@server/logger";
export const queryAccessAuditLogsQuery = z.object({
// iso string just validate its a parseable date
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
message: "timeStart must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000)),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
message: "timeEnd must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.default(new Date().toISOString()),
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
export const queryRequestAuditLogsParams = z.object({
orgId: z.string()
});
export function querySites(timeStart: number, timeEnd: number, orgId: string) {
return db
.select({
timestamp: requestAuditLog.timestamp,
orgId: requestAuditLog.orgId,
action: requestAuditLog.action,
reason: requestAuditLog.reason,
actorType: requestAuditLog.actorType,
actor: requestAuditLog.actor,
actorId: requestAuditLog.actorId,
resourceId: requestAuditLog.resourceId,
ip: requestAuditLog.ip,
location: requestAuditLog.location,
userAgent: requestAuditLog.userAgent,
metadata: requestAuditLog.metadata,
headers: requestAuditLog.headers,
query: requestAuditLog.query,
originalRequestURL: requestAuditLog.originalRequestURL,
scheme: requestAuditLog.scheme,
host: requestAuditLog.host,
path: requestAuditLog.path,
method: requestAuditLog.method,
tls: requestAuditLog.tls,
})
.from(requestAuditLog)
.where(
and(
gt(requestAuditLog.timestamp, timeStart),
lt(requestAuditLog.timestamp, timeEnd),
eq(requestAuditLog.orgId, orgId)
)
)
.orderBy(requestAuditLog.timestamp);
}
export function countQuery(timeStart: number, timeEnd: number, orgId: string) {
const countQuery = db
.select({ count: count() })
.from(requestAuditLog)
.where(
and(
gt(requestAuditLog.timestamp, timeStart),
lt(requestAuditLog.timestamp, timeEnd),
eq(requestAuditLog.orgId, orgId)
)
);
return countQuery;
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/request",
description: "Query the request audit log for an organization",
tags: [OpenAPITags.Org],
request: {
query: queryAccessAuditLogsQuery,
params: queryRequestAuditLogsParams
},
responses: {}
});
export async function queryRequestAuditLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
const baseQuery = querySites(timeStart, timeEnd, orgId);
const log = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery(timeStart, timeEnd, orgId);
const totalCount = totalCountResult[0].count;
return response<QueryRequestAuditLogResponse>(res, {
data: {
log: log,
pagination: {
total: totalCount,
limit,
offset
}
},
success: true,
error: false,
message: "Action audit logs retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -3,6 +3,7 @@ export type QueryActionAuditLogResponse = {
orgId: string;
action: string;
actorType: string;
actorId: string;
timestamp: number;
actor: string;
}[];
@@ -12,3 +13,33 @@ export type QueryActionAuditLogResponse = {
offset: number;
};
};
export type QueryRequestAuditLogResponse = {
log: {
timestamp: number;
orgId: string;
action: boolean;
reason: number;
actorType: string | null;
actor: string | null;
actorId: string | null;
resourceId: number | null;
ip: string | null;
location: string | null;
userAgent: string | null;
metadata: string | null;
headers: string | null;
query: string | null;
originalRequestURL: string | null;
scheme: string | null;
host: string | null;
path: string | null;
method: string | null;
tls: boolean | null;
}[];
pagination: {
total: number;
limit: number;
offset: number;
};
};

View File

@@ -69,7 +69,7 @@ export async function logRequestAudit(
if (!orgId) {
logger.warn("logRequestAudit: No organization context found");
orgId = "unknown";
orgId = "org_7g93l5xu7p61q14";
// return;
}
@@ -85,6 +85,26 @@ export async function logRequestAudit(
metadata = JSON.stringify(metadata);
}
const clientIp = body.requestIp
? (() => {
if (body.requestIp.startsWith("[") && body.requestIp.includes("]")) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = body.requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// ivp4
// split at last colon
const lastColonIndex = body.requestIp.lastIndexOf(":");
if (lastColonIndex !== -1) {
return body.requestIp.substring(0, lastColonIndex);
}
return body.requestIp;
})()
: undefined;
await db.insert(requestAuditLog).values({
timestamp,
orgId,
@@ -104,7 +124,7 @@ export async function logRequestAudit(
host: body.host,
path: body.path,
method: body.method,
ip: body.requestIp,
ip: clientIp,
tls: body.tls
});
} catch (error) {

View File

@@ -14,6 +14,7 @@ import * as supporterKey from "./supporterKey";
import * as accessToken from "./accessToken";
import * as idp from "./idp";
import * as apiKeys from "./apiKeys";
import * as logs from "./auditLogs";
import HttpCode from "@server/types/HttpCode";
import {
verifyAccessTokenAccess,
@@ -1180,3 +1181,13 @@ authRouter.delete(
}),
auth.deleteSecurityKey
);
authenticated.get(
"/org/:orgId/logs/request",
logs.queryRequestAuditLogs
)
authenticated.get(
"/org/:orgId/logs/request/export",
logs.exportRequestAuditLogs
)