feat(ee): audit logs (#1977)

feat: clickhouse driver
* sync
* updates
This commit is contained in:
Philip Okugbe
2026-03-01 01:29:03 +00:00
committed by GitHub
parent 85ce0d32bf
commit 69d7532c6c
62 changed files with 2600 additions and 191 deletions
@@ -1,6 +1,13 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateWorkspaceDto } from './create-workspace.dto';
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
import {
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@@ -34,4 +41,9 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
@IsOptional()
@IsInt()
@Min(1)
trashRetentionDays: number;
}
@@ -1,5 +1,6 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
@@ -33,6 +34,11 @@ import {
validateAllowedEmail,
validateSsoEnforcement,
} from '../../auth/auth.util';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class WorkspaceInvitationService {
@@ -46,6 +52,7 @@ export class WorkspaceInvitationService {
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
private readonly environmentService: EnvironmentService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async getInvitations(workspaceId: string, pagination: PaginationOptions) {
@@ -180,6 +187,24 @@ export class WorkspaceInvitationService {
workspace.hostname,
);
});
// Audit log for each invitation created
for (const invitation of invites) {
this.auditService.log({
event: AuditEvent.WORKSPACE_INVITE_CREATED,
resourceType: AuditResource.WORKSPACE_INVITATION,
resourceId: invitation.id,
changes: {
after: {
email: invitation.email,
role: invitation.role,
},
},
metadata: {
groupIds: invitation.groupIds,
},
});
}
}
}
@@ -296,6 +321,23 @@ export class WorkspaceInvitationService {
});
}
this.auditService.log({
event: AuditEvent.USER_CREATED,
resourceType: AuditResource.USER,
resourceId: newUser.id,
changes: {
after: {
name: newUser.name,
email: newUser.email,
role: invitation.role,
},
},
metadata: {
source: 'invitation',
invitationId: invitation.id,
},
});
if (this.environmentService.isCloud()) {
await this.billingQueue.add(QueueJob.STRIPE_SEATS_SYNC, {
workspaceId: workspace.id,
@@ -339,17 +381,48 @@ export class WorkspaceInvitationService {
invitedByUser.name,
workspace.hostname,
);
this.auditService.log({
event: AuditEvent.WORKSPACE_INVITE_RESENT,
resourceType: AuditResource.WORKSPACE_INVITATION,
resourceId: invitation.id,
metadata: {
email: invitation.email,
role: invitation.role,
},
});
}
async revokeInvitation(
invitationId: string,
workspaceId: string,
): Promise<void> {
const invitation = await this.db
.selectFrom('workspaceInvitations')
.select(['id', 'email', 'role'])
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
await this.db
.deleteFrom('workspaceInvitations')
.where('id', '=', invitationId)
.where('workspaceId', '=', workspaceId)
.execute();
if (invitation) {
this.auditService.log({
event: AuditEvent.WORKSPACE_INVITE_REVOKED,
resourceType: AuditResource.WORKSPACE_INVITATION,
resourceId: invitation.id,
changes: {
before: {
email: invitation.email,
role: invitation.role,
},
},
});
}
}
async getInvitationLinkById(
@@ -1,6 +1,7 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
@@ -31,11 +32,19 @@ import { v4 } from 'uuid';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers';
import {
generateRandomSuffixNumbers,
diffAuditTrackedFields,
} from '../../../common/helpers';
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class WorkspaceService {
@@ -57,6 +66,7 @@ export class WorkspaceService {
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async findById(workspaceId: string) {
@@ -280,7 +290,7 @@ export class WorkspaceService {
if (updateWorkspaceDto.enforceSso) {
const sso = await this.db
.selectFrom('authProviders')
.selectAll()
.select(['id'])
.where('isEnabled', '=', true)
.where('workspaceId', '=', workspaceId)
.execute();
@@ -295,9 +305,7 @@ export class WorkspaceService {
if (updateWorkspaceDto.emailDomains) {
const regex =
/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/;
const emailDomains = updateWorkspaceDto.emailDomains || [];
updateWorkspaceDto.emailDomains = emailDomains
.map((domain) => regex.exec(domain)?.[0])
.filter(Boolean);
@@ -313,93 +321,170 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
await this.workspaceRepo.updateApiSettings(
workspaceId,
'restrictToAdmins',
updateWorkspaceDto.restrictApiToAdmins,
);
delete updateWorkspaceDto.restrictApiToAdmins;
}
const before: Record<string, any> = {};
const after: Record<string, any> = {};
if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
await this.workspaceRepo.updateAiSettings(
workspaceId,
'search',
updateWorkspaceDto.aiSearch,
);
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
.select(['id', 'licenseKey', 'trashRetentionDays'])
.where('id', '=', workspaceId)
.executeTakeFirst();
if (updateWorkspaceDto.aiSearch) {
const tableExists = await isPageEmbeddingsTableExists(this.db);
if (!tableExists) {
throw new BadRequestException(
'Failed to activate. Make sure pgvector postgres extension is installed.',
);
}
await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
workspaceId,
});
} else {
// Schedule deletion after 24 hours
const deleteJobId = `ai-search-disabled-${workspaceId}`;
await this.aiQueue.add(
QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
{ workspaceId },
{
jobId: deleteJobId,
delay: 24 * 60 * 60 * 1000,
removeOnComplete: true,
removeOnFail: true,
},
);
}
delete updateWorkspaceDto.aiSearch;
}
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
await this.workspaceRepo.updateAiSettings(
workspaceId,
'generative',
updateWorkspaceDto.generativeAi,
);
delete updateWorkspaceDto.generativeAi;
}
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey)
) {
if (!this.licenseCheckService.isValidEELicense(ws.licenseKey)) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
await this.workspaceRepo.updateSharingSettings(
workspaceId,
'disabled',
updateWorkspaceDto.disablePublicSharing,
);
if (updateWorkspaceDto.disablePublicSharing) {
await this.shareRepo.deleteByWorkspaceId(workspaceId);
if (
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' &&
updateWorkspaceDto.trashRetentionDays !== ws.trashRetentionDays
) {
before.trashRetentionDays = ws.trashRetentionDays;
after.trashRetentionDays = updateWorkspaceDto.trashRetentionDays;
}
delete updateWorkspaceDto.disablePublicSharing;
}
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
if (updateWorkspaceDto.aiSearch) {
const tableExists = await isPageEmbeddingsTableExists(this.db);
if (!tableExists) {
throw new BadRequestException(
'Failed to activate. Make sure pgvector postgres extension is installed.',
);
}
}
const workspaceBefore = await this.workspaceRepo.findById(workspaceId);
const settingsBefore = (workspaceBefore?.settings ?? {}) as Record<
string,
any
>;
await executeTx(this.db, async (trx) => {
if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
const prev = settingsBefore?.api?.restrictToAdmins ?? false;
if (prev !== updateWorkspaceDto.restrictApiToAdmins) {
before.restrictApiToAdmins = prev;
after.restrictApiToAdmins = updateWorkspaceDto.restrictApiToAdmins;
}
await this.workspaceRepo.updateApiSettings(
workspaceId,
'restrictToAdmins',
updateWorkspaceDto.restrictApiToAdmins,
trx,
);
}
if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
const prev = settingsBefore?.ai?.search ?? false;
if (prev !== updateWorkspaceDto.aiSearch) {
before.aiSearch = prev;
after.aiSearch = updateWorkspaceDto.aiSearch;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
'search',
updateWorkspaceDto.aiSearch,
trx,
);
}
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
const prev = settingsBefore?.ai?.generative ?? false;
if (prev !== updateWorkspaceDto.generativeAi) {
before.generativeAi = prev;
after.generativeAi = updateWorkspaceDto.generativeAi;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
'generative',
updateWorkspaceDto.generativeAi,
trx,
);
}
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const prev = settingsBefore?.sharing?.disabled ?? false;
if (prev !== updateWorkspaceDto.disablePublicSharing) {
before.disablePublicSharing = prev;
after.disablePublicSharing = updateWorkspaceDto.disablePublicSharing;
}
await this.workspaceRepo.updateSharingSettings(
workspaceId,
'disabled',
updateWorkspaceDto.disablePublicSharing,
trx,
);
if (updateWorkspaceDto.disablePublicSharing) {
await this.shareRepo.deleteByWorkspaceId(workspaceId, trx);
}
}
delete updateWorkspaceDto.restrictApiToAdmins;
delete updateWorkspaceDto.aiSearch;
delete updateWorkspaceDto.generativeAi;
delete updateWorkspaceDto.disablePublicSharing;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
workspaceId,
trx,
);
});
if (after.aiSearch === true) {
await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
workspaceId,
});
} else if (after.aiSearch === false) {
const deleteJobId = `ai-search-disabled-${workspaceId}`;
await this.aiQueue.add(
QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
{ workspaceId },
{
jobId: deleteJobId,
delay: 24 * 60 * 60 * 1000,
removeOnComplete: true,
removeOnFail: true,
},
);
}
const workspace = await this.workspaceRepo.findById(workspaceId, {
withMemberCount: true,
withLicenseKey: true,
});
const columnChanges = diffAuditTrackedFields(
[
'name',
'logo',
'enforceSso',
'enforceMfa',
'emailDomains',
],
updateWorkspaceDto,
workspaceBefore,
workspace,
);
if (columnChanges) {
Object.assign(before, columnChanges.before);
Object.assign(after, columnChanges.after);
}
if (Object.keys(after).length > 0) {
this.auditService.log({
event: AuditEvent.WORKSPACE_UPDATED,
resourceType: AuditResource.WORKSPACE,
resourceId: workspaceId,
changes: { before, after },
});
}
const { licenseKey, ...rest } = workspace;
return {
...rest,
@@ -457,6 +542,16 @@ export class WorkspaceService {
user.id,
workspaceId,
);
this.auditService.log({
event: AuditEvent.USER_ROLE_CHANGED,
resourceType: AuditResource.USER,
resourceId: user.id,
changes: {
before: { role: user.role },
after: { role: newRole },
},
});
}
async generateHostname(
@@ -564,6 +659,19 @@ export class WorkspaceService {
});
});
this.auditService.log({
event: AuditEvent.USER_DELETED,
resourceType: AuditResource.USER,
resourceId: user.id,
changes: {
before: {
name: user.name,
email: user.email,
role: user.role,
},
},
});
try {
await this.attachmentQueue.add(QueueJob.DELETE_USER_AVATARS, user);
} catch (err) {