Files
pangolin/server/private/routers/auditLogs/queryConnectionAuditLog.ts
2026-03-23 21:41:53 -07:00

524 lines
16 KiB
TypeScript

/*
* 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 {
connectionAuditLog,
logsDb,
siteResources,
sites,
clients,
users,
primaryDb
} from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray } 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 { QueryConnectionAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response";
import logger from "@server/logger";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
export const queryConnectionAuditLogsQuery = z.object({
// iso string just validate its a parseable date
timeStart: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString())
.openapi({
type: "string",
format: "date-time",
description:
"Start time as ISO date string (defaults to 7 days ago)"
}),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string"
})
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(() => new Date().toISOString())
.openapi({
type: "string",
format: "date-time",
description:
"End time as ISO date string (defaults to current time)"
}),
protocol: z.string().optional(),
sourceAddr: z.string().optional(),
destAddr: z.string().optional(),
clientId: z
.string()
.optional()
.transform(Number)
.pipe(z.int().positive())
.optional(),
siteId: z
.string()
.optional()
.transform(Number)
.pipe(z.int().positive())
.optional(),
siteResourceId: z
.string()
.optional()
.transform(Number)
.pipe(z.int().positive())
.optional(),
userId: z.string().optional(),
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
});
export const queryConnectionAuditLogsParams = z.object({
orgId: z.string()
});
export const queryConnectionAuditLogsCombined =
queryConnectionAuditLogsQuery.merge(queryConnectionAuditLogsParams);
type Q = z.infer<typeof queryConnectionAuditLogsCombined>;
function getWhere(data: Q) {
return and(
gt(connectionAuditLog.startedAt, data.timeStart),
lt(connectionAuditLog.startedAt, data.timeEnd),
eq(connectionAuditLog.orgId, data.orgId),
data.protocol
? eq(connectionAuditLog.protocol, data.protocol)
: undefined,
data.sourceAddr
? eq(connectionAuditLog.sourceAddr, data.sourceAddr)
: undefined,
data.destAddr
? eq(connectionAuditLog.destAddr, data.destAddr)
: undefined,
data.clientId
? eq(connectionAuditLog.clientId, data.clientId)
: undefined,
data.siteId
? eq(connectionAuditLog.siteId, data.siteId)
: undefined,
data.siteResourceId
? eq(connectionAuditLog.siteResourceId, data.siteResourceId)
: undefined,
data.userId
? eq(connectionAuditLog.userId, data.userId)
: undefined
);
}
export function queryConnection(data: Q) {
return logsDb
.select({
sessionId: connectionAuditLog.sessionId,
siteResourceId: connectionAuditLog.siteResourceId,
orgId: connectionAuditLog.orgId,
siteId: connectionAuditLog.siteId,
clientId: connectionAuditLog.clientId,
userId: connectionAuditLog.userId,
sourceAddr: connectionAuditLog.sourceAddr,
destAddr: connectionAuditLog.destAddr,
protocol: connectionAuditLog.protocol,
startedAt: connectionAuditLog.startedAt,
endedAt: connectionAuditLog.endedAt,
bytesTx: connectionAuditLog.bytesTx,
bytesRx: connectionAuditLog.bytesRx
})
.from(connectionAuditLog)
.where(getWhere(data))
.orderBy(
desc(connectionAuditLog.startedAt),
desc(connectionAuditLog.id)
);
}
export function countConnectionQuery(data: Q) {
const countQuery = logsDb
.select({ count: count() })
.from(connectionAuditLog)
.where(getWhere(data));
return countQuery;
}
async function enrichWithDetails(
logs: Awaited<ReturnType<typeof queryConnection>>
) {
// Collect unique IDs from logs
const siteResourceIds = [
...new Set(
logs
.map((log) => log.siteResourceId)
.filter((id): id is number => id !== null && id !== undefined)
)
];
const siteIds = [
...new Set(
logs
.map((log) => log.siteId)
.filter((id): id is number => id !== null && id !== undefined)
)
];
const clientIds = [
...new Set(
logs
.map((log) => log.clientId)
.filter((id): id is number => id !== null && id !== undefined)
)
];
const userIds = [
...new Set(
logs
.map((log) => log.userId)
.filter((id): id is string => id !== null && id !== undefined)
)
];
// Fetch resource details from main database
const resourceMap = new Map<
number,
{ name: string; niceId: string }
>();
if (siteResourceIds.length > 0) {
const resourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name,
niceId: siteResources.niceId
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
for (const r of resourceDetails) {
resourceMap.set(r.siteResourceId, {
name: r.name,
niceId: r.niceId
});
}
}
// Fetch site details from main database
const siteMap = new Map<number, { name: string; niceId: string }>();
if (siteIds.length > 0) {
const siteDetails = await primaryDb
.select({
siteId: sites.siteId,
name: sites.name,
niceId: sites.niceId
})
.from(sites)
.where(inArray(sites.siteId, siteIds));
for (const s of siteDetails) {
siteMap.set(s.siteId, { name: s.name, niceId: s.niceId });
}
}
// Fetch client details from main database
const clientMap = new Map<
number,
{ name: string; niceId: string; type: string }
>();
if (clientIds.length > 0) {
const clientDetails = await primaryDb
.select({
clientId: clients.clientId,
name: clients.name,
niceId: clients.niceId,
type: clients.type
})
.from(clients)
.where(inArray(clients.clientId, clientIds));
for (const c of clientDetails) {
clientMap.set(c.clientId, {
name: c.name,
niceId: c.niceId,
type: c.type
});
}
}
// Fetch user details from main database
const userMap = new Map<
string,
{ email: string | null }
>();
if (userIds.length > 0) {
const userDetails = await primaryDb
.select({
userId: users.userId,
email: users.email
})
.from(users)
.where(inArray(users.userId, userIds));
for (const u of userDetails) {
userMap.set(u.userId, { email: u.email });
}
}
// Enrich logs with details
return logs.map((log) => ({
...log,
resourceName: log.siteResourceId
? resourceMap.get(log.siteResourceId)?.name ?? null
: null,
resourceNiceId: log.siteResourceId
? resourceMap.get(log.siteResourceId)?.niceId ?? null
: null,
siteName: log.siteId
? siteMap.get(log.siteId)?.name ?? null
: null,
siteNiceId: log.siteId
? siteMap.get(log.siteId)?.niceId ?? null
: null,
clientName: log.clientId
? clientMap.get(log.clientId)?.name ?? null
: null,
clientNiceId: log.clientId
? clientMap.get(log.clientId)?.niceId ?? null
: null,
clientType: log.clientId
? clientMap.get(log.clientId)?.type ?? null
: null,
userEmail: log.userId
? userMap.get(log.userId)?.email ?? null
: null
}));
}
async function queryUniqueFilterAttributes(
timeStart: number,
timeEnd: number,
orgId: string
) {
const baseConditions = and(
gt(connectionAuditLog.startedAt, timeStart),
lt(connectionAuditLog.startedAt, timeEnd),
eq(connectionAuditLog.orgId, orgId)
);
// Get unique protocols
const uniqueProtocols = await logsDb
.selectDistinct({
protocol: connectionAuditLog.protocol
})
.from(connectionAuditLog)
.where(baseConditions);
// Get unique destination addresses
const uniqueDestAddrs = await logsDb
.selectDistinct({
destAddr: connectionAuditLog.destAddr
})
.from(connectionAuditLog)
.where(baseConditions);
// Get unique client IDs
const uniqueClients = await logsDb
.selectDistinct({
clientId: connectionAuditLog.clientId
})
.from(connectionAuditLog)
.where(baseConditions);
// Get unique resource IDs
const uniqueResources = await logsDb
.selectDistinct({
siteResourceId: connectionAuditLog.siteResourceId
})
.from(connectionAuditLog)
.where(baseConditions);
// Get unique user IDs
const uniqueUsers = await logsDb
.selectDistinct({
userId: connectionAuditLog.userId
})
.from(connectionAuditLog)
.where(baseConditions);
// Enrich client IDs with names from main database
const clientIds = uniqueClients
.map((row) => row.clientId)
.filter((id): id is number => id !== null);
let clientsWithNames: Array<{ id: number; name: string }> = [];
if (clientIds.length > 0) {
const clientDetails = await primaryDb
.select({
clientId: clients.clientId,
name: clients.name
})
.from(clients)
.where(inArray(clients.clientId, clientIds));
clientsWithNames = clientDetails.map((c) => ({
id: c.clientId,
name: c.name
}));
}
// Enrich resource IDs with names from main database
const resourceIds = uniqueResources
.map((row) => row.siteResourceId)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
if (resourceIds.length > 0) {
const resourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, resourceIds));
resourcesWithNames = resourceDetails.map((r) => ({
id: r.siteResourceId,
name: r.name
}));
}
// Enrich user IDs with emails from main database
const userIdsList = uniqueUsers
.map((row) => row.userId)
.filter((id): id is string => id !== null);
let usersWithEmails: Array<{ id: string; email: string | null }> = [];
if (userIdsList.length > 0) {
const userDetails = await primaryDb
.select({
userId: users.userId,
email: users.email
})
.from(users)
.where(inArray(users.userId, userIdsList));
usersWithEmails = userDetails.map((u) => ({
id: u.userId,
email: u.email
}));
}
return {
protocols: uniqueProtocols
.map((row) => row.protocol)
.filter((protocol): protocol is string => protocol !== null),
destAddrs: uniqueDestAddrs
.map((row) => row.destAddr)
.filter((addr): addr is string => addr !== null),
clients: clientsWithNames,
resources: resourcesWithNames,
users: usersWithEmails
};
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/connection",
description: "Query the connection audit log for an organization",
tags: [OpenAPITags.Logs],
request: {
query: queryConnectionAuditLogsQuery,
params: queryConnectionAuditLogsParams
},
responses: {}
});
export async function queryConnectionAuditLogs(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = queryConnectionAuditLogsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = queryConnectionAuditLogsParams.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = { ...parsedQuery.data, ...parsedParams.data };
const baseQuery = queryConnection(data);
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset);
// Enrich with resource, site, client, and user details
const log = await enrichWithDetails(logsRaw);
const totalCountResult = await countConnectionQuery(data);
const totalCount = totalCountResult[0].count;
const filterAttributes = await queryUniqueFilterAttributes(
data.timeStart,
data.timeEnd,
data.orgId
);
return response<QueryConnectionAuditLogResponse>(res, {
data: {
log: log,
pagination: {
total: totalCount,
limit: data.limit,
offset: data.offset
},
filterAttributes
},
success: true,
error: false,
message: "Connection audit logs retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}