Access logs working

This commit is contained in:
Owen
2025-10-22 17:42:27 -07:00
parent 7f981f05fb
commit 0211f75cb6
28 changed files with 1003 additions and 79 deletions

View File

@@ -0,0 +1,102 @@
import { accessAuditLog, db } from "@server/db";
import { getCountryCodeForIp } from "@server/lib/geoip";
import logger from "@server/logger";
import NodeCache from "node-cache";
const cache = new NodeCache({
stdTTL: 5 // seconds
});
export async function logAccessAudit(data: {
action: boolean;
type: string;
orgId: string;
resourceId?: number;
user?: { username: string; userId: string };
apiKey?: { name: string | null; apiKeyId: string };
metadata?: any;
userAgent?: string;
requestIp?: string;
}) {
try {
let actorType: string | undefined;
let actor: string | undefined;
let actorId: string | undefined;
const user = data.user;
if (user) {
actorType = "user";
actor = user.username;
actorId = user.userId;
}
const apiKey = data.apiKey;
if (apiKey) {
actorType = "apiKey";
actor = apiKey.name || apiKey.apiKeyId;
actorId = apiKey.apiKeyId;
}
// if (!actorType || !actor || !actorId) {
// logger.warn("logRequestAudit: Incomplete actor information");
// return;
// }
const timestamp = Math.floor(Date.now() / 1000);
let metadata = null;
if (metadata) {
metadata = JSON.stringify(metadata);
}
const clientIp = data.requestIp
? (() => {
if (
data.requestIp.startsWith("[") &&
data.requestIp.includes("]")
) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = data.requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
return data.requestIp;
})()
: undefined;
const countryCode = data.requestIp
? await getCountryCodeFromIp(data.requestIp)
: undefined;
await db.insert(accessAuditLog).values({
timestamp: timestamp,
orgId: data.orgId,
actorType,
actor,
actorId,
action: data.action,
type: data.type,
metadata,
resourceId: data.resourceId,
userAgent: data.userAgent,
ip: clientIp,
location: countryCode
});
} catch (error) {
logger.error(error);
}
}
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
const geoIpCacheKey = `geoip_access:${ip}`;
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
if (!cachedCountryCode) {
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
// Cache for longer since IP geolocation doesn't change frequently
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
}
return cachedCountryCode;
}

View File

@@ -0,0 +1,81 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { OpenAPITags } from "@server/openApi";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { queryAccessAuditLogsParams, queryAccessAuditLogsQuery, queryAccess } from "./queryAccessAuditLog";
import { generateCSV } from "@server/routers/auditLogs/generateCSV";
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/access/export",
description: "Export the access audit log for an organization as CSV",
tags: [OpenAPITags.Org],
request: {
query: queryAccessAuditLogsQuery,
params: queryAccessAuditLogsParams
},
responses: {}
});
export async function exportAccessAuditLogs(
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 = queryAccessAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
const baseQuery = queryAccess(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="access-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

@@ -11,25 +11,20 @@
* This file is not licensed under the AGPLv3.
*/
import { actionAuditLog, db } 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 { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response";
import logger from "@server/logger";
import { queryActionAuditLogsParams, queryActionAuditLogsQuery, querySites } from "./queryActionAuditLog";
import { queryActionAuditLogsParams, queryActionAuditLogsQuery, queryAction } from "./queryActionAuditLog";
import { generateCSV } from "@server/routers/auditLogs/generateCSV";
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/actionk/export",
path: "/org/{orgId}/logs/action/export",
description: "Export the action audit log for an organization as CSV",
tags: [OpenAPITags.Org],
request: {
@@ -67,7 +62,7 @@ export async function exportActionAuditLogs(
}
const { orgId } = parsedParams.data;
const baseQuery = querySites(timeStart, timeEnd, orgId);
const baseQuery = queryAction(timeStart, timeEnd, orgId);
const log = await baseQuery.limit(limit).offset(offset);

View File

@@ -12,4 +12,6 @@
*/
export * from "./queryActionAuditLog";
export * from "./exportActionAuditLog";
export * from "./exportActionAuditLog";
export * from "./queryAccessAuditLog";
export * from "./exportAccessAuditLog";

View File

@@ -0,0 +1,173 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { accessAuditLog, db, resources } 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 { QueryAccessAuditLogResponse } 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 queryAccessAuditLogsParams = z.object({
orgId: z.string()
});
export function queryAccess(timeStart: number, timeEnd: number, orgId: string) {
return db
.select({
orgId: accessAuditLog.orgId,
action: accessAuditLog.action,
actorType: accessAuditLog.actorType,
actorId: accessAuditLog.actorId,
resourceId: accessAuditLog.resourceId,
resourceName: resources.name,
resourceNiceId: resources.niceId,
ip: accessAuditLog.ip,
location: accessAuditLog.location,
userAgent: accessAuditLog.userAgent,
metadata: accessAuditLog.metadata,
type: accessAuditLog.type,
timestamp: accessAuditLog.timestamp,
actor: accessAuditLog.actor
})
.from(accessAuditLog)
.leftJoin(resources, eq(accessAuditLog.resourceId, resources.resourceId))
.where(
and(
gt(accessAuditLog.timestamp, timeStart),
lt(accessAuditLog.timestamp, timeEnd),
eq(accessAuditLog.orgId, orgId)
)
)
.orderBy(accessAuditLog.timestamp);
}
export function countAccessQuery(timeStart: number, timeEnd: number, orgId: string) {
const countQuery = db
.select({ count: count() })
.from(accessAuditLog)
.where(
and(
gt(accessAuditLog.timestamp, timeStart),
lt(accessAuditLog.timestamp, timeEnd),
eq(accessAuditLog.orgId, orgId)
)
);
return countQuery;
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/access",
description: "Query the access audit log for an organization",
tags: [OpenAPITags.Org],
request: {
query: queryAccessAuditLogsQuery,
params: queryAccessAuditLogsParams
},
responses: {}
});
export async function queryAccessAuditLogs(
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 = queryAccessAuditLogsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
const baseQuery = queryAccess(timeStart, timeEnd, orgId);
const log = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countAccessQuery(timeStart, timeEnd, orgId);
const totalCount = totalCountResult[0].count;
return response<QueryAccessAuditLogResponse>(res, {
data: {
log: log,
pagination: {
total: totalCount,
limit,
offset
}
},
success: true,
error: false,
message: "Access audit logs retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -59,7 +59,7 @@ export const queryActionAuditLogsParams = z.object({
orgId: z.string()
});
export function querySites(timeStart: number, timeEnd: number, orgId: string) {
export function queryAction(timeStart: number, timeEnd: number, orgId: string) {
return db
.select({
orgId: actionAuditLog.orgId,
@@ -80,7 +80,7 @@ export function querySites(timeStart: number, timeEnd: number, orgId: string) {
.orderBy(actionAuditLog.timestamp);
}
export function countQuery(timeStart: number, timeEnd: number, orgId: string) {
export function countActionQuery(timeStart: number, timeEnd: number, orgId: string) {
const countQuery = db
.select({ count: count() })
.from(actionAuditLog)
@@ -134,11 +134,11 @@ export async function queryActionAuditLogs(
}
const { orgId } = parsedParams.data;
const baseQuery = querySites(timeStart, timeEnd, orgId);
const baseQuery = queryAction(timeStart, timeEnd, orgId);
const log = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery(timeStart, timeEnd, orgId);
const totalCountResult = await countActionQuery(timeStart, timeEnd, orgId);
const totalCount = totalCountResult[0].count;
return response<QueryActionAuditLogResponse>(res, {

View File

@@ -351,8 +351,17 @@ authenticated.get(
logs.queryActionAuditLogs
)
authenticated.get(
"/org/:orgId/logs/action/export",
logs.exportActionAuditLogs
)
authenticated.get(
"/org/:orgId/logs/access",
logs.queryAccessAuditLogs
)
authenticated.get(
"/org/:orgId/logs/access/export",
logs.exportAccessAuditLogs
)