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,5 +1,6 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -17,6 +18,11 @@ import { SpaceRole } from '../../../common/helpers/types/permission';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { executeTx } from '@docmost/db/utils';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class SpaceMemberService {
@@ -26,6 +32,7 @@ export class SpaceMemberService {
private spaceRepo: SpaceRepo,
private watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async addUserToSpace(
@@ -90,7 +97,6 @@ export class SpaceMemberService {
authUser: User,
workspaceId: string,
): Promise<void> {
// await this.spaceService.findAndValidateSpace(spaceId, workspaceId);
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
@@ -164,8 +170,45 @@ export class SpaceMemberService {
if (membersToAdd.length > 0) {
await this.spaceMemberRepo.insertSpaceMember(membersToAdd);
} else {
// either they are already members or do not exist on the workspace
// Audit log for each member added
for (const user of validUsers) {
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ADDED,
resourceType: AuditResource.SPACE_MEMBER,
resourceId: dto.spaceId,
spaceId: dto.spaceId,
changes: {
after: { role: dto.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
userId: user.id,
userName: user.name,
memberType: 'user',
},
});
}
for (const group of validGroups) {
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ADDED,
resourceType: AuditResource.SPACE_MEMBER,
resourceId: dto.spaceId,
spaceId: dto.spaceId,
changes: {
after: { role: dto.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
groupId: group.id,
groupName: group.name,
memberType: 'group',
},
});
}
}
}
@@ -230,6 +273,23 @@ export class SpaceMemberService {
{ trx },
);
});
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_REMOVED,
resourceType: AuditResource.SPACE_MEMBER,
resourceId: dto.spaceId,
spaceId: dto.spaceId,
changes: {
before: { role: spaceMember.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
userId: spaceMember.userId,
groupId: spaceMember.groupId,
memberType: spaceMember.userId ? 'user' : 'group',
},
});
}
async updateSpaceMemberRole(
@@ -280,6 +340,24 @@ export class SpaceMemberService {
spaceMember.id,
dto.spaceId,
);
this.auditService.log({
event: AuditEvent.SPACE_MEMBER_ROLE_CHANGED,
resourceType: AuditResource.SPACE_MEMBER,
resourceId: dto.spaceId,
spaceId: dto.spaceId,
changes: {
before: { role: spaceMember.role },
after: { role: dto.role },
},
metadata: {
spaceId: dto.spaceId,
spaceName: space.name,
userId: spaceMember.userId,
groupId: spaceMember.groupId,
memberType: spaceMember.userId ? 'user' : 'group',
},
});
}
async validateLastAdmin(spaceId: string): Promise<void> {
@@ -1,6 +1,7 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -21,6 +22,12 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { AuditEvent, AuditResource } from '../../../common/events/audit-events';
import { diffAuditTrackedFields } from '../../../common/helpers';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
@Injectable()
export class SpaceService {
@@ -32,6 +39,7 @@ export class SpaceService {
private licenseCheckService: LicenseCheckService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
async createSpace(
@@ -63,6 +71,19 @@ export class SpaceService {
trx,
);
this.auditService.log({
event: AuditEvent.SPACE_CREATED,
resourceType: AuditResource.SPACE,
resourceId: space.id,
spaceId: space.id,
changes: {
after: {
name: space.name,
slug: space.slug,
},
},
});
return { ...space, memberCount: 1 };
}
@@ -124,28 +145,74 @@ export class SpaceService {
'This feature requires a valid enterprise license',
);
}
await this.spaceRepo.updateSharingSettings(
updateSpaceDto.spaceId,
workspaceId,
'disabled',
updateSpaceDto.disablePublicSharing,
);
if (updateSpaceDto.disablePublicSharing) {
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId);
}
}
return await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
description: updateSpaceDto.description,
slug: updateSpaceDto.slug,
},
const spaceBefore = await this.spaceRepo.findById(
updateSpaceDto.spaceId,
workspaceId,
);
const settingsBefore = (spaceBefore?.settings ?? {}) as Record<string, any>;
const before: Record<string, any> = {};
const after: Record<string, any> = {};
let updatedSpace: Space;
await executeTx(this.db, async (trx) => {
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
const prev = settingsBefore?.sharing?.disabled ?? false;
if (prev !== updateSpaceDto.disablePublicSharing) {
before.disablePublicSharing = prev;
after.disablePublicSharing = updateSpaceDto.disablePublicSharing;
}
await this.spaceRepo.updateSharingSettings(
updateSpaceDto.spaceId,
workspaceId,
'disabled',
updateSpaceDto.disablePublicSharing,
trx,
);
if (updateSpaceDto.disablePublicSharing) {
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId, trx);
}
}
updatedSpace = await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
description: updateSpaceDto.description,
slug: updateSpaceDto.slug,
},
updateSpaceDto.spaceId,
workspaceId,
trx,
);
});
const columnChanges = diffAuditTrackedFields(
['name', 'slug', 'description'],
updateSpaceDto,
spaceBefore,
updatedSpace,
);
if (columnChanges) {
Object.assign(before, columnChanges.before);
Object.assign(after, columnChanges.after);
}
if (Object.keys(after).length > 0) {
this.auditService.log({
event: AuditEvent.SPACE_UPDATED,
resourceType: AuditResource.SPACE,
resourceId: updateSpaceDto.spaceId,
spaceId: updateSpaceDto.spaceId,
changes: { before, after },
});
}
return updatedSpace;
}
async getSpaceInfo(spaceId: string, workspaceId: string): Promise<Space> {
@@ -174,5 +241,19 @@ export class SpaceService {
await this.spaceRepo.deleteSpace(spaceId, workspaceId);
await this.attachmentQueue.add(QueueJob.DELETE_SPACE_ATTACHMENTS, space);
this.auditService.log({
event: AuditEvent.SPACE_DELETED,
resourceType: AuditResource.SPACE,
resourceId: spaceId,
spaceId: spaceId,
changes: {
before: {
name: space.name,
slug: space.slug,
description: space.description,
},
},
});
}
}