From dce84b9b092bf87b4bda66741cdbf29e39d321e1 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Oct 2025 17:58:52 -0700 Subject: [PATCH 01/29] Add action audit middleware and tables --- server/db/pg/schema/privateSchema.ts | 20 ++++- server/db/sqlite/schema/privateSchema.ts | 21 ++++- server/private/middlewares/index.ts | 2 +- server/private/middlewares/logActionAudit.ts | 88 ++++++++++++++++++++ 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 server/private/middlewares/logActionAudit.ts diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 67fb28ec..f33b88e1 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -6,7 +6,8 @@ import { integer, bigint, real, - text + text, + index } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; @@ -213,6 +214,22 @@ export const sessionTransferToken = pgTable("sessionTransferToken", { expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); +export const actionAuditLog = pgTable("actionAuditLog", { + id: serial("id").primaryKey(), + timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: varchar("actorType", { length: 50 }).notNull(), + actor: varchar("actor", { length: 255 }).notNull(), + actorId: varchar("actorId", { length: 255 }).notNull(), + action: varchar("action", { length: 100 }).notNull(), + metadata: text("metadata") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -230,3 +247,4 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type ActionAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 557ebfd6..cbf61cc1 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -2,10 +2,12 @@ import { sqliteTable, integer, text, - real + real, + index } from "drizzle-orm/sqlite-core"; import { InferSelectModel } from "drizzle-orm"; import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; +import { metadata } from "@app/app/[orgId]/settings/layout"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -207,6 +209,22 @@ export const sessionTransferToken = sqliteTable("sessionTransferToken", { expiresAt: integer("expiresAt").notNull() }); +export const actionAuditLog = sqliteTable("actionAuditLog", { + id: integer("id").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: text("actorType").notNull(), + actor: text("actor").notNull(), + actorId: text("actorId").notNull(), + action: text("action").notNull(), + metadata: text("metadata") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -224,3 +242,4 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type ActionAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/private/middlewares/index.ts b/server/private/middlewares/index.ts index c92b0d3d..32aa1a1f 100644 --- a/server/private/middlewares/index.ts +++ b/server/private/middlewares/index.ts @@ -15,4 +15,4 @@ export * from "./verifyCertificateAccess"; export * from "./verifyRemoteExitNodeAccess"; export * from "./verifyIdpAccess"; export * from "./verifyLoginPageAccess"; -export * from "../../lib/corsWithLoginPage"; \ No newline at end of file +export * from "./logActionAudit"; \ No newline at end of file diff --git a/server/private/middlewares/logActionAudit.ts b/server/private/middlewares/logActionAudit.ts new file mode 100644 index 00000000..488aff88 --- /dev/null +++ b/server/private/middlewares/logActionAudit.ts @@ -0,0 +1,88 @@ +/* + * 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 { ActionsEnum } from "@server/auth/actions"; +import { actionAuditLog, db } from "@server/db"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; + +export function logActionAudit(action: ActionsEnum) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + let orgId; + let actorType; + let actor; + let actorId; + + const user = req.user; + if (user) { + const userOrg = req.userOrg; + orgId = userOrg?.orgId; + actorType = "user"; + actor = user.username; + actorId = user.userId; + } + const apiKey = req.apiKey; + if (apiKey) { + const apiKeyOrg = req.apiKeyOrg; + orgId = apiKeyOrg?.orgId; + actorType = "apiKey"; + actor = apiKey.name; + actorId = apiKey.apiKeyId; + } + + if (!orgId) { + logger.warn("logActionAudit: No organization context found"); + return next(); + } + + if (!actorType || !actor || !actorId) { + logger.warn("logActionAudit: Incomplete actor information"); + return next(); + } + + const timestamp = Math.floor(Date.now() / 1000); + + let metadata = null; + if (req.params) { + metadata = JSON.stringify(req.params); + } + + await db.insert(actionAuditLog).values({ + timestamp, + orgId, + actorType, + actor, + actorId, + action, + metadata + }); + + return next(); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying logging action" + ) + ); + } + }; +} From 1f50bc3752d355c07f80ac9e7f90c09ccdb7f6f0 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Oct 2025 21:53:00 -0700 Subject: [PATCH 02/29] Add logActionAudit and query endpoint --- server/db/pg/schema/privateSchema.ts | 25 ++- server/db/pg/schema/schema.ts | 29 +++- server/db/sqlite/schema/privateSchema.ts | 22 +++ server/db/sqlite/schema/schema.ts | 28 +++- server/middlewares/logActionAudit.ts | 12 ++ server/private/routers/auditLogs/index.ts | 0 .../routers/auditLogs/queryActionAuditLog.ts | 147 ++++++++++++++++++ server/private/routers/external.ts | 34 ++-- server/private/routers/integration.ts | 7 +- server/routers/auditLogs/types.ts | 14 ++ server/routers/external.ts | 135 ++++++++++------ server/routers/integration.ts | 143 +++++++++++------ 12 files changed, 488 insertions(+), 108 deletions(-) create mode 100644 server/middlewares/logActionAudit.ts create mode 100644 server/private/routers/auditLogs/index.ts create mode 100644 server/private/routers/auditLogs/queryActionAuditLog.ts create mode 100644 server/routers/auditLogs/types.ts diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index f33b88e1..d2bb8841 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -230,6 +230,28 @@ export const actionAuditLog = pgTable("actionAuditLog", { index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); +export const identityAuditLog = pgTable("identityAuditLog", { + id: serial("id").primaryKey(), + timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: varchar("actorType", { length: 50 }).notNull(), + actor: varchar("actor", { length: 255 }).notNull(), + actorId: varchar("actorId", { length: 255 }).notNull(), + resourceId: integer("resourceId"), + ip: varchar("ip", { length: 45 }).notNull(), + type: varchar("type", { length: 100 }).notNull(), + action: varchar("action", { length: 100 }).notNull(), + location: text("location"), + path: text("path"), + userAgent: text("userAgent"), + metadata: text("details") +}, (table) => ([ + index("idx_identityAuditLog_timestamp").on(table.timestamp), + index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -247,4 +269,5 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; -export type ActionAuditLog = InferSelectModel; \ No newline at end of file +export type ActionAuditLog = InferSelectModel; +export type IdentityAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 4bed23f8..2dc937d9 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -6,7 +6,8 @@ import { integer, bigint, real, - text + text, + index } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; import { randomUUID } from "crypto"; @@ -671,6 +672,28 @@ export const setupTokens = pgTable("setupTokens", { dateUsed: varchar("dateUsed") }); +export const requestAuditLog = pgTable("requestAuditLog", { + id: serial("id").primaryKey(), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: varchar("actorType").notNull(), + actor: varchar("actor").notNull(), + actorId: varchar("actorId").notNull(), + resourceId: integer("resourceId"), + ip: varchar("ip").notNull(), + type: varchar("type").notNull(), + action: varchar("action").notNull(), + event: varchar("event").notNull(), + location: varchar("location"), + userAgent: varchar("userAgent"), + metadata: text("details") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -722,3 +745,7 @@ export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; +export type LicenseKey = InferSelectModel; +export type SecurityKey = InferSelectModel; +export type WebauthnChallenge = InferSelectModel; +export type RequestAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index cbf61cc1..bbd0aa3e 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -225,6 +225,28 @@ export const actionAuditLog = sqliteTable("actionAuditLog", { index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); +export const identityAuditLog = sqliteTable("identityAuditLog", { + id: integer("id").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: text("actorType").notNull(), + actor: text("actor").notNull(), + actorId: text("actorId").notNull(), + resourceId: integer("resourceId"), + ip: text("ip").notNull(), + type: text("type").notNull(), + action: text("action").notNull(), + location: text("location"), + path: text("path"), + userAgent: text("userAgent"), + metadata: text("details") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 3d6c6b0d..59b9bc2e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,6 +1,6 @@ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -710,6 +710,28 @@ export const idpOrg = sqliteTable("idpOrg", { orgMapping: text("orgMapping") }); +export const requestAuditLog = sqliteTable("requestAuditLog", { + id: integer("id").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: text("actorType").notNull(), + actor: text("actor").notNull(), + actorId: text("actorId").notNull(), + resourceId: integer("resourceId"), + ip: text("ip").notNull(), + type: text("type").notNull(), + action: text("action").notNull(), + event: text("event").notNull(), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("details") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -761,3 +783,7 @@ export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; +export type LicenseKey = InferSelectModel; +export type SecurityKey = InferSelectModel; +export type WebauthnChallenge = InferSelectModel; +export type RequestAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/middlewares/logActionAudit.ts b/server/middlewares/logActionAudit.ts new file mode 100644 index 00000000..a1521883 --- /dev/null +++ b/server/middlewares/logActionAudit.ts @@ -0,0 +1,12 @@ +import { ActionsEnum } from "@server/auth/actions"; +import { Request, Response, NextFunction } from "express"; + +export function logActionAudit(action: ActionsEnum) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + next(); + }; +} diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts new file mode 100644 index 00000000..4c10a3f0 --- /dev/null +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -0,0 +1,147 @@ +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"; + +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() +}); + +function querySites(timeStart: number, timeEnd: number, orgId: string) { + return db + .select({ + orgId: actionAuditLog.orgId, + action: actionAuditLog.action, + actorType: actionAuditLog.actorType, + timestamp: actionAuditLog.timestamp, + actor: actionAuditLog.actor + }) + .from(actionAuditLog) + .where( + and( + gt(actionAuditLog.timestamp, timeStart), + lt(actionAuditLog.timestamp, timeEnd), + eq(actionAuditLog.orgId, orgId) + ) + ) + .orderBy(actionAuditLog.timestamp); +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/action", + description: "Query the action 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 { + 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 = querySites(timeStart, timeEnd, orgId); + + const log = await baseQuery.limit(limit).offset(offset); + + const countQuery = db + .select({ count: count() }) + .from(actionAuditLog) + .where( + and( + gt(actionAuditLog.timestamp, timeStart), + lt(actionAuditLog.timestamp, timeEnd), + eq(actionAuditLog.orgId, orgId) + ) + ); + + const totalCountResult = await countQuery; + const totalCount = totalCountResult[0].count; + + return response(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") + ); + } +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 74cd6b0c..a7c927f2 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,6 +31,7 @@ import { } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { + logActionAudit, verifyCertificateAccess, verifyIdpAccess, verifyLoginPageAccess, @@ -72,7 +73,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createIdp), - orgIdp.createOrgOidcIdp + orgIdp.createOrgOidcIdp, + logActionAudit(ActionsEnum.createIdp) ); authenticated.post( @@ -81,7 +83,8 @@ authenticated.post( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.updateIdp), - orgIdp.updateOrgOidcIdp + orgIdp.updateOrgOidcIdp, + logActionAudit(ActionsEnum.updateIdp) ); authenticated.delete( @@ -90,7 +93,8 @@ authenticated.delete( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), - orgIdp.deleteOrgIdp + orgIdp.deleteOrgIdp, + logActionAudit(ActionsEnum.deleteIdp) ); authenticated.get( @@ -127,7 +131,8 @@ authenticated.post( verifyOrgAccess, verifyCertificateAccess, verifyUserHasAction(ActionsEnum.restartCertificate), - certificates.restartCertificate + certificates.restartCertificate, + logActionAudit(ActionsEnum.restartCertificate) ); if (build === "saas") { @@ -152,14 +157,16 @@ if (build === "saas") { "/org/:orgId/billing/create-checkout-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), - billing.createCheckoutSession + billing.createCheckoutSession, + logActionAudit(ActionsEnum.billing) ); authenticated.post( "/org/:orgId/billing/create-portal-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), - billing.createPortalSession + billing.createPortalSession, + logActionAudit(ActionsEnum.billing) ); authenticated.get( @@ -206,7 +213,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRemoteExitNode), - remoteExitNode.createRemoteExitNode + remoteExitNode.createRemoteExitNode, + logActionAudit(ActionsEnum.createRemoteExitNode) ); authenticated.get( @@ -240,7 +248,8 @@ authenticated.delete( verifyOrgAccess, verifyRemoteExitNodeAccess, verifyUserHasAction(ActionsEnum.deleteRemoteExitNode), - remoteExitNode.deleteRemoteExitNode + remoteExitNode.deleteRemoteExitNode, + logActionAudit(ActionsEnum.deleteRemoteExitNode) ); authenticated.put( @@ -248,7 +257,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createLoginPage), - loginPage.createLoginPage + loginPage.createLoginPage, + logActionAudit(ActionsEnum.createLoginPage) ); authenticated.post( @@ -257,7 +267,8 @@ authenticated.post( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), - loginPage.updateLoginPage + loginPage.updateLoginPage, + logActionAudit(ActionsEnum.updateLoginPage) ); authenticated.delete( @@ -266,7 +277,8 @@ authenticated.delete( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), - loginPage.deleteLoginPage + loginPage.deleteLoginPage, + logActionAudit(ActionsEnum.deleteLoginPage) ); authenticated.get( diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index d767424a..00bc167a 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -23,6 +23,7 @@ import { import { ActionsEnum } from "@server/auth/actions"; import { unauthenticated as ua, authenticated as a } from "@server/routers/integration"; +import { logActionAudit } from "#private/middlewares"; export const unauthenticated = ua; export const authenticated = a; @@ -31,12 +32,14 @@ authenticated.post( `/org/:orgId/send-usage-notification`, verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine verifyApiKeyHasAction(ActionsEnum.sendUsageNotification), - org.sendUsageNotification + org.sendUsageNotification, + logActionAudit(ActionsEnum.sendUsageNotification) ); authenticated.delete( "/idp/:idpId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdp), - orgIdp.deleteOrgIdp + orgIdp.deleteOrgIdp, + logActionAudit(ActionsEnum.deleteIdp) ); \ No newline at end of file diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts new file mode 100644 index 00000000..4530de79 --- /dev/null +++ b/server/routers/auditLogs/types.ts @@ -0,0 +1,14 @@ +export type QueryActionAuditLogResponse = { + log: { + orgId: string; + action: string; + actorType: string; + timestamp: number; + actor: string; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; diff --git a/server/routers/external.ts b/server/routers/external.ts index 8bd72f62..55b98242 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -44,6 +44,7 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; import { build } from "@server/build"; import { createStore } from "#dynamic/lib/rateLimitStore"; +import { logActionAudit } from "#dynamic/middlewares"; // Root routes export const unauthenticated = Router(); @@ -75,7 +76,8 @@ authenticated.post( "/org/:orgId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrg), - org.updateOrg + org.updateOrg, + logActionAudit(ActionsEnum.updateOrg) ); if (build !== "saas") { @@ -84,7 +86,8 @@ if (build !== "saas") { verifyOrgAccess, verifyUserIsOrgOwner, verifyUserHasAction(ActionsEnum.deleteOrg), - org.deleteOrg + org.deleteOrg, + logActionAudit(ActionsEnum.deleteOrg) ); } @@ -92,7 +95,8 @@ authenticated.put( "/org/:orgId/site", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createSite), - site.createSite + site.createSite, + logActionAudit(ActionsEnum.createSite) ); authenticated.get( "/org/:orgId/sites", @@ -149,7 +153,8 @@ authenticated.put( verifyClientsEnabled, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), - client.createClient + client.createClient, + logActionAudit(ActionsEnum.createClient) ); authenticated.delete( @@ -157,7 +162,8 @@ authenticated.delete( verifyClientsEnabled, verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), - client.deleteClient + client.deleteClient, + logActionAudit(ActionsEnum.deleteClient) ); authenticated.post( @@ -165,7 +171,8 @@ authenticated.post( verifyClientsEnabled, 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 - client.updateClient + client.updateClient, + logActionAudit(ActionsEnum.updateClient) ); // authenticated.get( @@ -178,15 +185,18 @@ authenticated.post( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.updateSite), - site.updateSite + site.updateSite, + logActionAudit(ActionsEnum.updateSite) ); authenticated.delete( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.deleteSite), - site.deleteSite + site.deleteSite, + logActionAudit(ActionsEnum.deleteSite) ); +// TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" authenticated.get( "/site/:siteId/docker/status", verifySiteAccess, @@ -203,13 +213,15 @@ authenticated.post( "/site/:siteId/docker/check", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.checkDockerSocket + site.checkDockerSocket, + // logActionAudit(ActionsEnum.getSite) ); authenticated.post( "/site/:siteId/docker/trigger", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.triggerFetchContainers + site.triggerFetchContainers, + // logActionAudit(ActionsEnum.getSite) ); authenticated.get( "/site/:siteId/docker/containers", @@ -224,7 +236,8 @@ authenticated.put( verifyOrgAccess, verifySiteAccess, verifyUserHasAction(ActionsEnum.createSiteResource), - siteResource.createSiteResource + siteResource.createSiteResource, + logActionAudit(ActionsEnum.createSiteResource) ); authenticated.get( @@ -257,7 +270,8 @@ authenticated.post( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource + siteResource.updateSiteResource, + logActionAudit(ActionsEnum.updateSiteResource) ); authenticated.delete( @@ -266,14 +280,16 @@ authenticated.delete( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource + siteResource.deleteSiteResource, + logActionAudit(ActionsEnum.deleteSiteResource) ); authenticated.put( "/org/:orgId/resource", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), - resource.createResource + resource.createResource, + logActionAudit(ActionsEnum.createResource) ); authenticated.get( @@ -313,15 +329,18 @@ authenticated.delete( "/org/:orgId/invitations/:inviteId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), - user.removeInvitation + user.removeInvitation, + logActionAudit(ActionsEnum.removeInvitation) ); authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, verifyUserHasAction(ActionsEnum.inviteUser), - user.inviteUser + user.inviteUser, + logActionAudit(ActionsEnum.inviteUser) ); // maybe make this /invite/create instead + unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated authenticated.get( @@ -354,20 +373,23 @@ authenticated.post( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResource), - resource.updateResource + resource.updateResource, + logActionAudit(ActionsEnum.updateResource) ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResource), - resource.deleteResource + resource.deleteResource, + logActionAudit(ActionsEnum.deleteResource) ); authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), - target.createTarget + target.createTarget, + logActionAudit(ActionsEnum.createTarget) ); authenticated.get( "/resource/:resourceId/targets", @@ -380,7 +402,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), - resource.createResourceRule + resource.createResourceRule, + logActionAudit(ActionsEnum.createResourceRule) ); authenticated.get( "/resource/:resourceId/rules", @@ -392,13 +415,15 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResourceRule), - resource.updateResourceRule + resource.updateResourceRule, + logActionAudit(ActionsEnum.updateResourceRule) ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule + resource.deleteResourceRule, + logActionAudit(ActionsEnum.deleteResourceRule) ); authenticated.get( @@ -411,20 +436,23 @@ authenticated.post( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.updateTarget), - target.updateTarget + target.updateTarget, + logActionAudit(ActionsEnum.updateTarget) ); authenticated.delete( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.deleteTarget), - target.deleteTarget + target.deleteTarget, + logActionAudit(ActionsEnum.deleteTarget) ); authenticated.put( "/org/:orgId/role", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), - role.createRole + role.createRole, + logActionAudit(ActionsEnum.createRole) ); authenticated.get( "/org/:orgId/roles", @@ -449,14 +477,16 @@ authenticated.delete( "/role/:roleId", verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), - role.deleteRole + role.deleteRole, + logActionAudit(ActionsEnum.deleteRole) ); authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), - user.addUserRole + user.addUserRole, + logActionAudit(ActionsEnum.addUserRole) ); authenticated.post( @@ -464,7 +494,8 @@ authenticated.post( verifyResourceAccess, verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), - resource.setResourceRoles + resource.setResourceRoles, + logActionAudit(ActionsEnum.setResourceRoles) ); authenticated.post( @@ -472,35 +503,40 @@ authenticated.post( verifyResourceAccess, verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), - resource.setResourceUsers + resource.setResourceUsers, + logActionAudit(ActionsEnum.setResourceUsers) ); authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePassword), - resource.setResourcePassword + resource.setResourcePassword, + logActionAudit(ActionsEnum.setResourcePassword) ); authenticated.post( `/resource/:resourceId/pincode`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePincode), - resource.setResourcePincode + resource.setResourcePincode, + logActionAudit(ActionsEnum.setResourcePincode) ); authenticated.post( `/resource/:resourceId/header-auth`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth + resource.setResourceHeaderAuth, + logActionAudit(ActionsEnum.setResourceHeaderAuth) ); authenticated.post( `/resource/:resourceId/whitelist`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist + resource.setResourceWhitelist, + logActionAudit(ActionsEnum.setResourceWhitelist) ); authenticated.get( @@ -514,14 +550,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken + accessToken.generateAccessToken, + logActionAudit(ActionsEnum.generateAccessToken) ); authenticated.delete( `/access-token/:accessTokenId`, verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken + accessToken.deleteAccessToken, + logActionAudit(ActionsEnum.deleteAcessToken) ); authenticated.get( @@ -594,7 +632,8 @@ authenticated.put( "/org/:orgId/user", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgUser), - user.createOrgUser + user.createOrgUser, + logActionAudit(ActionsEnum.createOrgUser) ); authenticated.post( @@ -602,7 +641,8 @@ authenticated.post( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.updateOrgUser), - user.updateOrgUser + user.updateOrgUser, + logActionAudit(ActionsEnum.updateOrgUser) ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); @@ -624,7 +664,8 @@ authenticated.delete( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), - user.removeUserOrg + user.removeUserOrg, + logActionAudit(ActionsEnum.removeUser) ); // authenticated.put( @@ -757,7 +798,8 @@ authenticated.post( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions + apiKeys.setApiKeyActions, + logActionAudit(ActionsEnum.setApiKeyActions) ); authenticated.get( @@ -772,7 +814,8 @@ authenticated.put( `/org/:orgId/api-key`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey + apiKeys.createOrgApiKey, + logActionAudit(ActionsEnum.createApiKey) ); authenticated.delete( @@ -780,7 +823,8 @@ authenticated.delete( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), - apiKeys.deleteOrgApiKey + apiKeys.deleteOrgApiKey, + logActionAudit(ActionsEnum.deleteApiKey) ); authenticated.get( @@ -795,7 +839,8 @@ authenticated.put( `/org/:orgId/domain`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgDomain), - domain.createOrgDomain + domain.createOrgDomain, + logActionAudit(ActionsEnum.createOrgDomain) ); authenticated.post( @@ -803,7 +848,8 @@ authenticated.post( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.restartOrgDomain), - domain.restartOrgDomain + domain.restartOrgDomain, + logActionAudit(ActionsEnum.restartOrgDomain) ); authenticated.delete( @@ -811,7 +857,8 @@ authenticated.delete( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), - domain.deleteAccountDomain + domain.deleteAccountDomain, + logActionAudit(ActionsEnum.deleteOrgDomain) ); // Auth routes diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 8808c931..f54189e6 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -29,7 +29,7 @@ import { import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; import { ActionsEnum } from "@server/auth/actions"; -import { build } from "@server/build"; +import { logActionAudit } from "#dynamic/middlewares"; export const unauthenticated = Router(); @@ -51,7 +51,8 @@ authenticated.put( "/org", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createOrg), - org.createOrg + org.createOrg, + logActionAudit(ActionsEnum.createOrg) ); authenticated.get( @@ -72,21 +73,24 @@ authenticated.post( "/org/:orgId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.updateOrg), - org.updateOrg + org.updateOrg, + logActionAudit(ActionsEnum.updateOrg) ); authenticated.delete( "/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteOrg), - org.deleteOrg + org.deleteOrg, + logActionAudit(ActionsEnum.deleteOrg) ); authenticated.put( "/org/:orgId/site", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createSite), - site.createSite + site.createSite, + logActionAudit(ActionsEnum.createSite) ); authenticated.get( @@ -121,14 +125,16 @@ authenticated.post( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.updateSite), - site.updateSite + site.updateSite, + logActionAudit(ActionsEnum.updateSite) ); authenticated.delete( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.deleteSite), - site.deleteSite + site.deleteSite, + logActionAudit(ActionsEnum.deleteSite) ); authenticated.get( @@ -142,7 +148,8 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.createSiteResource), - siteResource.createSiteResource + siteResource.createSiteResource, + logActionAudit(ActionsEnum.createSiteResource) ); authenticated.get( @@ -175,7 +182,8 @@ authenticated.post( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource + siteResource.updateSiteResource, + logActionAudit(ActionsEnum.updateSiteResource) ); authenticated.delete( @@ -184,21 +192,24 @@ authenticated.delete( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource + siteResource.deleteSiteResource, + logActionAudit(ActionsEnum.deleteSiteResource) ); authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), - resource.createResource + resource.createResource, + logActionAudit(ActionsEnum.createResource) ); authenticated.put( "/org/:orgId/site/:siteId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), - resource.createResource + resource.createResource, + logActionAudit(ActionsEnum.createResource) ); authenticated.get( @@ -233,7 +244,8 @@ authenticated.post( "/org/:orgId/create-invite", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.inviteUser), - user.inviteUser + user.inviteUser, + logActionAudit(ActionsEnum.inviteUser) ); authenticated.get( @@ -261,21 +273,24 @@ authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResource), - resource.updateResource + resource.updateResource, + logActionAudit(ActionsEnum.updateResource) ); authenticated.delete( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResource), - resource.deleteResource + resource.deleteResource, + logActionAudit(ActionsEnum.deleteResource) ); authenticated.put( "/resource/:resourceId/target", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createTarget), - target.createTarget + target.createTarget, + logActionAudit(ActionsEnum.createTarget) ); authenticated.get( @@ -289,7 +304,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createResourceRule), - resource.createResourceRule + resource.createResourceRule, + logActionAudit(ActionsEnum.createResourceRule) ); authenticated.get( @@ -303,14 +319,16 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), - resource.updateResourceRule + resource.updateResourceRule, + logActionAudit(ActionsEnum.updateResourceRule) ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule + resource.deleteResourceRule, + logActionAudit(ActionsEnum.deleteResourceRule) ); authenticated.get( @@ -324,21 +342,24 @@ authenticated.post( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.updateTarget), - target.updateTarget + target.updateTarget, + logActionAudit(ActionsEnum.updateTarget) ); authenticated.delete( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.deleteTarget), - target.deleteTarget + target.deleteTarget, + logActionAudit(ActionsEnum.deleteTarget) ); authenticated.put( "/org/:orgId/role", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createRole), - role.createRole + role.createRole, + logActionAudit(ActionsEnum.createRole) ); authenticated.get( @@ -352,7 +373,8 @@ authenticated.delete( "/role/:roleId", verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.deleteRole), - role.deleteRole + role.deleteRole, + logActionAudit(ActionsEnum.deleteRole) ); authenticated.get( @@ -367,7 +389,8 @@ authenticated.post( verifyApiKeyRoleAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.addUserRole), - user.addUserRole + user.addUserRole, + logActionAudit(ActionsEnum.addUserRole) ); authenticated.post( @@ -375,7 +398,8 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), - resource.setResourceRoles + resource.setResourceRoles, + logActionAudit(ActionsEnum.setResourceRoles) ); authenticated.post( @@ -383,35 +407,40 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), - resource.setResourceUsers + resource.setResourceUsers, + logActionAudit(ActionsEnum.setResourceUsers) ); authenticated.post( `/resource/:resourceId/password`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), - resource.setResourcePassword + resource.setResourcePassword, + logActionAudit(ActionsEnum.setResourcePassword) ); authenticated.post( `/resource/:resourceId/pincode`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), - resource.setResourcePincode + resource.setResourcePincode, + logActionAudit(ActionsEnum.setResourcePincode) ); authenticated.post( `/resource/:resourceId/header-auth`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth + resource.setResourceHeaderAuth, + logActionAudit(ActionsEnum.setResourceHeaderAuth) ); authenticated.post( `/resource/:resourceId/whitelist`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist + resource.setResourceWhitelist, + logActionAudit(ActionsEnum.setResourceWhitelist) ); authenticated.get( @@ -439,14 +468,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken + accessToken.generateAccessToken, + logActionAudit(ActionsEnum.generateAccessToken) ); authenticated.delete( `/access-token/:accessTokenId`, verifyApiKeyAccessTokenAccess, verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken + accessToken.deleteAccessToken, + logActionAudit(ActionsEnum.deleteAcessToken) ); authenticated.get( @@ -474,7 +505,8 @@ authenticated.post( "/user/:userId/2fa", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateUser), - user.updateUser2FA + user.updateUser2FA, + logActionAudit(ActionsEnum.updateUser) ); authenticated.get( @@ -495,7 +527,8 @@ authenticated.put( "/org/:orgId/user", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createOrgUser), - user.createOrgUser + user.createOrgUser, + logActionAudit(ActionsEnum.createOrgUser) ); authenticated.post( @@ -503,7 +536,8 @@ authenticated.post( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.updateOrgUser), - user.updateOrgUser + user.updateOrgUser, + logActionAudit(ActionsEnum.updateOrgUser) ); authenticated.delete( @@ -511,7 +545,8 @@ authenticated.delete( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.removeUser), - user.removeUserOrg + user.removeUserOrg, + logActionAudit(ActionsEnum.removeUser) ); // authenticated.put( @@ -531,7 +566,8 @@ authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions + apiKeys.setApiKeyActions, + logActionAudit(ActionsEnum.setApiKeyActions) ); authenticated.get( @@ -545,28 +581,32 @@ authenticated.put( `/org/:orgId/api-key`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey + apiKeys.createOrgApiKey, + logActionAudit(ActionsEnum.createApiKey) ); authenticated.delete( `/org/:orgId/api-key/:apiKeyId`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteApiKey), - apiKeys.deleteApiKey + apiKeys.deleteApiKey, + logActionAudit(ActionsEnum.deleteApiKey) ); authenticated.put( "/idp/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdp), - idp.createOidcIdp + idp.createOidcIdp, + logActionAudit(ActionsEnum.createIdp) ); authenticated.post( "/idp/:idpId/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdp), - idp.updateOidcIdp + idp.updateOidcIdp, + logActionAudit(ActionsEnum.updateIdp) ); authenticated.get( @@ -587,21 +627,24 @@ authenticated.put( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdpOrg), - idp.createIdpOrgPolicy + idp.createIdpOrgPolicy, + logActionAudit(ActionsEnum.createIdpOrg) ); authenticated.post( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), - idp.updateIdpOrgPolicy + idp.updateIdpOrgPolicy, + logActionAudit(ActionsEnum.updateIdpOrg) ); authenticated.delete( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), - idp.deleteIdpOrgPolicy + idp.deleteIdpOrgPolicy, + logActionAudit(ActionsEnum.deleteIdpOrg) ); authenticated.get( @@ -640,7 +683,8 @@ authenticated.put( verifyClientsEnabled, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createClient), - client.createClient + client.createClient, + logActionAudit(ActionsEnum.createClient) ); authenticated.delete( @@ -648,7 +692,8 @@ authenticated.delete( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.deleteClient), - client.deleteClient + client.deleteClient, + logActionAudit(ActionsEnum.deleteClient) ); authenticated.post( @@ -656,12 +701,14 @@ authenticated.post( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.updateClient), - client.updateClient + client.updateClient, + logActionAudit(ActionsEnum.updateClient) ); authenticated.put( "/org/:orgId/blueprint", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.applyBlueprint), - org.applyBlueprint + org.applyBlueprint, + logActionAudit(ActionsEnum.applyBlueprint) ); From bc941239ecc16c89ebd4ca804ae2696571845147 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Oct 2025 21:59:41 -0700 Subject: [PATCH 03/29] Fix the indexes --- server/db/pg/schema/schema.ts | 4 ++-- server/db/sqlite/schema/privateSchema.ts | 4 ++-- server/db/sqlite/schema/schema.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 2dc937d9..2170192b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -690,8 +690,8 @@ export const requestAuditLog = pgTable("requestAuditLog", { userAgent: varchar("userAgent"), metadata: text("details") }, (table) => ([ - index("idx_actionAuditLog_timestamp").on(table.timestamp), - index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) + index("idx_requestAuditLog_timestamp").on(table.timestamp), + index("idx_requestAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); export type Org = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index bbd0aa3e..81c76799 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -243,8 +243,8 @@ export const identityAuditLog = sqliteTable("identityAuditLog", { userAgent: text("userAgent"), metadata: text("details") }, (table) => ([ - index("idx_actionAuditLog_timestamp").on(table.timestamp), - index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) + index("idx_identityAuditLog_timestamp").on(table.timestamp), + index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); export type Limit = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 59b9bc2e..3dabb962 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -728,8 +728,8 @@ export const requestAuditLog = sqliteTable("requestAuditLog", { userAgent: text("userAgent"), metadata: text("details") }, (table) => ([ - index("idx_actionAuditLog_timestamp").on(table.timestamp), - index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) + index("idx_requestAuditLog_timestamp").on(table.timestamp), + index("idx_requestAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); export type Org = InferSelectModel; From 1ee52ad86b76c3e6a0e140e46294997db9301d00 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Oct 2025 21:59:51 -0700 Subject: [PATCH 04/29] Add headers --- server/private/routers/auditLogs/index.ts | 13 +++++++++++++ .../routers/auditLogs/queryActionAuditLog.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index e69de29b..de0b2d2b 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 4c10a3f0..27b8b658 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -1,3 +1,16 @@ +/* + * 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 { actionAuditLog, db } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; From 58443ef53fadfa3a7c5fca6ef98e05753cf681cd Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Oct 2025 22:25:00 -0700 Subject: [PATCH 05/29] Reorder log middleware --- server/private/routers/external.ts | 23 ++++--- server/private/routers/integration.ts | 4 +- server/routers/external.ts | 88 ++++++++++++------------- server/routers/integration.ts | 94 +++++++++++++-------------- 4 files changed, 103 insertions(+), 106 deletions(-) diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index a7c927f2..36a29788 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -26,7 +26,6 @@ import { Router } from "express"; import { verifyOrgAccess, verifyUserHasAction, - verifyUserIsOrgOwner, verifyUserIsServerAdmin } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; @@ -73,8 +72,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createIdp), + logActionAudit(ActionsEnum.createIdp), orgIdp.createOrgOidcIdp, - logActionAudit(ActionsEnum.createIdp) ); authenticated.post( @@ -83,8 +82,8 @@ authenticated.post( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.updateIdp), + logActionAudit(ActionsEnum.updateIdp), orgIdp.updateOrgOidcIdp, - logActionAudit(ActionsEnum.updateIdp) ); authenticated.delete( @@ -93,8 +92,8 @@ authenticated.delete( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), + logActionAudit(ActionsEnum.deleteIdp), orgIdp.deleteOrgIdp, - logActionAudit(ActionsEnum.deleteIdp) ); authenticated.get( @@ -131,8 +130,8 @@ authenticated.post( verifyOrgAccess, verifyCertificateAccess, verifyUserHasAction(ActionsEnum.restartCertificate), + logActionAudit(ActionsEnum.restartCertificate), certificates.restartCertificate, - logActionAudit(ActionsEnum.restartCertificate) ); if (build === "saas") { @@ -157,16 +156,16 @@ if (build === "saas") { "/org/:orgId/billing/create-checkout-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), billing.createCheckoutSession, - logActionAudit(ActionsEnum.billing) ); authenticated.post( "/org/:orgId/billing/create-portal-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), billing.createPortalSession, - logActionAudit(ActionsEnum.billing) ); authenticated.get( @@ -213,8 +212,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRemoteExitNode), + logActionAudit(ActionsEnum.createRemoteExitNode), remoteExitNode.createRemoteExitNode, - logActionAudit(ActionsEnum.createRemoteExitNode) ); authenticated.get( @@ -248,8 +247,8 @@ authenticated.delete( verifyOrgAccess, verifyRemoteExitNodeAccess, verifyUserHasAction(ActionsEnum.deleteRemoteExitNode), + logActionAudit(ActionsEnum.deleteRemoteExitNode), remoteExitNode.deleteRemoteExitNode, - logActionAudit(ActionsEnum.deleteRemoteExitNode) ); authenticated.put( @@ -257,8 +256,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createLoginPage), + logActionAudit(ActionsEnum.createLoginPage), loginPage.createLoginPage, - logActionAudit(ActionsEnum.createLoginPage) ); authenticated.post( @@ -267,8 +266,8 @@ authenticated.post( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), + logActionAudit(ActionsEnum.updateLoginPage), loginPage.updateLoginPage, - logActionAudit(ActionsEnum.updateLoginPage) ); authenticated.delete( @@ -277,8 +276,8 @@ authenticated.delete( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), + logActionAudit(ActionsEnum.deleteLoginPage), loginPage.deleteLoginPage, - logActionAudit(ActionsEnum.deleteLoginPage) ); authenticated.get( diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 00bc167a..21c74624 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -32,14 +32,14 @@ authenticated.post( `/org/:orgId/send-usage-notification`, verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine verifyApiKeyHasAction(ActionsEnum.sendUsageNotification), + logActionAudit(ActionsEnum.sendUsageNotification), org.sendUsageNotification, - logActionAudit(ActionsEnum.sendUsageNotification) ); authenticated.delete( "/idp/:idpId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdp), + logActionAudit(ActionsEnum.deleteIdp), orgIdp.deleteOrgIdp, - logActionAudit(ActionsEnum.deleteIdp) ); \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index 55b98242..ecdfe352 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -76,8 +76,8 @@ authenticated.post( "/org/:orgId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrg), + logActionAudit(ActionsEnum.updateOrg), org.updateOrg, - logActionAudit(ActionsEnum.updateOrg) ); if (build !== "saas") { @@ -86,8 +86,8 @@ if (build !== "saas") { verifyOrgAccess, verifyUserIsOrgOwner, verifyUserHasAction(ActionsEnum.deleteOrg), + logActionAudit(ActionsEnum.deleteOrg), org.deleteOrg, - logActionAudit(ActionsEnum.deleteOrg) ); } @@ -95,8 +95,8 @@ authenticated.put( "/org/:orgId/site", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createSite), - site.createSite, - logActionAudit(ActionsEnum.createSite) + logActionAudit(ActionsEnum.createSite), + site.createSite ); authenticated.get( "/org/:orgId/sites", @@ -153,8 +153,8 @@ authenticated.put( verifyClientsEnabled, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), + logActionAudit(ActionsEnum.createClient), client.createClient, - logActionAudit(ActionsEnum.createClient) ); authenticated.delete( @@ -162,8 +162,8 @@ authenticated.delete( verifyClientsEnabled, verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), + logActionAudit(ActionsEnum.deleteClient), client.deleteClient, - logActionAudit(ActionsEnum.deleteClient) ); authenticated.post( @@ -171,8 +171,8 @@ authenticated.post( verifyClientsEnabled, 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, - logActionAudit(ActionsEnum.updateClient) ); // authenticated.get( @@ -185,15 +185,15 @@ authenticated.post( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.updateSite), + logActionAudit(ActionsEnum.updateSite), site.updateSite, - logActionAudit(ActionsEnum.updateSite) ); authenticated.delete( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.deleteSite), + logActionAudit(ActionsEnum.deleteSite), site.deleteSite, - logActionAudit(ActionsEnum.deleteSite) ); // TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" @@ -214,14 +214,12 @@ authenticated.post( verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), site.checkDockerSocket, - // logActionAudit(ActionsEnum.getSite) ); authenticated.post( "/site/:siteId/docker/trigger", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), site.triggerFetchContainers, - // logActionAudit(ActionsEnum.getSite) ); authenticated.get( "/site/:siteId/docker/containers", @@ -236,8 +234,8 @@ authenticated.put( verifyOrgAccess, verifySiteAccess, verifyUserHasAction(ActionsEnum.createSiteResource), + logActionAudit(ActionsEnum.createSiteResource), siteResource.createSiteResource, - logActionAudit(ActionsEnum.createSiteResource) ); authenticated.get( @@ -270,8 +268,8 @@ authenticated.post( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.updateSiteResource), + logActionAudit(ActionsEnum.updateSiteResource), siteResource.updateSiteResource, - logActionAudit(ActionsEnum.updateSiteResource) ); authenticated.delete( @@ -280,16 +278,16 @@ authenticated.delete( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), + logActionAudit(ActionsEnum.deleteSiteResource), siteResource.deleteSiteResource, - logActionAudit(ActionsEnum.deleteSiteResource) ); authenticated.put( "/org/:orgId/resource", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), + logActionAudit(ActionsEnum.createResource), resource.createResource, - logActionAudit(ActionsEnum.createResource) ); authenticated.get( @@ -329,16 +327,16 @@ authenticated.delete( "/org/:orgId/invitations/:inviteId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), + logActionAudit(ActionsEnum.removeInvitation), user.removeInvitation, - logActionAudit(ActionsEnum.removeInvitation) ); authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, verifyUserHasAction(ActionsEnum.inviteUser), + logActionAudit(ActionsEnum.inviteUser), user.inviteUser, - logActionAudit(ActionsEnum.inviteUser) ); // maybe make this /invite/create instead unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated @@ -373,23 +371,23 @@ authenticated.post( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResource), + logActionAudit(ActionsEnum.updateResource), resource.updateResource, - logActionAudit(ActionsEnum.updateResource) ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResource), + logActionAudit(ActionsEnum.deleteResource), resource.deleteResource, - logActionAudit(ActionsEnum.deleteResource) ); authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), + logActionAudit(ActionsEnum.createTarget), target.createTarget, - logActionAudit(ActionsEnum.createTarget) ); authenticated.get( "/resource/:resourceId/targets", @@ -402,8 +400,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), + logActionAudit(ActionsEnum.createResourceRule), resource.createResourceRule, - logActionAudit(ActionsEnum.createResourceRule) ); authenticated.get( "/resource/:resourceId/rules", @@ -415,15 +413,15 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResourceRule), + logActionAudit(ActionsEnum.updateResourceRule), resource.updateResourceRule, - logActionAudit(ActionsEnum.updateResourceRule) ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), + logActionAudit(ActionsEnum.deleteResourceRule), resource.deleteResourceRule, - logActionAudit(ActionsEnum.deleteResourceRule) ); authenticated.get( @@ -436,23 +434,23 @@ authenticated.post( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.updateTarget), + logActionAudit(ActionsEnum.updateTarget), target.updateTarget, - logActionAudit(ActionsEnum.updateTarget) ); authenticated.delete( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.deleteTarget), + logActionAudit(ActionsEnum.deleteTarget), target.deleteTarget, - logActionAudit(ActionsEnum.deleteTarget) ); authenticated.put( "/org/:orgId/role", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), + logActionAudit(ActionsEnum.createRole), role.createRole, - logActionAudit(ActionsEnum.createRole) ); authenticated.get( "/org/:orgId/roles", @@ -477,16 +475,16 @@ authenticated.delete( "/role/:roleId", verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), + logActionAudit(ActionsEnum.deleteRole), role.deleteRole, - logActionAudit(ActionsEnum.deleteRole) ); authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), + logActionAudit(ActionsEnum.addUserRole), user.addUserRole, - logActionAudit(ActionsEnum.addUserRole) ); authenticated.post( @@ -494,8 +492,8 @@ authenticated.post( verifyResourceAccess, verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), resource.setResourceRoles, - logActionAudit(ActionsEnum.setResourceRoles) ); authenticated.post( @@ -503,40 +501,40 @@ authenticated.post( verifyResourceAccess, verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), resource.setResourceUsers, - logActionAudit(ActionsEnum.setResourceUsers) ); authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePassword), + logActionAudit(ActionsEnum.setResourcePassword), resource.setResourcePassword, - logActionAudit(ActionsEnum.setResourcePassword) ); authenticated.post( `/resource/:resourceId/pincode`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePincode), + logActionAudit(ActionsEnum.setResourcePincode), resource.setResourcePincode, - logActionAudit(ActionsEnum.setResourcePincode) ); authenticated.post( `/resource/:resourceId/header-auth`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), + logActionAudit(ActionsEnum.setResourceHeaderAuth), resource.setResourceHeaderAuth, - logActionAudit(ActionsEnum.setResourceHeaderAuth) ); authenticated.post( `/resource/:resourceId/whitelist`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceWhitelist), + logActionAudit(ActionsEnum.setResourceWhitelist), resource.setResourceWhitelist, - logActionAudit(ActionsEnum.setResourceWhitelist) ); authenticated.get( @@ -550,16 +548,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.generateAccessToken), + logActionAudit(ActionsEnum.generateAccessToken), accessToken.generateAccessToken, - logActionAudit(ActionsEnum.generateAccessToken) ); authenticated.delete( `/access-token/:accessTokenId`, verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), + logActionAudit(ActionsEnum.deleteAcessToken), accessToken.deleteAccessToken, - logActionAudit(ActionsEnum.deleteAcessToken) ); authenticated.get( @@ -632,8 +630,8 @@ authenticated.put( "/org/:orgId/user", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgUser), + logActionAudit(ActionsEnum.createOrgUser), user.createOrgUser, - logActionAudit(ActionsEnum.createOrgUser) ); authenticated.post( @@ -641,8 +639,8 @@ authenticated.post( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.updateOrgUser), + logActionAudit(ActionsEnum.updateOrgUser), user.updateOrgUser, - logActionAudit(ActionsEnum.updateOrgUser) ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); @@ -664,8 +662,8 @@ authenticated.delete( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), + logActionAudit(ActionsEnum.removeUser), user.removeUserOrg, - logActionAudit(ActionsEnum.removeUser) ); // authenticated.put( @@ -798,8 +796,8 @@ authenticated.post( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), + logActionAudit(ActionsEnum.setApiKeyActions), apiKeys.setApiKeyActions, - logActionAudit(ActionsEnum.setApiKeyActions) ); authenticated.get( @@ -814,8 +812,8 @@ authenticated.put( `/org/:orgId/api-key`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), + logActionAudit(ActionsEnum.createApiKey), apiKeys.createOrgApiKey, - logActionAudit(ActionsEnum.createApiKey) ); authenticated.delete( @@ -823,8 +821,8 @@ authenticated.delete( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), + logActionAudit(ActionsEnum.deleteApiKey), apiKeys.deleteOrgApiKey, - logActionAudit(ActionsEnum.deleteApiKey) ); authenticated.get( @@ -839,8 +837,8 @@ authenticated.put( `/org/:orgId/domain`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgDomain), + logActionAudit(ActionsEnum.createOrgDomain), domain.createOrgDomain, - logActionAudit(ActionsEnum.createOrgDomain) ); authenticated.post( @@ -848,8 +846,8 @@ authenticated.post( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.restartOrgDomain), + logActionAudit(ActionsEnum.restartOrgDomain), domain.restartOrgDomain, - logActionAudit(ActionsEnum.restartOrgDomain) ); authenticated.delete( @@ -857,8 +855,8 @@ authenticated.delete( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), + logActionAudit(ActionsEnum.deleteOrgDomain), domain.deleteAccountDomain, - logActionAudit(ActionsEnum.deleteOrgDomain) ); // Auth routes diff --git a/server/routers/integration.ts b/server/routers/integration.ts index f54189e6..1359ea5e 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -51,8 +51,8 @@ authenticated.put( "/org", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createOrg), + logActionAudit(ActionsEnum.createOrg), org.createOrg, - logActionAudit(ActionsEnum.createOrg) ); authenticated.get( @@ -73,24 +73,24 @@ authenticated.post( "/org/:orgId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.updateOrg), + logActionAudit(ActionsEnum.updateOrg), org.updateOrg, - logActionAudit(ActionsEnum.updateOrg) ); authenticated.delete( "/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteOrg), + logActionAudit(ActionsEnum.deleteOrg), org.deleteOrg, - logActionAudit(ActionsEnum.deleteOrg) ); authenticated.put( "/org/:orgId/site", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createSite), + logActionAudit(ActionsEnum.createSite), site.createSite, - logActionAudit(ActionsEnum.createSite) ); authenticated.get( @@ -125,16 +125,16 @@ authenticated.post( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.updateSite), + logActionAudit(ActionsEnum.updateSite), site.updateSite, - logActionAudit(ActionsEnum.updateSite) ); authenticated.delete( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.deleteSite), + logActionAudit(ActionsEnum.deleteSite), site.deleteSite, - logActionAudit(ActionsEnum.deleteSite) ); authenticated.get( @@ -148,8 +148,8 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.createSiteResource), + logActionAudit(ActionsEnum.createSiteResource), siteResource.createSiteResource, - logActionAudit(ActionsEnum.createSiteResource) ); authenticated.get( @@ -182,8 +182,8 @@ authenticated.post( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), + logActionAudit(ActionsEnum.updateSiteResource), siteResource.updateSiteResource, - logActionAudit(ActionsEnum.updateSiteResource) ); authenticated.delete( @@ -192,24 +192,24 @@ authenticated.delete( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), + logActionAudit(ActionsEnum.deleteSiteResource), siteResource.deleteSiteResource, - logActionAudit(ActionsEnum.deleteSiteResource) ); authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), + logActionAudit(ActionsEnum.createResource), resource.createResource, - logActionAudit(ActionsEnum.createResource) ); authenticated.put( "/org/:orgId/site/:siteId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), + logActionAudit(ActionsEnum.createResource), resource.createResource, - logActionAudit(ActionsEnum.createResource) ); authenticated.get( @@ -244,8 +244,8 @@ authenticated.post( "/org/:orgId/create-invite", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.inviteUser), + logActionAudit(ActionsEnum.inviteUser), user.inviteUser, - logActionAudit(ActionsEnum.inviteUser) ); authenticated.get( @@ -273,24 +273,24 @@ authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResource), + logActionAudit(ActionsEnum.updateResource), resource.updateResource, - logActionAudit(ActionsEnum.updateResource) ); authenticated.delete( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResource), + logActionAudit(ActionsEnum.deleteResource), resource.deleteResource, - logActionAudit(ActionsEnum.deleteResource) ); authenticated.put( "/resource/:resourceId/target", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createTarget), + logActionAudit(ActionsEnum.createTarget), target.createTarget, - logActionAudit(ActionsEnum.createTarget) ); authenticated.get( @@ -304,8 +304,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createResourceRule), + logActionAudit(ActionsEnum.createResourceRule), resource.createResourceRule, - logActionAudit(ActionsEnum.createResourceRule) ); authenticated.get( @@ -319,16 +319,16 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), + logActionAudit(ActionsEnum.updateResourceRule), resource.updateResourceRule, - logActionAudit(ActionsEnum.updateResourceRule) ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), + logActionAudit(ActionsEnum.deleteResourceRule), resource.deleteResourceRule, - logActionAudit(ActionsEnum.deleteResourceRule) ); authenticated.get( @@ -342,24 +342,24 @@ authenticated.post( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.updateTarget), + logActionAudit(ActionsEnum.updateTarget), target.updateTarget, - logActionAudit(ActionsEnum.updateTarget) ); authenticated.delete( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.deleteTarget), + logActionAudit(ActionsEnum.deleteTarget), target.deleteTarget, - logActionAudit(ActionsEnum.deleteTarget) ); authenticated.put( "/org/:orgId/role", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createRole), + logActionAudit(ActionsEnum.createRole), role.createRole, - logActionAudit(ActionsEnum.createRole) ); authenticated.get( @@ -373,8 +373,8 @@ authenticated.delete( "/role/:roleId", verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.deleteRole), + logActionAudit(ActionsEnum.deleteRole), role.deleteRole, - logActionAudit(ActionsEnum.deleteRole) ); authenticated.get( @@ -389,8 +389,8 @@ authenticated.post( verifyApiKeyRoleAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.addUserRole), + logActionAudit(ActionsEnum.addUserRole), user.addUserRole, - logActionAudit(ActionsEnum.addUserRole) ); authenticated.post( @@ -398,8 +398,8 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), resource.setResourceRoles, - logActionAudit(ActionsEnum.setResourceRoles) ); authenticated.post( @@ -407,40 +407,40 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), resource.setResourceUsers, - logActionAudit(ActionsEnum.setResourceUsers) ); authenticated.post( `/resource/:resourceId/password`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), + logActionAudit(ActionsEnum.setResourcePassword), resource.setResourcePassword, - logActionAudit(ActionsEnum.setResourcePassword) ); authenticated.post( `/resource/:resourceId/pincode`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), + logActionAudit(ActionsEnum.setResourcePincode), resource.setResourcePincode, - logActionAudit(ActionsEnum.setResourcePincode) ); authenticated.post( `/resource/:resourceId/header-auth`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), + logActionAudit(ActionsEnum.setResourceHeaderAuth), resource.setResourceHeaderAuth, - logActionAudit(ActionsEnum.setResourceHeaderAuth) ); authenticated.post( `/resource/:resourceId/whitelist`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), + logActionAudit(ActionsEnum.setResourceWhitelist), resource.setResourceWhitelist, - logActionAudit(ActionsEnum.setResourceWhitelist) ); authenticated.get( @@ -468,16 +468,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), + logActionAudit(ActionsEnum.generateAccessToken), accessToken.generateAccessToken, - logActionAudit(ActionsEnum.generateAccessToken) ); authenticated.delete( `/access-token/:accessTokenId`, verifyApiKeyAccessTokenAccess, verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), + logActionAudit(ActionsEnum.deleteAcessToken), accessToken.deleteAccessToken, - logActionAudit(ActionsEnum.deleteAcessToken) ); authenticated.get( @@ -505,8 +505,8 @@ authenticated.post( "/user/:userId/2fa", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateUser), + logActionAudit(ActionsEnum.updateUser), user.updateUser2FA, - logActionAudit(ActionsEnum.updateUser) ); authenticated.get( @@ -527,8 +527,8 @@ authenticated.put( "/org/:orgId/user", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createOrgUser), + logActionAudit(ActionsEnum.createOrgUser), user.createOrgUser, - logActionAudit(ActionsEnum.createOrgUser) ); authenticated.post( @@ -536,8 +536,8 @@ authenticated.post( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.updateOrgUser), + logActionAudit(ActionsEnum.updateOrgUser), user.updateOrgUser, - logActionAudit(ActionsEnum.updateOrgUser) ); authenticated.delete( @@ -545,8 +545,8 @@ authenticated.delete( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.removeUser), + logActionAudit(ActionsEnum.removeUser), user.removeUserOrg, - logActionAudit(ActionsEnum.removeUser) ); // authenticated.put( @@ -566,8 +566,8 @@ authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), + logActionAudit(ActionsEnum.setApiKeyActions), apiKeys.setApiKeyActions, - logActionAudit(ActionsEnum.setApiKeyActions) ); authenticated.get( @@ -581,32 +581,32 @@ authenticated.put( `/org/:orgId/api-key`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createApiKey), + logActionAudit(ActionsEnum.createApiKey), apiKeys.createOrgApiKey, - logActionAudit(ActionsEnum.createApiKey) ); authenticated.delete( `/org/:orgId/api-key/:apiKeyId`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteApiKey), + logActionAudit(ActionsEnum.deleteApiKey), apiKeys.deleteApiKey, - logActionAudit(ActionsEnum.deleteApiKey) ); authenticated.put( "/idp/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdp), + logActionAudit(ActionsEnum.createIdp), idp.createOidcIdp, - logActionAudit(ActionsEnum.createIdp) ); authenticated.post( "/idp/:idpId/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdp), + logActionAudit(ActionsEnum.updateIdp), idp.updateOidcIdp, - logActionAudit(ActionsEnum.updateIdp) ); authenticated.get( @@ -627,24 +627,24 @@ authenticated.put( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdpOrg), + logActionAudit(ActionsEnum.createIdpOrg), idp.createIdpOrgPolicy, - logActionAudit(ActionsEnum.createIdpOrg) ); authenticated.post( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), + logActionAudit(ActionsEnum.updateIdpOrg), idp.updateIdpOrgPolicy, - logActionAudit(ActionsEnum.updateIdpOrg) ); authenticated.delete( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), + logActionAudit(ActionsEnum.deleteIdpOrg), idp.deleteIdpOrgPolicy, - logActionAudit(ActionsEnum.deleteIdpOrg) ); authenticated.get( @@ -683,8 +683,8 @@ authenticated.put( verifyClientsEnabled, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createClient), + logActionAudit(ActionsEnum.createClient), client.createClient, - logActionAudit(ActionsEnum.createClient) ); authenticated.delete( @@ -692,8 +692,8 @@ authenticated.delete( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.deleteClient), + logActionAudit(ActionsEnum.deleteClient), client.deleteClient, - logActionAudit(ActionsEnum.deleteClient) ); authenticated.post( @@ -701,14 +701,14 @@ authenticated.post( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.updateClient), + logActionAudit(ActionsEnum.updateClient), client.updateClient, - logActionAudit(ActionsEnum.updateClient) ); authenticated.put( "/org/:orgId/blueprint", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.applyBlueprint), + logActionAudit(ActionsEnum.applyBlueprint), org.applyBlueprint, - logActionAudit(ActionsEnum.applyBlueprint) ); From f3149e46cd9d63cb8e3f8b4b3384a6751402d3f9 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Oct 2025 20:40:04 -0700 Subject: [PATCH 06/29] Starting to create frontend --- messages/en-US.json | 4 +- src/app/[orgId]/settings/logs/access/page.tsx | 54 ++++++++++++++++++ src/app/[orgId]/settings/logs/layout.tsx | 56 +++++++++++++++++++ src/app/[orgId]/settings/logs/page.tsx | 54 ++++++++++++++++++ src/app/navigation.tsx | 8 ++- 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/app/[orgId]/settings/logs/access/page.tsx create mode 100644 src/app/[orgId]/settings/logs/layout.tsx create mode 100644 src/app/[orgId]/settings/logs/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 4cffaf98..79042b7e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1891,5 +1891,7 @@ "cannotbeUndone": "This can not be undone.", "toConfirm": "to confirm", "deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?", - "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site." + "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.", + "sidebarLogs": "Logs", + "request": "Request" } diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx new file mode 100644 index 00000000..80e4e4a6 --- /dev/null +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -0,0 +1,54 @@ +"use client"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import AuthPageSettings, { + AuthPageSettingsRef +} from "@app/components/private/AuthPageSettings"; + +import { Button } from "@app/components/ui/button"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef } from "react"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatAxiosError } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; +import { useRouter } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; + +export default function GeneralPage() { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + + return

access

; +} diff --git a/src/app/[orgId]/settings/logs/layout.tsx b/src/app/[orgId]/settings/logs/layout.tsx new file mode 100644 index 00000000..01e0d248 --- /dev/null +++ b/src/app/[orgId]/settings/logs/layout.tsx @@ -0,0 +1,56 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { verifySession } from "@app/lib/auth/verifySession"; +import OrgProvider from "@app/providers/OrgProvider"; +import OrgUserProvider from "@app/providers/OrgUserProvider"; +import { GetOrgResponse } from "@server/routers/org"; +import { GetOrgUserResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { cache } from "react"; +import { getTranslations } from "next-intl/server"; + +type GeneralSettingsProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function GeneralSettingsPage({ + children, + params +}: GeneralSettingsProps) { + const { orgId } = await params; + + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect(`/`); + } + + const t = await getTranslations(); + + const navItems = [ + { + title: t("access"), + href: `/{orgId}/settings/logs/access` + }, + { + title: t("request"), + href: `/{orgId}/settings/logs/request` + } + ]; + + return ( + <> + + + {children} + + ); +} diff --git a/src/app/[orgId]/settings/logs/page.tsx b/src/app/[orgId]/settings/logs/page.tsx new file mode 100644 index 00000000..45b5a7de --- /dev/null +++ b/src/app/[orgId]/settings/logs/page.tsx @@ -0,0 +1,54 @@ +"use client"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import AuthPageSettings, { + AuthPageSettingsRef +} from "@app/components/private/AuthPageSettings"; + +import { Button } from "@app/components/ui/button"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef } from "react"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatAxiosError } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; +import { useRouter } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; + +export default function GeneralPage() { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + + return

dfas

; +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 2d6aaec8..6b453811 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -16,7 +16,8 @@ import { MonitorUp, // Added from 'dev' branch Server, Zap, - CreditCard + CreditCard, + Logs } from "lucide-react"; export type SidebarNavSection = { @@ -138,6 +139,11 @@ export const orgNavSections = ( } ] : []), + { + title: "sidebarLogs", + href: "/{orgId}/settings/logs", + icon: + }, { title: "sidebarSettings", href: "/{orgId}/settings/general", From 9a64f458150b3932e588c6eb36f34897ae34f116 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Oct 2025 15:26:03 -0700 Subject: [PATCH 07/29] Basic log table there --- messages/en-US.json | 9 +- server/private/routers/auditLogs/index.ts | 1 + server/private/routers/external.ts | 7 +- src/app/[orgId]/settings/logs/access/page.tsx | 251 ++++++++++-- src/app/navigation.tsx | 2 +- src/components/DateTimePicker.tsx | 209 ++++++++++ src/components/LogDataTable.tsx | 367 ++++++++++++++++++ 7 files changed, 804 insertions(+), 42 deletions(-) create mode 100644 src/components/DateTimePicker.tsx create mode 100644 src/components/LogDataTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 79042b7e..4a44ab33 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1893,5 +1893,12 @@ "deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?", "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.", "sidebarLogs": "Logs", - "request": "Request" + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs" } diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index de0b2d2b..1a1b1408 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -11,3 +11,4 @@ * This file is not licensed under the AGPLv3. */ +export * from "./queryActionAuditLog"; \ No newline at end of file diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 36a29788..7e81336b 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -21,8 +21,8 @@ import * as domain from "#private/routers/domain"; import * as auth from "#private/routers/auth"; import * as license from "#private/routers/license"; import * as generateLicense from "./generatedLicense"; +import * as logs from "#private/routers/auditLogs"; -import { Router } from "express"; import { verifyOrgAccess, verifyUserHasAction, @@ -345,3 +345,8 @@ authenticated.post( verifyUserIsServerAdmin, license.recheckStatus ); + +authenticated.get( + "/org/:orgId/logs/action", + logs.queryAccessAuditLogs +) \ No newline at end of file diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index 80e4e4a6..58479166 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -1,47 +1,15 @@ "use client"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import AuthPageSettings, { - AuthPageSettingsRef -} from "@app/components/private/AuthPageSettings"; - import { Button } from "@app/components/ui/button"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { toast } from "@app/hooks/useToast"; -import { useState, useRef } from "react"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; - -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { useState, useRef, useEffect } from "react"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { formatAxiosError } from "@app/lib/api"; -import { AxiosResponse } from "axios"; -import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; -import { useRouter } from "next/navigation"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionHeader, - SettingsSectionTitle, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionForm, - SettingsSectionFooter -} from "@app/components/Settings"; -import { useUserContext } from "@app/hooks/useUserContext"; +import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { build } from "@server/build"; +import { LogDataTable } from "@app/components/LogDataTable"; +import { ColumnDef } from "@tanstack/react-table"; +import { DateTimeValue } from "@app/components/DateTimePicker"; +import { Key, User } from "lucide-react"; export default function GeneralPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -49,6 +17,211 @@ export default function GeneralPage() { const api = createApiClient(useEnvContext()); const t = useTranslations(); const { env } = useEnvContext(); + const { orgId } = useParams(); - return

access

; + const [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + return { + startDate: { + date: yesterday, + }, + endDate: { + date: now, + } + }; + }; + + const [dateRange, setDateRange] = useState<{ startDate: DateTimeValue; endDate: DateTimeValue }>(getDefaultDateRange()); + + // Trigger search with default values on component mount + useEffect(() => { + const defaultRange = getDefaultDateRange(); + queryDateTime(defaultRange.startDate, defaultRange.endDate); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + queryDateTime(startDate, endDate); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + console.log("Date range changed:", { startDate, endDate }); + setIsRefreshing(true); + + try { + // Convert the date/time values to API parameters + let params: any = { + limit: 20, + offset: 0 + }; + + if (startDate?.date) { + const startDateTime = new Date(startDate.date); + if (startDate.time) { + const [hours, minutes, seconds] = startDate.time + .split(":") + .map(Number); + startDateTime.setHours(hours, minutes, seconds || 0); + } + params.timeStart = startDateTime.toISOString(); + } + + if (endDate?.date) { + const endDateTime = new Date(endDate.date); + if (endDate.time) { + const [hours, minutes, seconds] = endDate.time + .split(":") + .map(Number); + endDateTime.setHours(hours, minutes, seconds || 0); + } else { + // If no time is specified, set to NOW + const now = new Date(); + endDateTime.setHours(now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()); + } + params.timeEnd = endDateTime.toISOString(); + } + + const res = await api.get(`/org/${orgId}/logs/action`, { params }); + if (res.status === 200) { + setRows(res.data.data.log); + console.log("Fetched logs:", res.data); + } + } catch (error) { + toast({ + title: t("error"), + description: t("Failed to filter logs"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "timestamp", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.timestamp * 1000 + ).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "action", + header: ({ column }) => { + return ( + + ); + }, + // make the value capitalized + cell: ({ row }) => { + return ( + + {row.original.action.charAt(0).toUpperCase() + row.original.action.slice(1)} + + ); + }, + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return ( + + {row.original.actorType == "user" ? : } + {row.original.actor} + + ); + } + } + ]; + + return ( + <> + + + ); } diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 6b453811..80ef2e2e 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -141,7 +141,7 @@ export const orgNavSections = ( : []), { title: "sidebarLogs", - href: "/{orgId}/settings/logs", + href: "/{orgId}/settings/logs/access", icon: }, { diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx new file mode 100644 index 00000000..da6cb009 --- /dev/null +++ b/src/components/DateTimePicker.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { ChevronDownIcon, CalendarIcon } from "lucide-react"; + +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { Label } from "@app/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { ChangeEvent, useEffect, useState } from "react"; + +export interface DateTimeValue { + date?: Date; + time?: string; +} + +export interface DateTimePickerProps { + label?: string; + value?: DateTimeValue; + onChange?: (value: DateTimeValue) => void; + placeholder?: string; + className?: string; + disabled?: boolean; + showTime?: boolean; +} + +export function DateTimePicker({ + label, + value, + onChange, + placeholder = "Select date & time", + className, + disabled = false, + showTime = true, +}: DateTimePickerProps) { + const [open, setOpen] = useState(false); + const [internalDate, setInternalDate] = useState(value?.date); + const [internalTime, setInternalTime] = useState(value?.time || ""); + + // Sync internal state with external value prop + useEffect(() => { + setInternalDate(value?.date); + setInternalTime(value?.time || ""); + }, [value?.date, value?.time]); + + const handleDateChange = (date: Date | undefined) => { + setInternalDate(date); + const newValue = { date, time: internalTime }; + setOpen(false); + onChange?.(newValue); + }; + + const handleTimeChange = (event: ChangeEvent) => { + const time = event.target.value; + setInternalTime(time); + const newValue = { date: internalDate, time }; + onChange?.(newValue); + }; + + const getDisplayText = () => { + if (!internalDate) return placeholder; + + const dateStr = internalDate.toLocaleDateString(); + if (!showTime || !internalTime) return dateStr; + + return `${dateStr} ${internalTime}`; + }; + + const hasValue = internalDate || (showTime && internalTime); + + return ( +
+
+ {label && ( + + )} +
+ + + + + +
+
+
+ + { + let dateValue = undefined; + if (e.target.value) { + // Create date in local timezone to avoid offset issues + const parts = e.target.value.split('-'); + dateValue = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); + } + handleDateChange(dateValue); + }} + className="mt-1" + /> +
+ {showTime && ( +
+ + +
+ )} +
+
+
+
+
+
+
+ ); +} + +export interface DateRangePickerProps { + startLabel?: string; + endLabel?: string; + startValue?: DateTimeValue; + endValue?: DateTimeValue; + onStartChange?: (value: DateTimeValue) => void; + onEndChange?: (value: DateTimeValue) => void; + onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void; + className?: string; + disabled?: boolean; + showTime?: boolean; +} + +export function DateRangePicker({ +// startLabel = "From", +// endLabel = "To", + startValue, + endValue, + onStartChange, + onEndChange, + onRangeChange, + className, + disabled = false, + showTime = true, +}: DateRangePickerProps) { + const handleStartChange = (value: DateTimeValue) => { + onStartChange?.(value); + if (onRangeChange && endValue) { + onRangeChange(value, endValue); + } + }; + + const handleEndChange = (value: DateTimeValue) => { + onEndChange?.(value); + if (onRangeChange && startValue) { + onRangeChange(startValue, value); + } + }; + + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx new file mode 100644 index 00000000..9fc3789b --- /dev/null +++ b/src/components/LogDataTable.tsx @@ -0,0 +1,367 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Button } from "@app/components/ui/button"; +import { useEffect, useMemo, useState } from "react"; +import { Input } from "@app/components/ui/input"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Plus, Search, RefreshCw, Filter, X } from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle +} from "@app/components/ui/card"; +import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs"; +import { useTranslations } from "next-intl"; +import { DateRangePicker, DateTimeValue } from "@app/components/DateTimePicker"; + +const STORAGE_KEYS = { + PAGE_SIZE: "datatable-page-size", + getTablePageSize: (tableId?: string) => + tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE +}; + +const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { + if (typeof window === "undefined") return defaultSize; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + const stored = localStorage.getItem(key); + if (stored) { + const parsed = parseInt(stored, 10); + // Validate that it's a reasonable page size + if (parsed > 0 && parsed <= 1000) { + return parsed; + } + } + } catch (error) { + console.warn("Failed to read page size from localStorage:", error); + } + return defaultSize; +}; + +const setStoredPageSize = (pageSize: number, tableId?: string): void => { + if (typeof window === "undefined") return; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + localStorage.setItem(key, pageSize.toString()); + } catch (error) { + console.warn("Failed to save page size to localStorage:", error); + } +}; + +type TabFilter = { + id: string; + label: string; + filterFn: (row: any) => boolean; +}; + +type DataTableProps = { + columns: ColumnDef[]; + data: TData[]; + title?: string; + addButtonText?: string; + onRefresh?: () => void; + isRefreshing?: boolean; + searchPlaceholder?: string; + searchColumn?: string; + defaultSort?: { + id: string; + desc: boolean; + }; + tabs?: TabFilter[]; + defaultTab?: string; + persistPageSize?: boolean | string; + defaultPageSize?: number; + onDateRangeChange?: ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => void; + dateRange?: { + start: DateTimeValue; + end: DateTimeValue; + }; +}; + +export function LogDataTable({ + columns, + data, + title, + onRefresh, + isRefreshing, + searchPlaceholder = "Search...", + searchColumn = "name", + defaultSort, + tabs, + defaultTab, + persistPageSize = false, + defaultPageSize = 20, + onDateRangeChange, + dateRange +}: DataTableProps) { + const t = useTranslations(); + + // Determine table identifier for storage + const tableId = + typeof persistPageSize === "string" ? persistPageSize : undefined; + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + if (persistPageSize) { + return getStoredPageSize(tableId, defaultPageSize); + } + return defaultPageSize; + }); + + const [sorting, setSorting] = useState( + defaultSort ? [defaultSort] : [] + ); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState([]); + const [activeTab, setActiveTab] = useState( + defaultTab || tabs?.[0]?.id || "" + ); + + const [showDatePicker, setShowDatePicker] = useState(false); + const [startDate, setStartDate] = useState( + dateRange?.start || {} + ); + const [endDate, setEndDate] = useState(dateRange?.end || {}); + + // Sync internal date state with external dateRange prop + useEffect(() => { + if (dateRange?.start) { + setStartDate(dateRange.start); + } + if (dateRange?.end) { + setEndDate(dateRange.end); + } + }, [dateRange?.start, dateRange?.end]); + + // Apply tab filter to data + const filteredData = useMemo(() => { + if (!tabs || activeTab === "") { + return data; + } + + const activeTabFilter = tabs.find((tab) => tab.id === activeTab); + if (!activeTabFilter) { + return data; + } + + return data.filter(activeTabFilter.filterFn); + }, [data, tabs, activeTab]); + + const table = useReactTable({ + data: filteredData, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setGlobalFilter, + initialState: { + pagination: { + pageSize: pageSize, + pageIndex: 0 + } + }, + state: { + sorting, + columnFilters, + globalFilter + } + }); + + useEffect(() => { + const currentPageSize = table.getState().pagination.pageSize; + if (currentPageSize !== pageSize) { + table.setPageSize(pageSize); + + // Persist to localStorage if enabled + if (persistPageSize) { + setStoredPageSize(pageSize, tableId); + } + } + }, [pageSize, table, persistPageSize, tableId]); + + const handleTabChange = (value: string) => { + setActiveTab(value); + // Reset to first page when changing tabs + table.setPageIndex(0); + }; + + // Enhanced pagination component that updates our local state + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + table.setPageSize(newPageSize); + + // Persist immediately when changed + if (persistPageSize) { + setStoredPageSize(newPageSize, tableId); + } + }; + + const handleDateRangeChange = ( + start: DateTimeValue, + end: DateTimeValue + ) => { + setStartDate(start); + setEndDate(end); + onDateRangeChange?.(start, end); + }; + + const clearDateFilter = () => { + const emptyStart = {}; + const emptyEnd = {}; + setStartDate(emptyStart); + setEndDate(emptyEnd); + onDateRangeChange?.(emptyStart, emptyEnd); + setShowDatePicker(false); + }; + + const hasDateFilter = startDate?.date || endDate?.date; + + return ( +
+ + +
+
+ + table.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> + +
+ {tabs && tabs.length > 0 && ( + + + {tabs.map((tab) => ( + + {tab.label} ( + {data.filter(tab.filterFn).length}) + + ))} + + + )} + + +
+
+ {onRefresh && ( + + )} +
+
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results found. + + + )} + +
+
+ +
+
+
+
+ ); +} From bdc3b2425b52305e724959aa38b80dd3f346f892 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Oct 2025 17:35:13 -0700 Subject: [PATCH 08/29] Basic table working --- messages/en-US.json | 3 +- .../routers/auditLogs/exportActionAuditLog.ts | 102 ++++++++++++++++++ server/private/routers/auditLogs/index.ts | 3 +- .../routers/auditLogs/queryActionAuditLog.ts | 29 ++--- server/private/routers/external.ts | 6 ++ .../settings/logs/{access => action}/page.tsx | 37 +++++++ src/app/navigation.tsx | 2 +- src/components/DateTimePicker.tsx | 1 - src/components/LogDataTable.tsx | 56 ++++------ 9 files changed, 186 insertions(+), 53 deletions(-) create mode 100644 server/private/routers/auditLogs/exportActionAuditLog.ts rename src/app/[orgId]/settings/logs/{access => action}/page.tsx (84%) diff --git a/messages/en-US.json b/messages/en-US.json index 4a44ab33..e6790e0d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1900,5 +1900,6 @@ "action": "Action", "actor": "Actor", "timestamp": "Timestamp", - "accessLogs": "Access Logs" + "accessLogs": "Access Logs", + "exportCsv": "Export CSV" } diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts new file mode 100644 index 00000000..de6cf298 --- /dev/null +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -0,0 +1,102 @@ +/* + * 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 { 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 { 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"); +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/actionk/export", + description: "Export the action 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 { + 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 = 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="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") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index 1a1b1408..9b4a6e7f 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -11,4 +11,5 @@ * This file is not licensed under the AGPLv3. */ -export * from "./queryActionAuditLog"; \ No newline at end of file +export * from "./queryActionAuditLog"; +export * from "./exportActionAuditLog"; \ No newline at end of file diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 27b8b658..6139ce4e 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -59,7 +59,7 @@ export const queryAccessAuditLogsParams = z.object({ orgId: z.string() }); -function querySites(timeStart: number, timeEnd: number, orgId: string) { +export function querySites(timeStart: number, timeEnd: number, orgId: string) { return db .select({ orgId: actionAuditLog.orgId, @@ -79,6 +79,20 @@ function querySites(timeStart: number, timeEnd: number, orgId: string) { .orderBy(actionAuditLog.timestamp); } +export function countQuery(timeStart: number, timeEnd: number, orgId: string) { + const countQuery = db + .select({ count: count() }) + .from(actionAuditLog) + .where( + and( + gt(actionAuditLog.timestamp, timeStart), + lt(actionAuditLog.timestamp, timeEnd), + eq(actionAuditLog.orgId, orgId) + ) + ); + return countQuery; +} + registry.registerPath({ method: "get", path: "/org/{orgId}/logs/action", @@ -123,18 +137,7 @@ export async function queryAccessAuditLogs( const log = await baseQuery.limit(limit).offset(offset); - const countQuery = db - .select({ count: count() }) - .from(actionAuditLog) - .where( - and( - gt(actionAuditLog.timestamp, timeStart), - lt(actionAuditLog.timestamp, timeEnd), - eq(actionAuditLog.orgId, orgId) - ) - ); - - const totalCountResult = await countQuery; + const totalCountResult = await countQuery(timeStart, timeEnd, orgId); const totalCount = totalCountResult[0].count; return response(res, { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 7e81336b..566c1c55 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -349,4 +349,10 @@ authenticated.post( authenticated.get( "/org/:orgId/logs/action", logs.queryAccessAuditLogs +) + + +authenticated.get( + "/org/:orgId/logs/action/export", + logs.exportAccessAuditLogs ) \ No newline at end of file diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx similarity index 84% rename from src/app/[orgId]/settings/logs/access/page.tsx rename to src/app/[orgId]/settings/logs/action/page.tsx index 58479166..2fe8acd7 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -21,6 +21,7 @@ export default function GeneralPage() { const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); // Set default date range to last 24 hours const getDefaultDateRange = () => { @@ -127,6 +128,40 @@ export default function GeneralPage() { } }; + const exportData = async () => { + try { + setIsExporting(true); + const response = await api.get(`/org/${orgId}/logs/action/export`, { + responseType: "blob", + params: { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + }, + }); + + // Create a URL for the blob and trigger a download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + const epoch = Math.floor(Date.now() / 1000); + link.setAttribute("download", `access_audit_logs_${orgId}_${epoch}.csv`); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + setIsExporting(false); + } catch (error) { + toast({ + title: t("error"), + description: t("exportError"), + variant: "destructive" + }); + } + }; + const columns: ColumnDef[] = [ { accessorKey: "timestamp", @@ -212,6 +247,8 @@ export default function GeneralPage() { searchColumn="action" onRefresh={refreshData} isRefreshing={isRefreshing} + onExport={exportData} + isExporting={isExporting} onDateRangeChange={handleDateRangeChange} dateRange={{ start: dateRange.startDate, diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 80ef2e2e..4b8cfe58 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -141,7 +141,7 @@ export const orgNavSections = ( : []), { title: "sidebarLogs", - href: "/{orgId}/settings/logs/access", + href: "/{orgId}/settings/logs/action", icon: }, { diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx index da6cb009..f4efce70 100644 --- a/src/components/DateTimePicker.tsx +++ b/src/components/DateTimePicker.tsx @@ -50,7 +50,6 @@ export function DateTimePicker({ const handleDateChange = (date: Date | undefined) => { setInternalDate(date); const newValue = { date, time: internalTime }; - setOpen(false); onChange?.(newValue); }; diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 9fc3789b..5b9e0514 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -23,7 +23,7 @@ import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Plus, Search, RefreshCw, Filter, X } from "lucide-react"; +import { Plus, Search, RefreshCw, Filter, X, Download } from "lucide-react"; import { Card, CardContent, @@ -82,6 +82,8 @@ type DataTableProps = { title?: string; addButtonText?: string; onRefresh?: () => void; + onExport?: () => void; + isExporting?: boolean; isRefreshing?: boolean; searchPlaceholder?: string; searchColumn?: string; @@ -109,6 +111,8 @@ export function LogDataTable({ title, onRefresh, isRefreshing, + onExport, + isExporting, searchPlaceholder = "Search...", searchColumn = "name", defaultSort, @@ -142,7 +146,6 @@ export function LogDataTable({ defaultTab || tabs?.[0]?.id || "" ); - const [showDatePicker, setShowDatePicker] = useState(false); const [startDate, setStartDate] = useState( dateRange?.start || {} ); @@ -233,22 +236,11 @@ export function LogDataTable({ onDateRangeChange?.(start, end); }; - const clearDateFilter = () => { - const emptyStart = {}; - const emptyEnd = {}; - setStartDate(emptyStart); - setEndDate(emptyEnd); - onDateRangeChange?.(emptyStart, emptyEnd); - setShowDatePicker(false); - }; - - const hasDateFilter = startDate?.date || endDate?.date; - return (
-
+
({ String(e.target.value) ) } - className="w-full pl-8" + className="w-full pl-8 m-0" />
- {tabs && tabs.length > 0 && ( - - - {tabs.map((tab) => ( - - {tab.label} ( - {data.filter(tab.filterFn).length}) - - ))} - - - )} - ({ className="flex-wrap gap-2" />
-
+
{onRefresh && ( )} + {onExport && ( + + )}
From 1142d6ac48ce8268e933ee4cb3589073cab79b00 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Oct 2025 20:15:43 -0700 Subject: [PATCH 09/29] Date picker working --- messages/en-US.json | 3 +- package-lock.json | 77 ++++++- package.json | 2 + .../routers/auditLogs/queryActionAuditLog.ts | 1 + src/app/[orgId]/settings/logs/action/page.tsx | 150 +++++++----- src/app/[orgId]/settings/logs/layout.tsx | 4 +- src/components/DataTablePagination.tsx | 82 ++++++- src/components/DateTimePicker.tsx | 73 +++--- src/components/LogDataTable.tsx | 59 ++++- src/components/ui/calendar.tsx | 213 ++++++++++++++++++ 10 files changed, 555 insertions(+), 109 deletions(-) create mode 100644 src/components/ui/calendar.tsx diff --git a/messages/en-US.json b/messages/en-US.json index e6790e0d..521c4a39 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1901,5 +1901,6 @@ "actor": "Actor", "timestamp": "Timestamp", "accessLogs": "Access Logs", - "exportCsv": "Export CSV" + "exportCsv": "Export CSV", + "actorId": "Actor ID" } diff --git a/package-lock.json b/package-lock.json index f03f5a87..3457a122 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "cookies": "^0.9.1", "cors": "2.8.5", "crypto-js": "^4.2.0", + "date-fns": "4.1.0", "drizzle-orm": "0.44.6", "eslint": "9.37.0", "eslint-config-next": "15.5.6", @@ -81,6 +82,7 @@ "posthog-node": "^5.9.5", "qrcode.react": "4.2.0", "react": "19.2.0", + "react-day-picker": "9.11.1", "react-dom": "19.2.0", "react-easy-sort": "^1.8.0", "react-hook-form": "7.65.0", @@ -1628,6 +1630,7 @@ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -1979,6 +1982,12 @@ "kuler": "^2.0.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.51.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz", @@ -4015,6 +4024,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -6844,6 +6854,7 @@ "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7049,6 +7060,7 @@ "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7059,6 +7071,7 @@ "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -8490,6 +8503,7 @@ "integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -8576,6 +8590,7 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -8669,6 +8684,7 @@ "integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.14.0" } @@ -8697,6 +8713,7 @@ "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -8730,6 +8747,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -8740,6 +8758,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -8883,6 +8902,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -9556,6 +9576,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10086,6 +10107,7 @@ "integrity": "sha512-mXpa5jnIKKHeoGzBrUJrc65cXFKcILGZpU3FXR0pradUEm9MA7UZz02qfEejaMcm9iXrSOCenwwYMJ/tZ1y5Ig==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -10199,6 +10221,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -10936,6 +10959,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debounce": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", @@ -11839,6 +11878,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11935,6 +11975,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12102,6 +12143,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12391,6 +12433,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -17514,6 +17557,7 @@ "version": "4.0.3", "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18492,6 +18536,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -18668,6 +18713,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19119,15 +19165,38 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz", + "integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19419,6 +19488,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", "integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -19903,6 +19973,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21068,7 +21139,8 @@ "version": "4.1.14", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -21625,6 +21697,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22130,6 +22203,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -22437,6 +22511,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 44985a2f..ce222a85 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "cookies": "^0.9.1", "cors": "2.8.5", "crypto-js": "^4.2.0", + "date-fns": "4.1.0", "drizzle-orm": "0.44.6", "eslint": "9.37.0", "eslint-config-next": "15.5.6", @@ -104,6 +105,7 @@ "posthog-node": "^5.9.5", "qrcode.react": "4.2.0", "react": "19.2.0", + "react-day-picker": "9.11.1", "react-dom": "19.2.0", "react-easy-sort": "^1.8.0", "react-hook-form": "7.65.0", diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 6139ce4e..379266ae 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -65,6 +65,7 @@ export function querySites(timeStart: number, timeEnd: number, orgId: string) { orgId: actionAuditLog.orgId, action: actionAuditLog.action, actorType: actionAuditLog.actorType, + actorId: actionAuditLog.actorId, timestamp: actionAuditLog.timestamp, actor: actionAuditLog.actor }) diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 2fe8acd7..a681ec64 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -23,22 +23,31 @@ export default function GeneralPage() { const [isRefreshing, setIsRefreshing] = useState(false); const [isExporting, setIsExporting] = useState(false); + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [pageSize, setPageSize] = useState(20); + const [isLoading, setIsLoading] = useState(false); + // Set default date range to last 24 hours const getDefaultDateRange = () => { const now = new Date(); const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); - + return { startDate: { - date: yesterday, + date: yesterday }, endDate: { - date: now, + date: now } }; }; - const [dateRange, setDateRange] = useState<{ startDate: DateTimeValue; endDate: DateTimeValue }>(getDefaultDateRange()); + const [dateRange, setDateRange] = useState<{ + startDate: DateTimeValue; + endDate: DateTimeValue; + }>(getDefaultDateRange()); // Trigger search with default values on component mount useEffect(() => { @@ -51,21 +60,42 @@ export default function GeneralPage() { endDate: DateTimeValue ) => { setDateRange({ startDate, endDate }); - queryDateTime(startDate, endDate); + setCurrentPage(0); // Reset to first page when filtering + queryDateTime(startDate, endDate, 0, pageSize); + }; + + // Handle page changes + const handlePageChange = (newPage: number) => { + setCurrentPage(newPage); + queryDateTime( + dateRange.startDate, + dateRange.endDate, + newPage, + pageSize + ); + }; + + // Handle page size changes + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); }; const queryDateTime = async ( startDate: DateTimeValue, - endDate: DateTimeValue + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize ) => { - console.log("Date range changed:", { startDate, endDate }); - setIsRefreshing(true); + console.log("Date range changed:", { startDate, endDate, page, size }); + setIsLoading(true); try { // Convert the date/time values to API parameters let params: any = { - limit: 20, - offset: 0 + limit: size, + offset: page * size }; if (startDate?.date) { @@ -89,14 +119,20 @@ export default function GeneralPage() { } else { // If no time is specified, set to NOW const now = new Date(); - endDateTime.setHours(now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()); + endDateTime.setHours( + now.getHours(), + now.getMinutes(), + now.getSeconds(), + now.getMilliseconds() + ); } params.timeEnd = endDateTime.toISOString(); } const res = await api.get(`/org/${orgId}/logs/action`, { params }); if (res.status === 200) { - setRows(res.data.data.log); + setRows(res.data.data.log || []); + setTotalCount(res.data.data.pagination?.total || 0); console.log("Fetched logs:", res.data); } } catch (error) { @@ -106,17 +142,21 @@ export default function GeneralPage() { variant: "destructive" }); } finally { - setIsRefreshing(false); + setIsLoading(false); } }; - const refreshData = async () => { console.log("Data refreshed"); setIsRefreshing(true); try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); + // Refresh data with current date range and pagination + await queryDateTime( + dateRange.startDate, + dateRange.endDate, + currentPage, + pageSize + ); } catch (error) { toast({ title: t("error"), @@ -139,8 +179,8 @@ export default function GeneralPage() { : undefined, timeEnd: dateRange.endDate?.date ? new Date(dateRange.endDate.date).toISOString() - : undefined, - }, + : undefined + } }); // Create a URL for the blob and trigger a download @@ -148,7 +188,10 @@ export default function GeneralPage() { const link = document.createElement("a"); link.href = url; const epoch = Math.floor(Date.now() / 1000); - link.setAttribute("download", `access_audit_logs_${orgId}_${epoch}.csv`); + link.setAttribute( + "download", + `access_audit_logs_${orgId}_${epoch}.csv` + ); document.body.appendChild(link); link.click(); link.parentNode?.removeChild(link); @@ -166,21 +209,11 @@ export default function GeneralPage() { { accessorKey: "timestamp", header: ({ column }) => { - return ( - - ); + return t("timestamp"); }, cell: ({ row }) => { return ( -
+
{new Date( row.original.timestamp * 1000 ).toLocaleString()} @@ -191,48 +224,48 @@ export default function GeneralPage() { { accessorKey: "action", header: ({ column }) => { - return ( - - ); + return t("action"); }, // make the value capitalized cell: ({ row }) => { return ( - {row.original.action.charAt(0).toUpperCase() + row.original.action.slice(1)} + {row.original.action.charAt(0).toUpperCase() + + row.original.action.slice(1)} ); - }, + } }, { accessorKey: "actor", header: ({ column }) => { - return ( - - ); + return t("actor"); }, - cell: ({ row }) => { + cell: ({ row }) => { return ( - {row.original.actorType == "user" ? : } + {row.original.actorType == "user" ? ( + + ) : ( + + )} {row.original.actor} ); } + }, + { + accessorKey: "actorId", + header: ({ column }) => { + return t("actorId"); + }, + cell: ({ row }) => { + return ( + + {row.original.actorId} + + ); + } } ]; @@ -258,6 +291,13 @@ export default function GeneralPage() { id: "timestamp", desc: false }} + // Server-side pagination props + totalCount={totalCount} + currentPage={currentPage} + onPageChange={handlePageChange} + onPageSizeChange={handlePageSizeChange} + isLoading={isLoading} + defaultPageSize={pageSize} /> ); diff --git a/src/app/[orgId]/settings/logs/layout.tsx b/src/app/[orgId]/settings/logs/layout.tsx index 01e0d248..a5cee4a2 100644 --- a/src/app/[orgId]/settings/logs/layout.tsx +++ b/src/app/[orgId]/settings/logs/layout.tsx @@ -34,8 +34,8 @@ export default async function GeneralSettingsPage({ const navItems = [ { - title: t("access"), - href: `/{orgId}/settings/logs/access` + title: t("action"), + href: `/{orgId}/settings/logs/action` }, { title: t("request"), diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index af0a0fe6..a07354f7 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -19,11 +19,19 @@ import { useTranslations } from "next-intl"; interface DataTablePaginationProps { table: Table; onPageSizeChange?: (pageSize: number) => void; + onPageChange?: (pageIndex: number) => void; + totalCount?: number; + isServerPagination?: boolean; + isLoading?: boolean; } export function DataTablePagination({ table, - onPageSizeChange + onPageSizeChange, + onPageChange, + totalCount, + isServerPagination = false, + isLoading = false }: DataTablePaginationProps) { const t = useTranslations(); @@ -37,6 +45,51 @@ export function DataTablePagination({ } }; + const handlePageNavigation = (action: 'first' | 'previous' | 'next' | 'last') => { + if (isServerPagination && onPageChange) { + const currentPage = table.getState().pagination.pageIndex; + const pageCount = table.getPageCount(); + + let newPage: number; + switch (action) { + case 'first': + newPage = 0; + break; + case 'previous': + newPage = Math.max(0, currentPage - 1); + break; + case 'next': + newPage = Math.min(pageCount - 1, currentPage + 1); + break; + case 'last': + newPage = pageCount - 1; + break; + default: + return; + } + + if (newPage !== currentPage) { + onPageChange(newPage); + } + } else { + // Use table's built-in navigation for client-side pagination + switch (action) { + case 'first': + table.setPageIndex(0); + break; + case 'previous': + table.previousPage(); + break; + case 'next': + table.nextPage(); + break; + case 'last': + table.setPageIndex(table.getPageCount() - 1); + break; + } + } + }; + return (
@@ -61,14 +114,21 @@ export function DataTablePagination({
- {t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()})} + {isServerPagination && totalCount !== undefined ? ( + t('paginator', { + current: table.getState().pagination.pageIndex + 1, + last: Math.ceil(totalCount / table.getState().pagination.pageSize) + }) + ) : ( + t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()}) + )}
-
-
-
- - { - let dateValue = undefined; - if (e.target.value) { - // Create date in local timezone to avoid offset issues - const parts = e.target.value.split('-'); - dateValue = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); - } - handleDateChange(dateValue); - }} - className="mt-1" - /> -
- {showTime && ( -
+ {showTime ? ( +
+ { + handleDateChange(date); + if (!showTime) { + setOpen(false); + } + }} + className="flex-grow w-[250px]" + /> +
+
@@ -132,12 +129,22 @@ export function DateTimePicker({ step="1" value={internalTime} onChange={handleTimeChange} - className="mt-1 bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" />
- )} +
-
+ ) : ( + { + handleDateChange(date); + setOpen(false); + }} + /> + )}
diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 5b9e0514..3de4dc19 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -103,6 +103,12 @@ type DataTableProps = { start: DateTimeValue; end: DateTimeValue; }; + // Server-side pagination props + totalCount?: number; + currentPage?: number; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + isLoading?: boolean; }; export function LogDataTable({ @@ -121,7 +127,12 @@ export function LogDataTable({ persistPageSize = false, defaultPageSize = 20, onDateRangeChange, - dateRange + dateRange, + totalCount, + currentPage = 0, + onPageChange, + onPageSizeChange: onPageSizeChangeProp, + isLoading = false }: DataTableProps) { const t = useTranslations(); @@ -175,26 +186,39 @@ export function LogDataTable({ return data.filter(activeTabFilter.filterFn); }, [data, tabs, activeTab]); + // Determine if using server-side pagination + const isServerPagination = totalCount !== undefined; + const table = useReactTable({ data: filteredData, columns, getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), + // Only use client-side pagination if totalCount is not provided + ...(isServerPagination ? {} : { getPaginationRowModel: getPaginationRowModel() }), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setGlobalFilter, + // Configure pagination state + ...(isServerPagination ? { + manualPagination: true, + pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0, + } : {}), initialState: { pagination: { pageSize: pageSize, - pageIndex: 0 + pageIndex: currentPage } }, state: { sorting, columnFilters, - globalFilter + globalFilter, + pagination: { + pageSize: pageSize, + pageIndex: currentPage + } } }); @@ -210,6 +234,16 @@ export function LogDataTable({ } }, [pageSize, table, persistPageSize, tableId]); + // Update table page index when currentPage prop changes (server pagination) + useEffect(() => { + if (isServerPagination) { + const currentPageIndex = table.getState().pagination.pageIndex; + if (currentPageIndex !== currentPage) { + table.setPageIndex(currentPage); + } + } + }, [currentPage, table, isServerPagination]); + const handleTabChange = (value: string) => { setActiveTab(value); // Reset to first page when changing tabs @@ -225,6 +259,18 @@ export function LogDataTable({ if (persistPageSize) { setStoredPageSize(newPageSize, tableId); } + + // For server pagination, notify parent component + if (isServerPagination && onPageSizeChangeProp) { + onPageSizeChangeProp(newPageSize); + } + }; + + // Handle page changes for server pagination + const handlePageChange = (newPageIndex: number) => { + if (isServerPagination && onPageChange) { + onPageChange(newPageIndex); + } }; const handleDateRangeChange = ( @@ -276,7 +322,6 @@ export function LogDataTable({ )} {onExport && (
diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 00000000..a48a0f7c --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,213 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker" + +import { Button, buttonVariants } from "@/components/ui/button" +import { cn } from "@app/lib/cn" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "bg-popover absolute inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-[--cell-size] select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-muted-foreground select-none text-[0.8rem]", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", + defaultClassNames.day + ), + range_start: cn( + "bg-accent rounded-l-md", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + + ); + } + }, { accessorKey: "host", header: ({ column }) => { From 0211f75cb65ea895283c0327cb75a00bee63fc8f Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Oct 2025 17:42:27 -0700 Subject: [PATCH 17/29] Access logs working --- messages/en-US.json | 2 +- server/db/pg/schema/privateSchema.ts | 17 +- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/privateSchema.ts | 20 +- server/db/sqlite/schema/schema.ts | 2 +- server/private/lib/logAccessAudit.ts | 102 +++++ .../routers/auditLogs/exportAccessAuditLog.ts | 81 ++++ .../routers/auditLogs/exportActionAuditLog.ts | 11 +- server/private/routers/auditLogs/index.ts | 4 +- .../routers/auditLogs/queryAccessAuditLog.ts | 173 ++++++++ .../routers/auditLogs/queryActionAuditLog.ts | 8 +- server/private/routers/external.ts | 11 +- .../routers/auditLogs/exportRequstAuditLog.ts | 4 +- .../routers/auditLogs/queryRequstAuditLog.ts | 8 +- server/routers/auditLogs/types.ts | 26 +- server/routers/auth/login.ts | 66 ++- .../routers/resource/authWithAccessToken.ts | 24 +- server/routers/resource/authWithPassword.ts | 20 + server/routers/resource/authWithPincode.ts | 20 + server/routers/resource/authWithWhitelist.ts | 36 +- server/routers/resource/getExchangeToken.ts | 24 +- src/actions/server.ts | 1 + src/app/[orgId]/settings/logs/access/page.tsx | 375 ++++++++++++++++++ src/app/[orgId]/settings/logs/action/page.tsx | 2 +- src/app/[orgId]/settings/logs/layout.tsx | 11 +- .../[orgId]/settings/logs/request/page.tsx | 21 +- src/components/LogDataTable.tsx | 4 +- src/components/LoginForm.tsx | 7 +- 28 files changed, 1003 insertions(+), 79 deletions(-) create mode 100644 server/private/lib/logAccessAudit.ts create mode 100644 server/private/routers/auditLogs/exportAccessAuditLog.ts create mode 100644 server/private/routers/auditLogs/queryAccessAuditLog.ts create mode 100644 src/app/[orgId]/settings/logs/access/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index d1041ea1..cb470439 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1915,7 +1915,7 @@ "droppedByRule": "Dropped by Rule", "noSessions": "No Sessions", "temporaryRequestToken": "Temporary Request Token", - "noMoreAuthMethods": "No More Auth Methods", + "noMoreAuthMethods": "No Valid Auth", "ip": "IP Address", "reason": "Reason", "requestLogs": "Request Logs", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index d2bb8841..266a8646 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -230,23 +230,22 @@ export const actionAuditLog = pgTable("actionAuditLog", { index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); -export const identityAuditLog = pgTable("identityAuditLog", { +export const accessAuditLog = pgTable("accessAuditLog", { id: serial("id").primaryKey(), timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - actorType: varchar("actorType", { length: 50 }).notNull(), - actor: varchar("actor", { length: 255 }).notNull(), - actorId: varchar("actorId", { length: 255 }).notNull(), + actorType: varchar("actorType", { length: 50 }), + actor: varchar("actor", { length: 255 }), + actorId: varchar("actorId", { length: 255 }), resourceId: integer("resourceId"), - ip: varchar("ip", { length: 45 }).notNull(), + ip: varchar("ip", { length: 45 }), type: varchar("type", { length: 100 }).notNull(), - action: varchar("action", { length: 100 }).notNull(), + action: boolean("action").notNull(), location: text("location"), - path: text("path"), userAgent: text("userAgent"), - metadata: text("details") + metadata: text("metadata") }, (table) => ([ index("idx_identityAuditLog_timestamp").on(table.timestamp), index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) @@ -270,4 +269,4 @@ export type RemoteExitNodeSession = InferSelectModel< export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; export type ActionAuditLog = InferSelectModel; -export type IdentityAuditLog = InferSelectModel; \ No newline at end of file +export type AccessAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f204d5bc..f5322035 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -688,7 +688,7 @@ export const requestAuditLog = pgTable( ip: text("ip"), location: text("location"), userAgent: text("userAgent"), - metadata: text("details"), + metadata: text("metadata"), headers: text("headers"), // JSON blob query: text("query"), // JSON blob originalRequestURL: text("originalRequestURL"), diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 81c76799..89d11310 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -225,23 +225,22 @@ export const actionAuditLog = sqliteTable("actionAuditLog", { index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); -export const identityAuditLog = sqliteTable("identityAuditLog", { +export const accessAuditLog = sqliteTable("accessAuditLog", { id: integer("id").primaryKey({ autoIncrement: true }), timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - actorType: text("actorType").notNull(), - actor: text("actor").notNull(), - actorId: text("actorId").notNull(), + actorType: text("actorType"), + actor: text("actor"), + actorId: text("actorId"), resourceId: integer("resourceId"), - ip: text("ip").notNull(), - type: text("type").notNull(), - action: text("action").notNull(), + ip: text("ip"), location: text("location"), - path: text("path"), + type: text("type").notNull(), + action: integer("action", { mode: "boolean" }).notNull(), userAgent: text("userAgent"), - metadata: text("details") + metadata: text("metadata") }, (table) => ([ index("idx_identityAuditLog_timestamp").on(table.timestamp), index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) @@ -264,4 +263,5 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; -export type ActionAuditLog = InferSelectModel; \ No newline at end of file +export type ActionAuditLog = InferSelectModel; +export type AccessAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 28697318..6980dab3 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -733,7 +733,7 @@ export const requestAuditLog = sqliteTable( ip: text("ip"), location: text("location"), userAgent: text("userAgent"), - metadata: text("details"), + metadata: text("metadata"), headers: text("headers"), // JSON blob query: text("query"), // JSON blob originalRequestURL: text("originalRequestURL"), diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts new file mode 100644 index 00000000..9b3ae4d5 --- /dev/null +++ b/server/private/lib/logAccessAudit.ts @@ -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 { + 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; +} diff --git a/server/private/routers/auditLogs/exportAccessAuditLog.ts b/server/private/routers/auditLogs/exportAccessAuditLog.ts new file mode 100644 index 00000000..8a6398fa --- /dev/null +++ b/server/private/routers/auditLogs/exportAccessAuditLog.ts @@ -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 { + 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") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts index 4d15dd55..21329879 100644 --- a/server/private/routers/auditLogs/exportActionAuditLog.ts +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -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); diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index 9b4a6e7f..ac623c4c 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -12,4 +12,6 @@ */ export * from "./queryActionAuditLog"; -export * from "./exportActionAuditLog"; \ No newline at end of file +export * from "./exportActionAuditLog"; +export * from "./queryAccessAuditLog"; +export * from "./exportAccessAuditLog"; \ No newline at end of file diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts new file mode 100644 index 00000000..cad2027f --- /dev/null +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index dd82e21f..77f3bbd8 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -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(res, { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index d89b3294..445af82f 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -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 ) \ No newline at end of file diff --git a/server/routers/auditLogs/exportRequstAuditLog.ts b/server/routers/auditLogs/exportRequstAuditLog.ts index d8bf7916..759475c2 100644 --- a/server/routers/auditLogs/exportRequstAuditLog.ts +++ b/server/routers/auditLogs/exportRequstAuditLog.ts @@ -11,7 +11,7 @@ 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 { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, queryRequest } from "./queryRequstAuditLog"; import { generateCSV } from "./generateCSV"; registry.registerPath({ @@ -54,7 +54,7 @@ export async function exportRequestAuditLogs( } const { orgId } = parsedParams.data; - const baseQuery = querySites(timeStart, timeEnd, orgId); + const baseQuery = queryRequest(timeStart, timeEnd, orgId); const log = await baseQuery.limit(limit).offset(offset); diff --git a/server/routers/auditLogs/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequstAuditLog.ts index cadda1d7..b5af0e14 100644 --- a/server/routers/auditLogs/queryRequstAuditLog.ts +++ b/server/routers/auditLogs/queryRequstAuditLog.ts @@ -46,7 +46,7 @@ export const queryRequestAuditLogsParams = z.object({ orgId: z.string() }); -export function querySites(timeStart: number, timeEnd: number, orgId: string) { +export function queryRequest(timeStart: number, timeEnd: number, orgId: string) { return db .select({ timestamp: requestAuditLog.timestamp, @@ -84,7 +84,7 @@ export function querySites(timeStart: number, timeEnd: number, orgId: string) { .orderBy(requestAuditLog.timestamp); } -export function countQuery(timeStart: number, timeEnd: number, orgId: string) { +export function countRequestQuery(timeStart: number, timeEnd: number, orgId: string) { const countQuery = db .select({ count: count() }) .from(requestAuditLog) @@ -138,11 +138,11 @@ export async function queryRequestAuditLogs( } const { orgId } = parsedParams.data; - const baseQuery = querySites(timeStart, timeEnd, orgId); + const baseQuery = queryRequest(timeStart, timeEnd, orgId); const log = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery(timeStart, timeEnd, orgId); + const totalCountResult = await countRequestQuery(timeStart, timeEnd, orgId); const totalCount = totalCountResult[0].count; return response(res, { diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index 38617f44..893db5ef 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -44,4 +44,28 @@ export type QueryRequestAuditLogResponse = { limit: number; offset: number; }; -}; \ No newline at end of file +}; + +export type QueryAccessAuditLogResponse = { + log: { + orgId: string; + action: string; + type: string; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + ip: string | null; + location: string | null; + userAgent: string | null; + metadata: string | null; + actorType: string; + actorId: string; + timestamp: number; + actor: string; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 8dad5a42..a3fe1b54 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -3,7 +3,7 @@ import { generateSessionToken, serializeSessionCookie } from "@server/auth/sessions/app"; -import { db } from "@server/db"; +import { db, resources } from "@server/db"; import { users, securityKeys } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; @@ -18,12 +18,14 @@ import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import { verifySession } from "@server/auth/sessions/verifySession"; import { UserType } from "@server/types/UserTypes"; +import { logAccessAudit } from "@server/private/lib/logAccessAudit"; export const loginBodySchema = z .object({ email: z.string().toLowerCase().email(), password: z.string(), - code: z.string().optional() + code: z.string().optional(), + resourceGuid: z.string().optional() }) .strict(); @@ -52,7 +54,7 @@ export async function login( ); } - const { email, password, code } = parsedBody.data; + const { email, password, code, resourceGuid } = parsedBody.data; try { const { session: existingSession } = await verifySession(req); @@ -66,6 +68,28 @@ export async function login( }); } + let resourceId: number | null = null; + let orgId: string | null = null; + if (resourceGuid) { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceGuid, resourceGuid)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with GUID ${resourceGuid} not found` + ) + ); + } + + resourceId = resource.resourceId; + orgId = resource.orgId; + } + const existingUserRes = await db .select() .from(users) @@ -78,6 +102,18 @@ export async function login( `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` ); } + + if (resourceId && orgId) { + logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: false, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -98,6 +134,18 @@ export async function login( `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` ); } + + if (resourceId && orgId) { + logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: false, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -158,6 +206,18 @@ export async function login( `Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.` ); } + + if (resourceId && orgId) { + logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: false, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index 2d7fdf93..04317a73 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -10,11 +10,10 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; -import { - verifyResourceAccessToken -} from "@server/auth/verifyResourceAccessToken"; +import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import config from "@server/lib/config"; import stoi from "@server/lib/stoi"; +import { logAccessAudit } from "@server/private/lib/logAccessAudit"; const authWithAccessTokenBodySchema = z .object({ @@ -131,6 +130,16 @@ export async function authWithAccessToken( `Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } + + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: false, + type: "accessToken", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -150,6 +159,15 @@ export async function authWithAccessToken( doNotExtend: true }); + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: true, + type: "accessToken", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token, diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index 652c4e86..318c88d8 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -13,6 +13,7 @@ import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import config from "@server/lib/config"; +import { logAccessAudit } from "@server/private/lib/logAccessAudit"; export const authWithPasswordBodySchema = z .object({ @@ -113,6 +114,16 @@ export async function authWithPassword( `Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } + + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: false, + type: "password", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password") ); @@ -129,6 +140,15 @@ export async function authWithPassword( doNotExtend: true }); + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: true, + type: "password", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index d8733c18..2508b84c 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -12,6 +12,7 @@ import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import config from "@server/lib/config"; +import { logAccessAudit } from "@server/private/lib/logAccessAudit"; export const authWithPincodeBodySchema = z .object({ @@ -112,6 +113,16 @@ export async function authWithPincode( `Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } + + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: false, + type: "pincode", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN") ); @@ -128,6 +139,15 @@ export async function authWithPincode( doNotExtend: true }); + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: true, + type: "pincode", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index 07662f7f..37410b7b 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -1,11 +1,6 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { - orgs, - resourceOtp, - resources, - resourceWhitelist -} from "@server/db"; +import { orgs, resourceOtp, resources, resourceWhitelist } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, and } from "drizzle-orm"; @@ -17,13 +12,11 @@ import { createResourceSession } from "@server/auth/sessions/resource"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import logger from "@server/logger"; import config from "@server/lib/config"; +import { logAccessAudit } from "@server/private/lib/logAccessAudit"; const authWithWhitelistBodySchema = z .object({ - email: z - .string() - .toLowerCase() - .email(), + email: z.string().toLowerCase().email(), otp: z.string().optional() }) .strict(); @@ -126,6 +119,19 @@ export async function authWithWhitelist( `Email is not whitelisted. Email: ${email}. IP: ${req.ip}.` ); } + + if (org && resource) { + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: false, + type: "whitelistedEmail", + metadata: { email }, + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -219,6 +225,16 @@ export async function authWithWhitelist( doNotExtend: true }); + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: true, + metadata: { email }, + type: "whitelistedEmail", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index 605e5ca6..aefd1885 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -10,11 +10,10 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { generateSessionToken } from "@server/auth/sessions/app"; import config from "@server/lib/config"; -import { - encodeHexLowerCase -} from "@oslojs/encoding"; +import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { response } from "@server/lib/response"; +import { logAccessAudit } from "@server/private/lib/logAccessAudit"; const getExchangeTokenParams = z .object({ @@ -47,13 +46,13 @@ export async function getExchangeToken( const { resourceId } = parsedParams.data; - const resource = await db + const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); - if (resource.length === 0) { + if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -89,6 +88,21 @@ export async function getExchangeToken( doNotExtend: true }); + if (req.user) { + logAccessAudit({ + orgId: resource.orgId, + resourceId: resourceId, + user: { + username: req.user.username, + userId: req.user.userId + }, + action: true, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + logger.debug("Request token created successfully"); return response(res, { diff --git a/src/actions/server.ts b/src/actions/server.ts index b9dc6e55..5fbe1301 100644 --- a/src/actions/server.ts +++ b/src/actions/server.ts @@ -202,6 +202,7 @@ export type LoginRequest = { email: string; password: string; code?: string; + resourceGuid?: string; }; export type LoginResponse = { diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx new file mode 100644 index 00000000..af0bc8fc --- /dev/null +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -0,0 +1,375 @@ +"use client"; +import { Button } from "@app/components/ui/button"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef, useEffect } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useParams, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { LogDataTable } from "@app/components/LogDataTable"; +import { ColumnDef } from "@tanstack/react-table"; +import { DateTimeValue } from "@app/components/DateTimePicker"; +import { ArrowUpRight, Key, User } from "lucide-react"; +import Link from "next/link"; + +export default function GeneralPage() { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + const { orgId } = useParams(); + + const [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); + + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [pageSize, setPageSize] = useState(20); + const [isLoading, setIsLoading] = useState(false); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + return { + startDate: { + date: yesterday + }, + endDate: { + date: now + } + }; + }; + + const [dateRange, setDateRange] = useState<{ + startDate: DateTimeValue; + endDate: DateTimeValue; + }>(getDefaultDateRange()); + + // Trigger search with default values on component mount + useEffect(() => { + const defaultRange = getDefaultDateRange(); + queryDateTime(defaultRange.startDate, defaultRange.endDate); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + setCurrentPage(0); // Reset to first page when filtering + queryDateTime(startDate, endDate, 0, pageSize); + }; + + // Handle page changes + const handlePageChange = (newPage: number) => { + setCurrentPage(newPage); + queryDateTime( + dateRange.startDate, + dateRange.endDate, + newPage, + pageSize + ); + }; + + // Handle page size changes + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize + ) => { + console.log("Date range changed:", { startDate, endDate, page, size }); + setIsLoading(true); + + try { + // Convert the date/time values to API parameters + let params: any = { + limit: size, + offset: page * size + }; + + if (startDate?.date) { + const startDateTime = new Date(startDate.date); + if (startDate.time) { + const [hours, minutes, seconds] = startDate.time + .split(":") + .map(Number); + startDateTime.setHours(hours, minutes, seconds || 0); + } + params.timeStart = startDateTime.toISOString(); + } + + if (endDate?.date) { + const endDateTime = new Date(endDate.date); + if (endDate.time) { + const [hours, minutes, seconds] = endDate.time + .split(":") + .map(Number); + endDateTime.setHours(hours, minutes, seconds || 0); + } else { + // If no time is specified, set to NOW + const now = new Date(); + endDateTime.setHours( + now.getHours(), + now.getMinutes(), + now.getSeconds(), + now.getMilliseconds() + ); + } + params.timeEnd = endDateTime.toISOString(); + } + + const res = await api.get(`/org/${orgId}/logs/access`, { params }); + if (res.status === 200) { + setRows(res.data.data.log || []); + setTotalCount(res.data.data.pagination?.total || 0); + console.log("Fetched logs:", res.data); + } + } catch (error) { + toast({ + title: t("error"), + description: t("Failed to filter logs"), + variant: "destructive" + }); + } finally { + setIsLoading(false); + } + }; + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + // Refresh data with current date range and pagination + await queryDateTime( + dateRange.startDate, + dateRange.endDate, + currentPage, + pageSize + ); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const exportData = async () => { + try { + setIsExporting(true); + const response = await api.get(`/org/${orgId}/logs/access/export`, { + responseType: "blob", + params: { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined + } + }); + + // Create a URL for the blob and trigger a download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + const epoch = Math.floor(Date.now() / 1000); + link.setAttribute( + "download", + `access-audit-logs-${orgId}-${epoch}.csv` + ); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + setIsExporting(false); + } catch (error) { + toast({ + title: t("error"), + description: t("exportError"), + variant: "destructive" + }); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "timestamp", + header: ({ column }) => { + return t("timestamp"); + }, + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.timestamp * 1000 + ).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "action", + header: ({ column }) => { + return t("action"); + }, + cell: ({ row }) => { + return ( + + {row.original.action ? <>Allowed : <>Denied} + + ); + } + }, + { + accessorKey: "ip", + header: ({ column }) => { + return t("ip"); + } + }, + { + accessorKey: "location", + header: ({ column }) => { + return t("location"); + }, + cell: ({ row }) => { + return ( + + {row.original.location ? ( + + ({row.original.location}) + + ) : ( + + - + + )} + + ); + } + }, + { + accessorKey: "resourceName", + header: t("resource"), + cell: ({ row }) => { + return ( + + + + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return t("type"); + }, + cell: ({ row }) => { + return ( + + {/* {row.original.type == "pincode" ? ( + + ) : ( + + )} */} + {row.original.type.charAt(0).toUpperCase() + + row.original.type.slice(1)} + + ); + } + }, + { + accessorKey: "actor", + header: ({ column }) => { + return t("actor"); + }, + cell: ({ row }) => { + return ( + + {row.original.actor ? ( + <> + {row.original.actorType == "user" ? ( + + ) : ( + + )} + {row.original.actor} + + ) : ( + <>- + )} + + ); + } + }, + { + accessorKey: "actorId", + header: ({ column }) => { + return t("actorId"); + }, + cell: ({ row }) => { + return ( + + {row.original.actorId || "-"} + + ); + } + } + ]; + + return ( + <> + + + ); +} diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 180815d8..3b693515 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -190,7 +190,7 @@ export default function GeneralPage() { const epoch = Math.floor(Date.now() / 1000); link.setAttribute( "download", - `access-audit-logs-${orgId}-${epoch}.csv` + `action-audit-logs-${orgId}-${epoch}.csv` ); document.body.appendChild(link); link.click(); diff --git a/src/app/[orgId]/settings/logs/layout.tsx b/src/app/[orgId]/settings/logs/layout.tsx index f917a5d0..fb1d2071 100644 --- a/src/app/[orgId]/settings/logs/layout.tsx +++ b/src/app/[orgId]/settings/logs/layout.tsx @@ -1,13 +1,6 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { verifySession } from "@app/lib/auth/verifySession"; -import OrgProvider from "@app/providers/OrgProvider"; -import OrgUserProvider from "@app/providers/OrgUserProvider"; -import { GetOrgResponse } from "@server/routers/org"; -import { GetOrgUserResponse } from "@server/routers/user"; -import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { cache } from "react"; import { getTranslations } from "next-intl/server"; @@ -37,6 +30,10 @@ export default async function GeneralSettingsPage({ title: t("request"), href: `/{orgId}/settings/logs/request` }, + { + title: t("access"), + href: `/{orgId}/settings/logs/access` + }, { title: t("action"), href: `/{orgId}/settings/logs/action` diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 3dba67d0..fe4bb9f2 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -266,14 +266,25 @@ export default function GeneralPage() { ); } }, - + { + accessorKey: "action", + header: ({ column }) => { + return t("action"); + }, + cell: ({ row }) => { + return ( + + {row.original.action ? <>Allowed : <>Denied} + + ); + } + }, { accessorKey: "ip", header: ({ column }) => { return t("ip"); } }, - { accessorKey: "location", header: ({ column }) => { @@ -303,7 +314,11 @@ export default function GeneralPage() { - diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 2c1a33b1..b08d9856 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -373,8 +373,8 @@ export function LogDataTable({ typeof actionValue === "boolean" ) { className = actionValue - ? "bg-green-100 dark:bg-green-900" - : "bg-red-100 dark:bg-red-900"; + ? "bg-green-100 dark:bg-green-900/50" + : "bg-red-100 dark:bg-red-900/50"; } return ( diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index c55ad871..8149989e 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -22,7 +22,7 @@ import { CardTitle } from "@app/components/ui/card"; import { Alert, AlertDescription } from "@app/components/ui/alert"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { LockIcon, FingerprintIcon } from "lucide-react"; import { createApiClient } from "@app/lib/api"; import { @@ -74,6 +74,8 @@ export default function LoginForm({ const { env } = useEnvContext(); const api = createApiClient({ env }); + const { resourceGuid } = useParams(); + const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const hasIdp = idps && idps.length > 0; @@ -235,7 +237,8 @@ export default function LoginForm({ const response = await loginProxy({ email, password, - code + code, + resourceGuid: resourceGuid as string }); try { From eae2c37388fa5e3391e4686552baf277e6748c02 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Oct 2025 18:21:54 -0700 Subject: [PATCH 18/29] Add expandable columns --- .../routers/auditLogs/queryActionAuditLog.ts | 2 + .../routers/auditLogs/exportRequstAuditLog.ts | 10 +- .../routers/auditLogs/queryRequstAuditLog.ts | 106 +++++---- server/routers/auditLogs/types.ts | 11 +- src/app/[orgId]/settings/logs/access/page.tsx | 18 ++ src/app/[orgId]/settings/logs/action/page.tsx | 18 ++ .../[orgId]/settings/logs/request/page.tsx | 53 +++++ src/components/LogDataTable.tsx | 204 +++++++++++++----- 8 files changed, 316 insertions(+), 106 deletions(-) diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 77f3bbd8..9e357e8d 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -24,6 +24,7 @@ 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 { metadata } from "@app/app/[orgId]/settings/layout"; export const queryActionAuditLogsQuery = z.object({ // iso string just validate its a parseable date @@ -65,6 +66,7 @@ export function queryAction(timeStart: number, timeEnd: number, orgId: string) { orgId: actionAuditLog.orgId, action: actionAuditLog.action, actorType: actionAuditLog.actorType, + metadata: actionAuditLog.metadata, actorId: actionAuditLog.actorId, timestamp: actionAuditLog.timestamp, actor: actionAuditLog.actor diff --git a/server/routers/auditLogs/exportRequstAuditLog.ts b/server/routers/auditLogs/exportRequstAuditLog.ts index 759475c2..c1fb2872 100644 --- a/server/routers/auditLogs/exportRequstAuditLog.ts +++ b/server/routers/auditLogs/exportRequstAuditLog.ts @@ -41,7 +41,6 @@ export async function exportRequestAuditLogs( ) ); } - const { timeStart, timeEnd, limit, offset } = parsedQuery.data; const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { @@ -52,16 +51,17 @@ export async function exportRequestAuditLogs( ) ); } - const { orgId } = parsedParams.data; - const baseQuery = queryRequest(timeStart, timeEnd, orgId); + const data = { ...parsedQuery.data, ...parsedParams.data }; - const log = await baseQuery.limit(limit).offset(offset); + const baseQuery = queryRequest(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); const csvData = generateCSV(log); res.setHeader('Content-Type', 'text/csv'); - res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${orgId}-${Date.now()}.csv"`); + res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`); return res.send(csvData); } catch (error) { diff --git a/server/routers/auditLogs/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequstAuditLog.ts index b5af0e14..f4e24649 100644 --- a/server/routers/auditLogs/queryRequstAuditLog.ts +++ b/server/routers/auditLogs/queryRequstAuditLog.ts @@ -28,6 +28,11 @@ export const queryAccessAuditLogsQuery = z.object({ .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() .default(new Date().toISOString()), + action: z.boolean().optional(), + method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(), + reason: z.number().optional(), + resourceId: z.number().optional(), + actor: z.string().optional(), limit: z .string() .optional() @@ -46,55 +51,64 @@ export const queryRequestAuditLogsParams = z.object({ orgId: z.string() }); -export function queryRequest(timeStart: number, timeEnd: number, orgId: string) { +export const queryRequestAuditLogsCombined = + queryAccessAuditLogsQuery.merge(queryRequestAuditLogsParams); +type Q = z.infer; + +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 + ); +} + +export function queryRequest(data: Q) { 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, + 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( - and( - gt(requestAuditLog.timestamp, timeStart), - lt(requestAuditLog.timestamp, timeEnd), - eq(requestAuditLog.orgId, orgId) - ) - ) + .leftJoin( + resources, + eq(requestAuditLog.resourceId, resources.resourceId) + ) // TODO: Is this efficient? + .where(getWhere(data)) .orderBy(requestAuditLog.timestamp); } -export function countRequestQuery(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) - ) - ); +export function countRequestQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(requestAuditLog) + .where(getWhere(data)); return countQuery; } @@ -125,7 +139,6 @@ export async function queryRequestAuditLogs( ) ); } - const { timeStart, timeEnd, limit, offset } = parsedQuery.data; const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { @@ -136,13 +149,14 @@ export async function queryRequestAuditLogs( ) ); } - const { orgId } = parsedParams.data; - const baseQuery = queryRequest(timeStart, timeEnd, orgId); + const data = { ...parsedQuery.data, ...parsedParams.data }; - const log = await baseQuery.limit(limit).offset(offset); + const baseQuery = queryRequest(data); - const totalCountResult = await countRequestQuery(timeStart, timeEnd, orgId); + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countRequestQuery(data); const totalCount = totalCountResult[0].count; return response(res, { @@ -150,8 +164,8 @@ export async function queryRequestAuditLogs( log: log, pagination: { total: totalCount, - limit, - offset + limit: data.limit, + offset: data.offset } }, success: true, diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index 893db5ef..b35c2d9e 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -4,6 +4,7 @@ export type QueryActionAuditLogResponse = { action: string; actorType: string; actorId: string; + metadata: string | null; timestamp: number; actor: string; }[]; @@ -49,8 +50,9 @@ export type QueryRequestAuditLogResponse = { export type QueryAccessAuditLogResponse = { log: { orgId: string; - action: string; - type: string; + action: boolean; + actorType: string | null; + actorId: string | null; resourceId: number | null; resourceName: string | null; resourceNiceId: string | null; @@ -58,10 +60,9 @@ export type QueryAccessAuditLogResponse = { location: string | null; userAgent: string | null; metadata: string | null; - actorType: string; - actorId: string; + type: string; timestamp: number; - actor: string; + actor: string | null; }[]; pagination: { total: number; diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index af0bc8fc..e2b68ae0 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -340,6 +340,21 @@ export default function GeneralPage() { } ]; + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ Metadata: +
+                            {row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
+                        
+
+
+
+ ); + }; + return ( <> ); diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 3b693515..a332f0cb 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -269,6 +269,21 @@ export default function GeneralPage() { } ]; + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ Metadata: +
+                            {row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
+                        
+
+
+
+ ); + }; + return ( <> ); diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index fe4bb9f2..9c7f6bd4 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -313,6 +313,7 @@ export default function GeneralPage() { return ( e.stopPropagation()} > + ); + }, + size: 40 + }; + + return [expansionColumn, ...columns]; + }, [columns, expandable, expandedRows, toggleRowExpansion]); + const table = useReactTable({ data: filteredData, - columns, + columns: enhancedColumns, getCoreRowModel: getCoreRowModel(), // Only use client-side pagination if totalCount is not provided - ...(isServerPagination ? {} : { getPaginationRowModel: getPaginationRowModel() }), + ...(isServerPagination + ? {} + : { getPaginationRowModel: getPaginationRowModel() }), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setGlobalFilter, // Configure pagination state - ...(isServerPagination ? { - manualPagination: true, - pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0, - } : {}), + ...(isServerPagination + ? { + manualPagination: true, + pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0 + } + : {}), initialState: { pagination: { pageSize: pageSize, @@ -321,10 +388,7 @@ export function LogDataTable({ )} {onExport && ( - + + + + + + {emptyMessage} + + {/* Clear filter option */} + {selectedValue && ( + { + onValueChange(undefined); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear filter + + )} + {options.map((option) => ( + { + onValueChange( + selectedValue === option.value ? undefined : option.value + ); + setOpen(false); + }} + > + + {option.label} + + ))} + + + + + + ); +} \ No newline at end of file diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx index d079ff26..d0b6d40e 100644 --- a/src/components/DateTimePicker.tsx +++ b/src/components/DateTimePicker.tsx @@ -80,9 +80,9 @@ const getDisplayText = () => { return (
-
+
{label && ( -
} @@ -179,23 +217,24 @@ export default function GeneralPage() { string={org?.org.name || ""} title={t("orgDelete")} /> - - - - {t("orgGeneralSettings")} - - - {t("orgGeneralSettingsDescription")} - - - - -
- + + + + + + + {t("orgGeneralSettings")} + + + {t("orgGeneralSettingsDescription")} + + + + )} - - - - -
+
+
+
- {(build === "saas") && ( - - )} + + + + {t("logRetention")} + + + {t("logRetentionDescription")} + + + + {/* {build === "saas" && !subscription?.subscribed ? ( + + + {t("orgAuthPageDisabled")}{" "} + {t("subscriptionRequiredToUse")} + + + ) : null} */} + + + ( + + + {t("logRetentionRequestLabel")} + + + + + + + + {LOG_RETENTION_OPTIONS.map( + (option) => ( + + field.onChange( + option.value + ) + } + > + {t( + option.label + )} + + ) + )} + + + + + {t( + "logRetentionRequestDescription" + )} + + + + )} + /> + + {build != "oss" && ( + <> + ( + + + {t( + "logRetentionAccessLabel" + )} + + + + + + + + {LOG_RETENTION_OPTIONS.map( + ( + option + ) => ( + + field.onChange( + option.value + ) + } + > + {t( + option.label + )} + + ) + )} + + + + + {t( + "logRetentionAccessDescription" + )} + + + + )} + /> + ( + + + {t( + "logRetentionActionLabel" + )} + + + + + + + + {LOG_RETENTION_OPTIONS.map( + ( + option + ) => ( + + field.onChange( + option.value + ) + } + > + {t( + option.label + )} + + ) + )} + + + + + {t( + "logRetentionActionDescription" + )} + + + + )} + /> + + )} + + + + + + + {build === "saas" && } {/* Save Button */}
diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index 07e33824..4244222d 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -6,12 +6,21 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { getStoredPageSize, LogDataTable, setStoredPageSize } from "@app/components/LogDataTable"; +import { + getStoredPageSize, + LogDataTable, + setStoredPageSize +} from "@app/components/LogDataTable"; import { ColumnDef } from "@tanstack/react-table"; import { DateTimeValue } from "@app/components/DateTimePicker"; import { ArrowUpRight, Key, User } from "lucide-react"; import Link from "next/link"; import { ColumnFilter } from "@app/components/ColumnFilter"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { build } from "@server/build"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; export default function GeneralPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -21,6 +30,8 @@ export default function GeneralPage() { const t = useTranslations(); const { env } = useEnvContext(); const { orgId } = useParams(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); @@ -202,6 +213,16 @@ export default function GeneralPage() { } ) => { console.log("Date range changed:", { startDate, endDate, page, size }); + if ( + (build == "saas" && !subscription?.subscribed) || + (build == "enterprise" && !isUnlocked()) + ) { + console.log( + "Access denied: subscription inactive or license locked" + ); + return; + } + setIsLoading(true); try { @@ -583,6 +604,27 @@ export default function GeneralPage() { return ( <> + + + {build == "saas" && !subscription?.subscribed ? ( + + + {t("subscriptionRequiredToUse")} + + + ) : null} + + {build == "enterprise" && !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + ); diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 14281646..d71cc3ff 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -6,11 +6,20 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { getStoredPageSize, LogDataTable, setStoredPageSize } from "@app/components/LogDataTable"; +import { + getStoredPageSize, + LogDataTable, + setStoredPageSize +} from "@app/components/LogDataTable"; import { ColumnDef } from "@tanstack/react-table"; import { DateTimeValue } from "@app/components/DateTimePicker"; import { Key, User } from "lucide-react"; import { ColumnFilter } from "@app/components/ColumnFilter"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { build } from "@server/build"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; export default function GeneralPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -20,6 +29,8 @@ export default function GeneralPage() { const { env } = useEnvContext(); const { orgId } = useParams(); const searchParams = useSearchParams(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); @@ -187,6 +198,15 @@ export default function GeneralPage() { } ) => { console.log("Date range changed:", { startDate, endDate, page, size }); + if ( + (build == "saas" && !subscription?.subscribed) || + (build == "enterprise" && !isUnlocked()) + ) { + console.log( + "Access denied: subscription inactive or license locked" + ); + return; + } setIsLoading(true); try { @@ -435,6 +455,27 @@ export default function GeneralPage() { return ( <> + + + {build == "saas" && !subscription?.subscribed ? ( + + + {t("subscriptionRequiredToUse")} + + + ) : null} + + {build == "enterprise" && !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + ); diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index ed95eba3..8776c788 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -123,16 +123,20 @@ export const orgNavSections = ( href: "/{orgId}/settings/logs/request", icon: }, - { - title: "sidebarLogsAccess", - href: "/{orgId}/settings/logs/access", - icon: - }, - { - title: "sidebarLogsAction", - href: "/{orgId}/settings/logs/action", - icon: - }, + ...(build != "oss" + ? [ + { + title: "sidebarLogsAccess", + href: "/{orgId}/settings/logs/access", + icon: + }, + { + title: "sidebarLogsAction", + href: "/{orgId}/settings/logs/action", + icon: + } + ] + : []) ] }, { diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index 5c7af9d4..70d64f0c 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -23,6 +23,7 @@ interface DataTablePaginationProps { totalCount?: number; isServerPagination?: boolean; isLoading?: boolean; + disabled?: boolean; } export function DataTablePagination({ @@ -31,7 +32,8 @@ export function DataTablePagination({ onPageChange, totalCount, isServerPagination = false, - isLoading = false + isLoading = false, + disabled = false }: DataTablePaginationProps) { const t = useTranslations(); @@ -96,8 +98,9 @@ export function DataTablePagination({