diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 5d76850d..b71cc7a8 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -672,27 +672,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: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - actorType: varchar("actorType").notNull(), - actor: varchar("actor").notNull(), - actorId: varchar("actorId").notNull(), - resourceId: integer("resourceId"), - ip: varchar("ip").notNull(), - type: varchar("type").notNull(), - action: varchar("action").notNull(), - event: varchar("event").notNull(), - location: varchar("location"), - userAgent: varchar("userAgent"), - metadata: text("details") -}, (table) => ([ - index("idx_requestAuditLog_timestamp").on(table.timestamp), - index("idx_requestAuditLog_org_timestamp").on(table.orgId, table.timestamp) -])); +export const requestAuditLog = pgTable( + "requestAuditLog", + { + id: serial("id").primaryKey(), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId") + .notNull() + .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"), + type: text("type"), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("details"), + 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; @@ -748,4 +763,4 @@ export type IdpOidcConfig = InferSelectModel; export type LicenseKey = InferSelectModel; export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; -export type RequestAuditLog = InferSelectModel; \ No newline at end of file +export type RequestAuditLog = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 1649fe97..c7102a83 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, index } from "drizzle-orm/sqlite-core"; +import { boolean } from "yargs"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -142,11 +143,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"), @@ -156,7 +161,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" @@ -710,27 +717,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") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - actorType: text("actorType").notNull(), - actor: text("actor").notNull(), - actorId: text("actorId").notNull(), - resourceId: integer("resourceId"), - ip: text("ip").notNull(), - type: text("type").notNull(), - action: text("action").notNull(), - event: text("event").notNull(), - location: text("location"), - userAgent: text("userAgent"), - metadata: text("details") -}, (table) => ([ - index("idx_requestAuditLog_timestamp").on(table.timestamp), - index("idx_requestAuditLog_org_timestamp").on(table.orgId, table.timestamp) -])); +export const requestAuditLog = sqliteTable( + "requestAuditLog", + { + id: integer("id").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + 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"), + type: text("type"), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("details"), + 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; @@ -786,4 +808,4 @@ export type IdpOidcConfig = InferSelectModel; export type LicenseKey = InferSelectModel; export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; -export type RequestAuditLog = InferSelectModel; \ No newline at end of file +export type RequestAuditLog = InferSelectModel; diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts new file mode 100644 index 00000000..b0e2b236 --- /dev/null +++ b/server/routers/badger/logRequestAudit.ts @@ -0,0 +1,114 @@ +import { db, requestAuditLog } from "@server/db"; +import logger from "@server/logger"; + +/** + +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 + + */ + +export async function logRequestAudit( + data: { + action: boolean; + reason: number; + user?: { username: string; userId: string; orgId: string }; + apiKey?: { name: string; apiKeyId: string; orgId: string }; + metadata?: any; + resouceId?: number; + type?: string; + location?: string; + // userAgent?: string; + }, + body: { + path: string; + originalRequestURL: string; + scheme: string; + host: string; + method: string; + tls: boolean; + sessions?: Record | undefined; + headers?: Record | undefined; + query?: Record | undefined; + requestIp?: string | undefined; + } +) { + try { + let orgId: string | undefined; + let actorType: string | undefined; + let actor: string | undefined; + let actorId: string | undefined; + + const user = data.user; + if (user) { + orgId = user.orgId; + actorType = "user"; + actor = user.username; + actorId = user.userId; + } + const apiKey = data.apiKey; + if (apiKey) { + orgId = apiKey.orgId; + actorType = "apiKey"; + actor = apiKey.name; + actorId = apiKey.apiKeyId; + } + + if (!orgId) { + logger.warn("logRequestAudit: No organization context found"); + return; + } + + // 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); + } + + await db.insert(requestAuditLog).values({ + timestamp, + orgId, + actorType, + actor, + actorId, + metadata, + action: data.action, + resourceId: data.resouceId, + type: data.type, + 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: body.requestIp, + tls: body.tls + }); + } catch (error) { + logger.error(error); + } +} diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 523163e6..ba3cdc9f 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -37,6 +37,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"; // We'll see if this speeds anything up const cache = new NodeCache({ @@ -149,6 +150,15 @@ export async function verifyResourceSession( if (!result) { logger.debug(`Resource not found ${cleanHost}`); + + logRequestAudit( + { + action: false, + reason: 201 //resource not found + }, + parsedBody.data + ); + return notAllowed(res); } @@ -160,6 +170,15 @@ export async function verifyResourceSession( if (!resource) { logger.debug(`Resource not found ${cleanHost}`); + + logRequestAudit( + { + action: false, + reason: 201 //resource not found + }, + parsedBody.data + ); + return notAllowed(res); } @@ -167,6 +186,15 @@ export async function verifyResourceSession( if (blockAccess) { logger.debug("Resource blocked", host); + + logRequestAudit( + { + action: false, + reason: 202 //resource blocked + }, + parsedBody.data + ); + return notAllowed(res); } @@ -180,9 +208,28 @@ export async function verifyResourceSession( if (action == "ACCEPT") { logger.debug("Resource allowed by rule"); + + logRequestAudit( + { + action: true, + reason: 100 // allowed by rule + }, + 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 + }, + parsedBody.data + ); + return notAllowed(res); } else if (action == "PASS") { logger.debug( @@ -203,6 +250,15 @@ export async function verifyResourceSession( !headerAuth ) { logger.debug("Resource allowed because no auth"); + + logRequestAudit( + { + action: true, + reason: 101 // allowed no auth + }, + parsedBody.data + ); + return allowed(res); } @@ -254,6 +310,14 @@ export async function verifyResourceSession( } if (valid && tokenItem) { + logRequestAudit( + { + action: true, + reason: 102 // valid access token + }, + parsedBody.data + ); + return allowed(res); } } @@ -290,6 +354,14 @@ export async function verifyResourceSession( } if (valid && tokenItem) { + logRequestAudit( + { + action: true, + reason: 102 // valid access token + }, + parsedBody.data + ); + return allowed(res); } } @@ -301,6 +373,15 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because header auth is valid (cached)" ); + + logRequestAudit( + { + action: true, + reason: 103 // valid header auth + }, + parsedBody.data + ); + return allowed(res); } else if ( await verifyPassword( @@ -310,15 +391,33 @@ export async function verifyResourceSession( ) { cache.set(clientHeaderAuthKey, clientHeaderAuth); logger.debug("Resource allowed because header auth is valid"); + + logRequestAudit( + { + action: true, + reason: 103 // valid header auth + }, + 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 + }, + parsedBody.data + ); + return notAllowed(res); } } else if (headerAuth) { @@ -329,6 +428,14 @@ export async function verifyResourceSession( !password && !resource.emailWhitelistEnabled ) { + logRequestAudit( + { + action: false, + reason: 299 // no more auth methods + }, + parsedBody.data + ); + return notAllowed(res); } } @@ -341,6 +448,15 @@ export async function verifyResourceSession( }. IP: ${clientIp}.` ); } + + logRequestAudit( + { + action: false, + reason: 204 // no sessions + }, + parsedBody.data + ); + return notAllowed(res); } @@ -374,6 +490,15 @@ export async function verifyResourceSession( }. IP: ${clientIp}.` ); } + + logRequestAudit( + { + action: false, + reason: 205 // temporary request token + }, + parsedBody.data + ); + return notAllowed(res); } @@ -382,6 +507,15 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because pincode session is valid" ); + + logRequestAudit( + { + action: true, + reason: 104 // valid pincode + }, + parsedBody.data + ); + return allowed(res); } @@ -389,6 +523,15 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because password session is valid" ); + + logRequestAudit( + { + action: true, + reason: 105 // valid password + }, + parsedBody.data + ); + return allowed(res); } @@ -399,6 +542,15 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because whitelist session is valid" ); + + logRequestAudit( + { + action: true, + reason: 106 // valid email + }, + parsedBody.data + ); + return allowed(res); } @@ -406,6 +558,15 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because access token session is valid" ); + + logRequestAudit( + { + action: true, + reason: 102 // valid access token + }, + parsedBody.data + ); + return allowed(res); } @@ -433,6 +594,15 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because user session is valid" ); + + logRequestAudit( + { + action: true, + reason: 107 // valid sso + }, + parsedBody.data + ); + return allowed(res, allowedUserData); } } @@ -451,6 +621,14 @@ export async function verifyResourceSession( logger.debug(`Redirecting to login at ${redirectPath}`); + logRequestAudit( + { + action: false, + reason: 299 // no more auth methods + }, + parsedBody.data + ); + return notAllowed(res, redirectPath, resource.orgId); } catch (e) { console.error(e);