Feature/Add Audit and Deletion (#5994)

* feat(audit): add emitEvent and file-based audit logging

* fix(sanitize.util.ts): correct IPv6 sanitization for audit log IP masking

* feat(account): add account deletion

* fix(sanitize.util.ts): add recursive sanitization for nested metadata

* fix(MainLayout/Header/Workspace): refresh workspace list when clicking dropdown

* feat(views/account): require typed confirmation for account deletion
This commit is contained in:
yau-wd
2026-04-02 22:15:05 +08:00
committed by GitHub
parent 1707688e62
commit c4e689b97a
28 changed files with 2156 additions and 27 deletions
@@ -2,9 +2,9 @@ import { BlobServiceClient, ContainerClient, StorageSharedKeyCredential } from '
import multer from 'multer'
import { MulterAzureStorage } from 'multer-azure-blob-storage'
import { v4 as uuidv4 } from 'uuid'
import { winstonAzureBlob } from 'winston-azure-blob'
import { BaseStorageProvider } from './BaseStorageProvider'
import { FileInfo, StorageResult, StorageSizeResult } from './IStorageProvider'
import { winstonAzureBlob } from 'winston-azure-blob'
/**
* Extends MulterAzureStorage to set file.path from file.blobName after upload.
@@ -281,7 +281,7 @@ export class AzureBlobStorageProvider extends BaseStorageProvider {
return multer({ storage: azureStorage })
}
getLoggerTransports(logType: 'server' | 'error' | 'requests'): any[] {
getLoggerTransports(logType: 'server' | 'error' | 'requests' | 'audit'): any[] {
const connectionString = process.env.AZURE_BLOB_STORAGE_CONNECTION_STRING
const accountName = process.env.AZURE_BLOB_STORAGE_ACCOUNT_NAME
const accountKey = process.env.AZURE_BLOB_STORAGE_ACCOUNT_KEY
@@ -325,7 +325,18 @@ export class AzureBlobStorageProvider extends BaseStorageProvider {
level: 'debug'
})
]
} else if (logType === 'audit') {
return [
winstonAzureBlob({
...baseConfig,
blobName: 'logs/audit/audit',
rotatePeriod: 'YYYY-MM-DD',
extension: '.log.jsonl',
level: 'info'
})
]
}
return []
}
}
@@ -1,9 +1,9 @@
import fs from 'node:fs'
import path from 'node:path'
import { IStorageProvider, FileInfo, StorageResult, StorageSizeResult } from './IStorageProvider'
import sanitize from 'sanitize-filename'
import { getUserHome } from '../utils'
import { isPathTraversal, isUnsafeFilePath, isValidUUID } from '../validator'
import fs from 'node:fs'
import { FileInfo, IStorageProvider, StorageResult, StorageSizeResult } from './IStorageProvider'
export abstract class BaseStorageProvider implements IStorageProvider {
protected storagePath: string
@@ -38,7 +38,7 @@ export abstract class BaseStorageProvider implements IStorageProvider {
abstract removeFolderFromStorage(...paths: string[]): Promise<StorageSizeResult>
abstract getStorageSize(orgId: string): Promise<number>
abstract getMulterStorage(): any
abstract getLoggerTransports(logType: 'server' | 'error' | 'requests', config?: any): any[]
abstract getLoggerTransports(logType: 'server' | 'error' | 'requests' | 'audit', config?: any): any[]
/**
* Shared utility for sanitizing filenames to prevent path traversal and other issues
@@ -1,5 +1,5 @@
import { Storage, Bucket } from '@google-cloud/storage'
import { LoggingWinston } from '@google-cloud/logging-winston'
import { Bucket, Storage } from '@google-cloud/storage'
import multer from 'multer'
import { v4 as uuidv4 } from 'uuid'
import { BaseStorageProvider } from './BaseStorageProvider'
@@ -336,7 +336,7 @@ export class GCSStorageProvider extends BaseStorageProvider {
})
}
getLoggerTransports(logType: 'server' | 'error' | 'requests'): any[] {
getLoggerTransports(logType: 'server' | 'error' | 'requests' | 'audit'): any[] {
const gcsConfig = {
projectId: this.projectId,
keyFilename: this.keyFilename,
@@ -368,7 +368,15 @@ export class GCSStorageProvider extends BaseStorageProvider {
logName: 'requests'
})
]
} else if (logType === 'audit') {
return [
new LoggingWinston({
...gcsConfig,
logName: 'audit'
})
]
}
return []
}
}
@@ -94,5 +94,5 @@ export interface IStorageProvider {
/**
* Get the Winston logger transports for this provider
*/
getLoggerTransports(logType: 'server' | 'error' | 'requests', config?: any): any[]
getLoggerTransports(logType: 'server' | 'error' | 'requests' | 'audit', config?: any): any[]
}
@@ -1,8 +1,8 @@
import path from 'node:path'
import fs from 'fs'
import multer from 'multer'
import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'node:path'
import { transports } from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file'
import { BaseStorageProvider } from './BaseStorageProvider'
import { FileInfo, StorageResult, StorageSizeResult } from './IStorageProvider'
@@ -350,7 +350,7 @@ export class LocalStorageProvider extends BaseStorageProvider {
return process.env.HOME || process.env.USERPROFILE || process.env.HOMEPATH || ''
}
getLoggerTransports(logType: 'server' | 'error' | 'requests', config?: any): any[] {
getLoggerTransports(logType: 'server' | 'error' | 'requests' | 'audit', config?: any): any[] {
const logDir = config?.logging?.dir || path.join(this.getUserHome(), '.flowise', 'logs')
if (!fs.existsSync(logDir)) {
@@ -373,6 +373,15 @@ export class LocalStorageProvider extends BaseStorageProvider {
level: config?.logging?.express?.level ?? 'debug'
})
]
} else if (logType === 'audit') {
return [
new DailyRotateFile({
filename: path.join(logDir, 'audit-%DATE%.log.jsonl'),
datePattern: 'YYYY-MM-DD-HH',
maxSize: '20m',
level: 'info'
})
]
}
// For 'error' type, return empty array (handled by exceptionHandlers in logger.ts)
@@ -7,11 +7,11 @@ import {
S3Client,
S3ClientConfig
} from '@aws-sdk/client-s3'
import { Readable } from 'node:stream'
import multer from 'multer'
import multerS3 from 'multer-s3'
import { transports } from 'winston'
import { Readable } from 'node:stream'
import { v4 as uuidv4 } from 'uuid'
import { transports } from 'winston'
import { BaseStorageProvider } from './BaseStorageProvider'
import { FileInfo, StorageResult, StorageSizeResult } from './IStorageProvider'
@@ -507,7 +507,7 @@ export class S3StorageProvider extends BaseStorageProvider {
})
}
getLoggerTransports(logType: 'server' | 'error' | 'requests'): any[] {
getLoggerTransports(logType: 'server' | 'error' | 'requests' | 'audit'): any[] {
if (logType === 'server') {
const s3ServerStream = new S3StreamLogger({
bucket: this.bucket,
@@ -532,7 +532,17 @@ export class S3StorageProvider extends BaseStorageProvider {
config: this.s3Config
})
return [new transports.Stream({ stream: s3ServerReqStream })]
} else if (logType === 'audit') {
const instance = process.env.HOSTNAME || process.env.POD_NAME || String(process.pid)
const s3AuditStream = new S3StreamLogger({
bucket: this.bucket,
folder: 'logs/audit',
name_format: `audit-%Y-%m-%d-%H-%M-%S-%L-${instance}.log.jsonl`,
config: this.s3Config
})
return [new transports.Stream({ stream: s3AuditStream })]
}
return []
}
}
+2
View File
@@ -109,6 +109,7 @@
"flowise-components": "workspace:^",
"flowise-nim-container-manager": "^1.0.11",
"flowise-ui": "workspace:^",
"geoip-lite": "^1.4.10",
"global-agent": "^3.0.0",
"gulp": "^4.0.2",
"handlebars": "^4.7.8",
@@ -158,6 +159,7 @@
"@types/cors": "^2.8.12",
"@types/crypto-js": "^4.1.1",
"@types/express-session": "^1.18.0",
"@types/geoip-lite": "^1.4.4",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.6",
"@types/multer": "^1.4.7",
+21 -1
View File
@@ -16,6 +16,7 @@ import * as fs from 'fs'
import { StatusCodes } from 'http-status-codes'
import jwt from 'jsonwebtoken'
import path from 'path'
import Stripe from 'stripe'
import { LoginMethodStatus } from './enterprise/database/entities/login-method.entity'
import { ErrorMessage, LoggedInUser } from './enterprise/Interface.Enterprise'
import { Permissions } from './enterprise/rbac/Permissions'
@@ -33,7 +34,6 @@ import { UsageCacheManager } from './UsageCacheManager'
import { GeneralErrorMessage, LICENSE_QUOTAS } from './utils/constants'
import { getRunningExpressApp } from './utils/getRunningExpressApp'
import { ENTERPRISE_FEATURE_FLAGS } from './utils/quotaUsage'
import Stripe from 'stripe'
const allSSOProviders = ['azure', 'google', 'auth0', 'github']
export class IdentityManager {
@@ -526,4 +526,24 @@ export class IdentityManager {
throw error
}
}
/**
* Cancels a Stripe subscription and syncs the result to the usage cache.
*
* Requests cancellation via Stripe, then updates the subscription data in cache
* so usage and billing state stay consistent. Throws if the Stripe manager
* is not initialized.
*
* @param subscriptionId - The Stripe subscription ID to cancel
* @returns The cancelled Stripe subscription object
*/
public async cancelSubscription(subscriptionId: string) {
if (!this.stripeManager) throw new Error('Stripe manager is not initialized')
const subscription = await this.stripeManager.cancelSubscription(subscriptionId)
const cacheManager = await UsageCacheManager.getInstance()
await cacheManager.updateSubscriptionDataToCache(subscriptionId, {
subsriptionDetails: this.stripeManager.getSubscriptionObject(subscription)
})
return subscription
}
}
+16 -2
View File
@@ -1,7 +1,7 @@
import Stripe from 'stripe'
import { Request } from 'express'
import { UsageCacheManager } from './UsageCacheManager'
import Stripe from 'stripe'
import { UserPlan } from './Interface'
import { UsageCacheManager } from './UsageCacheManager'
import { LICENSE_QUOTAS } from './utils/constants'
export class StripeManager {
@@ -611,4 +611,18 @@ export class StripeManager {
throw error
}
}
/**
* Cancels a Stripe subscription immediately without proration or an immediate invoice.
*
* Calls the Stripe API to cancel the subscription with `prorate: false` and
* `invoice_now: false`. Throws if the Stripe client is not initialized.
*
* @param subscriptionId - The Stripe subscription ID to cancel
* @returns A promise resolving to the Stripe API response containing the cancelled subscription
*/
public async cancelSubscription(subscriptionId: string): Promise<Stripe.Response<Stripe.Subscription>> {
if (!this.stripe) throw new Error('Stripe is not initialized')
return await this.stripe.subscriptions.cancel(subscriptionId, { prorate: false, invoice_now: false })
}
}
@@ -1,6 +1,10 @@
import { NextFunction, Request, Response } from 'express'
import { StatusCodes } from 'http-status-codes'
import { QueryRunner } from 'typeorm'
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
import { GeneralErrorMessage } from '../../utils/constants'
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
import { emitEvent, TelemetryEventCategory, TelemetryEventResult } from '../../utils/telemetry'
import { Organization } from '../database/entities/organization.entity'
import { User } from '../database/entities/user.entity'
import { AccountDTO, AccountService } from '../services/account.service'
@@ -146,6 +150,45 @@ export class AccountController {
return res.json({ message: 'Authentication failed' })
}
}
public async delete(req: Request, res: Response, next: NextFunction) {
let queryRunner: QueryRunner | undefined
try {
const { confirmationText } = req.body
if (confirmationText !== 'permanently delete') {
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, 'Confirmation text must match "permanently delete"')
}
queryRunner = getRunningExpressApp().AppDataSource.createQueryRunner()
await queryRunner.connect()
if (!req.user || !req.ip) throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, GeneralErrorMessage.UNAUTHORIZED)
const accountService = new AccountService()
await accountService.delete(queryRunner, req.user, req.ip)
return res.status(StatusCodes.OK).json({ message: 'Account deleted' })
} catch (error) {
if (queryRunner && queryRunner.isTransactionActive) await queryRunner.rollbackTransaction()
await emitEvent({
category: TelemetryEventCategory.AUDIT,
eventType: 'account-deleted',
actionType: 'delete',
userId: req.user?.id ?? 'unknown',
orgId: req.user?.activeOrganizationId ?? 'unknown',
resourceId: req.user?.id ?? 'unknown',
ipAddress: req.ip,
result: TelemetryEventResult.FAILED,
metadata: {
failureReason: error instanceof InternalFlowiseError ? error.message : 'internal_error'
}
})
next(error)
} finally {
if (queryRunner && !queryRunner.isReleased) await queryRunner.release()
}
}
}
function sanitizeRegistrationDTO(data: AccountDTO): AccountDTO {
@@ -1,6 +1,6 @@
import express from 'express'
import { AccountController } from '../controllers/account.controller'
import { IdentityManager } from '../../IdentityManager'
import { AccountController } from '../controllers/account.controller'
import { checkAnyPermission } from '../rbac/PermissionCheck'
const router = express.Router()
@@ -36,4 +36,6 @@ router.get('/basic-auth', accountController.getBasicAuth)
router.post('/basic-auth', accountController.checkBasicAuth)
router.delete('/delete', accountController.delete)
export default router
@@ -1,8 +1,28 @@
import bcrypt from 'bcryptjs'
import { removeFolderFromStorage } from 'flowise-components'
import jwt, { JwtPayload } from 'jsonwebtoken'
import { StatusCodes } from 'http-status-codes'
import moment from 'moment'
import { DataSource, QueryRunner } from 'typeorm'
import { DataSource, In, QueryRunner } from 'typeorm'
import { ApiKey } from '../../database/entities/ApiKey'
import { Assistant } from '../../database/entities/Assistant'
import { ChatFlow } from '../../database/entities/ChatFlow'
import { ChatMessage } from '../../database/entities/ChatMessage'
import { ChatMessageFeedback } from '../../database/entities/ChatMessageFeedback'
import { Credential } from '../../database/entities/Credential'
import { CustomTemplate } from '../../database/entities/CustomTemplate'
import { Dataset } from '../../database/entities/Dataset'
import { DatasetRow } from '../../database/entities/DatasetRow'
import { DocumentStore } from '../../database/entities/DocumentStore'
import { DocumentStoreFileChunk } from '../../database/entities/DocumentStoreFileChunk'
import { Evaluation } from '../../database/entities/Evaluation'
import { EvaluationRun } from '../../database/entities/EvaluationRun'
import { Evaluator } from '../../database/entities/Evaluator'
import { Execution } from '../../database/entities/Execution'
import { Lead } from '../../database/entities/Lead'
import { Tool } from '../../database/entities/Tool'
import { UpsertHistory } from '../../database/entities/UpsertHistory'
import { Variable } from '../../database/entities/Variable'
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
import { IdentityManager } from '../../IdentityManager'
import { Platform, UserPlan } from '../../Interface'
@@ -10,6 +30,8 @@ import { GeneralErrorMessage } from '../../utils/constants'
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
import logger from '../../utils/logger'
import { checkUsageLimit } from '../../utils/quotaUsage'
import { emitEvent, TelemetryEventCategory, TelemetryEventResult } from '../../utils/telemetry'
import { WorkspaceShared } from '../database/entities/EnterpriseEntities'
import { OrganizationUser, OrganizationUserStatus } from '../database/entities/organization-user.entity'
import { Organization, OrganizationName } from '../database/entities/organization.entity'
import { GeneralRole, Role } from '../database/entities/role.entity'
@@ -693,6 +715,157 @@ export class AccountService {
}
}
/**
* Permanently deletes the logged-in user's account and all associated organization and workspace data.
*
* Only allowed on CLOUD platform. Validates that the user is the sole organization owner and that
* the organization has a subscription, then runs a transaction that removes organization and
* workspace memberships, deletes all workspace resources (chatflows, documents, evaluations,
* datasets, etc.), anonymizes the user record for GDPR, cancels the Stripe subscription, removes
* organization storage, and emits an audit event. Throws on validation failure or if the user is
* not found.
*
* @param queryRunner - TypeORM query runner for the database transaction
* @param loggedInUser - The authenticated user requesting account deletion
* @param ipAddress - Client IP address (e.g. for audit/telemetry)
* @returns A promise that resolves when deletion and cleanup complete, or rejects with an error
*/
public async delete(queryRunner: QueryRunner, loggedInUser: LoggedInUser, ipAddress: string): Promise<void> {
if (this.identityManager.getPlatformType() !== Platform.CLOUD)
throw new InternalFlowiseError(StatusCodes.FORBIDDEN, GeneralErrorMessage.FORBIDDEN)
if (!loggedInUser.id) throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, GeneralErrorMessage.UNAUTHORIZED)
// Step 3.1: Find User ID by Email
const user = await this.userService.readUserById(loggedInUser.id, queryRunner)
if (!user) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, UserErrorMessage.USER_NOT_FOUND)
// Step 3.1: Find Organization Memberships and Roles
const targetUserOrganizationMemberships = await this.organizationUserService.readOrganizationUserByUserId(user.id, queryRunner)
if (!targetUserOrganizationMemberships?.length)
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, OrganizationUserErrorMessage.ORGANIZATION_USER_NOT_FOUND)
// Step 3.1.1: Verify that there is only one owner
const organizationIdsWhereOwner = targetUserOrganizationMemberships
.filter((organizationUser) => organizationUser.isOrgOwner)
.map((organizationUser) => organizationUser)
if (organizationIdsWhereOwner.length !== 1)
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, GeneralErrorMessage.NOT_ALLOWED_TO_DELETE_OWNER)
const organizaiton = await this.organizationservice.readOrganizationById(organizationIdsWhereOwner[0].organizationId, queryRunner)
if (!organizaiton?.subscriptionId)
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, OrganizationErrorMessage.ORGANIZATION_HAS_NO_SUBSCRIPTION)
// Step 3.1.2: Verify how many people invited him as member
const organizationsUserWasInvitedTo = targetUserOrganizationMemberships
.filter((organizationUser) => !organizationUser.isOrgOwner)
.map((organizationUser) => organizationUser.organizationId)
// Step 3.3: Find All Members and Owner in the Organization
const organizationUsers = await this.organizationUserService.readOrganizationUserByOrganizationId(organizaiton.id, queryRunner)
if (!organizationUsers)
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, OrganizationUserErrorMessage.ORGANIZATION_USER_NOT_FOUND)
const membershipsWhereUserWasInvited = organizationUsers
.filter((organizationUser) => !organizationUser.isOrgOwner)
.map((organizationUser) => organizationUser.userId)
// Step 3.4: Find All Workspaces for the Organization
const workspaceIds = (await queryRunner.manager.findBy(Workspace, { organizationId: organizaiton.id })).map(
(workspace) => workspace.id
)
if (workspaceIds.length === 0) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, WorkspaceErrorMessage.WORKSPACE_NOT_FOUND)
const chatflowIds = (await queryRunner.manager.findBy(ChatFlow, { workspaceId: In(workspaceIds) })).map((chatflow) => chatflow.id)
const documentStoreIds = (await queryRunner.manager.findBy(DocumentStore, { workspaceId: In(workspaceIds) })).map(
(documentStore) => documentStore.id
)
const evaluationIds = (await queryRunner.manager.findBy(Evaluation, { workspaceId: In(workspaceIds) })).map(
(evaluation) => evaluation.id
)
const datasetIds = (await queryRunner.manager.findBy(Dataset, { workspaceId: In(workspaceIds) })).map((dataset) => dataset.id)
// Step 4: Deletion Process
await queryRunner.startTransaction()
// Step 4.1: Delete Organization Users with Member Role
await queryRunner.manager.delete(OrganizationUser, { userId: loggedInUser.id, organizationId: In(organizationsUserWasInvitedTo) })
await queryRunner.manager.delete(OrganizationUser, {
organizationId: organizaiton.id,
userId: In(membershipsWhereUserWasInvited)
})
// Step 4.2: Delete Workspace Users
await queryRunner.manager.delete(WorkspaceUser, { workspaceId: In(workspaceIds) })
await queryRunner.manager.delete(WorkspaceUser, { userId: loggedInUser.id })
// Step 4.3: Delete Roles created for the Organization
await queryRunner.manager.delete(Role, { organizationId: organizaiton.id })
// Step 4.4: Delete Workspace Child Data
// Step 4.4.1: Delete Chat Messages
await queryRunner.manager.delete(ChatMessageFeedback, { chatflowid: In(chatflowIds) })
await queryRunner.manager.delete(ChatMessage, { chatflowid: In(chatflowIds) })
// Step 4.4.2: Delete Upsert History
await queryRunner.manager.delete(UpsertHistory, { chatflowid: In(chatflowIds) })
await queryRunner.manager.delete(UpsertHistory, { chatflowid: In(documentStoreIds) }) // don't be alarm because we reuse the chatflowid for document store upsert history
// Step 4.4.3: Delete Leads
await queryRunner.manager.delete(Lead, { chatflowid: In(chatflowIds) })
// Step 4.4.4: Delete Document Store Data
await queryRunner.manager.delete(DocumentStoreFileChunk, { storeId: In(documentStoreIds) })
await queryRunner.manager.delete(DocumentStore, { workspaceId: In(workspaceIds) })
// Step 4.4.5: Delete Evaluation Data
await queryRunner.manager.delete(EvaluationRun, { evaluationId: In(evaluationIds) })
await queryRunner.manager.delete(Evaluation, { workspaceId: In(workspaceIds) })
// Step 4.4.6: Delete Dataset Data
await queryRunner.manager.delete(DatasetRow, { datasetId: In(datasetIds) })
await queryRunner.manager.delete(Dataset, { workspaceId: In(workspaceIds) })
// Step 4.4.7: Delete ChatFlows
await queryRunner.manager.delete(ChatFlow, { workspaceId: In(workspaceIds) })
// Step 4.4.8: Delete Other Workspace Resources
await queryRunner.manager.delete(ApiKey, { workspaceId: In(workspaceIds) })
await queryRunner.manager.delete(Variable, { workspaceId: In(workspaceIds) })
await queryRunner.manager.delete(Tool, { workspaceId: In(workspaceIds) })
await queryRunner.manager.delete(Credential, { workspaceId: In(workspaceIds) })
await queryRunner.manager.delete(Assistant, { workspaceId: In(workspaceIds) })
await queryRunner.manager.delete(Evaluator, { workspaceId: In(workspaceIds) })
await queryRunner.manager.delete(CustomTemplate, { workspaceId: In(workspaceIds) })
await queryRunner.manager.delete(Execution, { workspaceId: In(workspaceIds) })
// Step 4.4.9: Delete Workspace
await queryRunner.manager.delete(WorkspaceShared, { workspaceId: In(workspaceIds) })
await queryRunner.manager.delete(Workspace, { id: In(workspaceIds) })
// Step 5: Anonymize User Record (GDPR Compliance)
user.name = UserStatus.DELETED
user.email = `deleted_${user.id}_${Date.now()}@deleted.flowise`
user.status = UserStatus.DELETED
user.credential = null
user.tokenExpiry = null
user.tempToken = null
await queryRunner.manager.save(User, user)
// Step 6: Cancel Stripe Subscription
await this.identityManager.cancelSubscription(organizaiton.subscriptionId)
await queryRunner.commitTransaction()
// Step 7: Delete Organization Folder from Storage
await removeFolderFromStorage(organizaiton.id)
await emitEvent({
category: TelemetryEventCategory.AUDIT,
eventType: 'account-deleted',
actionType: 'delete',
userId: user.id,
orgId: organizaiton.id,
resourceId: user.id,
ipAddress: ipAddress,
result: TelemetryEventResult.SUCCESS
})
}
public async initiateEmailChange(userId: string, newEmail: string) {
const queryRunner = this.dataSource.createQueryRunner()
await queryRunner.connect()
@@ -13,7 +13,8 @@ export const enum OrganizationErrorMessage {
INVALID_ORGANIZATION_NAME = 'Invalid Organization Name',
ORGANIZATION_NOT_FOUND = 'Organization Not Found',
ORGANIZATION_FOUND_MULTIPLE = 'Organization Found Multiple',
ORGANIZATION_RESERVERD_NAME = 'Organization name cannot be Default Organization - this is a reserved name'
ORGANIZATION_RESERVERD_NAME = 'Organization name cannot be Default Organization - this is a reserved name',
ORGANIZATION_HAS_NO_SUBSCRIPTION = 'Organization has no subscription'
}
export class OrganizationService {
+36 -1
View File
@@ -1,8 +1,43 @@
import { generateAPIKey } from './apiKey'
import { compareKeys, generateAPIKey, generateSecretHash } from './apiKey'
describe('Api Key', () => {
it('should be able to generate a new api key', () => {
const apiKey = generateAPIKey()
expect(typeof apiKey === 'string').toEqual(true)
})
describe('generateSecretHash', () => {
it('should return a string in hash.salt format', () => {
const apiKey = 'test-api-key'
const stored = generateSecretHash(apiKey)
expect(typeof stored).toBe('string')
const parts = stored.split('.')
expect(parts).toHaveLength(2)
expect(parts[0]).toMatch(/^[0-9a-f]+$/)
expect(parts[1]).toMatch(/^[0-9a-f]+$/)
})
it('should produce different hashes for the same key (different salt)', () => {
const apiKey = 'test-api-key'
const stored1 = generateSecretHash(apiKey)
const stored2 = generateSecretHash(apiKey)
expect(stored1).not.toBe(stored2)
expect(compareKeys(stored1, apiKey)).toBe(true)
expect(compareKeys(stored2, apiKey)).toBe(true)
})
})
describe('compareKeys', () => {
it('should return true when supplied key matches stored hash', () => {
const apiKey = generateAPIKey()
const storedKey = generateSecretHash(apiKey)
expect(compareKeys(storedKey, apiKey)).toBe(true)
})
it('should return false when supplied key does not match stored hash', () => {
const apiKey = generateAPIKey()
const storedKey = generateSecretHash(apiKey)
expect(compareKeys(storedKey, 'wrong-key')).toBe(false)
})
})
})
+1 -1
View File
@@ -56,7 +56,7 @@ export const WHITELIST_URLS = [
GithubSSO.CALLBACK_URI
]
export const API_KEY_BLACKLIST_URLS = ['/api/v1/nvidia-nim']
export const API_KEY_BLACKLIST_URLS = ['/api/v1/nvidia-nim', '/api/v1/account/delete']
export const enum GeneralErrorMessage {
FORBIDDEN = 'Forbidden',
@@ -0,0 +1,280 @@
import { describe, expect, it } from '@jest/globals'
import { isIPv4, isIPv6, isValidIPAddress } from './ipValidation'
describe('IP Address Validation Utilities', () => {
describe('isValidIPAddress', () => {
describe('Valid IPv4 addresses', () => {
it('should return true for standard IPv4 address', () => {
expect(isValidIPAddress('192.168.1.1')).toBe(true)
})
it('should return true for localhost IPv4', () => {
expect(isValidIPAddress('127.0.0.1')).toBe(true)
})
it('should return true for 0.0.0.0', () => {
expect(isValidIPAddress('0.0.0.0')).toBe(true)
})
it('should return true for 255.255.255.255', () => {
expect(isValidIPAddress('255.255.255.255')).toBe(true)
})
it('should return true for public IPv4', () => {
expect(isValidIPAddress('8.8.8.8')).toBe(true)
})
it('should return true for private IPv4 (10.x.x.x)', () => {
expect(isValidIPAddress('10.0.0.1')).toBe(true)
})
it('should return true for private IPv4 (172.16.x.x)', () => {
expect(isValidIPAddress('172.16.0.1')).toBe(true)
})
it('should return true for private IPv4 (192.168.x.x)', () => {
expect(isValidIPAddress('192.168.0.1')).toBe(true)
})
})
describe('Valid IPv6 addresses', () => {
it('should return true for full IPv6 address', () => {
expect(isValidIPAddress('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(true)
})
it('should return true for compressed IPv6 address', () => {
expect(isValidIPAddress('2001:db8:85a3::8a2e:370:7334')).toBe(true)
})
it('should return true for IPv6 localhost (::1)', () => {
expect(isValidIPAddress('::1')).toBe(true)
})
it('should return true for IPv6 unspecified address (::)', () => {
expect(isValidIPAddress('::')).toBe(true)
})
it('should return true for IPv6 with zeros compressed', () => {
expect(isValidIPAddress('fe80::1')).toBe(true)
})
it('should return true for IPv6 link-local address', () => {
expect(isValidIPAddress('fe80::a00:27ff:fe4e:66a1')).toBe(true)
})
it('should return true for IPv4-mapped IPv6 address', () => {
expect(isValidIPAddress('::ffff:192.168.1.1')).toBe(true)
})
})
describe('Invalid IP addresses', () => {
it('should return false for empty string', () => {
expect(isValidIPAddress('')).toBe(false)
})
it('should return false for undefined', () => {
expect(isValidIPAddress(undefined as any)).toBe(false)
})
it('should return false for null', () => {
expect(isValidIPAddress(null as any)).toBe(false)
})
it('should return false for non-string input', () => {
expect(isValidIPAddress(123 as any)).toBe(false)
})
it('should return false for invalid IPv4 (too many octets)', () => {
expect(isValidIPAddress('192.168.1.1.1')).toBe(false)
})
it('should return false for invalid IPv4 (octet > 255)', () => {
expect(isValidIPAddress('192.168.256.1')).toBe(false)
})
it('should return false for invalid IPv4 (negative octet)', () => {
expect(isValidIPAddress('192.168.-1.1')).toBe(false)
})
it('should return false for invalid IPv4 (too few octets)', () => {
expect(isValidIPAddress('192.168.1')).toBe(false)
})
it('should return false for hostname', () => {
expect(isValidIPAddress('example.com')).toBe(false)
})
it('should return false for URL', () => {
expect(isValidIPAddress('http://192.168.1.1')).toBe(false)
})
it('should return false for invalid IPv6 (too many segments)', () => {
expect(isValidIPAddress('2001:0db8:85a3:0000:0000:8a2e:0370:7334:extra')).toBe(false)
})
it('should return false for malformed IPv6', () => {
expect(isValidIPAddress('gggg::1')).toBe(false)
})
it('should return false for random string', () => {
expect(isValidIPAddress('not-an-ip')).toBe(false)
})
})
})
describe('isIPv4', () => {
describe('Valid IPv4 addresses', () => {
it('should return true for standard IPv4 address', () => {
expect(isIPv4('192.168.1.1')).toBe(true)
})
it('should return true for localhost IPv4', () => {
expect(isIPv4('127.0.0.1')).toBe(true)
})
it('should return true for 0.0.0.0', () => {
expect(isIPv4('0.0.0.0')).toBe(true)
})
it('should return true for 255.255.255.255', () => {
expect(isIPv4('255.255.255.255')).toBe(true)
})
it('should return true for public IPv4 (8.8.8.8)', () => {
expect(isIPv4('8.8.8.8')).toBe(true)
})
it('should return true for public IPv4 (1.1.1.1)', () => {
expect(isIPv4('1.1.1.1')).toBe(true)
})
})
describe('Invalid or non-IPv4 addresses', () => {
it('should return false for IPv6 address', () => {
expect(isIPv4('2001:db8:85a3::8a2e:370:7334')).toBe(false)
})
it('should return false for IPv6 localhost', () => {
expect(isIPv4('::1')).toBe(false)
})
it('should return false for IPv4-mapped IPv6 address', () => {
expect(isIPv4('::ffff:192.168.1.1')).toBe(false)
})
it('should return false for invalid IPv4', () => {
expect(isIPv4('192.168.256.1')).toBe(false)
})
it('should return false for empty string', () => {
expect(isIPv4('')).toBe(false)
})
it('should return false for hostname', () => {
expect(isIPv4('example.com')).toBe(false)
})
})
})
describe('isIPv6', () => {
describe('Valid IPv6 addresses', () => {
it('should return true for full IPv6 address', () => {
expect(isIPv6('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(true)
})
it('should return true for compressed IPv6 address', () => {
expect(isIPv6('2001:db8:85a3::8a2e:370:7334')).toBe(true)
})
it('should return true for IPv6 localhost (::1)', () => {
expect(isIPv6('::1')).toBe(true)
})
it('should return true for IPv6 unspecified address (::)', () => {
expect(isIPv6('::')).toBe(true)
})
it('should return true for link-local IPv6', () => {
expect(isIPv6('fe80::1')).toBe(true)
})
it('should return true for IPv4-mapped IPv6 address', () => {
expect(isIPv6('::ffff:192.168.1.1')).toBe(true)
})
it('should return true for global unicast IPv6', () => {
expect(isIPv6('2607:f8b0:4005:805::200e')).toBe(true)
})
})
describe('Invalid or non-IPv6 addresses', () => {
it('should return false for IPv4 address', () => {
expect(isIPv6('192.168.1.1')).toBe(false)
})
it('should return false for IPv4 localhost', () => {
expect(isIPv6('127.0.0.1')).toBe(false)
})
it('should return false for invalid IPv6', () => {
expect(isIPv6('gggg::1')).toBe(false)
})
it('should return false for empty string', () => {
expect(isIPv6('')).toBe(false)
})
it('should return false for hostname', () => {
expect(isIPv6('example.com')).toBe(false)
})
it('should return false for malformed IPv6', () => {
expect(isIPv6('2001:0db8:85a3:0000:0000:8a2e:0370:7334:extra')).toBe(false)
})
})
})
describe('Edge cases and security considerations', () => {
it('isValidIPAddress should handle whitespace-trimmed input', () => {
// Note: Node's isIP doesn't auto-trim, so these should fail
expect(isValidIPAddress(' 192.168.1.1 ')).toBe(false)
})
it('isValidIPAddress should reject IP with port number', () => {
expect(isValidIPAddress('192.168.1.1:8080')).toBe(false)
})
it('isIPv4 should reject IPv4 with CIDR notation', () => {
expect(isIPv4('192.168.1.0/24')).toBe(false)
})
it('isIPv6 should reject IPv6 with CIDR notation', () => {
expect(isIPv6('2001:db8::/32')).toBe(false)
})
it('should handle special IPv4 addresses correctly', () => {
expect(isIPv4('169.254.0.1')).toBe(true) // Link-local
expect(isIPv4('224.0.0.1')).toBe(true) // Multicast
expect(isIPv4('255.255.255.255')).toBe(true) // Broadcast
})
it('should handle special IPv6 addresses correctly', () => {
expect(isIPv6('ff02::1')).toBe(true) // Multicast
expect(isIPv6('fc00::1')).toBe(true) // Unique local address
})
})
describe('Type safety', () => {
it('should handle various falsy values gracefully', () => {
expect(isValidIPAddress('' as string)).toBe(false)
expect(isValidIPAddress(null as any)).toBe(false)
expect(isValidIPAddress(undefined as any)).toBe(false)
})
it('should handle non-string types gracefully', () => {
expect(isValidIPAddress(123 as any)).toBe(false)
expect(isValidIPAddress({} as any)).toBe(false)
expect(isValidIPAddress([] as any)).toBe(false)
})
})
})
+43
View File
@@ -0,0 +1,43 @@
// packages/server/src/utils/ipValidation.ts
import { isIP } from 'net'
/**
* Validates if a string is a valid IPv4 or IPv6 address.
*
* Uses Node.js built-in `net.isIP()` for robust validation that handles
* standard formats, IPv4-mapped IPv6 addresses, and compressed IPv6 notation.
*
* @param ip - The IP address string to validate
* @returns `true` if the string is a valid IPv4 or IPv6 address, `false` otherwise
*/
export function isValidIPAddress(ip: string): boolean {
if (!ip || typeof ip !== 'string') return false
return isIP(ip) !== 0 // Returns 4 for IPv4, 6 for IPv6, 0 for invalid
}
/**
* Checks if a string is a valid IPv4 address.
*
* Uses Node.js built-in `net.isIP()` to determine if the address
* is specifically IPv4 format.
*
* @param ip - The IP address string to validate
* @returns `true` if the string is a valid IPv4 address, `false` otherwise
*/
export function isIPv4(ip: string): boolean {
return isIP(ip) === 4
}
/**
* Checks if a string is a valid IPv6 address.
*
* Uses Node.js built-in `net.isIP()` to determine if the address
* is specifically IPv6 format (including compressed notation and IPv4-mapped addresses).
*
* @param ip - The IP address string to validate
* @returns `true` if the string is a valid IPv6 address, `false` otherwise
*/
export function isIPv6(ip: string): boolean {
return isIP(ip) === 6
}
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'
// ---------- mocks (must be hoisted) ----------
const mockExistsSync = jest.fn()
const mockMkdirSync = jest.fn()
jest.mock('fs', () => ({
existsSync: (...args: any[]) => mockExistsSync(...args),
mkdirSync: (...args: any[]) => mockMkdirSync(...args)
}))
jest.mock('flowise-components', () => {
const winston = require('winston')
return {
StorageProviderFactory: {
getProvider: jest.fn(() => ({
getLoggerTransports: jest.fn(() => [new winston.transports.Console()])
}))
}
}
})
jest.mock('../../src/utils/config', () => ({
__esModule: true,
default: {
logging: { dir: '/tmp/flowise-test-logs' }
}
}))
// ---------- load logger only after mocks (so our mocks are used) ----------
let logger: any
let expressRequestLogger: (req: any, res: any, next: any) => void
let auditLogger: any
const ORIGINAL_ENV = process.env
describe('logger.ts', () => {
beforeAll(() => {
mockExistsSync.mockReturnValue(false) // so line 22 (mkdirSync) runs when module loads
jest.resetModules()
const mod = require('../../src/utils/logger')
logger = mod.default
expressRequestLogger = mod.expressRequestLogger
auditLogger = mod.auditLogger
// Assert here so beforeEach's clearAllMocks() doesn't wipe call history
expect(mockExistsSync).toHaveBeenCalled()
expect(mockMkdirSync).toHaveBeenCalled()
})
beforeEach(() => {
process.env = { ...ORIGINAL_ENV }
jest.clearAllMocks()
})
afterEach(() => {
process.env = ORIGINAL_ENV
})
describe('expressRequestLogger', () => {
const next = jest.fn()
it('calls next()', () => {
const req = { url: '/api/v1/chatflows', method: 'GET', params: {} } as any
expressRequestLogger(req, {} as any, next)
expect(next).toHaveBeenCalled()
})
it('does not log when URL is in unwantedLogURLs (ping)', () => {
const req = { url: '/api/v1/ping', method: 'GET', params: {} } as any
const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => {})
expressRequestLogger(req, {} as any, next)
expect(next).toHaveBeenCalled()
expect(infoSpy).not.toHaveBeenCalled()
infoSpy.mockRestore()
})
it('does not log when URL is in unwantedLogURLs (node-icon)', () => {
const req = { url: '/api/v1/node-icon/xyz', method: 'GET', params: {} } as any
const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => {})
expressRequestLogger(req, {} as any, next)
expect(next).toHaveBeenCalled()
expect(infoSpy).not.toHaveBeenCalled()
infoSpy.mockRestore()
})
it('does not log when URL does not match /api/v1/', () => {
const req = { url: '/health', method: 'GET', params: {} } as any
const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => {})
expressRequestLogger(req, {} as any, next)
expect(next).toHaveBeenCalled()
expect(infoSpy).not.toHaveBeenCalled()
infoSpy.mockRestore()
})
it('logs GET request (requestLogger.http path)', () => {
const req = { url: '/api/v1/chatflows', method: 'GET', params: {} } as any
expressRequestLogger(req, {} as any, next)
expect(next).toHaveBeenCalled()
})
it('logs POST request (requestLogger.info + logger.info path)', () => {
const req = { url: '/api/v1/chatflows', method: 'POST', params: {} } as any
const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => {})
expressRequestLogger(req, {} as any, next)
expect(next).toHaveBeenCalled()
expect(infoSpy).toHaveBeenCalled()
infoSpy.mockRestore()
})
it('logs PUT request', () => {
const req = { url: '/api/v1/chatflows/1', method: 'PUT', params: {} } as any
const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => {})
expressRequestLogger(req, {} as any, next)
expect(infoSpy).toHaveBeenCalled()
infoSpy.mockRestore()
})
it('logs DELETE request', () => {
const req = { url: '/api/v1/chatflows/1', method: 'DELETE', params: {} } as any
const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => {})
expressRequestLogger(req, {} as any, next)
expect(infoSpy).toHaveBeenCalled()
infoSpy.mockRestore()
})
it('when DEBUG=true runs sanitization (getSensitiveBodyFields, getSensitiveHeaderFields, sanitizeObject)', () => {
process.env.DEBUG = 'true'
process.env.LOG_SANITIZE_BODY_FIELDS = 'password,secret'
process.env.LOG_SANITIZE_HEADER_FIELDS = 'authorization'
const req = {
url: '/api/v1/chatflows',
method: 'POST',
params: {},
body: { password: 'mypass', user: '[email protected]' },
query: { token: '[email protected]' },
headers: { authorization: 'Bearer xxx', 'content-type': 'application/json' }
} as any
const res = {} as any
expressRequestLogger(req, res, next)
expect(next).toHaveBeenCalled()
})
it('when DEBUG=true and no sanitize env vars still includes body/query/headers', () => {
process.env.DEBUG = 'true'
delete process.env.LOG_SANITIZE_BODY_FIELDS
delete process.env.LOG_SANITIZE_HEADER_FIELDS
const req = {
url: '/api/v1/chatflows',
method: 'POST',
params: {},
body: { foo: 1 },
query: {},
headers: {}
} as any
expressRequestLogger(req, {} as any, next)
expect(next).toHaveBeenCalled()
})
})
describe('exports', () => {
it('default logger has log methods', () => {
expect(logger.info).toBeDefined()
expect(logger.error).toBeDefined()
expect(logger.warn).toBeDefined()
expect(logger.debug).toBeDefined()
})
it('auditLogger is defined', () => {
expect(auditLogger).toBeDefined()
expect(typeof auditLogger.log).toBe('function')
})
})
})
+11 -3
View File
@@ -1,8 +1,8 @@
import * as fs from 'fs'
import config from './config' // should be replaced by node-config or similar
import { createLogger, transports, format } from 'winston'
import { NextFunction, Request, Response } from 'express'
import { StorageProviderFactory } from 'flowise-components'
import * as fs from 'fs'
import { createLogger, format, transports } from 'winston'
import config from './config' // should be replaced by node-config or similar
const { combine, timestamp, printf, errors } = format
@@ -12,6 +12,7 @@ const provider = StorageProviderFactory.getProvider()
const serverTransports = provider.getLoggerTransports('server', config)
const errorTransports = provider.getLoggerTransports('error', config)
const requestTransports = provider.getLoggerTransports('requests', config)
const auditTransports = provider.getLoggerTransports('audit', config)
// expect the log dir be relative to the projects root
const logDir = config.logging.dir
@@ -143,4 +144,11 @@ export function expressRequestLogger(req: Request, res: Response, next: NextFunc
next()
}
export const auditLogger = createLogger({
format: combine(timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), format.json(), errors({ stack: true })),
defaultMeta: { package: 'server' },
exitOnError: false,
transports: [...(process.env.DEBUG === 'true' ? [new transports.Console()] : []), ...auditTransports]
})
export default logger
@@ -0,0 +1,588 @@
import { describe, expect, it } from '@jest/globals'
import { sanitizeAuditMetadata, sanitizeIPAddress, sanitizeNullBytes, sanitizeUser } from '../../src/utils/sanitize.util'
describe('Sanitization Utilities', () => {
describe('sanitizeNullBytes', () => {
describe('String sanitization', () => {
it('should remove null bytes from object string values', () => {
const input = { text: 'hello\u0000world' }
const result = sanitizeNullBytes(input)
expect(result.text).toBe('helloworld')
})
it('should return unchanged object when strings have no null bytes', () => {
const input = { text: 'clean string' }
const result = sanitizeNullBytes(input)
expect(result.text).toBe('clean string')
})
it('should handle empty string in object', () => {
const input = { text: '' }
const result = sanitizeNullBytes(input)
expect(result.text).toBe('')
})
})
describe('Object sanitization', () => {
it('should remove null bytes from object string values', () => {
const input = {
name: 'test\u0000user',
email: 'user\[email protected]',
age: 25
}
const result = sanitizeNullBytes(input)
expect(result.name).toBe('testuser')
expect(result.email).toBe('[email protected]')
expect(result.age).toBe(25)
})
it('should handle nested objects', () => {
const input = {
user: {
name: 'john\u0000doe',
profile: {
bio: 'hello\u0000world'
}
}
}
const result = sanitizeNullBytes(input)
expect(result.user.name).toBe('johndoe')
expect(result.user.profile.bio).toBe('helloworld')
})
it('should preserve non-string values in objects', () => {
const input = {
str: 'test\u0000',
num: 42,
bool: true,
nil: null,
undef: undefined
}
const result = sanitizeNullBytes(input)
expect(result.str).toBe('test')
expect(result.num).toBe(42)
expect(result.bool).toBe(true)
expect(result.nil).toBe(null)
expect(result.undef).toBe(undefined)
})
it('should handle empty object', () => {
const input = {}
const result = sanitizeNullBytes(input)
expect(result).toEqual({})
})
})
describe('Array sanitization', () => {
it('should remove null bytes from array string elements', () => {
const input = ['hello\u0000world', 'test\u0000data', 'clean']
const result = sanitizeNullBytes(input)
expect(result).toEqual(['helloworld', 'testdata', 'clean'])
})
it('should handle arrays with mixed types', () => {
const input = ['test\u0000', 123, true, null, undefined]
const result = sanitizeNullBytes(input)
expect(result).toEqual(['test', 123, true, null, undefined])
})
it('should handle nested arrays', () => {
const input = [
['a\u0000b', 'c\u0000d'],
['e\u0000f', 'g\u0000h']
]
const result = sanitizeNullBytes(input)
expect(result).toEqual([
['ab', 'cd'],
['ef', 'gh']
])
})
it('should handle arrays of objects', () => {
const input = [{ name: 'user1\u0000' }, { name: 'user2\u0000' }]
const result = sanitizeNullBytes(input)
expect(result).toEqual([{ name: 'user1' }, { name: 'user2' }])
})
it('should handle empty array', () => {
const input: any[] = []
const result = sanitizeNullBytes(input)
expect(result).toEqual([])
})
})
describe('Complex nested structures', () => {
it('should handle deeply nested mixed structures', () => {
const input = {
users: [
{
name: 'john\u0000',
emails: ['john\[email protected]'],
metadata: {
bio: 'hello\u0000world'
}
}
],
config: {
settings: ['opt1\u0000', 'opt2\u0000']
}
}
const result = sanitizeNullBytes(input)
expect(result.users[0].name).toBe('john')
expect(result.users[0].emails[0]).toBe('[email protected]')
expect(result.users[0].metadata.bio).toBe('helloworld')
expect(result.config.settings).toEqual(['opt1', 'opt2'])
})
it('should handle objects within arrays within objects', () => {
const input = {
data: [{ value: 'a\u0000' }, { value: 'b\u0000' }]
}
const result = sanitizeNullBytes(input)
expect(result.data[0].value).toBe('a')
expect(result.data[1].value).toBe('b')
})
})
describe('Edge cases', () => {
it('should mutate the original object (in-place modification)', () => {
const input = { name: 'test\u0000' }
const result = sanitizeNullBytes(input)
expect(result).toBe(input) // Same reference
expect(input.name).toBe('test')
})
// NOTE: Circular reference test removed - sanitizeNullBytes does not handle circular references
// and will cause an infinite loop. This is a known limitation of the stack-based implementation.
it('should handle null input', () => {
const result = sanitizeNullBytes(null)
expect(result).toBe(null)
})
it('should handle undefined input', () => {
const result = sanitizeNullBytes(undefined)
expect(result).toBe(undefined)
})
it('should handle number input', () => {
const result = sanitizeNullBytes(42)
expect(result).toBe(42)
})
it('should handle boolean input', () => {
const result = sanitizeNullBytes(true)
expect(result).toBe(true)
})
it('should skip inherited properties', () => {
const proto = { inherited: 'value\u0000' }
const input = Object.create(proto)
input.own = 'test\u0000'
const result = sanitizeNullBytes(input)
expect(result.own).toBe('test')
// Inherited property should not be sanitized
expect(proto.inherited).toBe('value\u0000')
})
})
})
describe('sanitizeUser', () => {
it('should remove credential from user object', () => {
const user = {
id: '123',
name: 'John',
email: '[email protected]',
credential: 'sensitive-credential'
}
const result = sanitizeUser(user)
expect(result.credential).toBeUndefined()
expect(result.id).toBe('123')
expect(result.name).toBe('John')
expect(result.email).toBe('[email protected]')
})
it('should remove tempToken from user object', () => {
const user = {
id: '123',
name: 'John',
tempToken: 'temporary-token-abc123'
}
const result = sanitizeUser(user)
expect(result.tempToken).toBeUndefined()
expect(result.id).toBe('123')
expect(result.name).toBe('John')
})
it('should remove tokenExpiry from user object', () => {
const user = {
id: '123',
name: 'John',
tokenExpiry: new Date('2025-12-31')
}
const result = sanitizeUser(user)
expect(result.tokenExpiry).toBeUndefined()
expect(result.id).toBe('123')
expect(result.name).toBe('John')
})
it('should remove all sensitive fields at once', () => {
const user = {
id: '123',
name: 'John',
email: '[email protected]',
credential: 'sensitive-credential',
tempToken: 'temp-token',
tokenExpiry: new Date('2025-12-31')
}
const result = sanitizeUser(user)
expect(result.credential).toBeUndefined()
expect(result.tempToken).toBeUndefined()
expect(result.tokenExpiry).toBeUndefined()
expect(result.id).toBe('123')
expect(result.name).toBe('John')
expect(result.email).toBe('[email protected]')
})
it('should handle partial user object (missing sensitive fields)', () => {
const user = {
id: '123',
name: 'John'
}
const result = sanitizeUser(user)
expect(result.id).toBe('123')
expect(result.name).toBe('John')
})
it('should mutate the original user object (in-place modification)', () => {
const user = {
id: '123',
credential: 'secret'
}
const result = sanitizeUser(user)
expect(result).toBe(user) // Same reference
expect(user.credential).toBeUndefined()
})
it('should handle empty user object', () => {
const user = {}
const result = sanitizeUser(user)
expect(result).toEqual({})
})
it('should preserve other user properties', () => {
const user = {
id: '123',
name: 'John',
email: '[email protected]',
status: 'ACTIVE',
createdDate: new Date('2024-01-01'),
updatedDate: new Date('2024-01-02'),
createdBy: 'admin',
credential: 'secret',
tempToken: 'token',
tokenExpiry: new Date('2025-12-31')
}
const result = sanitizeUser(user)
expect(result.id).toBe('123')
expect(result.name).toBe('John')
expect(result.email).toBe('[email protected]')
expect(result.status).toBe('ACTIVE')
expect(result.createdDate).toEqual(new Date('2024-01-01'))
expect(result.updatedDate).toEqual(new Date('2024-01-02'))
expect(result.createdBy).toBe('admin')
expect(result.credential).toBeUndefined()
expect(result.tempToken).toBeUndefined()
expect(result.tokenExpiry).toBeUndefined()
})
})
describe('sanitizeIPAddress', () => {
describe('IPv4 sanitization', () => {
it('should mask last octet of standard IPv4 address', () => {
expect(sanitizeIPAddress('192.168.1.100')).toBe('192.168.1.xxx')
})
it('should mask last octet of localhost', () => {
expect(sanitizeIPAddress('127.0.0.1')).toBe('127.0.0.xxx')
})
it('should mask last octet of public IP', () => {
expect(sanitizeIPAddress('8.8.8.8')).toBe('8.8.8.xxx')
})
it('should mask last octet of private IP (10.x.x.x)', () => {
expect(sanitizeIPAddress('10.0.0.1')).toBe('10.0.0.xxx')
})
it('should mask last octet of private IP (172.16.x.x)', () => {
expect(sanitizeIPAddress('172.16.254.1')).toBe('172.16.254.xxx')
})
it('should mask last octet of 0.0.0.0', () => {
expect(sanitizeIPAddress('0.0.0.0')).toBe('0.0.0.xxx')
})
it('should mask last octet of broadcast address', () => {
expect(sanitizeIPAddress('255.255.255.255')).toBe('255.255.255.xxx')
})
})
describe('IPv6 sanitization', () => {
it('should mask last 64 bits of full IPv6 address', () => {
const result = sanitizeIPAddress('2001:0db8:85a3:0000:0000:8a2e:0370:7334')
expect(result).toContain(':xxxx:xxxx:xxxx:xxxx')
expect(result).toContain('2001:0db8:85a3:0000')
})
it('should mask last 64 bits of compressed IPv6 address', () => {
const result = sanitizeIPAddress('2001:db8:85a3::8a2e:370:7334')
expect(result).toContain(':xxxx:xxxx:xxxx:xxxx')
})
it('should mask IPv6 localhost (::1)', () => {
const result = sanitizeIPAddress('::1')
expect(result).toContain(':xxxx:xxxx:xxxx:xxxx')
})
it('should mask IPv6 unspecified address (::)', () => {
const result = sanitizeIPAddress('::')
expect(result).toContain(':xxxx:xxxx:xxxx:xxxx')
})
it('should mask link-local IPv6 address', () => {
const result = sanitizeIPAddress('fe80::1')
expect(result).toContain(':xxxx:xxxx:xxxx:xxxx')
expect(result).toContain('fe80')
})
it('should mask global unicast IPv6 address', () => {
const result = sanitizeIPAddress('2607:f8b0:4005:805::200e')
expect(result).toContain(':xxxx:xxxx:xxxx:xxxx')
})
it('should mask IPv4-mapped IPv6 address (masks last 64 bits as IPv6)', () => {
const result = sanitizeIPAddress('::ffff:192.168.1.1')
expect(result).toBe('0000:0000:0000:0000:xxxx:xxxx:xxxx:xxxx')
})
})
describe('Invalid input handling', () => {
it('should return "unknown" for invalid IPv4', () => {
expect(sanitizeIPAddress('192.168.256.1')).toBe('unknown')
})
it('should return "unknown" for invalid IPv6', () => {
expect(sanitizeIPAddress('gggg::1')).toBe('unknown')
})
it('should return "unknown" for empty string', () => {
expect(sanitizeIPAddress('')).toBe('unknown')
})
it('should return "unknown" for hostname', () => {
expect(sanitizeIPAddress('example.com')).toBe('unknown')
})
it('should return "unknown" for URL', () => {
expect(sanitizeIPAddress('http://192.168.1.1')).toBe('unknown')
})
it('should return "unknown" for malformed IP', () => {
expect(sanitizeIPAddress('not-an-ip')).toBe('unknown')
})
it('should return "unknown" for IPv4 with port', () => {
expect(sanitizeIPAddress('192.168.1.1:8080')).toBe('unknown')
})
it('should return "unknown" for CIDR notation', () => {
expect(sanitizeIPAddress('192.168.1.0/24')).toBe('unknown')
})
})
describe('Privacy and GDPR compliance', () => {
it('should preserve network portion of IPv4 for analytics', () => {
const result = sanitizeIPAddress('192.168.1.100')
expect(result.startsWith('192.168.1.')).toBe(true)
})
it('should mask individual host identifier in IPv4', () => {
const result = sanitizeIPAddress('192.168.1.100')
expect(result).not.toContain('100')
expect(result.endsWith('xxx')).toBe(true)
})
it('should preserve network prefix of IPv6 for geolocation', () => {
const result = sanitizeIPAddress('2001:db8:85a3::8a2e:370:7334')
expect(result).toContain('2001')
})
it('should mask interface identifier in IPv6', () => {
const result = sanitizeIPAddress('2001:db8:85a3::8a2e:370:7334')
expect(result).toContain('xxxx')
expect(result).not.toContain('7334')
})
})
describe('Edge cases', () => {
it('should handle various IPv4 formats consistently', () => {
const ips = ['1.1.1.1', '10.10.10.10', '100.100.100.100']
const results = ips.map((ip) => sanitizeIPAddress(ip))
results.forEach((result) => {
expect(result.endsWith('.xxx')).toBe(true)
})
})
it('should handle different IPv6 compression styles', () => {
const ips = ['fe80::1', 'fe80:0:0:0:0:0:0:1', 'fe80::0:0:0:1']
const results = ips.map((ip) => sanitizeIPAddress(ip))
results.forEach((result) => {
expect(result).toContain('xxxx')
})
})
it('should return "unknown" if IP passes validation but has unexpected format', () => {
const ipValidation = require('../../src/utils/ipValidation')
const mockIsValid = jest.spyOn(ipValidation, 'isValidIPAddress')
mockIsValid.mockReturnValueOnce(true)
const result = sanitizeIPAddress('unusual-format')
expect(result).toBe('unknown')
mockIsValid.mockRestore()
})
it('should return "unknown" when IPv6 expands to non-8 groups (defensive)', () => {
const ipValidation = require('../../src/utils/ipValidation')
jest.spyOn(ipValidation, 'isValidIPAddress').mockReturnValueOnce(true)
jest.spyOn(ipValidation, 'isIPv4').mockReturnValueOnce(false)
jest.spyOn(ipValidation, 'isIPv6').mockReturnValueOnce(true)
const result = sanitizeIPAddress('1:2:3:4:5:6:7:8:9')
expect(result).toBe('unknown')
ipValidation.isValidIPAddress.mockRestore()
ipValidation.isIPv4.mockRestore()
ipValidation.isIPv6.mockRestore()
})
})
})
describe('sanitizeAuditMetadata', () => {
it('should return empty object for undefined/null', () => {
expect(sanitizeAuditMetadata(undefined)).toEqual({})
expect(sanitizeAuditMetadata(null)).toEqual({})
})
it('should redact sensitive keys (case-insensitive substring match)', () => {
const input = {
tokenExpiryMinutes: 15,
Password: 'p@ss',
Authorization: 'Bearer abc',
safeKey: 'ok'
}
expect(sanitizeAuditMetadata(input)).toEqual({
tokenExpiryMinutes: '********',
Password: '********',
Authorization: '********',
safeKey: 'ok'
})
})
it('should remove null bytes from string values', () => {
const input = { userAgent: 'Moz\u0000illa/5.0' }
expect(sanitizeAuditMetadata(input)).toEqual({ userAgent: 'Mozilla/5.0' })
})
it('should recursively sanitize nested objects with sensitive keys', () => {
const input = {
configuration: {
apiKey: 'secret-key-123',
timeout: 30
},
settings: {
password: 'mypassword'
}
}
expect(sanitizeAuditMetadata(input)).toEqual({
configuration: {
apiKey: '********',
timeout: 30
},
settings: {
password: '********'
}
})
})
it('should sanitize arrays when key name is not sensitive', () => {
const input = {
items: [{ apiKey: 'secret1' }, { apiKey: 'secret2' }],
ports: [8080, 3000]
}
expect(sanitizeAuditMetadata(input)).toEqual({
items: [{ apiKey: '********' }, { apiKey: '********' }],
ports: [8080, 3000]
})
})
it('should redact entire value when key itself is sensitive, even if it is an array', () => {
const input = {
tokens: ['token1', 'token2'],
passwords: ['pass1', 'pass2']
}
expect(sanitizeAuditMetadata(input)).toEqual({
tokens: '********',
passwords: '********'
})
})
})
describe('Integration scenarios', () => {
it('should sanitize user object with null bytes in sensitive fields', () => {
const user = {
name: 'john\u0000doe',
credential: 'secret\u0000token',
tempToken: 'temp\u0000token'
}
// First remove null bytes
const cleaned = sanitizeNullBytes(user)
// Then sanitize user
const result = sanitizeUser(cleaned)
expect(result.name).toBe('johndoe')
expect(result.credential).toBeUndefined()
expect(result.tempToken).toBeUndefined()
})
it('should sanitize audit event metadata with IP addresses', () => {
const event = {
user: 'admin\u0000',
action: 'login\u0000',
ipAddress: '192.168.1.100',
metadata: {
userAgent: 'Mozilla\u0000/5.0'
}
}
// Sanitize null bytes
const cleaned = sanitizeNullBytes(event)
// Sanitize IP
const sanitized = {
...cleaned,
ipAddress: sanitizeIPAddress(cleaned.ipAddress)
}
expect(sanitized.user).toBe('admin')
expect(sanitized.action).toBe('login')
expect(sanitized.ipAddress).toBe('192.168.1.xxx')
expect(sanitized.metadata.userAgent).toBe('Mozilla/5.0')
})
})
})
+133
View File
@@ -1,4 +1,5 @@
import { User } from '../enterprise/database/entities/user.entity'
import { isIPv4, isIPv6, isValidIPAddress } from './ipValidation'
export function sanitizeNullBytes(obj: any): any {
const stack = [obj]
@@ -40,3 +41,135 @@ export function sanitizeUser(user: Partial<User>) {
return user
}
/**
* Expands an IPv6 string to exactly 8 groups of 16-bit hex, handling :: and IPv4-mapped form.
*/
function expandIPv6ToGroups(ip: string): string[] {
let parts = ip.split(':')
// IPv4-mapped: last segment can be dotted decimal (e.g. 192.168.1.1)
const last = parts[parts.length - 1]
if (last?.includes('.')) {
const octets = last.split('.').map(Number)
if (octets.length === 4 && octets.every((n) => n >= 0 && n <= 255)) {
const high = (octets[0] << 8) | octets[1]
const low = (octets[2] << 8) | octets[3]
parts = [...parts.slice(0, -1), high.toString(16), low.toString(16)]
}
}
if (parts.includes('')) {
const emptyIdx = parts.indexOf('')
const zeroCount = 8 - (parts.length - 1)
const zeros = Array(zeroCount).fill('0000')
parts = [...parts.slice(0, emptyIdx), ...zeros, ...parts.slice(emptyIdx + 1)]
}
return parts.map((p) => p.padStart(4, '0').toLowerCase())
}
/**
* Masks the last octet of an IP address for privacy (GDPR compliance).
*
* This function sanitizes IP addresses by masking identifying information while
* preserving network information. For IPv4, the last octet is masked. For IPv6,
* the last 64 bits (interface identifier) are masked.
*
* @param ip - The IP address string to sanitize (IPv4 or IPv6 format)
* @returns The sanitized IP address with masked octets/bits, or 'unknown' if invalid
*/
export function sanitizeIPAddress(ip: string): string {
if (!isValidIPAddress(ip)) {
return 'unknown'
}
if (isIPv4(ip)) {
const parts = ip.split('.')
parts[3] = 'xxx'
return parts.join('.')
}
if (isIPv6(ip)) {
const groups = expandIPv6ToGroups(ip)
if (groups.length !== 8) return 'unknown'
const prefix = groups.slice(0, 4).join(':')
return `${prefix}:xxxx:xxxx:xxxx:xxxx`
}
return 'unknown'
}
/**
* Sanitizes audit-event metadata by redacting sensitive fields and removing null bytes.
*
* - Redacts values for keys whose names look sensitive (case-insensitive substring match),
* e.g. "password", "token", "secret", "authorization", etc.
* - Runs `sanitizeNullBytes()` to remove any `\u0000` characters from string values.
*
* Note: `sanitizeNullBytes()` mutates nested objects/arrays. Since this function only
* shallow-clones the input, nested objects may still be shared with the caller.
*
* @param metadata - Arbitrary event metadata (key/value pairs)
* @returns A sanitized metadata object safe for audit logging
*/
export function sanitizeAuditMetadata(metadata: Record<string, any> | undefined | null): Record<string, any> {
if (!metadata) return {}
const sensitiveFields = [
'password',
'pwd',
'pass',
'secret',
'token',
'apikey',
'api_key',
'accesstoken',
'access_token',
'refreshtoken',
'refresh_token',
'clientsecret',
'client_secret',
'privatekey',
'private_key',
'secretkey',
'secret_key',
'auth',
'authorization',
'credential',
'credentials',
'ssn',
'socialsecurity',
'creditcard',
'cvv'
]
function sanitizeValue(value: any, key: string): any {
const lowerKey = key.toLowerCase()
const isSensitive = sensitiveFields.some((field) => lowerKey.includes(field))
if (isSensitive) {
return '********'
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
return sanitizeObject(value)
}
if (Array.isArray(value)) {
return value.map((item, index) => sanitizeValue(item, `${key}[${index}]`))
}
return value
}
function sanitizeObject(obj: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {}
for (const key of Object.keys(obj)) {
result[key] = sanitizeValue(obj[key], key)
}
return result
}
return sanitizeNullBytes(sanitizeObject(metadata))
}
+252
View File
@@ -0,0 +1,252 @@
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
// ---------- mocks (must be hoisted) ----------
jest.mock('geoip-lite', () => ({ lookup: jest.fn() }))
jest.mock('uuid', () => ({ v4: jest.fn() }))
jest.mock('posthog-node', () => ({ PostHog: jest.fn() }))
jest.mock('../../src/utils', () => ({
getAppVersion: jest.fn()
}))
jest.mock('../../src/utils/ipValidation', () => ({
isIPv4: jest.fn(),
isIPv6: jest.fn(),
isValidIPAddress: jest.fn()
}))
jest.mock('../../src/utils/logger', () => ({
__esModule: true,
default: {
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn()
},
auditLogger: {
log: jest.fn()
}
}))
// ---------- imports under test ----------
import { emitEvent, Telemetry, TelemetryEventCategory, TelemetryEventResult } from '../../src/utils/telemetry'
const geoip = require('geoip-lite')
const uuid = require('uuid')
const posthogNode = require('posthog-node')
const utils = require('../../src/utils')
const ipValidation = require('../../src/utils/ipValidation')
const loggerModule = require('../../src/utils/logger')
const ORIGINAL_ENV = process.env
describe('utils/telemetry.ts', () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV }
jest.clearAllMocks()
jest.useFakeTimers()
jest.setSystemTime(new Date('2026-03-16T12:34:56.000Z'))
uuid.v4.mockReturnValue('test-uuid-1')
utils.getAppVersion.mockResolvedValue('3.0.13')
ipValidation.isValidIPAddress.mockReturnValue(true)
ipValidation.isIPv4.mockReturnValue(true)
ipValidation.isIPv6.mockReturnValue(false)
geoip.lookup.mockReturnValue({ country: 'US', region: 'CA' })
})
afterEach(() => {
jest.useRealTimers()
process.env = ORIGINAL_ENV
})
describe('Telemetry (PostHog) class', () => {
it('does not create PostHog when POSTHOG_PUBLIC_API_KEY is unset', async () => {
delete process.env.POSTHOG_PUBLIC_API_KEY
const t = new Telemetry()
expect(t.postHog).toBeUndefined()
expect(posthogNode.PostHog).not.toHaveBeenCalled()
})
it('creates PostHog when POSTHOG_PUBLIC_API_KEY is set and captures', async () => {
process.env.POSTHOG_PUBLIC_API_KEY = 'ph-key'
const capture = jest.fn()
const shutdownAsync = jest.fn()
posthogNode.PostHog.mockImplementation(() => ({ capture, shutdownAsync }))
const t = new Telemetry()
expect(posthogNode.PostHog).toHaveBeenCalledWith('ph-key')
await t.sendTelemetry('evt', { hello: 'world' }, 'org-1')
expect(utils.getAppVersion).toHaveBeenCalled()
expect(capture).toHaveBeenCalledWith({
event: 'evt',
distinctId: 'org-1',
properties: { hello: 'world', version: '3.0.13' }
})
await t.flush()
expect(shutdownAsync).toHaveBeenCalled()
})
it('sendTelemetry: uses default args and uuid distinctId when orgId omitted', async () => {
process.env.POSTHOG_PUBLIC_API_KEY = 'ph-key'
uuid.v4.mockReturnValue('distinct-uuid-777')
const capture = jest.fn()
const shutdownAsync = jest.fn()
posthogNode.PostHog.mockImplementation(() => ({ capture, shutdownAsync }))
const t = new Telemetry()
// omit both properties and orgId to hit defaults on line 25
await t.sendTelemetry('evt-default-args')
expect(capture).toHaveBeenCalledTimes(1)
expect(capture).toHaveBeenCalledWith({
event: 'evt-default-args',
distinctId: 'distinct-uuid-777', // hits orgId || uuidv4() branch on line 28
properties: { version: '3.0.13' }
})
})
})
describe('emitEvent()', () => {
it('logs an enriched + sanitized event', async () => {
await emitEvent({
category: TelemetryEventCategory.SECURITY,
eventType: 'password-reset-requested',
actionType: 'update',
userId: 'user-123',
orgId: 'org-456',
resourceId: 'res-789',
ipAddress: '203.0.113.42',
result: TelemetryEventResult.SUCCESS,
metadata: { tokenExpiryMinutes: 15 }
})
expect(loggerModule.auditLogger.log).toHaveBeenCalledTimes(1)
const payload = loggerModule.auditLogger.log.mock.calls[0][0]
expect(payload).toEqual(
expect.objectContaining({
level: 'info',
message: 'password-reset-requested',
eventId: 'test-uuid-1',
timestamp: '2026-03-16T12:34:56.000Z',
version: '3.0.13',
category: 'security',
eventType: 'password-reset-requested',
actionType: 'update',
userId: 'user-123',
orgId: 'org-456',
resourceId: 'res-789',
ipAddress: '203.0.113.xxx',
countryCode: 'US',
region: 'CA',
result: 'success',
metadata: { tokenExpiryMinutes: '********' }
})
)
})
it('invalid IP skips geo enrichment and masks to "unknown"', async () => {
ipValidation.isValidIPAddress.mockReturnValue(false)
await emitEvent({
category: TelemetryEventCategory.AUDIT,
eventType: 'bad-ip',
actionType: 'create',
userId: 'u',
orgId: 'o',
ipAddress: 'not-an-ip',
result: TelemetryEventResult.SUCCESS
})
expect(geoip.lookup).not.toHaveBeenCalled()
const payload = loggerModule.auditLogger.log.mock.calls[0][0]
expect(payload.ipAddress).toBe('unknown')
expect(payload.countryCode).toBeUndefined()
expect(payload.region).toBeUndefined()
})
it('geoip lookup returning null yields no country/region', async () => {
geoip.lookup.mockReturnValue(null)
await emitEvent({
category: TelemetryEventCategory.AUDIT,
eventType: 'geo-null',
actionType: 'read',
userId: 'u',
orgId: 'o',
ipAddress: '198.51.100.10',
result: TelemetryEventResult.SUCCESS
})
const payload = loggerModule.auditLogger.log.mock.calls[0][0]
expect(payload.countryCode).toBeUndefined()
expect(payload.region).toBeUndefined()
})
it('geoip lookup throw is swallowed and still logs event', async () => {
geoip.lookup.mockImplementation(() => {
throw new Error('geo fail')
})
await emitEvent({
category: TelemetryEventCategory.AUDIT,
eventType: 'geo-throws',
actionType: 'read',
userId: 'u',
orgId: 'o',
ipAddress: '198.51.100.10',
result: TelemetryEventResult.SUCCESS
})
expect(loggerModule.default.error).toHaveBeenCalled() // logs geo error
expect(loggerModule.auditLogger.log).toHaveBeenCalledTimes(1)
})
it('auditLogger.log throwing is swallowed (no throw)', async () => {
loggerModule.auditLogger.log.mockImplementationOnce(() => {
throw new Error('sink fail')
})
await expect(
emitEvent({
category: TelemetryEventCategory.SYSTEM,
eventType: 'sink-fails',
actionType: 'execute',
userId: 'u',
orgId: 'o',
result: TelemetryEventResult.SUCCESS
})
).resolves.toBeUndefined()
expect(loggerModule.default.error).toHaveBeenCalled()
})
it('getAppVersion throwing is swallowed (no throw)', async () => {
utils.getAppVersion.mockRejectedValueOnce(new Error('version fail'))
await expect(
emitEvent({
category: TelemetryEventCategory.SYSTEM,
eventType: 'version-fails',
actionType: 'execute',
userId: 'u',
orgId: 'o',
result: TelemetryEventResult.SUCCESS
})
).resolves.toBeUndefined()
expect(loggerModule.default.error).toHaveBeenCalled()
})
})
})
+128 -1
View File
@@ -1,6 +1,10 @@
import { v4 as uuidv4 } from 'uuid'
import geoip from 'geoip-lite'
import { PostHog } from 'posthog-node'
import { v4 as uuidv4 } from 'uuid'
import { getAppVersion } from '../utils'
import { isValidIPAddress } from './ipValidation'
import logger, { auditLogger } from './logger'
import { sanitizeAuditMetadata, sanitizeIPAddress } from './sanitize.util'
export enum TelemetryEventType {
'USER_CREATED' = 'user_created',
@@ -36,3 +40,126 @@ export class Telemetry {
}
}
}
/**
* Derives country code and region from an IP address using GeoIP lookup.
*
* This function performs a non-blocking geolocation lookup to extract geographic
* information before the IP address is masked for privacy compliance. Returns
* ISO country codes and region identifiers that are GDPR/HIPAA compliant.
*
* @param ipAddress - The IP address string to geolocate (must be valid IPv4 or IPv6)
* @returns An object containing optional `countryCode` (ISO 3166-1 alpha-2) and `region` (state/province), or empty object if lookup fails or IP is invalid
*/
function getGeolocation(ipAddress: string): {
countryCode?: string
region?: string
} {
if (!isValidIPAddress(ipAddress)) {
return {}
}
try {
const geo = geoip.lookup(ipAddress)
if (!geo) return {}
return {
countryCode: geo.country,
region: geo.region
}
} catch (error) {
logger.error(`Failed to resolve geolocation for IP: ${error}`)
return {}
}
}
/**
* Categories for telemetry events, used for filtering and compliance reporting.
*/
export enum TelemetryEventCategory {
AUDIT = 'audit', // Compliance and user action tracking (GDPR, HIPAA, ISO 27001)
METRIC = 'metric', // Performance and usage measurements
SECURITY = 'security', // Authentication, authorization, access control events
SYSTEM = 'system' // Operational events (startup, shutdown, errors)
}
/**
* Result status for telemetry events.
*/
export enum TelemetryEventResult {
SUCCESS = 'success',
FAILED = 'failed'
}
export interface TelemetryEventInput {
category: TelemetryEventCategory
eventType: string
actionType: string
userId: string
orgId: string
resourceId?: string
ipAddress?: string
result: TelemetryEventResult
metadata?: Record<string, any>
}
export interface TelemetryEventOutput {
eventId: string
timestamp: string
version: string
category: TelemetryEventCategory
eventType: string
actionType: string
userId: string
orgId: string
resourceId?: string
ipAddress?: string
countryCode?: string
region?: string
result: TelemetryEventResult
metadata?: Record<string, any>
}
/**
* Emits a structured audit/telemetry event to the configured audit log sink.
*
* Builds a `TelemetryEventOutput` record and writes it via `auditLogger` (provider-backed transports),
* enabling storage on local/S3/GCS/Azure depending on `STORAGE_TYPE`.
*
* Enrichment & sanitization:
* - Adds `eventId` (UUID v4), `timestamp` (ISO 8601), and `version` (app version).
* - If `ipAddress` is provided, attempts GeoIP lookup first to derive `countryCode`/`region`,
* then masks the IP via `sanitizeIPAddress` before logging.
* - Redacts sensitive keys inside `metadata` via `sanitizeAuditMetadata`.
*
* Reliability:
* - Best-effort / non-blocking: failures are caught and logged; this function does not throw.
*
* @param input - The event input describing category/action/outcome and optional context.
* @returns Resolves when the event has been handed off to the logger transport.
*/
export async function emitEvent(input: TelemetryEventInput): Promise<void> {
try {
const geo = input.ipAddress ? getGeolocation(input.ipAddress) : {}
const event: TelemetryEventOutput = {
eventId: uuidv4(),
timestamp: new Date().toISOString(),
version: await getAppVersion(),
category: input.category,
eventType: input.eventType,
actionType: input.actionType,
userId: input.userId,
orgId: input.orgId,
resourceId: input.resourceId,
ipAddress: input.ipAddress ? sanitizeIPAddress(input.ipAddress) : undefined,
countryCode: geo.countryCode,
region: geo.region,
result: input.result,
metadata: input.metadata ? sanitizeAuditMetadata(input.metadata) : undefined
}
auditLogger.log({ level: 'info', message: event.eventType, ...event })
} catch (error) {
logger.error(`Failed to emit event: ${error}`)
}
}
+3 -1
View File
@@ -11,6 +11,7 @@ const getBillingData = () => client.post('/account/billing')
const logout = () => client.post('/account/logout')
const getBasicAuth = () => client.get('/account/basic-auth')
const checkBasicAuth = (body) => client.post('/account/basic-auth', body)
const deleteAccount = (body) => client.delete('/account/delete', { data: body })
export default {
getBillingData,
@@ -23,5 +24,6 @@ export default {
resetPassword,
logout,
getBasicAuth,
checkBasicAuth
checkBasicAuth,
deleteAccount
}
@@ -118,6 +118,9 @@ const OrgWorkspaceBreadcrumbs = () => {
const handleWorkspaceClick = (event) => {
setWorkspaceAnchorEl(event.currentTarget)
// Refresh workspace list when dropdown opens
getWorkspacesByUserIdApi.request(user.id)
}
const handleOrgClose = () => {
@@ -99,6 +99,9 @@ const WorkspaceSwitcher = () => {
const handleClick = (event) => {
setAnchorEl(event.currentTarget)
// Refresh workspace list when dropdown opens
getWorkspacesByOrganizationIdUserIdApi.request(user.activeOrganizationId, user.id)
}
const handleClose = () => {
+123
View File
@@ -84,6 +84,8 @@ const AccountSettings = () => {
const [purchasedSeats, setPurchasedSeats] = useState(0)
const [occupiedSeats, setOccupiedSeats] = useState(0)
const [totalSeats, setTotalSeats] = useState(0)
const [openDeleteAccountDialog, setOpenDeleteAccountDialog] = useState(false)
const [deleteConfirmationText, setDeleteConfirmationText] = useState('')
const predictionsUsageInPercent = useMemo(() => {
return usage ? calculatePercentage(usage.predictions?.usage, usage.predictions?.limit) : 0
@@ -103,6 +105,7 @@ const AccountSettings = () => {
const updateAdditionalSeatsApi = useApi(userApi.updateAdditionalSeats)
const getCurrentUsageApi = useApi(userApi.getCurrentUsage)
const logoutApi = useApi(accountApi.logout)
const deleteAccountApi = useApi(accountApi.deleteAccount)
useEffect(() => {
if (currentUser) {
@@ -154,6 +157,13 @@ const AccountSettings = () => {
}
}, [logoutApi.data])
useEffect(() => {
if (deleteAccountApi.data?.message === 'Account deleted') {
store.dispatch(logoutSuccess())
window.location.href = '/login'
}
}, [deleteAccountApi.data])
useEffect(() => {
if (openRemoveSeatsDialog || openAddSeatsDialog) {
setSeatsQuantity(0)
@@ -854,6 +864,64 @@ const AccountSettings = () => {
</Box>
</SettingsSection>
)}
{isCloud && (
<>
<SettingsSection title='Delete Account'>
<Box
sx={{
width: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)'
}}
>
<Box
sx={{
gridColumn: 'span 2 / span 2',
display: 'flex',
flexDirection: 'column',
alignItems: 'start',
justifyContent: 'center',
gap: 1,
px: 2.5,
py: 2
}}
>
<Typography variant='body2' color='text.secondary'>
Permanently deletes all your data and cancels your subscription. This action cannot be
undone.
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'end',
gap: 2,
px: 2.5,
py: 2
}}
>
<Button
variant='contained'
color='error'
onClick={() => setOpenDeleteAccountDialog(true)}
disabled={deleteAccountApi.loading}
sx={{ borderRadius: 2, height: 40 }}
>
{deleteAccountApi.loading ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} color='inherit' />
Deleting...
</Box>
) : (
'Delete your account'
)}
</Button>
</Box>
</Box>
</SettingsSection>
</>
)}
</>
)}
</Stack>
@@ -1407,6 +1475,61 @@ const AccountSettings = () => {
</DialogActions>
)}
</Dialog>
{/* Delete Account Confirmation Dialog */}
<Dialog
fullWidth
maxWidth='xs'
open={openDeleteAccountDialog}
onClose={() => {
if (!deleteAccountApi.loading) {
setOpenDeleteAccountDialog(false)
setDeleteConfirmationText('')
}
}}
>
<DialogTitle>Delete Account</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<Typography>
This will permanently delete your account and all associated data. Your subscription will be cancelled
immediately and you will be logged out. This action cannot be undone and there is no way to recover your data.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant='body2'>
To confirm, please type <strong>permanently delete</strong> below:
</Typography>
<OutlinedInput
id='deleteConfirmation'
type='text'
fullWidth
placeholder='permanently delete'
value={deleteConfirmationText}
onChange={(e) => setDeleteConfirmationText(e.target.value)}
disabled={deleteAccountApi.loading}
/>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setOpenDeleteAccountDialog(false)
setDeleteConfirmationText('')
}}
disabled={deleteAccountApi.loading}
>
Cancel
</Button>
<Button
variant='contained'
color='error'
onClick={() => deleteAccountApi.request({ confirmationText: deleteConfirmationText })}
disabled={deleteAccountApi.loading || deleteConfirmationText !== 'permanently delete'}
>
{deleteAccountApi.loading ? <CircularProgress size={24} color='inherit' /> : 'Confirm'}
</Button>
</DialogActions>
</Dialog>
</MainCard>
)
}
+62
View File
@@ -942,6 +942,9 @@ importers:
flowise-ui:
specifier: workspace:^
version: link:../ui
geoip-lite:
specifier: ^1.4.10
version: 1.4.10
global-agent:
specifier: ^3.0.0
version: 3.0.0
@@ -1084,6 +1087,9 @@ importers:
'@types/express-session':
specifier: ^1.18.0
version: 1.18.0
'@types/geoip-lite':
specifier: ^1.4.4
version: 1.4.4
'@types/jest':
specifier: ^29.5.14
version: 29.5.14
@@ -8712,6 +8718,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==}
'@types/[email protected]':
resolution: {integrity: sha512-2uVfn+C6bX/H356H6mjxsWUA5u8LO8dJgSBIRO/NFlpMe4DESzacutD/rKYrTDKm1Ugv78b4Wz1KvpHrlv3jSw==}
'@types/[email protected]':
resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==}
@@ -9908,6 +9917,9 @@ packages:
resolution: {integrity: sha512-VPXfB4Vk49z1LHHodrEQ6Xf7W4gg1w0dAPROHngx7qgDjqmIQ+fXmwgGXTW/ITLai0YLSvWepJOP9EVpMnEAcw==}
engines: {node: '>= 0.10'}
[email protected]:
resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==}
[email protected]:
resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==}
@@ -12787,6 +12799,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
[email protected]:
resolution: {integrity: sha512-4N69uhpS3KFd97m00wiFEefwa+L+HT5xZbzPhwu+sDawStg6UN/dPwWtUfkQuZkGIY1Cj7wDVp80IsqNtGMi2w==}
engines: {node: '>=10.3.0'}
[email protected]:
resolution: {integrity: sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==}
@@ -13470,6 +13486,10 @@ packages:
resolution: {integrity: sha512-0SZXGNGZ+WzISQ67QDyZ2x0+wVxjjUndtD8oSeik/4ajifeiRufed8fCb8QW8VMyi4MXcS+UO1k/0NGhvq1PAg==}
engines: {node: '>=12.22.0'}
[email protected]:
resolution: {integrity: sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==}
engines: {node: '>= 0.10'}
[email protected]:
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
engines: {node: '>= 12'}
@@ -14534,6 +14554,10 @@ packages:
resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==}
engines: {node: '> 0.8'}
[email protected]:
resolution: {integrity: sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA==}
engines: {node: '>=0.2.0'}
[email protected]:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
@@ -17782,6 +17806,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==}
[email protected]:
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
[email protected]:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
@@ -18293,6 +18322,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
[email protected]:
resolution: {integrity: sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==}
[email protected]:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
@@ -30859,6 +30891,8 @@ snapshots:
'@types/express-serve-static-core': 5.0.6
'@types/serve-static': 1.15.5
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]':
@@ -32315,6 +32349,10 @@ snapshots:
dependencies:
async-done: 1.3.2
[email protected]:
dependencies:
lodash: 4.17.21
[email protected]: {}
[email protected]:
@@ -35995,6 +36033,16 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
async: 2.6.4
chalk: 4.1.2
iconv-lite: 0.6.3
ip-address: 5.9.4
lazy: 1.0.11
rimraf: 2.7.1
yauzl: 2.10.0
[email protected]: {}
[email protected]: {}
@@ -36925,6 +36973,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]:
dependencies:
jsbn: 1.1.0
lodash: 4.17.21
sprintf-js: 1.1.2
[email protected]:
dependencies:
jsbn: 1.1.0
@@ -38510,6 +38564,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
readable-stream: 2.3.8
@@ -42418,6 +42474,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
glob: 7.2.3
[email protected]:
dependencies:
glob: 7.2.3
@@ -43089,6 +43149,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: