diff --git a/messages/en-US.json b/messages/en-US.json index 6ac64aa1..c2fd35cc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1892,6 +1892,58 @@ "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.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "certResolver": "Certificate Resolver", "certResolverDescription": "Select the certificate resolver to use for this resource.", "selectCertResolver": "Select Certificate Resolver", 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/auth/actions.ts b/server/auth/actions.ts index 83582885..34278dfb 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -119,7 +119,9 @@ export enum ActionsEnum { updateLoginPage = "updateLoginPage", getLoginPage = "getLoginPage", deleteLoginPage = "deleteLoginPage", - applyBlueprint = "applyBlueprint" + applyBlueprint = "applyBlueprint", + viewLogs = "viewLogs", + exportLogs = "exportLogs" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 67fb28ec..266a8646 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,43 @@ 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 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 }), + actor: varchar("actor", { length: 255 }), + actorId: varchar("actorId", { length: 255 }), + resourceId: integer("resourceId"), + ip: varchar("ip", { length: 45 }), + type: varchar("type", { length: 100 }).notNull(), + action: boolean("action").notNull(), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("metadata") +}, (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; @@ -230,3 +268,5 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type ActionAuditLog = InferSelectModel; +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 4693a6d0..10cde5c5 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"; @@ -40,7 +41,16 @@ export const orgs = pgTable("orgs", { orgId: varchar("orgId").primaryKey(), name: varchar("name").notNull(), subnet: varchar("subnet"), - createdAt: text("createdAt") + createdAt: text("createdAt"), + settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever + .notNull() + .default(7), + settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess") + .notNull() + .default(0), + settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") + .notNull() + .default(0) }); export const orgDomains = pgTable("orgDomains", { @@ -687,6 +697,42 @@ 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: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + action: boolean("action").notNull(), + reason: integer("reason").notNull(), + actorType: text("actorType"), + actor: text("actor"), + actorId: text("actorId"), + resourceId: integer("resourceId"), + ip: text("ip"), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("metadata"), + headers: text("headers"), // JSON blob + query: text("query"), // JSON blob + originalRequestURL: text("originalRequestURL"), + scheme: text("scheme"), + host: text("host"), + path: text("path"), + method: text("method"), + tls: boolean("tls") + }, + (table) => [ + index("idx_requestAuditLog_timestamp").on(table.timestamp), + index("idx_requestAuditLog_org_timestamp").on( + table.orgId, + table.timestamp + ) + ] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -738,3 +784,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; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 557ebfd6..89d11310 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,43 @@ 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 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"), + actor: text("actor"), + actorId: text("actorId"), + resourceId: integer("resourceId"), + ip: text("ip"), + location: text("location"), + type: text("type").notNull(), + action: integer("action", { mode: "boolean" }).notNull(), + userAgent: text("userAgent"), + metadata: text("metadata") +}, (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; @@ -224,3 +263,5 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +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 a74041d8..1d1056e3 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,6 +1,7 @@ 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"; +import { boolean } from "yargs"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -33,7 +34,16 @@ export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), name: text("name").notNull(), subnet: text("subnet"), - createdAt: text("createdAt") + createdAt: text("createdAt"), + settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever + .notNull() + .default(7), + settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess") + .notNull() + .default(0), + settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") + .notNull() + .default(0) }); export const userDomains = sqliteTable("userDomains", { @@ -159,11 +169,15 @@ export const targets = sqliteTable("targets", { }); export const targetHealthCheck = sqliteTable("targetHealthCheck", { - targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ autoIncrement: true }), + targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ + autoIncrement: true + }), targetId: integer("targetId") .notNull() .references(() => targets.targetId, { onDelete: "cascade" }), - hcEnabled: integer("hcEnabled", { mode: "boolean" }).notNull().default(false), + hcEnabled: integer("hcEnabled", { mode: "boolean" }) + .notNull() + .default(false), hcPath: text("hcPath"), hcScheme: text("hcScheme"), hcMode: text("hcMode").default("http"), @@ -173,7 +187,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds hcTimeout: integer("hcTimeout").default(5), // in seconds hcHeaders: text("hcHeaders"), - hcFollowRedirects: integer("hcFollowRedirects", { mode: "boolean" }).default(true), + hcFollowRedirects: integer("hcFollowRedirects", { + mode: "boolean" + }).default(true), hcMethod: text("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code hcHealth: text("hcHealth").default("unknown") // "unknown", "healthy", "unhealthy" @@ -727,6 +743,42 @@ 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").references(() => orgs.orgId, { + onDelete: "cascade" + }), + action: integer("action", { mode: "boolean" }).notNull(), + reason: integer("reason").notNull(), + actorType: text("actorType"), + actor: text("actor"), + actorId: text("actorId"), + resourceId: integer("resourceId"), + ip: text("ip"), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("metadata"), + headers: text("headers"), // JSON blob + query: text("query"), // JSON blob + originalRequestURL: text("originalRequestURL"), + scheme: text("scheme"), + host: text("host"), + path: text("path"), + method: text("method"), + tls: integer("tls", { mode: "boolean" }) + }, + (table) => [ + index("idx_requestAuditLog_timestamp").on(table.timestamp), + index("idx_requestAuditLog_org_timestamp").on( + table.orgId, + table.timestamp + ) + ] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -779,3 +831,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; diff --git a/server/index.ts b/server/index.ts index f80fd16e..daa4b7d3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,6 +5,7 @@ import { runSetupFunctions } from "./setup"; import { createApiServer } from "./apiServer"; import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; +import { createIntegrationApiServer } from "./integrationApiServer"; import { ApiKey, ApiKeyOrg, @@ -13,14 +14,15 @@ import { User, UserOrg } from "@server/db"; -import { createIntegrationApiServer } from "./integrationApiServer"; import config from "@server/lib/config"; import { setHostMeta } from "@server/lib/hostMeta"; -import { initTelemetryClient } from "./lib/telemetry.js"; -import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js"; +import { initTelemetryClient } from "@server/lib/telemetry"; +import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager"; import { initCleanup } from "#dynamic/cleanup"; import license from "#dynamic/license/license"; -import { fetchServerIp } from "./lib/serverIpService.js"; +import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; +import { fetchServerIp } from "@server/lib/serverIpService"; + async function startServers() { await setHostMeta(); @@ -35,12 +37,13 @@ async function startServers() { initTelemetryClient(); + initLogCleanupInterval(); + // Start all servers const apiServer = createApiServer(); const internalServer = createInternalServer(); - let nextServer; - nextServer = await createNextServer(); + const nextServer = await createNextServer(); if (config.getRawConfig().traefik.file_mode) { const monitor = new TraefikConfigManager(); await monitor.start(); diff --git a/server/lib/billing/getOrgTierData.ts b/server/lib/billing/getOrgTierData.ts index 24664790..75f12559 100644 --- a/server/lib/billing/getOrgTierData.ts +++ b/server/lib/billing/getOrgTierData.ts @@ -1,8 +1,8 @@ export async function getOrgTierData( orgId: string ): Promise<{ tier: string | null; active: boolean }> { - let tier = null; - let active = false; + const tier = null; + const active = false; return { tier, active }; } diff --git a/server/lib/cleanupLogs.ts b/server/lib/cleanupLogs.ts new file mode 100644 index 00000000..7cc96ff9 --- /dev/null +++ b/server/lib/cleanupLogs.ts @@ -0,0 +1,62 @@ +import { db, orgs } from "@server/db"; +import { cleanUpOldLogs as cleanUpOldAccessLogs } from "@server/private/lib/logAccessAudit"; +import { cleanUpOldLogs as cleanUpOldActionLogs } from "@server/private/middlewares/logActionAudit"; +import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit"; +import { gt, or } from "drizzle-orm"; + +export function initLogCleanupInterval() { + return setInterval( + async () => { + const orgsToClean = await db + .select({ + orgId: orgs.orgId, + settingsLogRetentionDaysAction: + orgs.settingsLogRetentionDaysAction, + settingsLogRetentionDaysAccess: + orgs.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(orgs) + .where( + or( + gt(orgs.settingsLogRetentionDaysAction, 0), + gt(orgs.settingsLogRetentionDaysAccess, 0), + gt(orgs.settingsLogRetentionDaysRequest, 0) + ) + ); + + for (const org of orgsToClean) { + const { + orgId, + settingsLogRetentionDaysAction, + settingsLogRetentionDaysAccess, + settingsLogRetentionDaysRequest + } = org; + + if (settingsLogRetentionDaysAction > 0) { + await cleanUpOldActionLogs( + orgId, + settingsLogRetentionDaysRequest + ); + } + + if (settingsLogRetentionDaysAccess > 0) { + await cleanUpOldAccessLogs( + orgId, + settingsLogRetentionDaysRequest + ); + } + + if (settingsLogRetentionDaysRequest > 0) { + await cleanUpOldRequestLogs( + orgId, + settingsLogRetentionDaysRequest + ); + } + } + }, + // 3 * 60 * 60 * 1000 + 60 * 1000 // for testing + ); // every 3 hours +} diff --git a/server/lib/geoip.ts b/server/lib/geoip.ts index ac739fa3..5bc29ef9 100644 --- a/server/lib/geoip.ts +++ b/server/lib/geoip.ts @@ -6,7 +6,7 @@ export async function getCountryCodeForIp( ): Promise { try { if (!maxmindLookup) { - logger.warn( + logger.debug( "MaxMind DB path not configured, cannot perform GeoIP lookup" ); return; diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 629cafe9..66a92809 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -27,3 +27,4 @@ export * from "./verifyDomainAccess"; export * from "./verifyClientsEnabled"; export * from "./verifyUserIsOrgOwner"; export * from "./verifySiteResourceAccess"; +export * from "./logActionAudit"; \ 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/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts new file mode 100644 index 00000000..cc56d94c --- /dev/null +++ b/server/private/lib/logAccessAudit.ts @@ -0,0 +1,157 @@ +import { accessAuditLog, db, orgs } from "@server/db"; +import { getCountryCodeForIp } from "@server/lib/geoip"; +import logger from "@server/logger"; +import { and, eq, lt } from "drizzle-orm"; +import cache from "@server/lib/cache"; + +async function getAccessDays(orgId: string): Promise { + // check cache first + const cached = cache.get(`org_${orgId}_accessDays`); + if (cached !== undefined) { + return cached; + } + + const [org] = await db + .select({ + settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return 0; + } + + // store the result in cache + cache.set( + `org_${orgId}_accessDays`, + org.settingsLogRetentionDaysAction, + 300 + ); + + return org.settingsLogRetentionDaysAction; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + const now = Math.floor(Date.now() / 1000); + + const cutoffTimestamp = now - retentionDays * 24 * 60 * 60; + + try { + const deleteResult = await db + .delete(accessAuditLog) + .where( + and( + lt(accessAuditLog.timestamp, cutoffTimestamp), + eq(accessAuditLog.orgId, orgId) + ) + ); + + logger.info( + `Cleaned up ${deleteResult.changes} access audit logs older than ${retentionDays} days` + ); + } catch (error) { + logger.error("Error cleaning up old action audit logs:", error); + } +} + +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 { + const retentionDays = await getAccessDays(data.orgId); + if (retentionDays === 0) { + // do not log + return; + } + + 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/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index b8a2b32b..bbbdbcfd 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -789,7 +789,7 @@ export async function getTraefikConfig( continue; } - let tls = {}; + const tls = {}; if ( !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns ) { diff --git a/server/private/middlewares/index.ts b/server/private/middlewares/index.ts index c92b0d3d..bb4d9c05 100644 --- a/server/private/middlewares/index.ts +++ b/server/private/middlewares/index.ts @@ -15,4 +15,5 @@ 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"; +export * from "./verifySubscription"; \ 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..3dd2f084 --- /dev/null +++ b/server/private/middlewares/logActionAudit.ts @@ -0,0 +1,145 @@ +/* + * 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, orgs } 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"; +import { and, eq, lt } from "drizzle-orm"; +import cache from "@server/lib/cache"; + +async function getActionDays(orgId: string): Promise { + // check cache first + const cached = cache.get(`org_${orgId}_actionDays`); + if (cached !== undefined) { + return cached; + } + + const [org] = await db + .select({ + settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return 0; + } + + // store the result in cache + cache.set(`org_${orgId}_actionDays`, org.settingsLogRetentionDaysAction, 300); + + return org.settingsLogRetentionDaysAction; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + const now = Math.floor(Date.now() / 1000); + + const cutoffTimestamp = now - retentionDays * 24 * 60 * 60; + + try { + const deleteResult = await db + .delete(actionAuditLog) + .where( + and( + lt(actionAuditLog.timestamp, cutoffTimestamp), + eq(actionAuditLog.orgId, orgId) + ) + ); + + logger.info( + `Cleaned up ${deleteResult.changes} action audit logs older than ${retentionDays} days` + ); + } catch (error) { + logger.error("Error cleaning up old action audit logs:", error); + } +} + +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 retentionDays = await getActionDays(orgId); + if (retentionDays === 0) { + // do not log + 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" + ) + ); + } + }; +} + diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts new file mode 100644 index 00000000..5249c026 --- /dev/null +++ b/server/private/middlewares/verifySubscription.ts @@ -0,0 +1,50 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { build } from "@server/build"; +import { getOrgTierData } from "#private/lib/billing"; + +export async function verifyValidSubscription( + req: Request, + res: Response, + next: NextFunction +) { + try { + if (build != "saas") { + return next(); + } + + const tier = await getOrgTierData(req.params.orgId); + + if (!tier.active) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Organization does not have an active subscription" + ) + ); + } + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying subscription" + ) + ); + } +} diff --git a/server/private/routers/auditLogs/exportAccessAuditLog.ts b/server/private/routers/auditLogs/exportAccessAuditLog.ts new file mode 100644 index 00000000..89aef6cb --- /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 parsedParams = queryAccessAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAccess(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="access-audit-logs-${data.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 new file mode 100644 index 00000000..12c9ff8b --- /dev/null +++ b/server/private/routers/auditLogs/exportActionAuditLog.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 { queryActionAuditLogsParams, queryActionAuditLogsQuery, queryAction } from "./queryActionAuditLog"; +import { generateCSV } from "@server/routers/auditLogs/generateCSV"; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/action/export", + description: "Export the action audit log for an organization as CSV", + tags: [OpenAPITags.Org], + request: { + query: queryActionAuditLogsQuery, + params: queryActionAuditLogsParams + }, + responses: {} +}); + +export async function exportActionAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryActionAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAction(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="action-audit-logs-${data.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 new file mode 100644 index 00000000..ac623c4c --- /dev/null +++ b/server/private/routers/auditLogs/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from "./queryActionAuditLog"; +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..d6c9dea6 --- /dev/null +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -0,0 +1,258 @@ +/* + * 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()), + action: z + .union([z.boolean(), z.string()]) + .transform((val) => (typeof val === "string" ? val === "true" : val)) + .optional(), + actorType: z.string().optional(), + actorId: z.string().optional(), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + actor: z.string().optional(), + type: z.string().optional(), + location: z.string().optional(), + 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 const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge( + queryAccessAuditLogsParams +); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(accessAuditLog.timestamp, data.timeStart), + lt(accessAuditLog.timestamp, data.timeEnd), + eq(accessAuditLog.orgId, data.orgId), + data.resourceId + ? eq(accessAuditLog.resourceId, data.resourceId) + : undefined, + data.actor ? eq(accessAuditLog.actor, data.actor) : undefined, + data.actorType + ? eq(accessAuditLog.actorType, data.actorType) + : undefined, + data.actorId ? eq(accessAuditLog.actorId, data.actorId) : undefined, + data.location ? eq(accessAuditLog.location, data.location) : undefined, + data.type ? eq(accessAuditLog.type, data.type) : undefined, + data.action !== undefined + ? eq(accessAuditLog.action, data.action) + : undefined + ); +} + +export function queryAccess(data: Q) { + 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(getWhere(data)) + .orderBy(accessAuditLog.timestamp); +} + +export function countAccessQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(accessAuditLog) + .where(getWhere(data)); + return countQuery; +} + +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(accessAuditLog.timestamp, timeStart), + lt(accessAuditLog.timestamp, timeEnd), + eq(accessAuditLog.orgId, orgId) + ); + + // Get unique actors + const uniqueActors = await db + .selectDistinct({ + actor: accessAuditLog.actor + }) + .from(accessAuditLog) + .where(baseConditions); + + // Get unique locations + const uniqueLocations = await db + .selectDistinct({ + locations: accessAuditLog.location + }) + .from(accessAuditLog) + .where(baseConditions); + + // Get unique resources with names + const uniqueResources = await db + .selectDistinct({ + id: accessAuditLog.resourceId, + name: resources.name + }) + .from(accessAuditLog) + .leftJoin( + resources, + eq(accessAuditLog.resourceId, resources.resourceId) + ) + .where(baseConditions); + + return { + actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), + resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null), + locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null) + }; +} + +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 parsedParams = queryAccessAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAccess(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countAccessQuery(data); + const totalCount = totalCountResult[0].count; + + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit: data.limit, + offset: data.offset + }, + filterAttributes + }, + 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 new file mode 100644 index 00000000..f9dcbbf5 --- /dev/null +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -0,0 +1,211 @@ +/* + * 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"; + +export const queryActionAuditLogsQuery = 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()), + action: z.string().optional(), + actorType: z.string().optional(), + actorId: z.string().optional(), + actor: z.string().optional(), + 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 queryActionAuditLogsParams = z.object({ + orgId: z.string() +}); + +export const queryActionAuditLogsCombined = + queryActionAuditLogsQuery.merge(queryActionAuditLogsParams); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(actionAuditLog.timestamp, data.timeStart), + lt(actionAuditLog.timestamp, data.timeEnd), + eq(actionAuditLog.orgId, data.orgId), + data.actor ? eq(actionAuditLog.actor, data.actor) : undefined, + data.actorType ? eq(actionAuditLog.actorType, data.actorType) : undefined, + data.actorId ? eq(actionAuditLog.actorId, data.actorId) : undefined, + data.action ? eq(actionAuditLog.action, data.action) : undefined + ); +} + +export function queryAction(data: Q) { + return db + .select({ + orgId: actionAuditLog.orgId, + action: actionAuditLog.action, + actorType: actionAuditLog.actorType, + metadata: actionAuditLog.metadata, + actorId: actionAuditLog.actorId, + timestamp: actionAuditLog.timestamp, + actor: actionAuditLog.actor + }) + .from(actionAuditLog) + .where(getWhere(data)) + .orderBy(actionAuditLog.timestamp); +} + +export function countActionQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(actionAuditLog) + .where(getWhere(data)); + return countQuery; +} + +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(actionAuditLog.timestamp, timeStart), + lt(actionAuditLog.timestamp, timeEnd), + eq(actionAuditLog.orgId, orgId) + ); + + // Get unique actors + const uniqueActors = await db + .selectDistinct({ + actor: actionAuditLog.actor + }) + .from(actionAuditLog) + .where(baseConditions); + + const uniqueActions = await db + .selectDistinct({ + action: actionAuditLog.action + }) + .from(actionAuditLog) + .where(baseConditions); + + return { + actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), + actions: uniqueActions.map(row => row.action).filter((action): action is string => action !== null), + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/action", + description: "Query the action audit log for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryActionAuditLogsQuery, + params: queryActionAuditLogsParams + }, + responses: {} +}); + +export async function queryActionAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const parsedParams = queryActionAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAction(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countActionQuery(data); + const totalCount = totalCountResult[0].count; + + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit: data.limit, + offset: data.offset + }, + filterAttributes + }, + 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..b569e98f 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -21,20 +21,21 @@ 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, - verifyUserIsOrgOwner, verifyUserIsServerAdmin } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { + logActionAudit, verifyCertificateAccess, verifyIdpAccess, verifyLoginPageAccess, - verifyRemoteExitNodeAccess + verifyRemoteExitNodeAccess, + verifyValidSubscription } from "#private/middlewares"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; @@ -72,7 +73,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createIdp), - orgIdp.createOrgOidcIdp + logActionAudit(ActionsEnum.createIdp), + orgIdp.createOrgOidcIdp, ); authenticated.post( @@ -81,7 +83,8 @@ authenticated.post( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.updateIdp), - orgIdp.updateOrgOidcIdp + logActionAudit(ActionsEnum.updateIdp), + orgIdp.updateOrgOidcIdp, ); authenticated.delete( @@ -90,7 +93,8 @@ authenticated.delete( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), - orgIdp.deleteOrgIdp + logActionAudit(ActionsEnum.deleteIdp), + orgIdp.deleteOrgIdp, ); authenticated.get( @@ -127,7 +131,8 @@ authenticated.post( verifyOrgAccess, verifyCertificateAccess, verifyUserHasAction(ActionsEnum.restartCertificate), - certificates.restartCertificate + logActionAudit(ActionsEnum.restartCertificate), + certificates.restartCertificate, ); if (build === "saas") { @@ -152,14 +157,16 @@ if (build === "saas") { "/org/:orgId/billing/create-checkout-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), - billing.createCheckoutSession + logActionAudit(ActionsEnum.billing), + billing.createCheckoutSession, ); authenticated.post( "/org/:orgId/billing/create-portal-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), - billing.createPortalSession + logActionAudit(ActionsEnum.billing), + billing.createPortalSession, ); authenticated.get( @@ -206,7 +213,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRemoteExitNode), - remoteExitNode.createRemoteExitNode + logActionAudit(ActionsEnum.createRemoteExitNode), + remoteExitNode.createRemoteExitNode, ); authenticated.get( @@ -240,7 +248,8 @@ authenticated.delete( verifyOrgAccess, verifyRemoteExitNodeAccess, verifyUserHasAction(ActionsEnum.deleteRemoteExitNode), - remoteExitNode.deleteRemoteExitNode + logActionAudit(ActionsEnum.deleteRemoteExitNode), + remoteExitNode.deleteRemoteExitNode, ); authenticated.put( @@ -248,7 +257,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createLoginPage), - loginPage.createLoginPage + logActionAudit(ActionsEnum.createLoginPage), + loginPage.createLoginPage, ); authenticated.post( @@ -257,7 +267,8 @@ authenticated.post( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), - loginPage.updateLoginPage + logActionAudit(ActionsEnum.updateLoginPage), + loginPage.updateLoginPage, ); authenticated.delete( @@ -266,7 +277,8 @@ authenticated.delete( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), - loginPage.deleteLoginPage + logActionAudit(ActionsEnum.deleteLoginPage), + loginPage.deleteLoginPage, ); authenticated.get( @@ -334,3 +346,41 @@ authenticated.post( verifyUserIsServerAdmin, license.recheckStatus ); + +authenticated.get( + "/org/:orgId/logs/action", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logs.queryActionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/action/export", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportActionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/access", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logs.queryAccessAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/access/export", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportAccessAuditLogs +); \ No newline at end of file diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index abfbd02f..1fc5d512 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -35,7 +35,9 @@ import { loginPageOrg, LoginPage, resourceHeaderAuth, - ResourceHeaderAuth + ResourceHeaderAuth, + orgs, + requestAuditLog } from "@server/db"; import { resources, @@ -301,7 +303,8 @@ function loadEncryptData() { return; // already loaded } - encryptionKeyPath = privateConfig.getRawPrivateConfig().server.encryption_key_path; + encryptionKeyPath = + privateConfig.getRawPrivateConfig().server.encryption_key_path; if (!fs.existsSync(encryptionKeyPath)) { throw new Error( @@ -1583,3 +1586,193 @@ hybridRouter.post( } } ); + +hybridRouter.get( + "/org/:orgId/get-retention-days", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getOrgLoginPageParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const [org] = await db + .select({ + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + return response(res, { + data: { + settingsLogRetentionDaysRequest: + org.settingsLogRetentionDaysRequest + }, + success: true, + error: false, + message: "Log retention days retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } + } +); + +const batchLogsSchema = z.object({ + logs: z.array( + z.object({ + timestamp: z.number(), + orgId: z.string().optional(), + actorType: z.string().optional(), + actor: z.string().optional(), + actorId: z.string().optional(), + metadata: z.string().nullable(), + action: z.boolean(), + resourceId: z.number().optional(), + reason: z.number(), + location: z.string().optional(), + originalRequestURL: z.string(), + scheme: z.string(), + host: z.string(), + path: z.string(), + method: z.string(), + ip: z.string().optional(), + tls: z.boolean() + }) + ) +}); + +hybridRouter.post( + "/org/:orgId/logs/batch", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getOrgLoginPageParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = batchLogsSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { logs } = parsedBody.data; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + // Batch insert all logs in a single query + const logEntries = logs.map((logEntry) => ({ + timestamp: logEntry.timestamp, + orgId: logEntry.orgId, + actorType: logEntry.actorType, + actor: logEntry.actor, + actorId: logEntry.actorId, + metadata: logEntry.metadata, + action: logEntry.action, + resourceId: logEntry.resourceId, + reason: logEntry.reason, + location: logEntry.location, + // userAgent: data.userAgent, // TODO: add this + // headers: data.body.headers, + // query: data.body.query, + originalRequestURL: logEntry.originalRequestURL, + scheme: logEntry.scheme, + host: logEntry.host, + path: logEntry.path, + method: logEntry.method, + ip: logEntry.ip, + tls: logEntry.tls + })); + + await db.insert(requestAuditLog).values(logEntries); + + return response(res, { + data: null, + success: true, + error: false, + message: "Logs saved 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/integration.ts b/server/private/routers/integration.ts index d767424a..21c74624 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 + logActionAudit(ActionsEnum.sendUsageNotification), + org.sendUsageNotification, ); authenticated.delete( "/idp/:idpId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdp), - orgIdp.deleteOrgIdp + logActionAudit(ActionsEnum.deleteIdp), + orgIdp.deleteOrgIdp, ); \ No newline at end of file diff --git a/server/routers/auditLogs/exportRequstAuditLog.ts b/server/routers/auditLogs/exportRequstAuditLog.ts new file mode 100644 index 00000000..89df2d3f --- /dev/null +++ b/server/routers/auditLogs/exportRequstAuditLog.ts @@ -0,0 +1,68 @@ +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 { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, queryRequest } from "./queryRequstAuditLog"; +import { generateCSV } from "./generateCSV"; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/request", + description: "Query the request audit log for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryRequestAuditLogsParams + }, + responses: {} +}); + +export async function exportRequestAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + 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-${data.orgId}-${Date.now()}.csv"`); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/generateCSV.ts b/server/routers/auditLogs/generateCSV.ts new file mode 100644 index 00000000..8a067069 --- /dev/null +++ b/server/routers/auditLogs/generateCSV.ts @@ -0,0 +1,16 @@ +export 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"); +} \ No newline at end of file diff --git a/server/routers/auditLogs/index.ts b/server/routers/auditLogs/index.ts new file mode 100644 index 00000000..4823831d --- /dev/null +++ b/server/routers/auditLogs/index.ts @@ -0,0 +1,2 @@ +export * from "./queryRequstAuditLog"; +export * from "./exportRequstAuditLog"; \ No newline at end of file diff --git a/server/routers/auditLogs/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequstAuditLog.ts new file mode 100644 index 00000000..d41b14b6 --- /dev/null +++ b/server/routers/auditLogs/queryRequstAuditLog.ts @@ -0,0 +1,276 @@ +import { db, requestAuditLog, resources } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count } 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 { QueryRequestAuditLogResponse } 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()), + action: z + .union([z.boolean(), z.string()]) + .transform((val) => (typeof val === "string" ? val === "true" : val)) + .optional(), + method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(), + reason: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + actor: z.string().optional(), + location: z.string().optional(), + host: z.string().optional(), + path: z.string().optional(), + 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 queryRequestAuditLogsParams = z.object({ + orgId: z.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, + data.host ? eq(requestAuditLog.host, data.host) : undefined, + data.location ? eq(requestAuditLog.location, data.location) : undefined, + data.path ? eq(requestAuditLog.path, data.path) : undefined, + data.action !== undefined + ? eq(requestAuditLog.action, data.action) + : undefined + ); +} + +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, + tls: requestAuditLog.tls, + resourceName: resources.name, + resourceNiceId: resources.niceId + }) + .from(requestAuditLog) + .leftJoin( + resources, + eq(requestAuditLog.resourceId, resources.resourceId) + ) // TODO: Is this efficient? + .where(getWhere(data)) + .orderBy(requestAuditLog.timestamp); +} + +export function countRequestQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(requestAuditLog) + .where(getWhere(data)); + return countQuery; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/request", + description: "Query the request audit log for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryRequestAuditLogsParams + }, + responses: {} +}); + +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(requestAuditLog.timestamp, timeStart), + lt(requestAuditLog.timestamp, timeEnd), + eq(requestAuditLog.orgId, orgId) + ); + + // Get unique actors + const uniqueActors = await db + .selectDistinct({ + actor: requestAuditLog.actor + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique locations + const uniqueLocations = await db + .selectDistinct({ + locations: requestAuditLog.location + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique actors + const uniqueHosts = await db + .selectDistinct({ + hosts: requestAuditLog.host + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique actors + const uniquePaths = await db + .selectDistinct({ + paths: requestAuditLog.path + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique resources with names + const uniqueResources = await db + .selectDistinct({ + id: requestAuditLog.resourceId, + name: resources.name + }) + .from(requestAuditLog) + .leftJoin( + resources, + eq(requestAuditLog.resourceId, resources.resourceId) + ) + .where(baseConditions); + + return { + actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), + resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null), + locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null), + hosts: uniqueHosts.map(row => row.hosts).filter((host): host is string => host !== null), + paths: uniquePaths.map(row => row.paths).filter((path): path is string => path !== null) + }; +} + +export async function queryRequestAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryRequest(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countRequestQuery(data); + const totalCount = totalCountResult[0].count; + + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit: data.limit, + offset: data.offset + }, + filterAttributes + }, + success: true, + error: false, + message: "Action audit logs retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts new file mode 100644 index 00000000..81cef733 --- /dev/null +++ b/server/routers/auditLogs/types.ts @@ -0,0 +1,93 @@ +export type QueryActionAuditLogResponse = { + log: { + orgId: string; + action: string; + actorType: string; + actorId: string; + metadata: string | null; + timestamp: number; + actor: string; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + filterAttributes: { + actors: string[]; + }; +}; + +export type QueryRequestAuditLogResponse = { + log: { + timestamp: number; + action: boolean; + reason: number; + orgId: string | null; + actorType: string | null; + actor: string | null; + actorId: string | null; + resourceId: number | null; + resourceNiceId: string | null; + resourceName: string | null; + ip: string | null; + location: string | null; + userAgent: string | null; + metadata: string | null; + headers: string | null; + query: string | null; + originalRequestURL: string | null; + scheme: string | null; + host: string | null; + path: string | null; + method: string | null; + tls: boolean | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + filterAttributes: { + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + hosts: string[]; + paths: string[]; + }; +}; + +export type QueryAccessAuditLogResponse = { + log: { + orgId: string; + action: boolean; + actorType: string | null; + actorId: string | null; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + ip: string | null; + location: string | null; + userAgent: string | null; + metadata: string | null; + type: string; + timestamp: number; + actor: string | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + filterAttributes: { + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + }; +}; \ No newline at end of file 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/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts new file mode 100644 index 00000000..b0754aed --- /dev/null +++ b/server/routers/badger/logRequestAudit.ts @@ -0,0 +1,191 @@ +import { db, orgs, requestAuditLog } from "@server/db"; +import logger from "@server/logger"; +import { and, eq, lt } from "drizzle-orm"; +import cache from "@server/lib/cache"; + +/** + +Reasons: +100 - Allowed by Rule +101 - Allowed No Auth +102 - Valid Access Token +103 - Valid header auth +104 - Valid Pincode +105 - Valid Password +106 - Valid email +107 - Valid SSO + +201 - Resource Not Found +202 - Resource Blocked +203 - Dropped by Rule +204 - No Sessions +205 - Temporary Request Token +299 - No More Auth Methods + + */ + +async function getRetentionDays(orgId: string): Promise { + // check cache first + const cached = cache.get(`org_${orgId}_retentionDays`); + if (cached !== undefined) { + return cached; + } + + const [org] = await db + .select({ + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return 0; + } + + // store the result in cache + cache.set( + `org_${orgId}_retentionDays`, + org.settingsLogRetentionDaysRequest, + 300 + ); + + return org.settingsLogRetentionDaysRequest; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + const now = Math.floor(Date.now() / 1000); + + const cutoffTimestamp = now - retentionDays * 24 * 60 * 60; + + try { + const deleteResult = await db + .delete(requestAuditLog) + .where( + and( + lt(requestAuditLog.timestamp, cutoffTimestamp), + eq(requestAuditLog.orgId, orgId) + ) + ); + + logger.info( + `Cleaned up ${deleteResult.changes} request audit logs older than ${retentionDays} days` + ); + } catch (error) { + logger.error("Error cleaning up old request audit logs:", error); + } +} + +export async function logRequestAudit( + data: { + action: boolean; + reason: number; + resourceId?: number; + orgId?: string; + location?: string; + user?: { username: string; userId: string }; + apiKey?: { name: string | null; apiKeyId: string }; + metadata?: any; + // userAgent?: string; + }, + body: { + path: string; + originalRequestURL: string; + scheme: string; + host: string; + method: string; + tls: boolean; + sessions?: Record; + headers?: Record; + query?: Record; + requestIp?: string; + } +) { + try { + if (data.orgId) { + const retentionDays = await getRetentionDays(data.orgId); + if (retentionDays === 0) { + // do not log + return; + } + } + + 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 = body.requestIp + ? (() => { + if ( + body.requestIp.startsWith("[") && + body.requestIp.includes("]") + ) { + // if brackets are found, extract the IPv6 address from between the brackets + const ipv6Match = body.requestIp.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + + // ivp4 + // split at last colon + const lastColonIndex = body.requestIp.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return body.requestIp.substring(0, lastColonIndex); + } + return body.requestIp; + })() + : undefined; + + await db.insert(requestAuditLog).values({ + timestamp, + orgId: data.orgId, + actorType, + actor, + actorId, + metadata, + action: data.action, + resourceId: data.resourceId, + reason: data.reason, + location: data.location, + // userAgent: data.userAgent, // TODO: add this + // headers: data.body.headers, + // query: data.body.query, + originalRequestURL: body.originalRequestURL, + scheme: body.scheme, + host: body.host, + path: body.path, + method: body.method, + ip: clientIp, + tls: body.tls + }); + } catch (error) { + logger.error(error); + } +} diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 29109b5c..af29dadb 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1,9 +1,4 @@ -import { generateSessionToken } from "@server/auth/sessions/app"; -import { - createResourceSession, - serializeResourceSessionCookie, - validateResourceSessionToken -} from "@server/auth/sessions/resource"; +import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { getResourceByDomain, @@ -35,6 +30,7 @@ import { getCountryCodeForIp } from "@server/lib/geoip"; import { getOrgTierData } from "#dynamic/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import { verifyPassword } from "@server/auth/password"; +import { logRequestAudit } from "./logRequestAudit"; import cache from "@server/lib/cache"; const verifyResourceSessionSchema = z.object({ @@ -121,6 +117,10 @@ export async function verifyResourceSession( logger.debug("Client IP:", { clientIp }); + const ipCC = clientIp + ? await getCountryCodeFromIp(clientIp) + : undefined; + let cleanHost = host; // if the host ends with :port, strip it if (cleanHost.match(/:[0-9]{1,5}$/)) { @@ -143,6 +143,19 @@ export async function verifyResourceSession( if (!result) { logger.debug(`Resource not found ${cleanHost}`); + + // TODO: we cant log this for now because we dont know the org + // eventually it would be cool to show this for the server admin + + // logRequestAudit( + // { + // action: false, + // reason: 201, //resource not found + // location: ipCC + // }, + // parsedBody.data + // ); + return notAllowed(res); } @@ -154,6 +167,19 @@ export async function verifyResourceSession( if (!resource) { logger.debug(`Resource not found ${cleanHost}`); + + // TODO: we cant log this for now because we dont know the org + // eventually it would be cool to show this for the server admin + + // logRequestAudit( + // { + // action: false, + // reason: 201, //resource not found + // location: ipCC + // }, + // parsedBody.data + // ); + return notAllowed(res); } @@ -161,6 +187,18 @@ export async function verifyResourceSession( if (blockAccess) { logger.debug("Resource blocked", host); + + logRequestAudit( + { + action: false, + reason: 202, //resource blocked + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } @@ -169,14 +207,40 @@ export async function verifyResourceSession( const action = await checkRules( resource.resourceId, clientIp, - path + path, + ipCC ); if (action == "ACCEPT") { logger.debug("Resource allowed by rule"); + + logRequestAudit( + { + action: true, + reason: 100, // allowed by rule + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } else if (action == "DROP") { logger.debug("Resource denied by rule"); + + // TODO: add rules type + logRequestAudit( + { + action: false, + reason: 203, // dropped by rules + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } else if (action == "PASS") { logger.debug( @@ -197,6 +261,18 @@ export async function verifyResourceSession( !headerAuth ) { logger.debug("Resource allowed because no auth"); + + logRequestAudit( + { + action: true, + reason: 101, // allowed no auth + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -248,6 +324,21 @@ export async function verifyResourceSession( } if (valid && tokenItem) { + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId, + } + }, + parsedBody.data + ); + return allowed(res); } } @@ -284,6 +375,21 @@ export async function verifyResourceSession( } if (valid && tokenItem) { + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId, + } + }, + parsedBody.data + ); + return allowed(res); } } @@ -295,6 +401,18 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because header auth is valid (cached)" ); + + logRequestAudit( + { + action: true, + reason: 103, // valid header auth + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + }, + parsedBody.data + ); + return allowed(res); } else if ( await verifyPassword( @@ -304,15 +422,39 @@ export async function verifyResourceSession( ) { cache.set(clientHeaderAuthKey, clientHeaderAuth, 5); logger.debug("Resource allowed because header auth is valid"); + + logRequestAudit( + { + action: true, + reason: 103, // valid header auth + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } - if ( // we dont want to redirect if this is the only auth method and we did not pass here + if ( + // we dont want to redirect if this is the only auth method and we did not pass here !sso && !pincode && !password && !resource.emailWhitelistEnabled ) { + logRequestAudit( + { + action: false, + reason: 299, // no more auth methods + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } } else if (headerAuth) { @@ -323,6 +465,17 @@ export async function verifyResourceSession( !password && !resource.emailWhitelistEnabled ) { + logRequestAudit( + { + action: false, + reason: 299, // no more auth methods + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } } @@ -335,6 +488,18 @@ export async function verifyResourceSession( }. IP: ${clientIp}.` ); } + + logRequestAudit( + { + action: false, + reason: 204, // no sessions + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } @@ -368,6 +533,18 @@ export async function verifyResourceSession( }. IP: ${clientIp}.` ); } + + logRequestAudit( + { + action: false, + reason: 205, // temporary request token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } @@ -376,6 +553,18 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because pincode session is valid" ); + + logRequestAudit( + { + action: true, + reason: 104, // valid pincode + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -383,6 +572,18 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because password session is valid" ); + + logRequestAudit( + { + action: true, + reason: 105, // valid password + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -393,6 +594,18 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because whitelist session is valid" ); + + logRequestAudit( + { + action: true, + reason: 106, // valid email + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -400,6 +613,22 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because access token session is valid" ); + + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: resourceSession.accessTokenTitle, + apiKeyId: resourceSession.accessTokenId, + } + }, + parsedBody.data + ); + return allowed(res); } @@ -427,6 +656,22 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because user session is valid" ); + + logRequestAudit( + { + action: true, + reason: 107, // valid sso + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + user: { + username: allowedUserData.username, + userId: resourceSession.userId + } + }, + parsedBody.data + ); + return allowed(res, allowedUserData); } } @@ -445,6 +690,17 @@ export async function verifyResourceSession( logger.debug(`Redirecting to login at ${redirectPath}`); + logRequestAudit( + { + action: false, + reason: 299, // no more auth methods + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res, redirectPath, resource.orgId); } catch (e) { console.error(e); @@ -615,7 +871,8 @@ async function isUserAllowedToAccessResource( async function checkRules( resourceId: number, clientIp: string | undefined, - path: string | undefined + path: string | undefined, + ipCC?: string ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> { const ruleCacheKey = `rules:${resourceId}`; @@ -784,11 +1041,20 @@ export function isPathAllowed(pattern: string, path: string): boolean { return result; } -async function isIpInGeoIP(ip: string, countryCode: string): Promise { - if (countryCode == "ALL") { +async function isIpInGeoIP( + ipCountryCode: string, + checkCountryCode: string +): Promise { + if (checkCountryCode == "ALL") { return true; } + logger.debug(`IP ${ipCountryCode} is in country: ${checkCountryCode}`); + + return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase(); +} + +async function getCountryCodeFromIp(ip: string): Promise { const geoIpCacheKey = `geoip:${ip}`; let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey); @@ -799,9 +1065,7 @@ async function isIpInGeoIP(ip: string, countryCode: string): Promise { cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes } - logger.debug(`IP ${ip} is in country: ${cachedCountryCode}`); - - return cachedCountryCode?.toUpperCase() === countryCode.toUpperCase(); + return cachedCountryCode; } function extractBasicAuth( diff --git a/server/routers/external.ts b/server/routers/external.ts index f862e5cf..9c30c073 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -14,6 +14,7 @@ import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; import * as idp from "./idp"; import * as apiKeys from "./apiKeys"; +import * as logs from "./auditLogs"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -44,6 +45,8 @@ 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"; +import { log } from "console"; // Root routes export const unauthenticated = Router(); @@ -75,7 +78,8 @@ authenticated.post( "/org/:orgId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrg), - org.updateOrg + logActionAudit(ActionsEnum.updateOrg), + org.updateOrg, ); if (build !== "saas") { @@ -84,7 +88,8 @@ if (build !== "saas") { verifyOrgAccess, verifyUserIsOrgOwner, verifyUserHasAction(ActionsEnum.deleteOrg), - org.deleteOrg + logActionAudit(ActionsEnum.deleteOrg), + org.deleteOrg, ); } @@ -92,6 +97,7 @@ authenticated.put( "/org/:orgId/site", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createSite), + logActionAudit(ActionsEnum.createSite), site.createSite ); authenticated.get( @@ -149,7 +155,8 @@ authenticated.put( verifyClientsEnabled, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), - client.createClient + logActionAudit(ActionsEnum.createClient), + client.createClient, ); authenticated.delete( @@ -157,7 +164,8 @@ authenticated.delete( verifyClientsEnabled, verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), - client.deleteClient + logActionAudit(ActionsEnum.deleteClient), + client.deleteClient, ); authenticated.post( @@ -165,7 +173,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 + logActionAudit(ActionsEnum.updateClient), + client.updateClient, ); // authenticated.get( @@ -178,15 +187,18 @@ authenticated.post( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.updateSite), - site.updateSite + logActionAudit(ActionsEnum.updateSite), + site.updateSite, ); authenticated.delete( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.deleteSite), - site.deleteSite + logActionAudit(ActionsEnum.deleteSite), + site.deleteSite, ); +// TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" authenticated.get( "/site/:siteId/docker/status", verifySiteAccess, @@ -203,13 +215,13 @@ authenticated.post( "/site/:siteId/docker/check", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.checkDockerSocket + site.checkDockerSocket, ); authenticated.post( "/site/:siteId/docker/trigger", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.triggerFetchContainers + site.triggerFetchContainers, ); authenticated.get( "/site/:siteId/docker/containers", @@ -224,7 +236,8 @@ authenticated.put( verifyOrgAccess, verifySiteAccess, verifyUserHasAction(ActionsEnum.createSiteResource), - siteResource.createSiteResource + logActionAudit(ActionsEnum.createSiteResource), + siteResource.createSiteResource, ); authenticated.get( @@ -257,7 +270,8 @@ authenticated.post( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource + logActionAudit(ActionsEnum.updateSiteResource), + siteResource.updateSiteResource, ); authenticated.delete( @@ -266,14 +280,16 @@ authenticated.delete( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource + logActionAudit(ActionsEnum.deleteSiteResource), + siteResource.deleteSiteResource, ); authenticated.put( "/org/:orgId/resource", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), - resource.createResource + logActionAudit(ActionsEnum.createResource), + resource.createResource, ); authenticated.get( @@ -334,15 +350,18 @@ authenticated.delete( "/org/:orgId/invitations/:inviteId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), - user.removeInvitation + logActionAudit(ActionsEnum.removeInvitation), + user.removeInvitation, ); authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, verifyUserHasAction(ActionsEnum.inviteUser), - user.inviteUser + logActionAudit(ActionsEnum.inviteUser), + user.inviteUser, ); // maybe make this /invite/create instead + unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated authenticated.get( @@ -375,20 +394,23 @@ authenticated.post( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResource), - resource.updateResource + logActionAudit(ActionsEnum.updateResource), + resource.updateResource, ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResource), - resource.deleteResource + logActionAudit(ActionsEnum.deleteResource), + resource.deleteResource, ); authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), - target.createTarget + logActionAudit(ActionsEnum.createTarget), + target.createTarget, ); authenticated.get( "/resource/:resourceId/targets", @@ -401,7 +423,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), - resource.createResourceRule + logActionAudit(ActionsEnum.createResourceRule), + resource.createResourceRule, ); authenticated.get( "/resource/:resourceId/rules", @@ -413,13 +436,15 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResourceRule), - resource.updateResourceRule + logActionAudit(ActionsEnum.updateResourceRule), + resource.updateResourceRule, ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule + logActionAudit(ActionsEnum.deleteResourceRule), + resource.deleteResourceRule, ); authenticated.get( @@ -432,20 +457,23 @@ authenticated.post( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.updateTarget), - target.updateTarget + logActionAudit(ActionsEnum.updateTarget), + target.updateTarget, ); authenticated.delete( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.deleteTarget), - target.deleteTarget + logActionAudit(ActionsEnum.deleteTarget), + target.deleteTarget, ); authenticated.put( "/org/:orgId/role", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), - role.createRole + logActionAudit(ActionsEnum.createRole), + role.createRole, ); authenticated.get( "/org/:orgId/roles", @@ -470,14 +498,16 @@ authenticated.delete( "/role/:roleId", verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), - role.deleteRole + logActionAudit(ActionsEnum.deleteRole), + role.deleteRole, ); authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), - user.addUserRole + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole, ); authenticated.post( @@ -485,7 +515,8 @@ authenticated.post( verifyResourceAccess, verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), - resource.setResourceRoles + logActionAudit(ActionsEnum.setResourceRoles), + resource.setResourceRoles, ); authenticated.post( @@ -493,35 +524,40 @@ authenticated.post( verifyResourceAccess, verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), - resource.setResourceUsers + logActionAudit(ActionsEnum.setResourceUsers), + resource.setResourceUsers, ); authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePassword), - resource.setResourcePassword + logActionAudit(ActionsEnum.setResourcePassword), + resource.setResourcePassword, ); authenticated.post( `/resource/:resourceId/pincode`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePincode), - resource.setResourcePincode + logActionAudit(ActionsEnum.setResourcePincode), + resource.setResourcePincode, ); authenticated.post( `/resource/:resourceId/header-auth`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth + logActionAudit(ActionsEnum.setResourceHeaderAuth), + resource.setResourceHeaderAuth, ); authenticated.post( `/resource/:resourceId/whitelist`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist + logActionAudit(ActionsEnum.setResourceWhitelist), + resource.setResourceWhitelist, ); authenticated.get( @@ -535,14 +571,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken + logActionAudit(ActionsEnum.generateAccessToken), + accessToken.generateAccessToken, ); authenticated.delete( `/access-token/:accessTokenId`, verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken + logActionAudit(ActionsEnum.deleteAcessToken), + accessToken.deleteAccessToken, ); authenticated.get( @@ -615,7 +653,8 @@ authenticated.put( "/org/:orgId/user", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgUser), - user.createOrgUser + logActionAudit(ActionsEnum.createOrgUser), + user.createOrgUser, ); authenticated.post( @@ -623,7 +662,8 @@ authenticated.post( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.updateOrgUser), - user.updateOrgUser + logActionAudit(ActionsEnum.updateOrgUser), + user.updateOrgUser, ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); @@ -645,7 +685,8 @@ authenticated.delete( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), - user.removeUserOrg + logActionAudit(ActionsEnum.removeUser), + user.removeUserOrg, ); // authenticated.put( @@ -778,7 +819,8 @@ authenticated.post( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions + logActionAudit(ActionsEnum.setApiKeyActions), + apiKeys.setApiKeyActions, ); authenticated.get( @@ -793,7 +835,8 @@ authenticated.put( `/org/:orgId/api-key`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey + logActionAudit(ActionsEnum.createApiKey), + apiKeys.createOrgApiKey, ); authenticated.delete( @@ -801,7 +844,8 @@ authenticated.delete( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), - apiKeys.deleteOrgApiKey + logActionAudit(ActionsEnum.deleteApiKey), + apiKeys.deleteOrgApiKey, ); authenticated.get( @@ -816,7 +860,8 @@ authenticated.put( `/org/:orgId/domain`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgDomain), - domain.createOrgDomain + logActionAudit(ActionsEnum.createOrgDomain), + domain.createOrgDomain, ); authenticated.post( @@ -824,7 +869,8 @@ authenticated.post( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.restartOrgDomain), - domain.restartOrgDomain + logActionAudit(ActionsEnum.restartOrgDomain), + domain.restartOrgDomain, ); authenticated.delete( @@ -832,7 +878,23 @@ authenticated.delete( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), - domain.deleteAccountDomain + logActionAudit(ActionsEnum.deleteOrgDomain), + domain.deleteAccountDomain, +); + +authenticated.get( + "/org/:orgId/logs/request", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryRequestAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/request/export", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportRequestAuditLogs ); // Auth routes @@ -1155,4 +1217,4 @@ authRouter.delete( store: createStore() }), auth.deleteSecurityKey -); +); \ No newline at end of file diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 03b72ddd..9cd2d3ca 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 + logActionAudit(ActionsEnum.createOrg), + org.createOrg, ); authenticated.get( @@ -72,21 +73,24 @@ authenticated.post( "/org/:orgId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.updateOrg), - org.updateOrg + logActionAudit(ActionsEnum.updateOrg), + org.updateOrg, ); authenticated.delete( "/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteOrg), - org.deleteOrg + logActionAudit(ActionsEnum.deleteOrg), + org.deleteOrg, ); authenticated.put( "/org/:orgId/site", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createSite), - site.createSite + logActionAudit(ActionsEnum.createSite), + site.createSite, ); authenticated.get( @@ -121,14 +125,16 @@ authenticated.post( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.updateSite), - site.updateSite + logActionAudit(ActionsEnum.updateSite), + site.updateSite, ); authenticated.delete( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.deleteSite), - site.deleteSite + logActionAudit(ActionsEnum.deleteSite), + site.deleteSite, ); authenticated.get( @@ -142,7 +148,8 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.createSiteResource), - siteResource.createSiteResource + logActionAudit(ActionsEnum.createSiteResource), + siteResource.createSiteResource, ); authenticated.get( @@ -175,7 +182,8 @@ authenticated.post( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource + logActionAudit(ActionsEnum.updateSiteResource), + siteResource.updateSiteResource, ); authenticated.delete( @@ -184,21 +192,24 @@ authenticated.delete( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource + logActionAudit(ActionsEnum.deleteSiteResource), + siteResource.deleteSiteResource, ); authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), - resource.createResource + logActionAudit(ActionsEnum.createResource), + resource.createResource, ); authenticated.put( "/org/:orgId/site/:siteId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), - resource.createResource + logActionAudit(ActionsEnum.createResource), + resource.createResource, ); authenticated.get( @@ -233,7 +244,8 @@ authenticated.post( "/org/:orgId/create-invite", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.inviteUser), - user.inviteUser + logActionAudit(ActionsEnum.inviteUser), + user.inviteUser, ); authenticated.get( @@ -261,21 +273,24 @@ authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResource), - resource.updateResource + logActionAudit(ActionsEnum.updateResource), + resource.updateResource, ); authenticated.delete( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResource), - resource.deleteResource + logActionAudit(ActionsEnum.deleteResource), + resource.deleteResource, ); authenticated.put( "/resource/:resourceId/target", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createTarget), - target.createTarget + logActionAudit(ActionsEnum.createTarget), + target.createTarget, ); authenticated.get( @@ -289,7 +304,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createResourceRule), - resource.createResourceRule + logActionAudit(ActionsEnum.createResourceRule), + resource.createResourceRule, ); authenticated.get( @@ -303,14 +319,16 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), - resource.updateResourceRule + logActionAudit(ActionsEnum.updateResourceRule), + resource.updateResourceRule, ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule + logActionAudit(ActionsEnum.deleteResourceRule), + resource.deleteResourceRule, ); authenticated.get( @@ -324,21 +342,24 @@ authenticated.post( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.updateTarget), - target.updateTarget + logActionAudit(ActionsEnum.updateTarget), + target.updateTarget, ); authenticated.delete( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.deleteTarget), - target.deleteTarget + logActionAudit(ActionsEnum.deleteTarget), + target.deleteTarget, ); authenticated.put( "/org/:orgId/role", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createRole), - role.createRole + logActionAudit(ActionsEnum.createRole), + role.createRole, ); authenticated.get( @@ -352,7 +373,8 @@ authenticated.delete( "/role/:roleId", verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.deleteRole), - role.deleteRole + logActionAudit(ActionsEnum.deleteRole), + role.deleteRole, ); authenticated.get( @@ -367,7 +389,8 @@ authenticated.post( verifyApiKeyRoleAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.addUserRole), - user.addUserRole + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole, ); authenticated.post( @@ -375,7 +398,8 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), - resource.setResourceRoles + logActionAudit(ActionsEnum.setResourceRoles), + resource.setResourceRoles, ); authenticated.post( @@ -383,35 +407,40 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), - resource.setResourceUsers + logActionAudit(ActionsEnum.setResourceUsers), + resource.setResourceUsers, ); authenticated.post( `/resource/:resourceId/password`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), - resource.setResourcePassword + logActionAudit(ActionsEnum.setResourcePassword), + resource.setResourcePassword, ); authenticated.post( `/resource/:resourceId/pincode`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), - resource.setResourcePincode + logActionAudit(ActionsEnum.setResourcePincode), + resource.setResourcePincode, ); authenticated.post( `/resource/:resourceId/header-auth`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth + logActionAudit(ActionsEnum.setResourceHeaderAuth), + resource.setResourceHeaderAuth, ); authenticated.post( `/resource/:resourceId/whitelist`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist + logActionAudit(ActionsEnum.setResourceWhitelist), + resource.setResourceWhitelist, ); authenticated.post( @@ -439,14 +468,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken + logActionAudit(ActionsEnum.generateAccessToken), + accessToken.generateAccessToken, ); authenticated.delete( `/access-token/:accessTokenId`, verifyApiKeyAccessTokenAccess, verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken + logActionAudit(ActionsEnum.deleteAcessToken), + accessToken.deleteAccessToken, ); authenticated.get( @@ -474,7 +505,8 @@ authenticated.post( "/user/:userId/2fa", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateUser), - user.updateUser2FA + logActionAudit(ActionsEnum.updateUser), + user.updateUser2FA, ); authenticated.get( @@ -495,7 +527,8 @@ authenticated.put( "/org/:orgId/user", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createOrgUser), - user.createOrgUser + logActionAudit(ActionsEnum.createOrgUser), + user.createOrgUser, ); authenticated.post( @@ -503,7 +536,8 @@ authenticated.post( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.updateOrgUser), - user.updateOrgUser + logActionAudit(ActionsEnum.updateOrgUser), + user.updateOrgUser, ); authenticated.delete( @@ -511,7 +545,8 @@ authenticated.delete( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.removeUser), - user.removeUserOrg + logActionAudit(ActionsEnum.removeUser), + user.removeUserOrg, ); // authenticated.put( @@ -531,7 +566,8 @@ authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions + logActionAudit(ActionsEnum.setApiKeyActions), + apiKeys.setApiKeyActions, ); authenticated.get( @@ -545,28 +581,32 @@ authenticated.put( `/org/:orgId/api-key`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey + logActionAudit(ActionsEnum.createApiKey), + apiKeys.createOrgApiKey, ); authenticated.delete( `/org/:orgId/api-key/:apiKeyId`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteApiKey), - apiKeys.deleteApiKey + logActionAudit(ActionsEnum.deleteApiKey), + apiKeys.deleteApiKey, ); authenticated.put( "/idp/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdp), - idp.createOidcIdp + logActionAudit(ActionsEnum.createIdp), + idp.createOidcIdp, ); authenticated.post( "/idp/:idpId/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdp), - idp.updateOidcIdp + logActionAudit(ActionsEnum.updateIdp), + idp.updateOidcIdp, ); authenticated.get( @@ -587,21 +627,24 @@ authenticated.put( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdpOrg), - idp.createIdpOrgPolicy + logActionAudit(ActionsEnum.createIdpOrg), + idp.createIdpOrgPolicy, ); authenticated.post( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), - idp.updateIdpOrgPolicy + logActionAudit(ActionsEnum.updateIdpOrg), + idp.updateIdpOrgPolicy, ); authenticated.delete( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), - idp.deleteIdpOrgPolicy + logActionAudit(ActionsEnum.deleteIdpOrg), + idp.deleteIdpOrgPolicy, ); authenticated.get( @@ -640,7 +683,8 @@ authenticated.put( verifyClientsEnabled, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createClient), - client.createClient + logActionAudit(ActionsEnum.createClient), + client.createClient, ); authenticated.delete( @@ -648,7 +692,8 @@ authenticated.delete( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.deleteClient), - client.deleteClient + logActionAudit(ActionsEnum.deleteClient), + client.deleteClient, ); authenticated.post( @@ -656,12 +701,14 @@ authenticated.post( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.updateClient), - client.updateClient + logActionAudit(ActionsEnum.updateClient), + client.updateClient, ); authenticated.put( "/org/:orgId/blueprint", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.applyBlueprint), - org.applyBlueprint + logActionAudit(ActionsEnum.applyBlueprint), + org.applyBlueprint, ); diff --git a/server/routers/org/getOrg.ts b/server/routers/org/getOrg.ts index 35c1a5f7..2497f9a6 100644 --- a/server/routers/org/getOrg.ts +++ b/server/routers/org/getOrg.ts @@ -49,13 +49,13 @@ export async function getOrg( const { orgId } = parsedParams.data; - const org = await db + const [org] = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); - if (org.length === 0) { + if (!org) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -66,7 +66,7 @@ export async function getOrg( return response(res, { data: { - org: org[0] + org }, success: true, error: false, diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 64075cab..f96ac8a3 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -9,6 +9,9 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { build } from "@server/build"; +import { getOrgTierData } from "@server/lib/billing"; +import { TierId } from "@server/lib/billing/tiers"; const updateOrgParamsSchema = z .object({ @@ -18,7 +21,19 @@ const updateOrgParamsSchema = z const updateOrgBodySchema = z .object({ - name: z.string().min(1).max(255).optional() + name: z.string().min(1).max(255).optional(), + settingsLogRetentionDaysRequest: z + .number() + .min(build === "saas" ? 0 : -1) + .optional(), + settingsLogRetentionDaysAccess: z + .number() + .min(build === "saas" ? 0 : -1) + .optional(), + settingsLogRetentionDaysAction: z + .number() + .min(build === "saas" ? 0 : -1) + .optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -71,10 +86,24 @@ export async function updateOrg( const { orgId } = parsedParams.data; + const { tier } = await getOrgTierData(orgId); // returns null in oss + if ( + tier != TierId.STANDARD && + parsedBody.data.settingsLogRetentionDaysRequest && + parsedBody.data.settingsLogRetentionDaysRequest > 30 + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You are not allowed to set log retention days greater than 30 because you are not subscribed to the Standard tier" + ) + ); + } + const updatedOrg = await db .update(orgs) .set({ - name: parsedBody.data.name + ...parsedBody.data }) .where(eq(orgs.orgId, orgId)) .returning(); 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..b75c3ed7 100644 --- a/src/actions/server.ts +++ b/src/actions/server.ts @@ -80,10 +80,12 @@ async function makeApiRequest( const headersList = await reqHeaders(); const host = headersList.get("host"); + const xForwardedFor = headersList.get("x-forwarded-for"); const headers: Record = { "Content-Type": "application/json", "X-CSRF-Token": "x-csrf-protection", + ...(xForwardedFor ? { "X-Forwarded-For": xForwardedFor } : {}), ...(cookieHeader && { Cookie: cookieHeader }), ...additionalHeaders }; @@ -202,6 +204,7 @@ export type LoginRequest = { email: string; password: string; code?: string; + resourceGuid?: string; }; export type LoginResponse = { diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index b301fd32..ff38d2cc 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -42,15 +42,38 @@ import { import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { ChevronDown, SubscriptIcon } from "lucide-react"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; // Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), - subnet: z.string().optional() + subnet: z.string().optional(), + settingsLogRetentionDaysRequest: z.number(), + settingsLogRetentionDaysAccess: z.number(), + settingsLogRetentionDaysAction: z.number() }); type GeneralFormValues = z.infer; +const LOG_RETENTION_OPTIONS = [ + { label: "logRetentionDisabled", value: 0 }, + { label: "logRetention3Days", value: 3 }, + { label: "logRetention7Days", value: 7 }, + { label: "logRetention14Days", value: 14 }, + { label: "logRetention30Days", value: 30 }, + { label: "logRetention90Days", value: 90 }, + ...(build != "saas" ? [{ label: "logRetentionForever", value: -1 }] : []) +]; + export default function GeneralPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { orgUser } = userOrgUserContext(); @@ -60,6 +83,8 @@ export default function GeneralPage() { const { user } = useUserContext(); const t = useTranslations(); const { env } = useEnvContext(); + const { isUnlocked } = useLicenseStatusContext(); + const subscription = useSubscriptionStatusContext(); const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); @@ -69,7 +94,13 @@ export default function GeneralPage() { resolver: zodResolver(GeneralFormSchema), defaultValues: { name: org?.org.name, - subnet: org?.org.subnet || "" // Add default value for subnet + subnet: org?.org.subnet || "", // Add default value for subnet + settingsLogRetentionDaysRequest: + org.org.settingsLogRetentionDaysRequest ?? 15, + settingsLogRetentionDaysAccess: + org.org.settingsLogRetentionDaysAccess ?? 15, + settingsLogRetentionDaysAction: + org.org.settingsLogRetentionDaysAction ?? 15 }, mode: "onChange" }); @@ -131,8 +162,14 @@ export default function GeneralPage() { try { // Update organization await api.post(`/org/${org?.org.orgId}`, { - name: data.name + name: data.name, // subnet: data.subnet // Include subnet in the API request + settingsLogRetentionDaysRequest: + data.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysAccess: + data.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysAction: + data.settingsLogRetentionDaysAction }); // Also save auth page settings if they have unsaved changes @@ -159,6 +196,11 @@ export default function GeneralPage() { } } + const getLabelForValue = (value: number) => { + const option = LOG_RETENTION_OPTIONS.find((opt) => opt.value === value); + return option ? t(option.label) : `${value} days`; + }; + return ( -

- {t("orgQuestionRemove")} -

+

{t("orgQuestionRemove")}

{t("orgMessageRemove")}

} @@ -179,23 +219,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")} + + + + + ( + + + {t("logRetentionRequestLabel")} + + + + + + + + {LOG_RETENTION_OPTIONS.filter((option) => { + if (build == "saas" && !subscription?.subscribed && option.value > 30) { + return false; + } + return true; + }).map( + (option) => ( + + field.onChange( + option.value + ) + } + > + {t( + option.label + )} + + ) + )} + + + + + {t( + "logRetentionRequestDescription" + )} + + + + )} + /> + + {build != "oss" && ( + <> + {build == "saas" && + !subscription?.subscribed ? ( + + + {t( + "subscriptionRequiredToUse" + )} + + + ) : null} + + {build == "enterprise" && + !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + + ( + + + {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 new file mode 100644 index 00000000..56071976 --- /dev/null +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -0,0 +1,662 @@ +"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, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +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); + const router = useRouter(); + const searchParams = useSearchParams(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + const { orgId } = useParams(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); + + const [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [filterAttributes, setFilterAttributes] = useState<{ + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + }>({ + actors: [], + resources: [], + locations: [] + }); + + // Filter states - unified object for all filters + const [filters, setFilters] = useState<{ + action?: string; + type?: string; + resourceId?: string; + location?: string; + actor?: string; + }>({ + action: searchParams.get("action") || undefined, + type: searchParams.get("type") || undefined, + resourceId: searchParams.get("resourceId") || undefined, + location: searchParams.get("location") || undefined, + actor: searchParams.get("actor") || undefined + }); + + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + return getStoredPageSize("access-audit-logs", 20); + }); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + // if the time is in the url params, use that instead + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + if (startParam && endParam) { + return { + startDate: { + date: new Date(startParam) + }, + endDate: { + date: new Date(endParam) + } + }; + } + + 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, + 0, + pageSize + ); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + setCurrentPage(0); // Reset to first page when filtering + // put the search params in the url for the time + updateUrlParamsForAllFilters({ + start: startDate.date?.toISOString() || "", + end: endDate.date?.toISOString() || "" + }); + + 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); + setStoredPageSize(newPageSize, "access-audit-logs"); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + }; + + // Handle filter changes generically + const handleFilterChange = ( + filterType: keyof typeof filters, + value: string | undefined + ) => { + // Create new filters object with updated value + const newFilters = { + ...filters, + [filterType]: value + }; + + setFilters(newFilters); + setCurrentPage(0); // Reset to first page when filtering + + // Update URL params + updateUrlParamsForAllFilters(newFilters); + + // Trigger new query with updated filters (pass directly to avoid async state issues) + queryDateTime( + dateRange.startDate, + dateRange.endDate, + 0, + pageSize, + newFilters + ); + }; + + const updateUrlParamsForAllFilters = ( + newFilters: + | typeof filters + | { + start: string; + end: string; + } + ) => { + const params = new URLSearchParams(searchParams); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize, + filtersParam?: { + action?: string; + type?: string; + resourceId?: string; + location?: string; + actor?: string; + } + ) => { + 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 { + // Use the provided filters or fall back to current state + const activeFilters = filtersParam || filters; + + // Convert the date/time values to API parameters + const params: any = { + limit: size, + offset: page * size, + ...activeFilters + }; + + 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); + setFilterAttributes(res.data.data.filterAttributes); + 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); + + // Prepare query params for export + const params: any = { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + ...filters + }; + + const response = await api.get(`/org/${orgId}/logs/access/export`, { + responseType: "blob", + params + }); + + // 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")} + + handleFilterChange("action", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.action ? <>Allowed : <>Denied} + + ); + } + }, + { + accessorKey: "ip", + header: ({ column }) => { + return t("ip"); + } + }, + { + accessorKey: "location", + header: ({ column }) => { + return ( +
+ {t("location")} + ({ + value: location, + label: location + }) + )} + selectedValue={filters.location} + onValueChange={(value) => + handleFilterChange("location", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.location ? ( + + {row.original.location} + + ) : ( + + - + + )} + + ); + } + }, + { + accessorKey: "resourceName", + header: ({ column }) => { + return ( +
+ {t("resource")} + ({ + value: res.id.toString(), + label: res.name || "Unnamed Resource" + }))} + selectedValue={filters.resourceId} + onValueChange={(value) => + handleFilterChange("resourceId", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + + + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( +
+ {t("type")} + + handleFilterChange("type", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + // should be capitalized first letter + return ( + + {row.original.type.charAt(0).toUpperCase() + + row.original.type.slice(1) || "-"} + + ); + } + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( +
+ {t("actor")} + ({ + value: actor, + label: actor + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + 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 || "-"} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+ {row.userAgent != "node" && ( +
+ User Agent: +

+ {row.userAgent || "N/A"} +

+
+ )} +
+ Metadata: +
+                            {row.metadata
+                                ? JSON.stringify(
+                                      JSON.parse(row.metadata),
+                                      null,
+                                      2
+                                  )
+                                : "N/A"}
+                        
+
+
+
+ ); + }; + + 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 new file mode 100644 index 00000000..b9845afa --- /dev/null +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -0,0 +1,515 @@ +"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, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +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); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + const { orgId } = useParams(); + const searchParams = useSearchParams(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); + + const [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [filterAttributes, setFilterAttributes] = useState<{ + actors: string[]; + actions: string[]; + }>({ + actors: [], + actions: [] + }); + + // Filter states - unified object for all filters + const [filters, setFilters] = useState<{ + action?: string; + actor?: string; + }>({ + action: searchParams.get("action") || undefined, + actor: searchParams.get("actor") || undefined + }); + + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + return getStoredPageSize("action-audit-logs", 20); + }); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + // if the time is in the url params, use that instead + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + if (startParam && endParam) { + return { + startDate: { + date: new Date(startParam) + }, + endDate: { + date: new Date(endParam) + } + }; + } + + 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, + 0, + pageSize + ); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + setCurrentPage(0); // Reset to first page when filtering + // put the search params in the url for the time + updateUrlParamsForAllFilters({ + start: startDate.date?.toISOString() || "", + end: endDate.date?.toISOString() || "" + }); + + 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); + setStoredPageSize(newPageSize, "action-audit-logs"); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + }; + + // Handle filter changes generically + const handleFilterChange = ( + filterType: keyof typeof filters, + value: string | undefined + ) => { + // Create new filters object with updated value + const newFilters = { + ...filters, + [filterType]: value + }; + + setFilters(newFilters); + setCurrentPage(0); // Reset to first page when filtering + + // Update URL params + updateUrlParamsForAllFilters(newFilters); + + // Trigger new query with updated filters (pass directly to avoid async state issues) + queryDateTime( + dateRange.startDate, + dateRange.endDate, + 0, + pageSize, + newFilters + ); + }; + + const updateUrlParamsForAllFilters = ( + newFilters: + | typeof filters + | { + start: string; + end: string; + } + ) => { + const params = new URLSearchParams(searchParams); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize, + filtersParam?: { + action?: string; + actor?: string; + } + ) => { + 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 { + // Use the provided filters or fall back to current state + const activeFilters = filtersParam || filters; + + // Convert the date/time values to API parameters + const params: any = { + limit: size, + offset: page * size, + ...activeFilters + }; + + 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 || []); + setTotalCount(res.data.data.pagination?.total || 0); + setFilterAttributes(res.data.data.filterAttributes); + 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); + + // Prepare query params for export + const params: any = { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + ...filters + }; + + const response = await api.get(`/org/${orgId}/logs/action/export`, { + responseType: "blob", + params + }); + + // 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", + `action-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")} + ({ + label: + action.charAt(0).toUpperCase() + + action.slice(1), + value: action + }))} + selectedValue={filters.action} + onValueChange={(value) => + handleFilterChange("action", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.action.charAt(0).toUpperCase() + + row.original.action.slice(1)} + + ); + } + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( +
+ {t("actor")} + ({ + value: actor, + label: actor + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.actorType == "user" ? ( + + ) : ( + + )} + {row.original.actor} + + ); + } + }, + { + accessorKey: "actorId", + header: ({ column }) => { + return t("actorId"); + }, + cell: ({ row }) => { + return ( + + {row.original.actorId} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ Metadata: +
+                            {row.metadata
+                                ? JSON.stringify(
+                                      JSON.parse(row.metadata),
+                                      null,
+                                      2
+                                  )
+                                : "N/A"}
+                        
+
+
+
+ ); + }; + + return ( + <> + + + {build == "saas" && !subscription?.subscribed ? ( + + + {t("subscriptionRequiredToUse")} + + + ) : null} + + {build == "enterprise" && !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + + + + ); +} diff --git a/src/app/[orgId]/settings/logs/layout.tsx b/src/app/[orgId]/settings/logs/layout.tsx new file mode 100644 index 00000000..96958403 --- /dev/null +++ b/src/app/[orgId]/settings/logs/layout.tsx @@ -0,0 +1,22 @@ +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +type GeneralSettingsProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function GeneralSettingsPage({ + children, + params +}: GeneralSettingsProps) { + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect(`/`); + } + + 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/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx new file mode 100644 index 00000000..7aeef772 --- /dev/null +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -0,0 +1,796 @@ +"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, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { getStoredPageSize, LogDataTable, setStoredPageSize } from "@app/components/LogDataTable"; +import { ColumnDef } from "@tanstack/react-table"; +import { DateTimeValue } from "@app/components/DateTimePicker"; +import { Key, RouteOff, User, Lock, Unlock, ArrowUpRight } from "lucide-react"; +import Link from "next/link"; +import { ColumnFilter } from "@app/components/ColumnFilter"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; + +export default function GeneralPage() { + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + const { orgId } = useParams(); + const searchParams = useSearchParams(); + + 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 [isLoading, setIsLoading] = useState(false); + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + return getStoredPageSize("request-audit-logs", 20); + }); + + const [filterAttributes, setFilterAttributes] = useState<{ + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + hosts: string[]; + paths: string[]; + }>({ + actors: [], + resources: [], + locations: [], + hosts: [], + paths: [] + }); + + // Filter states - unified object for all filters + const [filters, setFilters] = useState<{ + action?: string; + resourceId?: string; + host?: string; + location?: string; + actor?: string; + method?: string; + reason?: string; + path?: string; + }>({ + action: searchParams.get("action") || undefined, + host: searchParams.get("host") || undefined, + resourceId: searchParams.get("resourceId") || undefined, + location: searchParams.get("location") || undefined, + actor: searchParams.get("actor") || undefined, + method: searchParams.get("method") || undefined, + reason: searchParams.get("reason") || undefined, + path: searchParams.get("path") || undefined + }); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + // if the time is in the url params, use that instead + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + if (startParam && endParam) { + return { + startDate: { + date: new Date(startParam) + }, + endDate: { + date: new Date(endParam) + } + }; + } + + 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, + 0, + pageSize + ); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + setCurrentPage(0); // Reset to first page when filtering + // put the search params in the url for the time + updateUrlParamsForAllFilters({ + start: startDate.date?.toISOString() || "", + end: endDate.date?.toISOString() || "" + }); + + 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); + setStoredPageSize(newPageSize, "request-audit-logs"); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + }; + + // Handle filter changes generically + const handleFilterChange = ( + filterType: keyof typeof filters, + value: string | undefined + ) => { + console.log(`${filterType} filter changed:`, value); + + // Create new filters object with updated value + const newFilters = { + ...filters, + [filterType]: value + }; + + setFilters(newFilters); + setCurrentPage(0); // Reset to first page when filtering + + // Update URL params + updateUrlParamsForAllFilters(newFilters); + + // Trigger new query with updated filters (pass directly to avoid async state issues) + queryDateTime( + dateRange.startDate, + dateRange.endDate, + 0, + pageSize, + newFilters + ); + }; + + const updateUrlParamsForAllFilters = ( + newFilters: + | typeof filters + | { + start: string; + end: string; + } + ) => { + const params = new URLSearchParams(searchParams); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize, + filtersParam?: { + action?: string; + type?: string; + } + ) => { + console.log("Date range changed:", { startDate, endDate, page, size }); + setIsLoading(true); + + try { + // Use the provided filters or fall back to current state + const activeFilters = filtersParam || filters; + + // Convert the date/time values to API parameters + const params: any = { + limit: size, + offset: page * size, + ...activeFilters + }; + + 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/request`, { params }); + if (res.status === 200) { + setRows(res.data.data.log || []); + setTotalCount(res.data.data.pagination?.total || 0); + setFilterAttributes(res.data.data.filterAttributes); + 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); + + // Prepare query params for export + const params: any = { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + ...filters + }; + + const response = await api.get( + `/org/${orgId}/logs/request/export`, + { + responseType: "blob", + params + } + ); + + // 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", + `request-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" + }); + } + }; + + // 100 - Allowed by Rule + // 101 - Allowed No Auth + // 102 - Valid Access Token + // 103 - Valid header auth + // 104 - Valid Pincode + // 105 - Valid Password + // 106 - Valid email + // 107 - Valid SSO + + // 201 - Resource Not Found + // 202 - Resource Blocked + // 203 - Dropped by Rule + // 204 - No Sessions + // 205 - Temporary Request Token + // 299 - No More Auth Methods + + const reasonMap: any = { + 100: t("allowedByRule"), + 101: t("allowedNoAuth"), + 102: t("validAccessToken"), + 103: t("validHeaderAuth"), + 104: t("validPincode"), + 105: t("validPassword"), + 106: t("validEmail"), + 107: t("validSSO"), + 201: t("resourceNotFound"), + 202: t("resourceBlocked"), + 203: t("droppedByRule"), + 204: t("noSessions"), + 205: t("temporaryRequestToken"), + 299: t("noMoreAuthMethods") + }; + + // resourceId: integer("resourceId"), + // userAgent: text("userAgent"), + // metadata: text("details"), + // headers: text("headers"), // JSON blob + // query: text("query"), // JSON blob + // originalRequestURL: text("originalRequestURL"), + // scheme: text("scheme"), + + 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")} + + handleFilterChange("action", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.action ? <>Allowed : <>Denied} + + ); + } + }, + { + accessorKey: "ip", + header: ({ column }) => { + return t("ip"); + } + }, + { + accessorKey: "location", + header: ({ column }) => { + return ( +
+ {t("location")} + ({ + value: location, + label: location + }) + )} + selectedValue={filters.location} + onValueChange={(value) => + handleFilterChange("location", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.location ? ( + + {row.original.location} + + ) : ( + + - + + )} + + ); + } + }, + { + accessorKey: "resourceName", + header: ({ column }) => { + return ( +
+ {t("resource")} + ({ + value: res.id.toString(), + label: res.name || "Unnamed Resource" + }))} + selectedValue={filters.resourceId} + onValueChange={(value) => + handleFilterChange("resourceId", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + e.stopPropagation()} + > + + + ); + } + }, + { + accessorKey: "host", + header: ({ column }) => { + return ( +
+ {t("host")} + ({ + value: host, + label: host + }))} + selectedValue={filters.host} + onValueChange={(value) => + handleFilterChange("host", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.tls ? ( + + ) : ( + + )} + {row.original.host} + + ); + } + }, + { + accessorKey: "path", + header: ({ column }) => { + return ( +
+ {t("path")} + ({ + value: path, + label: path + }))} + selectedValue={filters.path} + onValueChange={(value) => + handleFilterChange("path", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + } + }, + + // { + // accessorKey: "scheme", + // header: ({ column }) => { + // return t("scheme"); + // }, + // }, + { + accessorKey: "method", + header: ({ column }) => { + return ( +
+ {t("method")} + + handleFilterChange("method", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + } + }, + { + accessorKey: "reason", + header: ({ column }) => { + return ( +
+ {t("reason")} + + handleFilterChange("reason", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {reasonMap[row.original.reason]} + + ); + } + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( +
+ {t("actor")} + ({ + value: actor, + label: actor + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.actor ? ( + <> + {row.original.actorType == "user" ? ( + + ) : ( + + )} + {row.original.actor} + + ) : ( + <>- + )} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ User Agent: +

+ {row.userAgent || "N/A"} +

+
+
+ Original URL: +

+ {row.originalRequestURL || "N/A"} +

+
+
+ Scheme: +

+ {row.scheme || "N/A"} +

+
+
+ Metadata: +
+                            {row.metadata
+                                ? JSON.stringify(
+                                      JSON.parse(row.metadata),
+                                      null,
+                                      2
+                                  )
+                                : "N/A"}
+                        
+
+ {row.headers && ( +
+ Headers: +
+                                {JSON.stringify(
+                                    JSON.parse(row.headers),
+                                    null,
+                                    2
+                                )}
+                            
+
+ )} + {row.query && ( +
+ Query Parameters: +
+                                {JSON.stringify(JSON.parse(row.query), null, 2)}
+                            
+
+ )} +
+
+ ); + }; + + return ( + <> + + + + + ); +} \ No newline at end of file diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 2d6aaec8..8776c788 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -16,7 +16,10 @@ import { MonitorUp, // Added from 'dev' branch Server, Zap, - CreditCard + CreditCard, + Logs, + SquareMousePointer, + ScanEye } from "lucide-react"; export type SidebarNavSection = { @@ -112,6 +115,30 @@ export const orgNavSections = ( } ] }, + { + heading: "Analytics", + items: [ + { + title: "sidebarLogsRequest", + href: "/{orgId}/settings/logs/request", + icon: + }, + ...(build != "oss" + ? [ + { + title: "sidebarLogsAccess", + href: "/{orgId}/settings/logs/access", + icon: + }, + { + title: "sidebarLogsAction", + href: "/{orgId}/settings/logs/action", + icon: + } + ] + : []) + ] + }, { heading: "Organization", items: [ diff --git a/src/components/ColumnFilter.tsx b/src/components/ColumnFilter.tsx new file mode 100644 index 00000000..eee91ecc --- /dev/null +++ b/src/components/ColumnFilter.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command"; +import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react"; +import { cn } from "@app/lib/cn"; + +interface FilterOption { + value: string; + label: string; +} + +interface ColumnFilterProps { + options: FilterOption[]; + selectedValue?: string; + onValueChange: (value: string | undefined) => void; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; +} + +export function ColumnFilter({ + options, + selectedValue, + onValueChange, + placeholder, + searchPlaceholder = "Search...", + emptyMessage = "No options found", + className +}: ColumnFilterProps) { + const [open, setOpen] = useState(false); + + const selectedOption = options.find(option => option.value === selectedValue); + + return ( + + + + + + + + + {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/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index af0a0fe6..70d64f0c 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -19,11 +19,21 @@ import { useTranslations } from "next-intl"; interface DataTablePaginationProps { table: Table; onPageSizeChange?: (pageSize: number) => void; + onPageChange?: (pageIndex: number) => void; + totalCount?: number; + isServerPagination?: boolean; + isLoading?: boolean; + disabled?: boolean; } export function DataTablePagination({ table, - onPageSizeChange + onPageSizeChange, + onPageChange, + totalCount, + isServerPagination = false, + isLoading = false, + disabled = false }: DataTablePaginationProps) { const t = useTranslations(); @@ -37,14 +47,60 @@ 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 (
+
+
+
+ ) : ( + { + handleDateChange(date); + setOpen(false); + }} + /> + )} + + + + + + ); +} + +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/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index b529dbba..5b0fdbab 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -4,10 +4,10 @@ import React from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { cn } from "@app/lib/cn"; -import { buttonVariants } from "@/components/ui/button"; import { Badge } from "@app/components/ui/badge"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useTranslations } from "next-intl"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; export type HorizontalTabs = Array<{ title: string; @@ -30,6 +30,7 @@ export function HorizontalTabs({ const pathname = usePathname(); const params = useParams(); const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + const subscription = useSubscriptionStatusContext(); const t = useTranslations(); function hydrateHref(href: string) { diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx new file mode 100644 index 00000000..58f7623e --- /dev/null +++ b/src/components/LogDataTable.tsx @@ -0,0 +1,529 @@ +"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, + Download, + ChevronRight, + ChevronDown +} 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 +}; + +export 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; +}; + +export 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; + onExport?: () => void; + isExporting?: boolean; + isRefreshing?: boolean; + searchPlaceholder?: string; + searchColumn?: string; + defaultSort?: { + id: string; + desc: boolean; + }; + tabs?: TabFilter[]; + defaultTab?: string; + disabled?: boolean; + onDateRangeChange?: ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => void; + dateRange?: { + start: DateTimeValue; + end: DateTimeValue; + }; + // Server-side pagination props + totalCount?: number; + pageSize: number; + currentPage?: number; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + isLoading?: boolean; + // Row expansion props + expandable?: boolean; + renderExpandedRow?: (row: TData) => React.ReactNode; +}; + +export function LogDataTable({ + columns, + data, + title, + onRefresh, + isRefreshing, + onExport, + isExporting, + // searchPlaceholder = "Search...", + // searchColumn = "name", + defaultSort, + tabs, + defaultTab, + onDateRangeChange, + pageSize, + dateRange, + totalCount, + currentPage = 0, + onPageChange, + onPageSizeChange: onPageSizeChangeProp, + isLoading = false, + expandable = false, + disabled=false, + renderExpandedRow +}: DataTableProps) { + const t = useTranslations(); + + const [sorting, setSorting] = useState( + defaultSort ? [defaultSort] : [] + ); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState([]); + const [activeTab, setActiveTab] = useState( + defaultTab || tabs?.[0]?.id || "" + ); + + const [startDate, setStartDate] = useState( + dateRange?.start || {} + ); + const [endDate, setEndDate] = useState(dateRange?.end || {}); + const [expandedRows, setExpandedRows] = useState>(new Set()); + + // 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 disabled, return empty array to prevent data loading + if (disabled) { + return []; + } + + 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, disabled]); + + // Toggle row expansion + const toggleRowExpansion = (rowId: string) => { + setExpandedRows((prev) => { + const newSet = new Set(prev); + if (newSet.has(rowId)) { + newSet.delete(rowId); + } else { + newSet.add(rowId); + } + return newSet; + }); + }; + + // Determine if using server-side pagination + const isServerPagination = totalCount !== undefined; + + // Create columns with expansion column if expandable + const enhancedColumns = useMemo(() => { + if (!expandable) { + return columns; + } + + const expansionColumn: ColumnDef = { + id: "expand", + header: () => null, + cell: ({ row }) => { + const isExpanded = expandedRows.has(row.id); + return ( + + ); + }, + size: 40 + }; + + return [expansionColumn, ...columns]; + }, [columns, expandable, expandedRows, toggleRowExpansion, disabled]); + + const table = useReactTable({ + data: filteredData, + columns: enhancedColumns, + getCoreRowModel: getCoreRowModel(), + // 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: currentPage + } + }, + state: { + sorting, + columnFilters, + globalFilter, + pagination: { + pageSize: pageSize, + pageIndex: currentPage + } + } + }); + + // 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]); + + // 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) => { + if (disabled) return; + + setActiveTab(value); + // Reset to first page when changing tabs + table.setPageIndex(0); + }; + + // Enhanced pagination component that updates our local state + const handlePageSizeChange = (newPageSize: number) => { + if (disabled) return; + + // setPageSize(newPageSize); + table.setPageSize(newPageSize); + + // Persist immediately when changed + // 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 (disabled) return; + + if (isServerPagination && onPageChange) { + onPageChange(newPageIndex); + } + }; + + const handleDateRangeChange = ( + start: DateTimeValue, + end: DateTimeValue + ) => { + if (disabled) return; + + setStartDate(start); + setEndDate(end); + onDateRangeChange?.(start, end); + }; + + return ( +
+ + +
+ {/*
+ + table.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8 m-0" + /> + +
*/} + +
+
+ {onRefresh && ( + + )} + {onExport && ( + + )} +
+
+ + + + {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) => { + const isExpanded = + expandable && expandedRows.has(row.id); + return [ + + expandable && !disabled + ? toggleRowExpansion( + row.id + ) + : undefined + } + className="text-xs" // made smaller + > + {row + .getVisibleCells() + .map((cell) => { + const originalRow = + row.original as any; + const actionValue = + originalRow?.action; + let className = ""; + + if ( + typeof actionValue === + "boolean" + ) { + className = + actionValue + ? "bg-green-100 dark:bg-green-900/50" + : "bg-red-100 dark:bg-red-900/50"; + } + + return ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ); + })} + , + isExpanded && + renderExpandedRow && ( + + + {renderExpandedRow( + row.original + )} + + + ) + ].filter(Boolean); + }).flat() + ) : ( + + + No results found. + + + )} + +
+
+ +
+
+
+
+ ); +} 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 { diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 00000000..f27bab31 --- /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 ( +