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
+137 -13
View File
@@ -5,6 +5,7 @@ import {
ForbiddenException,
HttpCode,
HttpStatus,
Inject,
NotFoundException,
Post,
UseGuards,
@@ -25,7 +26,7 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
import { SidebarPageDto } from './dto/sidebar-page.dto';
import {
SpaceCaslAction,
@@ -40,6 +41,12 @@ import {
jsonToHtml,
jsonToMarkdown,
} from '../../collaboration/collaboration.util';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
import { getPageTitle } from '../../common/helpers';
@UseGuards(JwtAuthGuard)
@Controller('pages')
@@ -50,6 +57,7 @@ export class PageController {
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@HttpCode(HttpStatus.OK)
@@ -129,6 +137,19 @@ export class PageController {
const permissions = { canEdit, hasRestriction };
this.auditService.log({
event: AuditEvent.PAGE_CREATED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
after: {
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
if (
createPageDto.format &&
createPageDto.format !== 'json' &&
@@ -153,8 +174,10 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const { hasRestriction } =
await this.pageAccessService.validateCanEdit(page, user);
const { hasRestriction } = await this.pageAccessService.validateCanEdit(
page,
user,
);
const updatedPage = await this.pageService.update(
page,
@@ -202,6 +225,21 @@ export class PageController {
);
}
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
this.auditService.log({
event: AuditEvent.PAGE_DELETED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
before: {
pageId: page.id,
slugId: page.slugId,
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
} else {
// User with edit permission can delete
await this.pageAccessService.validateCanEdit(page, user);
@@ -211,6 +249,21 @@ export class PageController {
user.id,
workspace.id,
);
this.auditService.log({
event: AuditEvent.PAGE_TRASHED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
before: {
pageId: page.id,
slugId: page.slugId,
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
}
}
@@ -227,20 +280,30 @@ export class PageController {
throw new NotFoundException('Page not found');
}
//Todo: currently, this means if they are not admins, they need to add a space admin to the page, which is not possible as it was soft-deleted
// so page is virtually lost. Fix.
// only users with "can edit" space level permission can restore pages
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
//TODO: can users with page level edit, but no space level edit restore pages they can edit?
// Check page-level edit permission (if restoring to a restricted ancestor)
// make sure they have page level access to the page
await this.pageAccessService.validateCanEdit(page, user);
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
this.auditService.log({
event: AuditEvent.PAGE_RESTORED,
resourceType: AuditResource.PAGE,
resourceId: page.id,
spaceId: page.spaceId,
changes: {
after: {
title: getPageTitle(page.title),
spaceId: page.spaceId,
},
},
});
return this.pageRepo.findById(pageIdDto.pageId, {
includeHasChildren: true,
});
@@ -286,7 +349,7 @@ export class PageController {
deletedPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
@@ -410,7 +473,26 @@ export class PageController {
await this.pageAccessService.validateCanEdit(movedPage, user);
// Moves only accessible pages; inaccessible child pages become root pages in original space
return this.pageService.movePageToSpace(movedPage, dto.spaceId, user.id);
const { childPageIds } = await this.pageService.movePageToSpace(
movedPage,
dto.spaceId,
user.id,
);
this.auditService.log({
event: AuditEvent.PAGE_MOVED_TO_SPACE,
resourceType: AuditResource.PAGE,
resourceId: movedPage.id,
spaceId: movedPage.spaceId,
changes: {
before: { spaceId: movedPage.spaceId },
after: { spaceId: dto.spaceId },
},
metadata: {
title: getPageTitle(movedPage.title),
...(childPageIds.length > 0 && { childPageIds }),
},
});
}
@HttpCode(HttpStatus.OK)
@@ -425,6 +507,8 @@ export class PageController {
// Inaccessible child branches are automatically skipped during duplication
await this.pageAccessService.validateCanView(copiedPage, user);
let result;
// If spaceId is provided, it's a copy to different space
if (dto.spaceId) {
const abilities = await Promise.all([
@@ -440,7 +524,27 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.duplicatePage(copiedPage, dto.spaceId, user);
result = await this.pageService.duplicatePage(
copiedPage,
dto.spaceId,
user,
);
this.auditService.log({
event: AuditEvent.PAGE_DUPLICATED,
resourceType: AuditResource.PAGE,
resourceId: result.id,
spaceId: dto.spaceId,
metadata: {
sourcePageId: copiedPage.id,
title: getPageTitle(copiedPage.title),
sourceSpaceId: copiedPage.spaceId,
targetSpaceId: dto.spaceId,
...(result.childPageIds.length > 0 && {
childPageIds: result.childPageIds,
}),
},
});
} else {
// If no spaceId, it's a duplicate in same space
const ability = await this.spaceAbility.createForUser(
@@ -451,8 +555,28 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.duplicatePage(copiedPage, undefined, user);
result = await this.pageService.duplicatePage(
copiedPage,
undefined,
user,
);
this.auditService.log({
event: AuditEvent.PAGE_DUPLICATED,
resourceType: AuditResource.PAGE,
resourceId: result.id,
spaceId: copiedPage.spaceId,
metadata: {
sourcePageId: copiedPage.id,
title: getPageTitle(copiedPage.title),
...(result.childPageIds.length > 0 && {
childPageIds: result.childPageIds,
}),
},
});
}
return result;
}
@HttpCode(HttpStatus.OK)
@@ -368,6 +368,8 @@ export class PageService {
}
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
let childPageIds: string[] = [];
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: false,
});
@@ -413,11 +415,13 @@ export class PageService {
const pageIdsToMove = accessiblePages.map((p) => p.id);
childPageIds = pageIdsToMove.filter((id) => id !== rootPage.id);
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
await this.pageRepo.updatePages(
{ spaceId },
pageIdsToMove.filter((id) => id !== rootPage.id),
childPageIds,
trx,
);
}
@@ -462,6 +466,8 @@ export class PageService {
});
}
});
return { childPageIds };
}
async duplicatePage(
@@ -680,10 +686,12 @@ export class PageService {
});
const hasChildren = pages.length > 1;
const childPageIds = insertedPageIds.filter((id) => id !== newPageId);
return {
...duplicatedPage,
hasChildren,
childPageIds,
};
}
@@ -6,10 +6,11 @@ import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
const DEFAULT_RETENTION_DAYS = 30;
@Injectable()
export class TrashCleanupService {
private readonly logger = new Logger(TrashCleanupService.name);
private readonly RETENTION_DAYS = 30;
constructor(
@InjectKysely() private readonly db: KyselyDB,
@@ -21,36 +22,46 @@ export class TrashCleanupService {
try {
this.logger.debug('Starting trash cleanup job');
const retentionDate = new Date();
retentionDate.setDate(retentionDate.getDate() - this.RETENTION_DAYS);
// Get all pages that were deleted more than 30 days ago
const oldDeletedPages = await this.db
.selectFrom('pages')
.select(['id', 'spaceId', 'workspaceId'])
.where('deletedAt', '<', retentionDate)
const workspaces = await this.db
.selectFrom('workspaces')
.select(['id', 'trashRetentionDays'])
.where('deletedAt', 'is', null)
.execute();
if (oldDeletedPages.length === 0) {
this.logger.debug('No old trash items to clean up');
return;
}
let totalCleaned = 0;
this.logger.debug(`Found ${oldDeletedPages.length} pages to clean up`);
for (const workspace of workspaces) {
const retentionDays =
workspace.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
// Process each page
for (const page of oldDeletedPages) {
try {
await this.cleanupPage(page.id);
} catch (error) {
this.logger.error(
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error.stack : undefined,
);
const retentionDate = new Date();
retentionDate.setDate(retentionDate.getDate() - retentionDays);
const oldDeletedPages = await this.db
.selectFrom('pages')
.select(['id'])
.where('workspaceId', '=', workspace.id)
.where('deletedAt', '<', retentionDate)
.execute();
for (const page of oldDeletedPages) {
try {
await this.cleanupPage(page.id);
totalCleaned++;
} catch (error) {
this.logger.error(
`Failed to cleanup page ${page.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error.stack : undefined,
);
}
}
}
this.logger.debug('Trash cleanup job completed');
this.logger.debug(
totalCleaned > 0
? `Trash cleanup completed: ${totalCleaned} pages cleaned`
: 'No old trash items to clean up',
);
} catch (error) {
this.logger.error(
'Trash cleanup job failed',