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:
parent
9e686af2e3
commit
41ce6308fa
7 changed files with 526 additions and 7 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules",
|
||||
"test", "dist", "**/*spec.ts"]
|
||||
"test", "dist", "**/*spec.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue