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
@@ -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 };
}
}