From cd76fa01398964bcc7643ec2086cf0a0fff6b030 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 20 Nov 2025 02:55:33 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8add=20analytics=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/auditLogs/index.ts | 5 +- .../auditLogs/queryRequestAnalytics.ts | 294 ++++++++++++++++++ server/routers/external.ts | 96 +++--- 3 files changed, 348 insertions(+), 47 deletions(-) create mode 100644 server/routers/auditLogs/queryRequestAnalytics.ts diff --git a/server/routers/auditLogs/index.ts b/server/routers/auditLogs/index.ts index 4823831d..9bea762f 100644 --- a/server/routers/auditLogs/index.ts +++ b/server/routers/auditLogs/index.ts @@ -1,2 +1,3 @@ -export * from "./queryRequstAuditLog"; -export * from "./exportRequstAuditLog"; \ No newline at end of file +export * from "./queryRequestAuditLog"; +export * from "./queryRequestAnalytics"; +export * from "./exportRequestAuditLog"; diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts new file mode 100644 index 00000000..b5b27b40 --- /dev/null +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -0,0 +1,294 @@ +import { db, requestAuditLog, resources } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count, sql } 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 response from "@server/lib/response"; +import logger from "@server/logger"; + +const queryAccessAuditLogsQuery = 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)) + .optional(), + 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)" + }), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional() +}); + +const queryRequestAuditLogsParams = z.object({ + orgId: z.string() +}); + +const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge( + queryRequestAuditLogsParams +); + +type Q = z.infer; + +async function query(query: Q) { + let baseConditions = and( + eq(requestAuditLog.orgId, query.orgId), + lt(requestAuditLog.timestamp, query.timeEnd) + ); + + if (query.timeStart) { + baseConditions = and( + baseConditions, + gt(requestAuditLog.timestamp, query.timeStart) + ); + } + if (query.resourceId) { + baseConditions = and( + baseConditions, + eq(requestAuditLog.resourceId, query.resourceId) + ); + } + + const [totalRequests] = await db + .select({ total: count() }) + .from(requestAuditLog) + .where(baseConditions); + + const [totalBlocked] = await db + .select({ blocked: count() }) + .from(requestAuditLog) + .where(and(baseConditions, eq(requestAuditLog.action, false))); + + const requestsPerCountry = await db + .select({ + country_code: requestAuditLog.location, + total: sql`count(${requestAuditLog.id})` + .mapWith(Number) + .as("total") + }) + .from(requestAuditLog) + .where(baseConditions) + .groupBy(requestAuditLog.location); + + return { requestsPerCountry, totalBlocked, totalRequests }; +} + +// function getWhere(data: Q) { +// return and( +// gt(requestAuditLog.timestamp, data.timeStart), +// lt(requestAuditLog.timestamp, data.timeEnd), +// eq(requestAuditLog.orgId, data.orgId), +// data.resourceId +// ? eq(requestAuditLog.resourceId, data.resourceId) +// : undefined, +// data.actor ? eq(requestAuditLog.actor, data.actor) : undefined, +// data.method ? eq(requestAuditLog.method, data.method) : undefined, +// data.reason ? eq(requestAuditLog.reason, data.reason) : undefined, +// data.host ? eq(requestAuditLog.host, data.host) : undefined, +// data.location ? eq(requestAuditLog.location, data.location) : undefined, +// data.path ? eq(requestAuditLog.path, data.path) : undefined, +// data.action !== undefined +// ? eq(requestAuditLog.action, data.action) +// : undefined +// ); +// } + +// function queryRequest(data: Q) { +// return db +// .select({ +// id: requestAuditLog.id, +// 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, +// resourceName: resources.name, +// resourceNiceId: resources.niceId +// }) +// .from(requestAuditLog) +// .leftJoin( +// resources, +// eq(requestAuditLog.resourceId, resources.resourceId) +// ) // TODO: Is this efficient? +// .where(getWhere(data)) +// .orderBy(desc(requestAuditLog.timestamp), desc(requestAuditLog.id)); +// } + +// function countRequestQuery(data: Q) { +// const countQuery = db +// .select({ count: count() }) +// .from(requestAuditLog) +// .where(getWhere(data)); +// return countQuery; +// } + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/analytics", + description: "Query the request audit analytics for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryRequestAuditLogsParams + }, + responses: {} +}); + +// async function queryUniqueFilterAttributes( +// timeStart: number, +// timeEnd: number, +// orgId: string +// ) { +// const baseConditions = and( +// gt(requestAuditLog.timestamp, timeStart), +// lt(requestAuditLog.timestamp, timeEnd), +// eq(requestAuditLog.orgId, orgId) +// ); + +// // Get unique actors +// const uniqueActors = await db +// .selectDistinct({ +// actor: requestAuditLog.actor +// }) +// .from(requestAuditLog) +// .where(baseConditions); + +// // Get unique locations +// const uniqueLocations = await db +// .selectDistinct({ +// locations: requestAuditLog.location +// }) +// .from(requestAuditLog) +// .where(baseConditions); + +// // Get unique actors +// const uniqueHosts = await db +// .selectDistinct({ +// hosts: requestAuditLog.host +// }) +// .from(requestAuditLog) +// .where(baseConditions); + +// // Get unique actors +// const uniquePaths = await db +// .selectDistinct({ +// paths: requestAuditLog.path +// }) +// .from(requestAuditLog) +// .where(baseConditions); + +// // Get unique resources with names +// const uniqueResources = await db +// .selectDistinct({ +// id: requestAuditLog.resourceId, +// name: resources.name +// }) +// .from(requestAuditLog) +// .leftJoin( +// resources, +// eq(requestAuditLog.resourceId, resources.resourceId) +// ) +// .where(baseConditions); + +// return { +// actors: uniqueActors +// .map((row) => row.actor) +// .filter((actor): actor is string => actor !== null), +// resources: uniqueResources.filter( +// (row): row is { id: number; name: string | null } => row.id !== null +// ), +// locations: uniqueLocations +// .map((row) => row.locations) +// .filter((location): location is string => location !== null), +// hosts: uniqueHosts +// .map((row) => row.hosts) +// .filter((host): host is string => host !== null), +// paths: uniquePaths +// .map((row) => row.paths) +// .filter((path): path is string => path !== null) +// }; +// } + +export type QueryRequestAnalyticsResponse = Awaited>; + +export async function queryRequestAnalytics( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const params = { ...parsedQuery.data, ...parsedParams.data }; + + const data = await query(params); + + return response(res, { + data, + 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") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index f500f483..0d2186c0 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -80,7 +80,7 @@ authenticated.post( verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrg), logActionAudit(ActionsEnum.updateOrg), - org.updateOrg, + org.updateOrg ); if (build !== "saas") { @@ -90,7 +90,7 @@ if (build !== "saas") { verifyUserIsOrgOwner, verifyUserHasAction(ActionsEnum.deleteOrg), logActionAudit(ActionsEnum.deleteOrg), - org.deleteOrg, + org.deleteOrg ); } @@ -157,7 +157,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), logActionAudit(ActionsEnum.createClient), - client.createClient, + client.createClient ); authenticated.delete( @@ -166,7 +166,7 @@ authenticated.delete( verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), logActionAudit(ActionsEnum.deleteClient), - client.deleteClient, + client.deleteClient ); authenticated.post( @@ -175,10 +175,9 @@ authenticated.post( verifyClientAccess, // this will check if the user has access to the client verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client logActionAudit(ActionsEnum.updateClient), - client.updateClient, + client.updateClient ); - // authenticated.get( // "/site/:siteId/roles", // verifySiteAccess, @@ -190,7 +189,7 @@ authenticated.post( verifySiteAccess, verifyUserHasAction(ActionsEnum.updateSite), logActionAudit(ActionsEnum.updateSite), - site.updateSite, + site.updateSite ); authenticated.delete( @@ -198,7 +197,7 @@ authenticated.delete( verifySiteAccess, verifyUserHasAction(ActionsEnum.deleteSite), logActionAudit(ActionsEnum.deleteSite), - site.deleteSite, + site.deleteSite ); // TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" @@ -218,13 +217,13 @@ authenticated.post( "/site/:siteId/docker/check", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.checkDockerSocket, + site.checkDockerSocket ); authenticated.post( "/site/:siteId/docker/trigger", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.triggerFetchContainers, + site.triggerFetchContainers ); authenticated.get( "/site/:siteId/docker/containers", @@ -240,7 +239,7 @@ authenticated.put( verifySiteAccess, verifyUserHasAction(ActionsEnum.createSiteResource), logActionAudit(ActionsEnum.createSiteResource), - siteResource.createSiteResource, + siteResource.createSiteResource ); authenticated.get( @@ -274,7 +273,7 @@ authenticated.post( verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.updateSiteResource), logActionAudit(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource, + siteResource.updateSiteResource ); authenticated.delete( @@ -284,7 +283,7 @@ authenticated.delete( verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource, + siteResource.deleteSiteResource ); authenticated.put( @@ -292,7 +291,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), logActionAudit(ActionsEnum.createResource), - resource.createResource, + resource.createResource ); authenticated.get( @@ -354,7 +353,7 @@ authenticated.delete( verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), logActionAudit(ActionsEnum.removeInvitation), - user.removeInvitation, + user.removeInvitation ); authenticated.post( @@ -362,7 +361,7 @@ authenticated.post( verifyOrgAccess, verifyUserHasAction(ActionsEnum.inviteUser), logActionAudit(ActionsEnum.inviteUser), - user.inviteUser, + user.inviteUser ); // maybe make this /invite/create instead unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated @@ -398,14 +397,14 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResource), logActionAudit(ActionsEnum.updateResource), - resource.updateResource, + resource.updateResource ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResource), logActionAudit(ActionsEnum.deleteResource), - resource.deleteResource, + resource.deleteResource ); authenticated.put( @@ -413,7 +412,7 @@ authenticated.put( verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), - target.createTarget, + target.createTarget ); authenticated.get( "/resource/:resourceId/targets", @@ -427,7 +426,7 @@ authenticated.put( verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), logActionAudit(ActionsEnum.createResourceRule), - resource.createResourceRule, + resource.createResourceRule ); authenticated.get( "/resource/:resourceId/rules", @@ -440,14 +439,14 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResourceRule), logActionAudit(ActionsEnum.updateResourceRule), - resource.updateResourceRule, + resource.updateResourceRule ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), logActionAudit(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule, + resource.deleteResourceRule ); authenticated.get( @@ -461,14 +460,14 @@ authenticated.post( verifyTargetAccess, verifyUserHasAction(ActionsEnum.updateTarget), logActionAudit(ActionsEnum.updateTarget), - target.updateTarget, + target.updateTarget ); authenticated.delete( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.deleteTarget), logActionAudit(ActionsEnum.deleteTarget), - target.deleteTarget, + target.deleteTarget ); authenticated.put( @@ -476,7 +475,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), logActionAudit(ActionsEnum.createRole), - role.createRole, + role.createRole ); authenticated.get( "/org/:orgId/roles", @@ -502,7 +501,7 @@ authenticated.delete( verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), logActionAudit(ActionsEnum.deleteRole), - role.deleteRole, + role.deleteRole ); authenticated.post( "/role/:roleId/add/:userId", @@ -510,7 +509,7 @@ authenticated.post( verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole, + user.addUserRole ); authenticated.post( @@ -519,7 +518,7 @@ authenticated.post( verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), - resource.setResourceRoles, + resource.setResourceRoles ); authenticated.post( @@ -528,7 +527,7 @@ authenticated.post( verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), - resource.setResourceUsers, + resource.setResourceUsers ); authenticated.post( @@ -536,7 +535,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePassword), logActionAudit(ActionsEnum.setResourcePassword), - resource.setResourcePassword, + resource.setResourcePassword ); authenticated.post( @@ -544,7 +543,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePincode), logActionAudit(ActionsEnum.setResourcePincode), - resource.setResourcePincode, + resource.setResourcePincode ); authenticated.post( @@ -552,7 +551,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), logActionAudit(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth, + resource.setResourceHeaderAuth ); authenticated.post( @@ -560,7 +559,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceWhitelist), logActionAudit(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist, + resource.setResourceWhitelist ); authenticated.get( @@ -575,7 +574,7 @@ authenticated.post( verifyResourceAccess, verifyUserHasAction(ActionsEnum.generateAccessToken), logActionAudit(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken, + accessToken.generateAccessToken ); authenticated.delete( @@ -583,7 +582,7 @@ authenticated.delete( verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), logActionAudit(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken, + accessToken.deleteAccessToken ); authenticated.get( @@ -657,7 +656,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgUser), logActionAudit(ActionsEnum.createOrgUser), - user.createOrgUser, + user.createOrgUser ); authenticated.post( @@ -666,7 +665,7 @@ authenticated.post( verifyUserAccess, verifyUserHasAction(ActionsEnum.updateOrgUser), logActionAudit(ActionsEnum.updateOrgUser), - user.updateOrgUser, + user.updateOrgUser ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); @@ -690,7 +689,7 @@ authenticated.delete( verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), logActionAudit(ActionsEnum.removeUser), - user.removeUserOrg, + user.removeUserOrg ); // authenticated.put( @@ -821,7 +820,7 @@ authenticated.post( verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), logActionAudit(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions, + apiKeys.setApiKeyActions ); authenticated.get( @@ -837,7 +836,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), logActionAudit(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey, + apiKeys.createOrgApiKey ); authenticated.delete( @@ -846,7 +845,7 @@ authenticated.delete( verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), logActionAudit(ActionsEnum.deleteApiKey), - apiKeys.deleteOrgApiKey, + apiKeys.deleteOrgApiKey ); authenticated.get( @@ -862,7 +861,7 @@ authenticated.put( verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgDomain), logActionAudit(ActionsEnum.createOrgDomain), - domain.createOrgDomain, + domain.createOrgDomain ); authenticated.post( @@ -871,7 +870,7 @@ authenticated.post( verifyDomainAccess, verifyUserHasAction(ActionsEnum.restartOrgDomain), logActionAudit(ActionsEnum.restartOrgDomain), - domain.restartOrgDomain, + domain.restartOrgDomain ); authenticated.delete( @@ -880,7 +879,7 @@ authenticated.delete( verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), logActionAudit(ActionsEnum.deleteOrgDomain), - domain.deleteAccountDomain, + domain.deleteAccountDomain ); authenticated.get( @@ -890,6 +889,13 @@ authenticated.get( logs.queryRequestAuditLogs ); +authenticated.get( + "/org/:orgId/logs/analytics", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryRequestAnalytics +); + authenticated.get( "/org/:orgId/logs/request/export", verifyOrgAccess, @@ -1239,4 +1245,4 @@ authRouter.delete( store: createStore() }), auth.deleteSecurityKey -); \ No newline at end of file +);