feat(page-permission): OSS-ify page-level permission module

Adds a native page-permission controller + service under
apps/server/src/core/page/page-permission, wired into PageModule.

LicenseCheckService now declares PAGE_PERMISSIONS and SHARING_CONTROLS
as Acadenice OSS features so hasFeature() / resolveFeatures() always
expose them regardless of EE plan, keeping useHasFeature() and the
server-side guards consistent.

tsconfig.build.json excludes vitest.config.ts from the Nest build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-11 09:54:00 +00:00
parent 9e686af2e3
commit 41ce6308fa
7 changed files with 526 additions and 7 deletions

View file

@ -0,0 +1,67 @@
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsOptional,
IsString,
IsUUID,
ValidateIf,
} from 'class-validator';
export const PAGE_PERMISSION_ROLES = ['reader', 'writer'] as const;
export type PagePermissionRoleValue = (typeof PAGE_PERMISSION_ROLES)[number];
export class PageIdBodyDto {
@IsString()
pageId: string;
}
export class AddPagePermissionDto {
@IsString()
pageId: string;
@IsIn(PAGE_PERMISSION_ROLES as unknown as string[])
role: PagePermissionRoleValue;
@ValidateIf((o) => !o.groupIds || o.groupIds.length === 0)
@IsArray()
@ArrayNotEmpty()
@IsUUID('all', { each: true })
userIds?: string[];
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
groupIds?: string[];
}
export class RemovePagePermissionDto {
@IsString()
pageId: string;
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
userIds?: string[];
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
groupIds?: string[];
}
export class UpdatePagePermissionDto {
@IsString()
pageId: string;
@IsIn(PAGE_PERMISSION_ROLES as unknown as string[])
role: PagePermissionRoleValue;
@ValidateIf((o) => !o.groupId)
@IsUUID()
userId?: string;
@IsOptional()
@IsUUID()
groupId?: string;
}

View file

@ -0,0 +1,97 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PagePermissionService } from './page-permission.service';
import {
AddPagePermissionDto,
PageIdBodyDto,
RemovePagePermissionDto,
UpdatePagePermissionDto,
} from './dto/page-permission.dto';
/**
* Page-sharing endpoints Outline-style granular access.
* Mounted under the same /pages prefix as PageController, so the existing
* client (`apps/client/src/ee/page-permission/services/page-permission-service.ts`)
* works without any URL changes.
*/
@UseGuards(JwtAuthGuard)
@Controller('pages')
export class PagePermissionController {
constructor(private readonly pagePermissionService: PagePermissionService) {}
@HttpCode(HttpStatus.NO_CONTENT)
@Post('restrict')
async restrict(@Body() dto: PageIdBodyDto, @AuthUser() user: User) {
await this.pagePermissionService.restrictPage(dto.pageId, user);
}
@HttpCode(HttpStatus.NO_CONTENT)
@Post('remove-restriction')
async unrestrict(@Body() dto: PageIdBodyDto, @AuthUser() user: User) {
await this.pagePermissionService.unrestrictPage(dto.pageId, user);
}
@HttpCode(HttpStatus.NO_CONTENT)
@Post('add-permission')
async addPermission(
@Body() dto: AddPagePermissionDto,
@AuthUser() user: User,
) {
await this.pagePermissionService.addPermissions(dto, user);
}
@HttpCode(HttpStatus.NO_CONTENT)
@Post('remove-permission')
async removePermission(
@Body() dto: RemovePagePermissionDto,
@AuthUser() user: User,
) {
await this.pagePermissionService.removePermissions(dto, user);
}
@HttpCode(HttpStatus.NO_CONTENT)
@Post('update-permission')
async updatePermission(
@Body() dto: UpdatePagePermissionDto,
@AuthUser() user: User,
) {
await this.pagePermissionService.updatePermissionRole(dto, user);
}
@HttpCode(HttpStatus.OK)
@Post('permissions')
async listPermissions(
@Body() body: PageIdBodyDto & { cursor?: string; query?: string },
@AuthUser() user: User,
) {
const pagination: PaginationOptions = {
limit: 50,
cursor: body.cursor,
query: body.query,
} as PaginationOptions;
return this.pagePermissionService.listPermissions(
body.pageId,
user,
pagination as any,
);
}
@HttpCode(HttpStatus.OK)
@Post('permission-info')
async getRestrictionInfo(
@Body() dto: PageIdBodyDto,
@AuthUser() user: User,
) {
return this.pagePermissionService.getRestrictionInfo(dto.pageId, user);
}
}

View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PagePermissionController } from './page-permission.controller';
import { PagePermissionService } from './page-permission.service';
@Module({
controllers: [PagePermissionController],
providers: [PagePermissionService],
})
export class PagePermissionModule {}

View file

@ -0,0 +1,316 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { Page, User } from '@docmost/db/types/entity.types';
import { PageAccessService } from '../page-access/page-access.service';
import {
PageAccessLevel,
PagePermissionRole,
} from '../../../common/helpers/types/permission';
import {
AddPagePermissionDto,
RemovePagePermissionDto,
UpdatePagePermissionDto,
} from './dto/page-permission.dto';
interface RestrictionInfo {
restrictionId?: string;
hasDirectRestriction: boolean;
hasInheritedRestriction: boolean;
inheritedFrom?: { id: string; slugId: string; title: string };
userAccess: { canView: boolean; canEdit: boolean; canManage: boolean };
}
/**
* Page-sharing service Outline-style granular access on top of the native
* page_access / page_permissions tables. Reuses `PagePermissionRepo` for
* persistence and `PageAccessService` to authorize the caller.
*
* Authorization rule for managing permissions: caller must have edit access
* to the page (page-level if restricted, space-level otherwise). This matches
* Outline's "Editors can share" behavior.
*/
@Injectable()
export class PagePermissionService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService,
) {}
private async loadPageOrThrow(pageId: string): Promise<Page> {
const page = await this.pageRepo.findById(pageId);
if (!page) throw new NotFoundException('Page not found');
return page;
}
/**
* Restrict the page promote its access level to RESTRICTED and seed the
* caller as a writer so they don't lock themselves out. Idempotent.
*/
async restrictPage(pageId: string, user: User): Promise<void> {
const page = await this.loadPageOrThrow(pageId);
await this.pageAccessService.validateCanEdit(page, user);
await this.db.transaction().execute(async (trx) => {
const existing = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
trx,
);
let pageAccess = existing;
if (!pageAccess) {
pageAccess = await this.pagePermissionRepo.insertPageAccess(
{
pageId: page.id,
workspaceId: page.workspaceId,
spaceId: page.spaceId,
accessLevel: PageAccessLevel.RESTRICTED,
creatorId: user.id,
},
trx,
);
}
// Seed the caller as a writer so they retain access after restriction.
const existingPerm = await this.pagePermissionRepo.findPagePermissionByUserId(
pageAccess.id,
user.id,
trx,
);
if (!existingPerm) {
await this.pagePermissionRepo.insertPagePermissions(
[
{
pageAccessId: pageAccess.id,
userId: user.id,
role: PagePermissionRole.WRITER,
addedById: user.id,
},
],
trx,
);
}
});
}
async unrestrictPage(pageId: string, user: User): Promise<void> {
const page = await this.loadPageOrThrow(pageId);
await this.pageAccessService.validateCanEdit(page, user);
// ON DELETE CASCADE on page_permissions handles cleanup.
await this.pagePermissionRepo.deletePageAccess(page.id);
}
async addPermissions(dto: AddPagePermissionDto, user: User): Promise<void> {
const page = await this.loadPageOrThrow(dto.pageId);
await this.pageAccessService.validateCanEdit(page, user);
const userIds = dto.userIds ?? [];
const groupIds = dto.groupIds ?? [];
if (userIds.length === 0 && groupIds.length === 0) {
return;
}
await this.db.transaction().execute(async (trx) => {
let pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
trx,
);
// Auto-promote to restricted on first add — matches the Notion-like
// affordance "share with X = make it restricted".
if (!pageAccess) {
pageAccess = await this.pagePermissionRepo.insertPageAccess(
{
pageId: page.id,
workspaceId: page.workspaceId,
spaceId: page.spaceId,
accessLevel: PageAccessLevel.RESTRICTED,
creatorId: user.id,
},
trx,
);
}
const newRows = [
...userIds.map((uid) => ({
pageAccessId: pageAccess!.id,
userId: uid,
role: dto.role,
addedById: user.id,
})),
...groupIds.map((gid) => ({
pageAccessId: pageAccess!.id,
groupId: gid,
role: dto.role,
addedById: user.id,
})),
];
// Drop any existing rows for the same (pageAccess, user/group) so the
// insert is idempotent and the role is updated on re-add.
if (userIds.length > 0) {
await this.pagePermissionRepo.deletePagePermissionsByUserIds(
pageAccess!.id,
userIds,
trx,
);
}
if (groupIds.length > 0) {
await this.pagePermissionRepo.deletePagePermissionsByGroupIds(
pageAccess!.id,
groupIds,
trx,
);
}
await this.pagePermissionRepo.insertPagePermissions(newRows, trx);
});
}
async removePermissions(
dto: RemovePagePermissionDto,
user: User,
): Promise<void> {
const page = await this.loadPageOrThrow(dto.pageId);
await this.pageAccessService.validateCanEdit(page, user);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
);
if (!pageAccess) return;
if (dto.userIds && dto.userIds.length > 0) {
await this.pagePermissionRepo.deletePagePermissionsByUserIds(
pageAccess.id,
dto.userIds,
);
}
if (dto.groupIds && dto.groupIds.length > 0) {
await this.pagePermissionRepo.deletePagePermissionsByGroupIds(
pageAccess.id,
dto.groupIds,
);
}
}
async updatePermissionRole(
dto: UpdatePagePermissionDto,
user: User,
): Promise<void> {
const page = await this.loadPageOrThrow(dto.pageId);
await this.pageAccessService.validateCanEdit(page, user);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
);
if (!pageAccess) {
throw new NotFoundException('Page is not restricted');
}
if (!dto.userId && !dto.groupId) {
throw new ForbiddenException('userId or groupId required');
}
// Guard against demoting the last writer to reader (locks out everyone).
if (dto.role === PagePermissionRole.READER) {
const writers = await this.pagePermissionRepo.countWritersByPageAccessId(
pageAccess.id,
);
const target = dto.userId
? await this.pagePermissionRepo.findPagePermissionByUserId(
pageAccess.id,
dto.userId,
)
: await this.pagePermissionRepo.findPagePermissionByGroupId(
pageAccess.id,
dto.groupId!,
);
if (
writers <= 1 &&
target?.role === PagePermissionRole.WRITER
) {
throw new ForbiddenException(
'Cannot demote the last writer of a restricted page',
);
}
}
await this.pagePermissionRepo.updatePagePermissionRole(
pageAccess.id,
dto.role,
{ userId: dto.userId, groupId: dto.groupId },
);
}
async listPermissions(
pageId: string,
user: User,
pagination: { limit: number; cursor?: string; query?: string },
) {
const page = await this.loadPageOrThrow(pageId);
// Only people who can view the page can see who has access.
await this.pageAccessService.validateCanView(page, user);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
);
if (!pageAccess) {
return { items: [], meta: { hasNextPage: false, hasPrevPage: false } };
}
return this.pagePermissionRepo.getPagePermissionsPaginated(
pageAccess.id,
pagination as any,
);
}
async getRestrictionInfo(
pageId: string,
user: User,
): Promise<RestrictionInfo> {
const page = await this.loadPageOrThrow(pageId);
await this.pageAccessService.validateCanView(page, user);
const direct = await this.pagePermissionRepo.findPageAccessByPageId(
page.id,
);
const ancestor = await this.pagePermissionRepo.findRestrictedAncestor(
page.id,
);
// findRestrictedAncestor returns the SAME page when the page itself is
// restricted (depth=0). We only consider it "inherited" when depth > 0.
const isInherited =
!!ancestor && ancestor.depth > 0 && ancestor.pageId !== page.id;
const inheritedPage = isInherited
? await this.pageRepo.findById(ancestor!.pageId)
: undefined;
const { hasAnyRestriction, canAccess, canEdit } =
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
return {
restrictionId: direct?.id,
hasDirectRestriction: !!direct,
hasInheritedRestriction: isInherited,
inheritedFrom: inheritedPage
? {
id: inheritedPage.id,
slugId: inheritedPage.slugId,
title: inheritedPage.title ?? '',
}
: undefined,
userAccess: {
canView: hasAnyRestriction ? canAccess : true,
canEdit: hasAnyRestriction ? canEdit : true,
// For now: anyone who can edit can manage permissions. Mirrors the
// service-level rule used by addPermissions/restrict.
canManage: hasAnyRestriction ? canEdit : true,
},
};
}
}

View file

@ -6,10 +6,17 @@ import { TrashCleanupService } from './services/trash-cleanup.service';
import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
import { WatcherModule } from '../watcher/watcher.module';
import { PagePermissionController } from './page-permission/page-permission.controller';
import { PagePermissionService } from './page-permission/page-permission.service';
@Module({
controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService],
controllers: [PageController, PagePermissionController],
providers: [
PageService,
PageHistoryService,
TrashCleanupService,
PagePermissionService,
],
exports: [PageService, PageHistoryService],
imports: [StorageModule, CollaborationModule, WatcherModule],
})

View file

@ -1,6 +1,21 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { EnvironmentService } from './environment.service';
import { Feature } from '../../common/features';
/**
* Acadenice features OSS-ifiees inconditionnellement (open-source).
* Toujours injectees dans la reponse `resolveFeatures` quel que soit le plan
* ou la license. Permet de garder le pipeline `useHasFeature(...)` cote front
* et les guards cote serveur sans toucher au flux EE.
*
* Why: reproduit la logique du Patch 020 (branding) et R4.5 (audit, api-keys)
* appliques par overrides UI directs. Centralise ici pour rester DRY.
*/
const ACADENICE_OSS_FEATURES: ReadonlyArray<string> = [
Feature.PAGE_PERMISSIONS,
Feature.SHARING_CONTROLS,
];
@Injectable()
export class LicenseCheckService {
@ -27,6 +42,9 @@ export class LicenseCheckService {
}
hasFeature(licenseKey: string, feature: string, plan?: string): boolean {
if (ACADENICE_OSS_FEATURES.includes(feature)) {
return true;
}
if (this.environmentService.isCloud()) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
@ -63,17 +81,22 @@ export class LicenseCheckService {
}
resolveFeatures(licenseKey: string, plan: string): string[] {
let base: string[];
if (this.environmentService.isCloud()) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
return [...getFeaturesForCloudPlan(plan)];
base = [...getFeaturesForCloudPlan(plan)];
} catch {
return [];
base = [];
}
} else {
base = this.getFeatures(licenseKey);
}
return this.getFeatures(licenseKey);
// Inject Acadenice OSS-ified features unconditionally.
const set = new Set(base);
for (const f of ACADENICE_OSS_FEATURES) set.add(f);
return [...set];
}
resolveTier(licenseKey: string, plan: string): string {

View file

@ -1,5 +1,5 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules",
"test", "dist", "**/*spec.ts"]
"test", "dist", "**/*spec.ts", "vitest.config.ts"]
}