diff --git a/apps/server/src/core/page/services/page-permission.service.ts b/apps/server/src/core/page/services/page-permission.service.ts index 373526e6..56f1bba8 100644 --- a/apps/server/src/core/page/services/page-permission.service.ts +++ b/apps/server/src/core/page/services/page-permission.service.ts @@ -366,20 +366,19 @@ export class PagePermissionService { } } + /** + * Check if user has writer permission on ALL restricted ancestors of a page. + * Used for permission management operations. + */ async hasWritePermission(userId: string, pageId: string): Promise { - const restrictedAncestor = - await this.pagePermissionRepo.findRestrictedAncestor(pageId); + const hasRestriction = + await this.pagePermissionRepo.hasRestrictedAncestor(pageId); - if (!restrictedAncestor) { - return false; + if (!hasRestriction) { + return false; // no restrictions, defer to space permissions } - const permission = await this.pagePermissionRepo.getUserPagePermission( - userId, - restrictedAncestor.pageId, - ); - - return permission?.role === PagePermissionRole.WRITER; + return this.pagePermissionRepo.canUserEditPage(userId, pageId); } async hasPageAccess(pageId: string): Promise { @@ -402,46 +401,34 @@ export class PagePermissionService { /** * Check if user can view a page. + * User must have permission (reader or writer) on EVERY restricted ancestor. * Returns true if: - * - Page has no restricted ancestor: fall back to space permission - * - Page has restricted ancestor: user has reader or writer permission on that ancestor + * - No ancestors are restricted (defer to space permission) + * - User has permission on all restricted ancestors */ async canViewPage(userId: string, pageId: string): Promise { - const restrictedAncestor = - await this.pagePermissionRepo.findRestrictedAncestor(pageId); - - if (!restrictedAncestor) { - return true; // no page-level restriction, defer to space permission - } - - const permission = await this.pagePermissionRepo.getUserPagePermission( - userId, - restrictedAncestor.pageId, - ); - - return !!permission; // has any permission (reader or writer) + return this.pagePermissionRepo.canUserAccessPage(userId, pageId); } /** * Check if user can edit a page. + * User must have WRITER permission on EVERY restricted ancestor. * Returns true if: - * - Page has no restricted ancestor: fall back to space permission - * - Page has restricted ancestor: user has writer permission on that ancestor + * - No ancestors are restricted (defer to space permission) + * - User has writer permission on all restricted ancestors */ async canEditPage(userId: string, pageId: string): Promise { - const restrictedAncestor = - await this.pagePermissionRepo.findRestrictedAncestor(pageId); + return this.pagePermissionRepo.canUserEditPage(userId, pageId); + } - if (!restrictedAncestor) { - return true; // no page-level restriction, defer to space permission - } - - const permission = await this.pagePermissionRepo.getUserPagePermission( - userId, - restrictedAncestor.pageId, - ); - - return permission?.role === PagePermissionRole.WRITER; + /** + * Filter page IDs to only those the user can access. + */ + async filterAccessiblePages( + pageIds: string[], + userId: string, + ): Promise { + return this.pagePermissionRepo.filterAccessiblePageIds(pageIds, userId); } /** diff --git a/apps/server/src/database/repos/page/page-permission.repo.ts b/apps/server/src/database/repos/page/page-permission.repo.ts index cf3c4f09..e9b5bd27 100644 --- a/apps/server/src/database/repos/page/page-permission.repo.ts +++ b/apps/server/src/database/repos/page/page-permission.repo.ts @@ -267,4 +267,145 @@ export class PagePermissionRepo { .orderBy('pageHierarchy.depth', 'asc') .executeTakeFirst(); } + + /** + * Check if user can access a page by verifying they have permission on ALL restricted ancestors. + * Returns true if: + * - No ancestors are restricted, OR + * - User has permission (reader or writer) on every restricted ancestor + */ + async canUserAccessPage(userId: string, pageId: string): Promise { + // Find any restricted ancestor where user lacks permission + const deniedAncestor = await this.db + .selectFrom('pageHierarchy') + .innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId') + .leftJoin('pagePermissions', (join) => + join + .onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id') + .on((eb) => + eb.or([ + eb('pagePermissions.userId', '=', userId), + eb( + 'pagePermissions.groupId', + 'in', + eb + .selectFrom('groupUsers') + .select('groupUsers.groupId') + .where('groupUsers.userId', '=', userId), + ), + ]), + ), + ) + .select('pageAccess.pageId') + .where('pageHierarchy.descendantId', '=', pageId) + .where('pagePermissions.id', 'is', null) + .executeTakeFirst(); + + return !deniedAncestor; + } + + /** + * Check if user can edit a page by verifying they have WRITER permission on ALL restricted ancestors. + * Returns true if: + * - No ancestors are restricted, OR + * - User has writer permission on every restricted ancestor + */ + async canUserEditPage(userId: string, pageId: string): Promise { + // Find any restricted ancestor where user lacks writer permission + const deniedAncestor = await this.db + .selectFrom('pageHierarchy') + .innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId') + .leftJoin('pagePermissions', (join) => + join + .onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id') + .on('pagePermissions.role', '=', 'writer') + .on((eb) => + eb.or([ + eb('pagePermissions.userId', '=', userId), + eb( + 'pagePermissions.groupId', + 'in', + eb + .selectFrom('groupUsers') + .select('groupUsers.groupId') + .where('groupUsers.userId', '=', userId), + ), + ]), + ), + ) + .select('pageAccess.pageId') + .where('pageHierarchy.descendantId', '=', pageId) + .where('pagePermissions.id', 'is', null) + .executeTakeFirst(); + + return !deniedAncestor; + } + + /** + * Filter a list of page IDs to only those the user can access. + * Efficient single-query implementation for bulk filtering. + */ + async filterAccessiblePageIds( + pageIds: string[], + userId: string, + ): Promise { + if (pageIds.length === 0) return []; + + // For each page, count restricted ancestors vs permitted ancestors + // A page is accessible if restrictedCount == permittedCount + const results = await this.db + .selectFrom('pages') + .select('pages.id') + .where('pages.id', 'in', pageIds) + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('pageHierarchy') + .innerJoin( + 'pageAccess', + 'pageAccess.pageId', + 'pageHierarchy.ancestorId', + ) + .leftJoin('pagePermissions', (join) => + join + .onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id') + .on((eb) => + eb.or([ + eb('pagePermissions.userId', '=', userId), + eb( + 'pagePermissions.groupId', + 'in', + eb + .selectFrom('groupUsers') + .select('groupUsers.groupId') + .where('groupUsers.userId', '=', userId), + ), + ]), + ), + ) + .select('pageAccess.pageId') + .whereRef('pageHierarchy.descendantId', '=', 'pages.id') + .where('pagePermissions.id', 'is', null), + ), + ), + ) + .execute(); + + return results.map((r) => r.id); + } + + /** + * Check if a page or any of its ancestors has restrictions. + * Used to determine if page-level permission checks are needed. + */ + async hasRestrictedAncestor(pageId: string): Promise { + const result = await this.db + .selectFrom('pageHierarchy') + .innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId') + .select('pageAccess.id') + .where('pageHierarchy.descendantId', '=', pageId) + .executeTakeFirst(); + + return !!result; + } }