feat(ee): page-level access/permissions (#1971)

* Add page_hierarchy table

* feat(ee): page-level permissions

* pagination

* rename migration
fixes

* fix

* tabs

* fix theme

* cleanup

* sync

* page permissions notification
* other fixes

* sharing disbled

* fix column nodes

* toggle error handling
This commit is contained in:
Philip Okugbe
2026-02-26 19:49:10 +00:00
committed by GitHub
parent 22f33bab7c
commit 59e945562d
75 changed files with 4235 additions and 363 deletions
@@ -53,6 +53,7 @@ import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import { RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page/page-access/page-access.service';
@Controller()
export class AttachmentController {
@@ -67,6 +68,7 @@ export class AttachmentController {
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService,
) {}
@UseGuards(JwtAuthGuard)
@@ -111,13 +113,7 @@ export class AttachmentController {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanEdit(page, user);
const spaceId = page.spaceId;
@@ -172,15 +168,13 @@ export class AttachmentController {
throw new NotFoundException();
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException();
}
await this.pageAccessService.validateCanView(page, user);
try {
return await this.sendFileResponse(req, res, attachment, 'private');
} catch (err) {
@@ -24,6 +24,7 @@ import {
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { PageAccessService } from '../page/page-access/page-access.service';
@UseGuards(JwtAuthGuard)
@Controller('comments')
@@ -33,6 +34,7 @@ export class CommentController {
private readonly commentRepo: CommentRepo,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@@ -47,10 +49,7 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanEdit(page, user);
return this.commentService.create(
{
@@ -75,10 +74,8 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return this.commentService.findByPageId(page.id, pagination);
}
@@ -90,13 +87,13 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return comment;
}
@@ -108,18 +105,13 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException(
'You must have space edit permission to edit comments',
);
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
return this.commentService.update(comment, dto, user);
}
@@ -131,41 +123,27 @@ export class CommentController {
throw new NotFoundException('Comment not found');
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(comment.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// Check page-level edit permission first
await this.pageAccessService.validateCanEdit(page, user);
// Check if user is the comment owner
const isOwner = comment.creatorId === user.id;
if (isOwner) {
/*
// Check if comment has children from other users
const hasChildrenFromOthers =
await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
// Owner can delete if no children from other users
if (!hasChildrenFromOthers) {
await this.commentRepo.deleteComment(comment.id);
return;
}
// If has children from others, only space admin can delete
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'Only space admins can delete comments with replies from other users',
);
}*/
await this.commentRepo.deleteComment(comment.id);
return;
}
const ability = await this.spaceAbility.createForUser(
user,
comment.spaceId,
);
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
+2
View File
@@ -14,6 +14,7 @@ import { SearchModule } from './search/search.module';
import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module';
import { PageAccessModule } from './page/page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
@@ -31,6 +32,7 @@ import { WatcherModule } from './watcher/watcher.module';
SpaceModule,
GroupModule,
CaslModule,
PageAccessModule,
ShareModule,
NotificationModule,
WatcherModule,
@@ -3,6 +3,7 @@ export const NotificationType = {
COMMENT_CREATED: 'comment.created',
COMMENT_RESOLVED: 'comment.resolved',
PAGE_USER_MENTION: 'page.user_mention',
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
} as const;
export type NotificationType =
@@ -4,10 +4,9 @@ import { NotificationController } from './notification.controller';
import { NotificationProcessor } from './notification.processor';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
import { WsModule } from '../../ws/ws.module';
@Module({
imports: [WsModule],
imports: [],
controllers: [NotificationController],
providers: [
NotificationService,
@@ -8,6 +8,7 @@ import {
ICommentNotificationJob,
ICommentResolvedNotificationJob,
IPageMentionNotificationJob,
IPermissionGrantedNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
@@ -33,7 +34,8 @@ export class NotificationProcessor
job: Job<
| ICommentNotificationJob
| ICommentResolvedNotificationJob
| IPageMentionNotificationJob,
| IPageMentionNotificationJob
| IPermissionGrantedNotificationJob,
void
>,
): Promise<void> {
@@ -66,6 +68,14 @@ export class NotificationProcessor
break;
}
case QueueJob.PAGE_PERMISSION_GRANTED: {
await this.pageNotificationService.processPermissionGranted(
job.data as IPermissionGrantedNotificationJob,
appUrl,
);
break;
}
default:
this.logger.warn(`Unknown notification job: ${job.name}`);
}
@@ -8,6 +8,7 @@ import {
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { CommentMentionEmail } from '@docmost/transactional/emails/comment-mention-email';
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
@@ -22,6 +23,7 @@ export class CommentNotificationService {
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
) {}
@@ -59,12 +61,19 @@ export class CommentNotificationService {
const allCandidateIds = [
...new Set([...mentionedUserIds, ...recipientIds]),
];
const usersWithAccess =
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
allCandidateIds,
spaceId,
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[...usersWithSpaceAccess],
);
const usersWithAccess = new Set(usersWithPageAccess);
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
@@ -146,6 +155,13 @@ export class CommentNotificationService {
return;
}
const hasPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[commentCreatorId],
);
if (hasPageAccess.length === 0) return;
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
@@ -1,11 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { IPageMentionNotificationJob } from '../../../integrations/queue/constants/queue.interface';
import {
IPageMentionNotificationJob,
IPermissionGrantedNotificationJob,
} from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
import { getPageTitle } from '../../../common/helpers';
@Injectable()
@@ -14,6 +19,7 @@ export class PageNotificationService {
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {}
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
@@ -28,12 +34,19 @@ export class PageNotificationService {
if (newMentions.length === 0) return;
const candidateUserIds = newMentions.map((m) => m.userId);
const usersWithAccess =
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
candidateUserIds,
spaceId,
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[...usersWithSpaceAccess],
);
const usersWithAccess = new Set(usersWithPageAccess);
const accessibleMentions = newMentions.filter((m) =>
usersWithAccess.has(m.userId),
);
@@ -97,6 +110,52 @@ export class PageNotificationService {
}
}
async processPermissionGranted(
data: IPermissionGrantedNotificationJob,
appUrl: string,
) {
const { userIds, pageId, spaceId, workspaceId, actorId, role } = data;
if (userIds.length === 0) return;
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(userIds, spaceId);
if (usersWithSpaceAccess.size === 0) return;
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
if (!context) return;
const { actor, pageTitle, basePageUrl } = context;
const accessLabel = role === 'writer' ? 'edit' : 'view';
for (const userId of usersWithSpaceAccess) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_PERMISSION_GRANTED,
actorId,
pageId,
spaceId,
data: { role },
});
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
PermissionGrantedEmail({
actorName: actor.name,
pageTitle,
pageUrl: basePageUrl,
accessLabel,
}),
);
}
}
private async getPageContext(
actorId: string,
pageId: string,
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PageAccessService } from './page-access.service';
@Global()
@Module({
providers: [PageAccessService],
exports: [PageAccessService],
})
export class PageAccessModule {}
@@ -0,0 +1,102 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Page, User } from '@docmost/db/types/entity.types';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
@Injectable()
export class PageAccessService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
/**
* Validate user can view page, throws ForbiddenException if not.
* If page has restrictions: page-level permission determines access.
* If no restrictions: space-level permission determines access.
*/
async validateCanView(page: Page, user: User): Promise<void> {
// TODO: cache by pageId and userId.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const canAccess = await this.pagePermissionRepo.canUserAccessPage(
user.id,
page.id,
);
if (!canAccess) {
throw new ForbiddenException();
}
}
/**
* Validate user can view page AND return effective canEdit permission.
* Combines access check + edit permission in a single query pass.
*/
async validateCanViewWithPermissions(
page: Page,
user: User,
): Promise<{ canEdit: boolean; hasRestriction: boolean }> {
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasAnyRestriction, canAccess, canEdit } =
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
if (hasAnyRestriction && !canAccess) {
throw new ForbiddenException();
}
return {
canEdit: hasAnyRestriction
? canEdit
: ability.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
hasRestriction: hasAnyRestriction,
};
}
/**
* Validate user can edit page, throws ForbiddenException if not.
* If page has restrictions: page-level writer permission determines access.
* If no restrictions: space-level edit permission determines access.
*/
async validateCanEdit(
page: Page,
user: User,
): Promise<{ hasRestriction: boolean }> {
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasAnyRestriction, canEdit } =
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
if (hasAnyRestriction) {
// Page has restrictions - use page-level permission
if (!canEdit) {
throw new ForbiddenException();
}
} else {
// No restrictions - use space-level permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
return { hasRestriction: hasAnyRestriction };
}
}
+99 -39
View File
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { PageService } from './services/page.service';
import { PageAccessService } from './page-access/page-access.service';
import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
@@ -48,6 +49,7 @@ export class PageController {
private readonly pageRepo: PageRepo,
private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
) {}
@HttpCode(HttpStatus.OK)
@@ -65,10 +67,10 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { canEdit, hasRestriction } =
await this.pageAccessService.validateCanViewWithPermissions(page, user);
const permissions = { canEdit, hasRestriction };
if (dto.format && dto.format !== 'json' && page.content) {
const contentOutput =
@@ -78,10 +80,11 @@ export class PageController {
return {
...page,
content: contentOutput,
permissions,
};
}
return page;
return { ...page, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -91,12 +94,28 @@ export class PageController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
createPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
if (createPageDto.parentPageId) {
// Creating under a parent page - check edit permission on parent
const parentPage = await this.pageRepo.findById(
createPageDto.parentPageId,
);
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== createPageDto.spaceId
) {
throw new NotFoundException('Parent page not found');
}
await this.pageAccessService.validateCanEdit(parentPage, user);
} else {
// Creating at root level - require space-level permission
const ability = await this.spaceAbility.createForUser(
user,
createPageDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
const page = await this.pageService.create(
@@ -105,6 +124,11 @@ export class PageController {
createPageDto,
);
const { canEdit, hasRestriction } =
await this.pageAccessService.validateCanViewWithPermissions(page, user);
const permissions = { canEdit, hasRestriction };
if (
createPageDto.format &&
createPageDto.format !== 'json' &&
@@ -114,10 +138,10 @@ export class PageController {
createPageDto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return { ...page, content: contentOutput };
return { ...page, content: contentOutput, permissions };
}
return page;
return { ...page, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -129,10 +153,8 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasRestriction } =
await this.pageAccessService.validateCanEdit(page, user);
const updatedPage = await this.pageService.update(
page,
@@ -140,6 +162,8 @@ export class PageController {
user,
);
const permissions = { canEdit: true, hasRestriction };
if (
updatePageDto.format &&
updatePageDto.format !== 'json' &&
@@ -149,10 +173,10 @@ export class PageController {
updatePageDto.format === 'markdown'
? jsonToMarkdown(updatedPage.content)
: jsonToHtml(updatedPage.content);
return { ...updatedPage, content: contentOutput };
return { ...updatedPage, content: contentOutput, permissions };
}
return updatedPage;
return { ...updatedPage, permissions };
}
@HttpCode(HttpStatus.OK)
@@ -179,10 +203,9 @@ export class PageController {
}
await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
} else {
// Soft delete requires page manage permissions
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// User with edit permission can delete
await this.pageAccessService.validateCanEdit(page, user);
await this.pageService.removePage(
deletePageDto.pageId,
user.id,
@@ -204,11 +227,18 @@ 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.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, 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)
await this.pageAccessService.validateCanEdit(page, user);
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
return this.pageRepo.findById(pageIdDto.pageId, {
@@ -235,6 +265,7 @@ export class PageController {
return this.pageService.getRecentSpacePages(
recentPageDto.spaceId,
user.id,
pagination,
);
}
@@ -261,6 +292,7 @@ export class PageController {
return this.pageService.getDeletedSpacePages(
deletedPageDto.spaceId,
user.id,
pagination,
);
}
@@ -278,10 +310,7 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
}
@@ -297,13 +326,14 @@ export class PageController {
throw new NotFoundException('Page history not found');
}
const ability = await this.spaceAbility.createForUser(
user,
history.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
// Get the page to check permissions
const page = await this.pageRepo.findById(history.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
return history;
}
@@ -335,7 +365,18 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId);
const spaceCanEdit = ability.can(
SpaceCaslAction.Edit,
SpaceCaslSubject.Page,
);
return this.pageService.getSidebarPages(
spaceId,
pagination,
dto.pageId,
user.id,
spaceCanEdit,
);
}
@HttpCode(HttpStatus.OK)
@@ -365,7 +406,11 @@ export class PageController {
throw new ForbiddenException();
}
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
// Check page-level edit permission on the source page
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);
}
@HttpCode(HttpStatus.OK)
@@ -376,6 +421,10 @@ export class PageController {
throw new NotFoundException('Page to copy not found');
}
// Check page-level view permission on the source page (need to read to copy)
// Inaccessible child branches are automatically skipped during duplication
await this.pageAccessService.validateCanView(copiedPage, user);
// If spaceId is provided, it's a copy to different space
if (dto.spaceId) {
const abilities = await Promise.all([
@@ -418,10 +467,23 @@ export class PageController {
user,
movedPage.spaceId,
);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// Check page-level edit permission
await this.pageAccessService.validateCanEdit(movedPage, user);
// If moving to a new parent, check permission on the target parent
if (dto.parentPageId && dto.parentPageId !== movedPage.parentPageId) {
const targetParent = await this.pageRepo.findById(dto.parentPageId);
if (!targetParent || targetParent.deletedAt) {
throw new NotFoundException('Target parent page not found');
}
await this.pageAccessService.validateCanEdit(targetParent, user);
}
return this.pageService.movePage(dto, movedPage);
}
@@ -433,10 +495,8 @@ export class PageController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return this.pageService.getPageBreadCrumbs(page.id);
}
}
@@ -7,6 +7,7 @@ import {
import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
@@ -48,6 +49,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
import { markdownToHtml } from '@docmost/editor-ext';
import { WatcherService } from '../../watcher/watcher.service';
import { sql } from 'kysely';
@Injectable()
export class PageService {
@@ -55,6 +57,7 @@ export class PageService {
constructor(
private pageRepo: PageRepo,
private pagePermissionRepo: PagePermissionRepo,
private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService,
@@ -92,7 +95,11 @@ export class PageService {
createPageDto.parentPageId,
);
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== createPageDto.spaceId
) {
throw new NotFoundException('Parent page not found');
}
@@ -262,6 +269,8 @@ export class PageService {
spaceId: string,
pagination: PaginationOptions,
pageId?: string,
userId?: string,
spaceCanEdit?: boolean,
): Promise<CursorPaginationResult<Partial<Page> & { hasChildren: boolean }>> {
let query = this.db
.selectFrom('pages')
@@ -286,8 +295,8 @@ export class PageService {
query = query.where('parentPageId', 'is', null);
}
return executeWithCursorPagination(query, {
perPage: 250,
const result = await executeWithCursorPagination(query, {
perPage: 200,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
@@ -303,10 +312,97 @@ export class PageService {
id: cursor.id,
}),
});
if (userId && result.items.length > 0) {
const hasRestrictions =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
if (!hasRestrictions) {
result.items = result.items.map((p: any) => ({
...p,
canEdit: spaceCanEdit ?? true,
}));
} else {
const pageIds = result.items.map((p: any) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const permissionMap = new Map(
accessiblePages.map((p) => [p.id, p.canEdit]),
);
result.items = result.items
.filter((p: any) => permissionMap.has(p.id))
.map((p: any) => ({
...p,
canEdit: permissionMap.get(p.id) && (spaceCanEdit ?? true),
}));
const pagesWithChildren = result.items.filter(
(p: any) => p.hasChildren,
);
if (pagesWithChildren.length > 0) {
const parentIds = pagesWithChildren.map((p: any) => p.id);
const parentsWithAccessibleChildren =
await this.pagePermissionRepo.getParentIdsWithAccessibleChildren(
parentIds,
userId,
);
const hasAccessibleChildrenSet = new Set(
parentsWithAccessibleChildren,
);
result.items = result.items.map((p: any) => ({
...p,
hasChildren: p.hasChildren && hasAccessibleChildrenSet.has(p.id),
}));
}
}
}
return result;
}
async movePageToSpace(rootPage: Page, spaceId: string) {
async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: false,
});
// Filter to only accessible pages while maintaining tree integrity
const accessiblePages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
userId,
rootPage.spaceId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
// Find inaccessible pages whose parent is being moved - these need to be orphaned
const pagesToOrphan = allPages.filter(
(p) =>
!accessibleIds.has(p.id) &&
p.parentPageId &&
accessibleIds.has(p.parentPageId),
);
await executeTx(this.db, async (trx) => {
// Orphan inaccessible child pages (make them root pages in original space)
for (const page of pagesToOrphan) {
const orphanPosition = await this.nextPagePosition(
rootPage.spaceId,
null,
);
await this.pageRepo.updatePage(
{ parentPageId: null, position: orphanPosition },
page.id,
trx,
);
}
// Update root page
const nextPosition = await this.nextPagePosition(spaceId);
await this.pageRepo.updatePage(
@@ -314,48 +410,54 @@ export class PageService {
rootPage.id,
trx,
);
const pageIds = await this.pageRepo
.getPageAndDescendants(rootPage.id, { includeContent: false })
.then((pages) => pages.map((page) => page.id));
// The first id is the root page id
if (pageIds.length > 1) {
// Update sub pages
const pageIdsToMove = accessiblePages.map((p) => p.id);
if (pageIdsToMove.length > 1) {
// Update sub pages (all accessible pages except root)
await this.pageRepo.updatePages(
{ spaceId },
pageIds.filter((id) => id !== rootPage.id),
pageIdsToMove.filter((id) => id !== rootPage.id),
trx,
);
}
if (pageIds.length > 0) {
if (pageIdsToMove.length > 0) {
// Clear page-level permissions - moved pages inherit destination space permissions
// (page_permissions cascade deletes via foreign key)
await trx
.deleteFrom('pageAccess')
.where('pageId', 'in', pageIdsToMove)
.execute();
// update spaceId in shares
await trx
.updateTable('shares')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update comments
await trx
.updateTable('comments')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.where('pageId', 'in', pageIdsToMove)
.execute();
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },
pageIds,
pageIdsToMove,
trx,
);
// Update watchers and remove those without access to new space
await this.watcherService.movePageWatchersToSpace(pageIds, spaceId, {
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
trx,
});
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIds,
pageId: pageIdsToMove,
workspaceId: rootPage.workspaceId,
});
}
@@ -381,10 +483,18 @@ export class PageService {
nextPosition = await this.nextPagePosition(spaceId);
}
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: true,
});
// Filter to only accessible pages while maintaining tree integrity
const pages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
authUser.id,
rootPage.spaceId,
);
const pageMap = new Map<string, CopyPageMapEntry>();
pages.forEach((page) => {
pageMap.set(page.id, {
@@ -592,7 +702,11 @@ export class PageService {
// changing the page's parent
if (dto.parentPageId) {
const parentPage = await this.pageRepo.findById(dto.parentPageId);
if (!parentPage || parentPage.spaceId !== movedPage.spaceId) {
if (
!parentPage ||
parentPage.deletedAt ||
parentPage.spaceId !== movedPage.spaceId
) {
throw new NotFoundException('Parent page not found');
}
parentPageId = parentPage.id;
@@ -623,7 +737,6 @@ export class PageService {
'spaceId',
'deletedAt',
])
.select((eb) => this.pageRepo.withHasChildren(eb))
.where('id', '=', childPageId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
@@ -639,30 +752,21 @@ export class PageService {
'p.spaceId',
'p.deletedAt',
])
.select(
exp
.selectFrom('pages as child')
.select((eb) =>
eb
.case()
.when(eb.fn.countAll(), '>', 0)
.then(true)
.else(false)
.end()
.as('count'),
)
.whereRef('child.parentPageId', '=', 'id')
.where('child.deletedAt', 'is', null)
.limit(1)
.as('hasChildren'),
)
//.select((eb) => this.withHasChildren(eb))
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_ancestors')
.selectAll()
.selectAll('page_ancestors')
.select((eb) =>
eb.exists(
eb
.selectFrom('pages as child')
.select(sql`1`.as('one'))
.whereRef('child.parentPageId', '=', 'page_ancestors.id')
.where('child.deletedAt', 'is', null),
).as('hasChildren'),
)
.execute();
return ancestors.reverse();
@@ -670,23 +774,72 @@ export class PageService {
async getRecentSpacePages(
spaceId: string,
userId: string,
pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
const result = await this.pageRepo.getRecentPagesInSpace(
spaceId,
pagination,
);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
spaceId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async getRecentPages(
userId: string,
pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getRecentPages(userId, pagination);
const result = await this.pageRepo.getRecentPages(userId, pagination);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async getDeletedSpacePages(
spaceId: string,
userId: string,
pagination: PaginationOptions,
): Promise<CursorPaginationResult<Page>> {
return this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
const result = await this.pageRepo.getDeletedPagesInSpace(
spaceId,
pagination,
);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
spaceId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
}
async forceDelete(pageId: string, workspaceId: string): Promise<void> {
@@ -776,4 +929,61 @@ export class PageService {
return prosemirrorJson;
}
/**
* Filters a list of pages to only those accessible to the user while maintaining tree integrity.
* A page is included only if:
* 1. The user has access to it
* 2. Its parent is also included (or it's the root page)
* This ensures that if a middle page is inaccessible, its entire subtree is excluded.
*/
private async filterAccessibleTreePages<
T extends { id: string; parentPageId: string | null },
>(
pages: T[],
rootPageId: string,
userId: string,
spaceId?: string,
): Promise<T[]> {
if (pages.length === 0) return [];
const pageIds = pages.map((p) => p.id);
const accessibleIds = await this.pagePermissionRepo.filterAccessiblePageIds(
{
pageIds,
userId,
spaceId,
},
);
const accessibleSet = new Set(accessibleIds);
// Prune: include a page only if it's accessible AND its parent chain to root is included
const includedIds = new Set<string>();
// Process pages in a way that ensures parents are processed before children
// We do this by iterating until no more pages can be added
let changed = true;
while (changed) {
changed = false;
for (const page of pages) {
if (includedIds.has(page.id)) continue;
if (!accessibleSet.has(page.id)) continue;
// Root page: include if accessible
if (page.id === rootPageId) {
includedIds.add(page.id);
changed = true;
continue;
}
// Non-root: include if parent is already included
if (page.parentPageId && includedIds.has(page.parentPageId)) {
includedIds.add(page.id);
changed = true;
}
}
}
return pages.filter((p) => includedIds.has(p.id));
}
}
+30 -2
View File
@@ -7,6 +7,7 @@ import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
@@ -18,6 +19,7 @@ export class SearchService {
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
private pagePermissionRepo: PagePermissionRepo,
) {}
async searchPage(
@@ -115,10 +117,23 @@ export class SearchService {
}
//@ts-ignore
queryResults = await queryResults.execute();
let results: any[] = await queryResults.execute();
// Filter results by page-level permissions (if user is authenticated)
if (opts.userId && results.length > 0) {
const pageIds = results.map((r: any) => r.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId: opts.userId,
spaceId: searchParams.spaceId,
});
const accessibleSet = new Set(accessibleIds);
results = results.filter((r: any) => accessibleSet.has(r.id));
}
//@ts-ignore
const searchResults = queryResults.map((result: SearchResponseDto) => {
const searchResults = results.map((result: SearchResponseDto) => {
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
@@ -207,6 +222,19 @@ export class SearchService {
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
pages = await pageSearch.execute();
}
// Filter by page-level permissions
if (pages.length > 0) {
const pageIds = pages.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
spaceId: suggestion?.spaceId,
});
const accessibleSet = new Set(accessibleIds);
pages = pages.filter((p) => accessibleSet.has(p.id));
}
}
return { users, groups, pages };
+28 -19
View File
@@ -11,12 +11,7 @@ import {
} from '@nestjs/common';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { ShareService } from './share.service';
import {
CreateShareDto,
@@ -26,6 +21,8 @@ import {
UpdateShareDto,
} from './dto/share.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageAccessService } from '../page/page-access/page-access.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@@ -38,9 +35,10 @@ import { hasLicenseOrEE } from '../../common/helpers';
export class ShareController {
constructor(
private readonly shareService: ShareService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService,
private readonly environmentService: EnvironmentService,
) {}
@@ -119,10 +117,7 @@ export class ShareController {
throw new NotFoundException('Shared page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
await this.pageAccessService.validateCanView(page, user);
return this.shareService.getShareForPage(page.id, workspace.id);
}
@@ -140,9 +135,17 @@ export class ShareController {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
// User must be able to edit the page to create a share
//TODO: i dont think this is neccessary if we prevent restricted pages from getting shared
// rather, use space level permission and workspace/space level sharing restriction
await this.pageAccessService.validateCanEdit(page, user);
// Prevent sharing restricted pages
const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
page.id,
);
if (isRestricted) {
throw new BadRequestException('Cannot share a restricted page');
}
const sharingAllowed = await this.shareService.isSharingAllowed(
@@ -170,11 +173,14 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(share.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// User must be able to edit the page to update its share
await this.pageAccessService.validateCanEdit(page, user);
return this.shareService.updateShare(share.id, updateShareDto);
}
@@ -187,11 +193,14 @@ export class ShareController {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
const page = await this.pageRepo.findById(share.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// User must be able to edit the page to delete its share
await this.pageAccessService.validateCanEdit(page, user);
await this.shareRepo.deleteShare(share.id);
}
+22 -5
View File
@@ -19,6 +19,7 @@ import {
} from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types';
import { validate as isValidUUID } from 'uuid';
@@ -31,6 +32,7 @@ export class ShareService {
constructor(
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB,
private readonly tokenService: TokenService,
) {}
@@ -41,12 +43,20 @@ export class ShareService {
throw new NotFoundException('Share not found');
}
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
includeContent: false,
});
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
if (isRestricted) {
throw new NotFoundException('Share not found');
}
return { share, pageTree: pageList };
if (share.includeSubPages) {
const pageTree =
await this.pageRepo.getPageAndDescendantsExcludingRestricted(
share.pageId,
{ includeContent: false },
);
return { share, pageTree };
} else {
return { share, pageTree: [] };
}
@@ -112,6 +122,13 @@ export class ShareService {
throw new NotFoundException('Shared page not found');
}
// Block access to restricted pages
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(page.id);
if (isRestricted) {
throw new NotFoundException('Shared page not found');
}
page.content = await this.updatePublicAttachments(page);
return { page, share };