mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
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:
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Generated
+62
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user