feat: audit Logs (#17660)

This commit is contained in:
Félix Malfait
2026-02-04 13:30:55 +01:00
committed by GitHub
parent 6407474461
commit 177cae8c53
75 changed files with 2950 additions and 49 deletions
@@ -4,7 +4,6 @@ import {
AuditException,
AuditExceptionCode,
} from 'src/engine/core-modules/audit/audit.exception';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuditResolver } from './audit.resolver';
@@ -56,12 +55,12 @@ describe('AuditResolver', () => {
const result = await resolver.trackAnalytics(
input,
{ id: 'workspace-1' } as WorkspaceEntity,
{ id: 'user-1' } as UserEntity,
'user-workspace-1',
);
expect(auditService.createContext).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'user-workspace-1',
});
expect(mockInsertPageviewEvent).toHaveBeenCalledWith('Test Page', {});
expect(result).toBe('Pageview created');
@@ -86,12 +85,12 @@ describe('AuditResolver', () => {
const result = await resolver.trackAnalytics(
input,
{ id: 'workspace-2' } as WorkspaceEntity,
{ id: 'user-2' } as UserEntity,
'user-workspace-2',
);
expect(auditService.createContext).toHaveBeenCalledWith({
workspaceId: 'workspace-2',
userId: 'user-2',
userWorkspaceId: 'user-workspace-2',
});
expect(mockInsertWorkspaceEvent).toHaveBeenCalledWith(
'Custom Domain Activated',
@@ -121,12 +120,12 @@ describe('AuditResolver', () => {
const result = await resolver.createObjectEvent(
input,
{ id: 'workspace-3' } as WorkspaceEntity,
{ id: 'user-3' } as UserEntity,
'user-workspace-3',
);
expect(auditService.createContext).toHaveBeenCalledWith({
workspaceId: 'workspace-3',
userId: 'user-3',
userWorkspaceId: 'user-workspace-3',
});
expect(mockInsertObjectEvent).toHaveBeenCalledWith(
@@ -9,9 +9,8 @@ import {
import { CreateObjectEventInput } from 'src/engine/core-modules/audit/dtos/create-object-event.input';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@@ -37,9 +36,13 @@ export class AuditResolver {
createAnalyticsInput: CreateAnalyticsInputV2,
@AuthWorkspace({ allowUndefined: true })
workspace: WorkspaceEntity | undefined,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
) {
return this.trackAnalytics(createAnalyticsInput, workspace, user);
return this.trackAnalytics(
createAnalyticsInput,
workspace,
userWorkspaceId,
);
}
@Mutation(() => Analytics)
@@ -48,7 +51,7 @@ export class AuditResolver {
@Args()
createObjectEventInput: CreateObjectEventInput,
@AuthWorkspace() workspace: WorkspaceEntity | undefined,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
) {
if (!workspace) {
throw new AuditException(
@@ -59,7 +62,7 @@ export class AuditResolver {
const analyticsContext = this.auditService.createContext({
workspaceId: workspace.id,
userId: user?.id,
userWorkspaceId,
});
return analyticsContext.createObjectEvent(createObjectEventInput.event, {
@@ -77,11 +80,11 @@ export class AuditResolver {
createAnalyticsInput: CreateAnalyticsInputV2,
@AuthWorkspace({ allowUndefined: true })
workspace: WorkspaceEntity | undefined,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
) {
const analyticsContext = this.auditService.createContext({
workspaceId: workspace?.id,
userId: user?.id,
userWorkspaceId,
});
if (isPageviewAnalyticsInput(createAnalyticsInput)) {
@@ -30,7 +30,7 @@ export class CreateAuditLogFromInternalEvent {
const auditService = this.auditService.createContext({
workspaceId: workspaceEventBatch.workspaceId,
userId: eventData.userId,
userWorkspaceId: eventData.userWorkspaceId,
});
// Since these are object record events, we use createObjectEvent
@@ -25,12 +25,14 @@ export class AuditService {
createContext(context?: {
workspaceId?: string | null | undefined;
userId?: string | null | undefined;
userWorkspaceId?: string | null | undefined;
}) {
const userIdAndWorkspaceId = context
const contextFields = context
? {
...(context.userId ? { userId: context.userId } : {}),
...(context.workspaceId ? { workspaceId: context.workspaceId } : {}),
...(context.userWorkspaceId
? { userWorkspaceId: context.userWorkspaceId }
: {}),
}
: {};
@@ -41,7 +43,7 @@ export class AuditService {
) =>
this.preventIfDisabled(() =>
this.clickHouseService.insert('workspaceEvent', [
{ ...userIdAndWorkspaceId, ...makeTrackEvent(event, properties) },
{ ...contextFields, ...makeTrackEvent(event, properties) },
]),
),
createObjectEvent: <T extends TrackEventName>(
@@ -53,7 +55,7 @@ export class AuditService {
) =>
this.preventIfDisabled(() =>
this.clickHouseService.insert('objectEvent', [
{ ...userIdAndWorkspaceId, ...makeTrackEvent(event, properties) },
{ ...contextFields, ...makeTrackEvent(event, properties) },
]),
),
createPageviewEvent: (
@@ -62,7 +64,7 @@ export class AuditService {
) =>
this.preventIfDisabled(() =>
this.clickHouseService.insert('pageview', [
{ ...userIdAndWorkspaceId, ...makePageview(name, properties) },
{ ...contextFields, ...makePageview(name, properties) },
]),
),
};
@@ -15,7 +15,7 @@ export type GenericTrackEvent<E extends string = string> = {
properties: any;
timestamp: string;
version: string;
userId?: string;
userWorkspaceId?: string;
workspaceId?: string;
};
@@ -590,7 +590,7 @@ export class AuthResolver {
workspaceId,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
impersonatorUserId,
_impersonatorUserId: impersonatorUserId,
impersonatedUserId,
},
);
@@ -703,7 +703,7 @@ export class AuthResolver {
const auditService = this.auditService.createContext({
workspaceId: workspace.id,
userId: impersonatorUserWorkspace.user.id,
userWorkspaceId: impersonatorUserWorkspace.id,
});
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
@@ -397,20 +397,20 @@ export class AuthService {
workspaceId,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
impersonatorUserId,
_impersonatorUserId,
impersonatedUserId,
}: {
workspaceId: string;
impersonatorUserWorkspaceId: string;
impersonatedUserWorkspaceId: string;
impersonatorUserId: string;
_impersonatorUserId: string;
impersonatedUserId: string;
}): Promise<AuthTokens> {
const correlationId = randomUUID();
const analytics = this.auditService.createContext({
workspaceId,
userId: impersonatorUserId,
userWorkspaceId: impersonatorUserWorkspaceId,
});
analytics.insertWorkspaceEvent('Monitoring', {
@@ -53,6 +53,12 @@ describe('transformStripeEntitlementUpdatedEventToDatabaseEntitlement', () => {
value: false,
workspaceId: 'workspaceId',
},
{
key: BillingEntitlementKey.AUDIT_LOGS,
stripeCustomerId: 'cus_123',
value: false,
workspaceId: 'workspaceId',
},
]);
});
@@ -103,6 +109,12 @@ describe('transformStripeEntitlementUpdatedEventToDatabaseEntitlement', () => {
value: false,
workspaceId: 'workspaceId',
},
{
key: BillingEntitlementKey.AUDIT_LOGS,
stripeCustomerId: 'cus_123',
value: false,
workspaceId: 'workspaceId',
},
]);
});
});
@@ -4,4 +4,5 @@ export enum BillingEntitlementKey {
SSO = 'SSO',
CUSTOM_DOMAIN = 'CUSTOM_DOMAIN',
RLS = 'RLS',
AUDIT_LOGS = 'AUDIT_LOGS',
}
@@ -100,6 +100,7 @@ describe('ClientConfigController', () => {
isTwoFactorAuthenticationEnabled: false,
allowRequestsToTwentyIcons: true,
isCloudflareIntegrationEnabled: false,
isClickHouseConfigured: false,
};
jest
@@ -203,4 +203,7 @@ export class ClientConfig {
@Field(() => Boolean)
isCloudflareIntegrationEnabled: boolean;
@Field(() => Boolean)
isClickHouseConfigured: boolean;
}
@@ -161,6 +161,7 @@ describe('ClientConfigService', () => {
isImapSmtpCaldavEnabled: false,
calendarBookingPageId: 'team/twenty/talk-to-us',
isCloudflareIntegrationEnabled: false,
isClickHouseConfigured: false,
});
});
@@ -202,6 +202,7 @@ export class ClientConfigService {
? calendarBookingPageId
: undefined,
isCloudflareIntegrationEnabled: this.isCloudflareIntegrationEnabled(),
isClickHouseConfigured: !!this.twentyConfigService.get('CLICKHOUSE_URL'),
};
return clientConfig;
@@ -72,6 +72,7 @@ import { DashboardModule } from 'src/modules/dashboard/dashboard.module';
import { AuditModule } from './audit/audit.module';
import { ClientConfigModule } from './client-config/client-config.module';
import { EventLogsModule } from './event-logs/event-logs.module';
import { FileModule } from './file/file.module';
@Module({
@@ -158,6 +159,7 @@ import { FileModule } from './file/file.module';
TrashCleanupModule,
DashboardModule,
RowLevelPermissionModule,
EventLogsModule,
],
exports: [
AuditModule,
@@ -0,0 +1,35 @@
/* @license Enterprise */
import { Command, CommandRunner } from 'nest-commander';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { EVENT_LOG_CLEANUP_CRON_PATTERN } from 'src/engine/core-modules/event-logs/cleanup/constants/event-log-cleanup-cron-pattern.constant';
import { EventLogCleanupCronJob } from 'src/engine/core-modules/event-logs/cleanup/crons/event-log-cleanup.cron.job';
@Command({
name: 'cron:event-log-cleanup',
description:
'Starts a cron job to clean up old event logs based on workspace retention settings',
})
export class EventLogCleanupCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>({
jobName: EventLogCleanupCronJob.name,
data: undefined,
options: {
repeat: {
pattern: EVENT_LOG_CLEANUP_CRON_PATTERN,
},
},
});
}
}
@@ -0,0 +1,2 @@
// Run daily at 3 AM UTC
export const EVENT_LOG_CLEANUP_CRON_PATTERN = '0 3 * * *';
@@ -0,0 +1,97 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { EVENT_LOG_CLEANUP_CRON_PATTERN } from 'src/engine/core-modules/event-logs/cleanup/constants/event-log-cleanup-cron-pattern.constant';
import {
EventLogCleanupJob,
type EventLogCleanupJobData,
} from 'src/engine/core-modules/event-logs/cleanup/jobs/event-log-cleanup.job';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
@Processor(MessageQueue.cronQueue)
export class EventLogCleanupCronJob {
private readonly logger = new Logger(EventLogCleanupCronJob.name);
constructor(
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectMessageQueue(MessageQueue.workspaceQueue)
private readonly messageQueueService: MessageQueueService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
@Process(EventLogCleanupCronJob.name)
@SentryCronMonitor(
EventLogCleanupCronJob.name,
EVENT_LOG_CLEANUP_CRON_PATTERN,
)
async handle(): Promise<void> {
const workspaces = await this.getActiveWorkspaces();
if (workspaces.length === 0) {
this.logger.log('No active workspaces found for event log cleanup');
return;
}
this.logger.log(
`Enqueuing event log cleanup jobs for ${workspaces.length} workspace(s)`,
);
for (const workspace of workspaces) {
try {
await this.messageQueueService.add<EventLogCleanupJobData>(
EventLogCleanupJob.name,
{
workspaceId: workspace.id,
eventLogRetentionDays: workspace.eventLogRetentionDays,
},
);
} catch (error) {
this.exceptionHandlerService.captureExceptions([error], {
workspace: {
id: workspace.id,
},
});
}
}
this.logger.log(
`Successfully enqueued ${workspaces.length} event log cleanup job(s)`,
);
}
private async getActiveWorkspaces(): Promise<
Array<{ id: string; eventLogRetentionDays: number }>
> {
const workspaces = await this.workspaceRepository.find({
where: {
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
select: ['id', 'eventLogRetentionDays'],
order: { id: 'ASC' },
});
if (workspaces.length === 0) {
return [];
}
return workspaces.map((workspace) => ({
id: workspace.id,
eventLogRetentionDays: workspace.eventLogRetentionDays,
}));
}
}
@@ -0,0 +1,23 @@
/* @license Enterprise */
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/cleanup/commands/event-log-cleanup.cron.command';
import { EventLogCleanupCronJob } from 'src/engine/core-modules/event-logs/cleanup/crons/event-log-cleanup.cron.job';
import { EventLogCleanupJob } from 'src/engine/core-modules/event-logs/cleanup/jobs/event-log-cleanup.job';
import { EventLogCleanupService } from 'src/engine/core-modules/event-logs/cleanup/services/event-log-cleanup.service';
@Module({
imports: [TypeOrmModule.forFeature([WorkspaceEntity]), ClickHouseModule],
providers: [
EventLogCleanupService,
EventLogCleanupJob,
EventLogCleanupCronJob,
EventLogCleanupCronCommand,
],
exports: [EventLogCleanupCronCommand],
})
export class EventLogCleanupModule {}
@@ -0,0 +1,41 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { EventLogCleanupService } from 'src/engine/core-modules/event-logs/cleanup/services/event-log-cleanup.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
export type EventLogCleanupJobData = {
workspaceId: string;
eventLogRetentionDays: number;
};
@Injectable()
@Processor(MessageQueue.workspaceQueue)
export class EventLogCleanupJob {
private readonly logger = new Logger(EventLogCleanupJob.name);
constructor(
private readonly eventLogCleanupService: EventLogCleanupService,
) {}
@Process(EventLogCleanupJob.name)
async handle(data: EventLogCleanupJobData): Promise<void> {
const { workspaceId, eventLogRetentionDays } = data;
try {
await this.eventLogCleanupService.cleanupWorkspaceEventLogs({
workspaceId,
retentionDays: eventLogRetentionDays,
});
} catch (error) {
this.logger.error(
`Event log cleanup failed for workspace ${workspaceId}`,
error instanceof Error ? error.stack : String(error),
);
throw error;
}
}
}
@@ -0,0 +1,74 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { EventLogTable } from 'twenty-shared/types';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
[EventLogTable.PAGEVIEW]: 'pageview',
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
};
export type EventLogCleanupParams = {
workspaceId: string;
retentionDays: number;
};
@Injectable()
export class EventLogCleanupService {
private readonly logger = new Logger(EventLogCleanupService.name);
constructor(private readonly clickHouseService: ClickHouseService) {}
async cleanupWorkspaceEventLogs({
workspaceId,
retentionDays,
}: EventLogCleanupParams): Promise<void> {
if (!this.clickHouseService.getMainClient()) {
this.logger.debug(
'ClickHouse not configured, skipping event log cleanup',
);
return;
}
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
for (const table of Object.values(EventLogTable)) {
const tableName = CLICKHOUSE_TABLE_NAMES[table];
try {
// ClickHouse ALTER TABLE DELETE is async by default
// We use lightweight deletes (mutations) which are efficient
const success = await this.clickHouseService.executeCommand(
`ALTER TABLE ${tableName} DELETE WHERE "workspaceId" = {workspaceId:String} AND "timestamp" < {cutoffDate:DateTime64(3)}`,
{
workspaceId,
cutoffDate: formatDateForClickHouse(cutoffDate),
},
);
if (success) {
this.logger.log(
`Scheduled deletion of old ${tableName} events for workspace ${workspaceId} (retention: ${retentionDays} days)`,
);
} else {
this.logger.warn(
`Failed to schedule deletion for ${tableName} in workspace ${workspaceId}`,
);
}
} catch (error) {
this.logger.error(
`Error cleaning up ${tableName} for workspace ${workspaceId}`,
error instanceof Error ? error.stack : String(error),
);
}
}
}
}
@@ -0,0 +1,28 @@
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class EventLogDateRangeInput {
@Field(() => Date, { nullable: true })
start?: Date;
@Field(() => Date, { nullable: true })
end?: Date;
}
@InputType()
export class EventLogFiltersInput {
@Field(() => String, { nullable: true })
eventType?: string;
@Field(() => String, { nullable: true })
userWorkspaceId?: string;
@Field(() => EventLogDateRangeInput, { nullable: true })
dateRange?: EventLogDateRangeInput;
@Field(() => String, { nullable: true })
recordId?: string;
@Field(() => String, { nullable: true })
objectMetadataId?: string;
}
@@ -0,0 +1,28 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { EventLogTable } from 'twenty-shared/types';
import { EventLogFiltersInput } from './event-log-filters.input';
import { registerEventLogTableEnum } from './event-log-table.enum';
registerEventLogTableEnum();
@InputType()
export class EventLogQueryInput {
@Field(() => EventLogTable)
table: EventLogTable;
@Field(() => EventLogFiltersInput, { nullable: true })
filters?: EventLogFiltersInput;
@Field(() => Int, { nullable: true, defaultValue: 100 })
@IsInt()
@Min(1)
@Max(10000)
@IsOptional()
first?: number;
@Field(() => String, { nullable: true })
after?: string;
}
@@ -0,0 +1,48 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
@ObjectType()
export class EventLogRecord {
@Field(() => String)
event: string;
@Field(() => Date)
timestamp: Date;
@Field(() => String, { nullable: true })
userWorkspaceId?: string;
@Field(() => GraphQLJSON, { nullable: true })
properties?: Record<string, unknown>;
@Field(() => String, { nullable: true })
recordId?: string;
@Field(() => String, { nullable: true })
objectMetadataId?: string;
@Field(() => Boolean, { nullable: true })
isCustom?: boolean;
}
@ObjectType()
export class EventLogPageInfo {
@Field(() => String, { nullable: true })
endCursor?: string;
@Field(() => Boolean)
hasNextPage: boolean;
}
@ObjectType()
export class EventLogQueryResult {
@Field(() => [EventLogRecord])
records: EventLogRecord[];
@Field(() => Int)
totalCount: number;
@Field(() => EventLogPageInfo)
pageInfo: EventLogPageInfo;
}
@@ -0,0 +1,11 @@
import { registerEnumType } from '@nestjs/graphql';
import { EventLogTable } from 'twenty-shared/types';
export const registerEventLogTableEnum = () => {
registerEnumType(EventLogTable, {
name: 'EventLogTable',
});
};
export { EventLogTable };
@@ -0,0 +1,38 @@
/* @license Enterprise */
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum EventLogsExceptionCode {
CLICKHOUSE_NOT_CONFIGURED = 'CLICKHOUSE_NOT_CONFIGURED',
NO_ENTITLEMENT = 'NO_ENTITLEMENT',
}
const getEventLogsExceptionUserFriendlyMessage = (
code: EventLogsExceptionCode,
) => {
switch (code) {
case EventLogsExceptionCode.CLICKHOUSE_NOT_CONFIGURED:
return msg`Audit logs require ClickHouse to be configured.`;
case EventLogsExceptionCode.NO_ENTITLEMENT:
return msg`Audit logs require an Enterprise subscription.`;
default:
assertUnreachable(code);
}
};
export class EventLogsException extends CustomException<EventLogsExceptionCode> {
constructor(
message: string,
code: EventLogsExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getEventLogsExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,23 @@
/* @license Enterprise */
import { Module } from '@nestjs/common';
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { EventLogsResolver } from './event-logs.resolver';
import { EventLogsService } from './event-logs.service';
@Module({
imports: [
ClickHouseModule,
PermissionsModule,
BillingModule,
GuardRedirectModule,
],
providers: [EventLogsResolver, EventLogsService],
exports: [EventLogsService],
})
export class EventLogsModule {}
@@ -0,0 +1,49 @@
/* @license Enterprise */
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { EnterpriseFeaturesEnabledGuard } from 'src/engine/core-modules/auth/guards/enterprise-features-enabled.guard';
import { EventLogsGraphqlApiExceptionFilter } from 'src/engine/core-modules/event-logs/filters/event-logs-graphql-api-exception.filter';
import { ForbiddenExceptionGraphqlFilter } from 'src/engine/core-modules/event-logs/filters/forbidden-exception-graphql.filter';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { EventLogsService } from './event-logs.service';
import { EventLogQueryInput } from './dtos/event-log-query.input';
import { EventLogQueryResult } from './dtos/event-log-result.output';
@Resolver()
@UseFilters(
ForbiddenExceptionGraphqlFilter,
AuthGraphqlApiExceptionFilter,
EventLogsGraphqlApiExceptionFilter,
PermissionsGraphqlApiExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
@UsePipes(ResolverValidationPipe)
export class EventLogsResolver {
constructor(private readonly eventLogsService: EventLogsService) {}
@UseGuards(
WorkspaceAuthGuard,
EnterpriseFeaturesEnabledGuard,
SettingsPermissionGuard(PermissionFlagType.SECURITY),
)
@Query(() => EventLogQueryResult)
async eventLogs(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('input') input: EventLogQueryInput,
): Promise<EventLogQueryResult> {
return this.eventLogsService.queryEventLogs(workspace.id, input);
}
}
@@ -0,0 +1,233 @@
/* @license Enterprise */
import { BadRequestException, Injectable } from '@nestjs/common';
import { EventLogTable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import {
EventLogsException,
EventLogsExceptionCode,
} from './event-logs.exception';
import { EventLogFiltersInput } from './dtos/event-log-filters.input';
import { EventLogQueryInput } from './dtos/event-log-query.input';
import {
EventLogQueryResult,
EventLogRecord,
} from './dtos/event-log-result.output';
type ClickHouseEventRecord = {
event?: string;
name?: string;
timestamp: string;
userWorkspaceId?: string;
properties?: Record<string, unknown>;
recordId?: string;
objectMetadataId?: string;
isCustom?: boolean;
};
const ALLOWED_TABLES = Object.values(EventLogTable);
const MAX_LIMIT = 10000;
const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
[EventLogTable.PAGEVIEW]: 'pageview',
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
};
@Injectable()
export class EventLogsService {
constructor(
private readonly clickHouseService: ClickHouseService,
private readonly billingService: BillingService,
) {}
async queryEventLogs(
workspaceId: string,
input: EventLogQueryInput,
): Promise<EventLogQueryResult> {
await this.validateAccess(workspaceId);
if (!ALLOWED_TABLES.includes(input.table)) {
throw new BadRequestException(`Invalid table: ${input.table}`);
}
const limit = Math.min(input.first ?? 100, MAX_LIMIT);
const tableName = CLICKHOUSE_TABLE_NAMES[input.table];
const eventFieldName =
input.table === EventLogTable.PAGEVIEW ? 'name' : 'event';
const whereClauses: string[] = ['"workspaceId" = {workspaceId:String}'];
const params: Record<string, unknown> = { workspaceId };
this.applyFilters(
whereClauses,
params,
input.filters,
eventFieldName,
input.table,
);
const paginationClauses = [...whereClauses];
if (isDefined(input.after)) {
const cursorMs = this.decodeCursor(input.after);
paginationClauses.push(
'"timestamp" < fromUnixTimestamp64Milli({cursorMs:Int64})',
);
params.cursorMs = cursorMs;
}
const filterWhereClause = whereClauses.join(' AND ');
const paginationWhereClause = paginationClauses.join(' AND ');
const countQuery = `
SELECT count() as totalCount
FROM ${tableName}
WHERE ${filterWhereClause}
`;
const query = `
SELECT *
FROM ${tableName}
WHERE ${paginationWhereClause}
ORDER BY "timestamp" DESC
LIMIT {limit:Int32}
`;
params.limit = limit + 1;
const [records, countResult] = await Promise.all([
this.clickHouseService.select<ClickHouseEventRecord>(query, params),
this.clickHouseService.select<{ totalCount: number }>(countQuery, params),
]);
const totalCount = countResult[0]?.totalCount ?? 0;
const hasNextPage = records.length > limit;
if (hasNextPage) {
records.pop();
}
const normalizedRecords = this.normalizeRecords(records, input.table);
const lastRecord = normalizedRecords[normalizedRecords.length - 1];
const endCursor =
hasNextPage && lastRecord
? this.encodeCursor(lastRecord.timestamp)
: undefined;
return {
records: normalizedRecords,
totalCount,
pageInfo: {
endCursor,
hasNextPage,
},
};
}
private async validateAccess(workspaceId: string): Promise<void> {
if (!this.clickHouseService.getMainClient()) {
throw new EventLogsException(
'Audit logs require ClickHouse to be configured. Please set the CLICKHOUSE_URL environment variable.',
EventLogsExceptionCode.CLICKHOUSE_NOT_CONFIGURED,
);
}
const hasEntitlement = await this.billingService.hasEntitlement(
workspaceId,
BillingEntitlementKey.AUDIT_LOGS,
);
if (!hasEntitlement) {
throw new EventLogsException(
'Audit logs require an Enterprise subscription.',
EventLogsExceptionCode.NO_ENTITLEMENT,
);
}
}
private applyFilters(
whereClauses: string[],
params: Record<string, unknown>,
filters: EventLogFiltersInput | undefined,
eventFieldName: string,
table: EventLogTable,
): void {
if (!isDefined(filters)) {
return;
}
if (isDefined(filters.eventType)) {
whereClauses.push(
`lower("${eventFieldName}") LIKE {eventTypePattern:String}`,
);
params.eventTypePattern = `%${filters.eventType.toLowerCase()}%`;
}
if (isDefined(filters.userWorkspaceId)) {
whereClauses.push('"userWorkspaceId" = {userWorkspaceId:String}');
params.userWorkspaceId = filters.userWorkspaceId;
}
if (isDefined(filters.dateRange?.start)) {
whereClauses.push('"timestamp" >= {startDate:DateTime64(3)}');
params.startDate = formatDateForClickHouse(filters.dateRange.start);
}
if (isDefined(filters.dateRange?.end)) {
whereClauses.push('"timestamp" <= {endDate:DateTime64(3)}');
params.endDate = formatDateForClickHouse(filters.dateRange.end);
}
if (table === EventLogTable.OBJECT_EVENT) {
if (isDefined(filters.recordId)) {
whereClauses.push('"recordId" = {recordId:String}');
params.recordId = filters.recordId;
}
if (isDefined(filters.objectMetadataId)) {
whereClauses.push('"objectMetadataId" = {objectMetadataId:String}');
params.objectMetadataId = filters.objectMetadataId;
}
}
}
private encodeCursor(timestamp: Date): string {
return Buffer.from(String(timestamp.getTime())).toString('base64');
}
private decodeCursor(cursor: string): number {
return parseInt(Buffer.from(cursor, 'base64').toString('utf-8'), 10);
}
private normalizeRecords(
records: ClickHouseEventRecord[],
table: EventLogTable,
): EventLogRecord[] {
return records.map((record) => {
const eventName =
table === EventLogTable.PAGEVIEW
? (record.name ?? '')
: (record.event ?? '');
return {
event: eventName,
timestamp: new Date(record.timestamp),
userWorkspaceId: record.userWorkspaceId,
properties: record.properties,
recordId: record.recordId,
objectMetadataId: record.objectMetadataId,
isCustom: record.isCustom,
};
});
}
}
@@ -0,0 +1,13 @@
/* @license Enterprise */
import { Catch, type ExceptionFilter } from '@nestjs/common';
import { EventLogsException } from 'src/engine/core-modules/event-logs/event-logs.exception';
import { eventLogsGraphqlApiExceptionHandler } from 'src/engine/core-modules/event-logs/utils/event-logs-graphql-api-exception-handler.util';
@Catch(EventLogsException)
export class EventLogsGraphqlApiExceptionFilter implements ExceptionFilter {
catch(exception: EventLogsException) {
return eventLogsGraphqlApiExceptionHandler(exception);
}
}
@@ -0,0 +1,18 @@
import {
Catch,
type ExceptionFilter,
ForbiddenException,
} from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { AuthenticationError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(ForbiddenException)
export class ForbiddenExceptionGraphqlFilter implements ExceptionFilter {
catch(exception: ForbiddenException) {
throw new AuthenticationError(exception.message, {
userFriendlyMessage: msg`Authentication required.`,
});
}
}
@@ -0,0 +1,22 @@
/* @license Enterprise */
import { assertUnreachable } from 'twenty-shared/utils';
import {
type EventLogsException,
EventLogsExceptionCode,
} from 'src/engine/core-modules/event-logs/event-logs.exception';
import { ForbiddenError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
export const eventLogsGraphqlApiExceptionHandler = (
exception: EventLogsException,
) => {
switch (exception.code) {
case EventLogsExceptionCode.CLICKHOUSE_NOT_CONFIGURED:
case EventLogsExceptionCode.NO_ENTITLEMENT:
throw new ForbiddenError(exception);
default: {
assertUnreachable(exception.code);
}
}
};
@@ -141,7 +141,7 @@ export class ImpersonationService {
) {
const auditService = this.auditService.createContext({
workspaceId: impersonatorUserWorkspace.workspace.id,
userId: impersonatorUserWorkspace.user.id,
userWorkspaceId: impersonatorUserWorkspace.id,
});
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
@@ -8,6 +8,7 @@ import {
IsString,
IsUUID,
Matches,
Max,
Min,
} from 'class-validator';
@@ -99,6 +100,13 @@ export class UpdateWorkspaceInput {
@IsOptional()
trashRetentionDays?: number;
@Field({ nullable: true })
@IsInt()
@Min(30) // Minimum 30 days retention for audit compliance
@Max(1095) // Maximum 3 years (matches ClickHouse table-level TTL)
@IsOptional()
eventLogRetentionDays?: number;
@Field({ nullable: true })
@IsString()
@IsOptional()
@@ -72,6 +72,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
displayName: PermissionFlagType.WORKSPACE,
logo: PermissionFlagType.WORKSPACE,
trashRetentionDays: PermissionFlagType.WORKSPACE,
eventLogRetentionDays: PermissionFlagType.SECURITY,
inviteHash: PermissionFlagType.WORKSPACE_MEMBERS,
isPublicInviteLinkEnabled: PermissionFlagType.SECURITY,
allowImpersonation: PermissionFlagType.SECURITY,
@@ -104,6 +104,10 @@ export class WorkspaceEntity {
@Column({ type: 'integer', default: 14 })
trashRetentionDays: number;
@Field()
@Column({ type: 'integer', default: 90 })
eventLogRetentionDays: number;
// Relations
@OneToMany(() => AppTokenEntity, (appToken) => appToken.workspace, {
cascade: true,