feat: audit Logs (#17660)
This commit is contained in:
@@ -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)) {
|
||||
|
||||
+1
-1
@@ -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) },
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
+1
-1
@@ -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', {
|
||||
|
||||
+12
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+1
@@ -4,4 +4,5 @@ export enum BillingEntitlementKey {
|
||||
SSO = 'SSO',
|
||||
CUSTOM_DOMAIN = 'CUSTOM_DOMAIN',
|
||||
RLS = 'RLS',
|
||||
AUDIT_LOGS = 'AUDIT_LOGS',
|
||||
}
|
||||
|
||||
+1
@@ -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;
|
||||
}
|
||||
|
||||
+1
@@ -161,6 +161,7 @@ describe('ClientConfigService', () => {
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
calendarBookingPageId: 'team/twenty/talk-to-us',
|
||||
isCloudflareIntegrationEnabled: false,
|
||||
isClickHouseConfigured: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+35
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// Run daily at 3 AM UTC
|
||||
export const EVENT_LOG_CLEANUP_CRON_PATTERN = '0 3 * * *';
|
||||
+97
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
+23
@@ -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 {}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
+11
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user