From dce84b9b092bf87b4bda66741cdbf29e39d321e1 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Oct 2025 17:58:52 -0700 Subject: [PATCH] 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" + ) + ); + } + }; +}