better permissions filtering

This commit is contained in:
Philipinho
2026-02-10 17:03:01 -08:00
parent 648101860c
commit 7610e8458b
5 changed files with 120 additions and 47 deletions
@@ -286,6 +286,7 @@ export class PageService {
allPages, allPages,
rootPage.id, rootPage.id,
userId, userId,
rootPage.spaceId,
); );
const accessibleIds = new Set(accessiblePages.map((p) => p.id)); const accessibleIds = new Set(accessiblePages.map((p) => p.id));
@@ -389,6 +390,7 @@ export class PageService {
allPages, allPages,
rootPage.id, rootPage.id,
authUser.id, authUser.id,
rootPage.spaceId,
); );
const pageMap = new Map<string, CopyPageMapEntry>(); const pageMap = new Map<string, CopyPageMapEntry>();
@@ -683,12 +685,13 @@ export class PageService {
if (result.items.length > 0) { if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id); const pageIds = result.items.map((p) => p.id);
const accessiblePages = const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds, pageIds,
userId, userId,
); spaceId,
const accessibleSet = new Set(accessiblePages.map((p) => p.id)); });
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id)); result.items = result.items.filter((p) => accessibleSet.has(p.id));
} }
@@ -703,12 +706,12 @@ export class PageService {
if (result.items.length > 0) { if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id); const pageIds = result.items.map((p) => p.id);
const accessiblePages = const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds, pageIds,
userId, userId,
); });
const accessibleSet = new Set(accessiblePages.map((p) => p.id)); const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id)); result.items = result.items.filter((p) => accessibleSet.has(p.id));
} }
@@ -727,12 +730,13 @@ export class PageService {
if (result.items.length > 0) { if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id); const pageIds = result.items.map((p) => p.id);
const accessiblePages = const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds, pageIds,
userId, userId,
); spaceId,
const accessibleSet = new Set(accessiblePages.map((p) => p.id)); });
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id)); result.items = result.items.filter((p) => accessibleSet.has(p.id));
} }
@@ -806,19 +810,18 @@ export class PageService {
pages: T[], pages: T[],
rootPageId: string, rootPageId: string,
userId: string, userId: string,
spaceId?: string,
): Promise<T[]> { ): Promise<T[]> {
if (pages.length === 0) return []; if (pages.length === 0) return [];
const pageIds = pages.map((p) => p.id); const pageIds = pages.map((p) => p.id);
const accessiblePages = const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds, pageIds,
userId, userId,
); spaceId,
const accessibleSet = new Set(accessiblePages.map((p) => p.id)); });
const accessibleSet = new Set(accessibleIds);
// Build a map for quick lookup
const pageMap = new Map(pages.map((p) => [p.id, p]));
// Prune: include a page only if it's accessible AND its parent chain to root is included // Prune: include a page only if it's accessible AND its parent chain to root is included
const includedIds = new Set<string>(); const includedIds = new Set<string>();
+11 -9
View File
@@ -122,12 +122,13 @@ export class SearchService {
// Filter results by page-level permissions (if user is authenticated) // Filter results by page-level permissions (if user is authenticated)
if (opts.userId && results.length > 0) { if (opts.userId && results.length > 0) {
const pageIds = results.map((r: any) => r.id); const pageIds = results.map((r: any) => r.id);
const accessiblePages = const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds, pageIds,
opts.userId, userId: opts.userId,
); spaceId: searchParams.spaceId,
const accessibleSet = new Set(accessiblePages.map((p) => p.id)); });
const accessibleSet = new Set(accessibleIds);
results = results.filter((r: any) => accessibleSet.has(r.id)); results = results.filter((r: any) => accessibleSet.has(r.id));
} }
@@ -225,12 +226,13 @@ export class SearchService {
// Filter by page-level permissions // Filter by page-level permissions
if (pages.length > 0) { if (pages.length > 0) {
const pageIds = pages.map((p) => p.id); const pageIds = pages.map((p) => p.id);
const accessiblePages = const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds, pageIds,
userId, userId,
); spaceId: suggestion?.spaceId,
const accessibleSet = new Set(accessiblePages.map((p) => p.id)); });
const accessibleSet = new Set(accessibleIds);
pages = pages.filter((p) => accessibleSet.has(p.id)); pages = pages.filter((p) => accessibleSet.has(p.id));
} }
} }
@@ -612,6 +612,83 @@ export class PagePermissionRepo {
* Returns page IDs with their permission level (canEdit). * Returns page IDs with their permission level (canEdit).
* Single query implementation for efficiency. * Single query implementation for efficiency.
*/ */
async filterAccessiblePageIds(opts: {
pageIds: string[];
userId: string;
spaceId?: string;
}): Promise<string[]> {
const { pageIds, userId, spaceId } = opts;
if (pageIds.length === 0) return [];
if (spaceId) {
const hasRestrictions = await this.hasRestrictedPagesInSpace(spaceId);
if (!hasRestrictions) {
return pageIds;
}
}
const results = await this.db
.withRecursive('allAncestors', (qb) =>
qb
.selectFrom('pages')
.select([
'pages.id as pageId',
'pages.id as ancestorId',
'pages.parentPageId',
])
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin(
'allAncestors',
'allAncestors.parentPageId',
'pages.id',
)
.select([
'allAncestors.pageId',
'pages.id as ancestorId',
'pages.parentPageId',
]),
),
)
.selectFrom('pages')
.select('pages.id')
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('allAncestors')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'allAncestors.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
this.userGroupIdsSubquery(eb, userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('allAncestors.pageId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.execute();
return results.map((r) => r.id);
}
async filterAccessiblePageIdsWithPermissions( async filterAccessiblePageIdsWithPermissions(
pageIds: string[], pageIds: string[],
userId: string, userId: string,
@@ -645,7 +722,6 @@ export class PagePermissionRepo {
) )
.selectFrom('pages') .selectFrom('pages')
.select('pages.id') .select('pages.id')
// Check if user lacks writer permission on any restricted ancestor
.select((eb) => .select((eb) =>
eb eb
.case() .case()
@@ -690,7 +766,6 @@ export class PagePermissionRepo {
.as('canEdit'), .as('canEdit'),
) )
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`) .where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
// Filter: user must have access (any permission on all restricted ancestors)
.where(({ not, exists, selectFrom }) => .where(({ not, exists, selectFrom }) =>
not( not(
exists( exists(
@@ -380,12 +380,11 @@ export class ExportService {
// Filter to only accessible pages if permissions are enforced // Filter to only accessible pages if permissions are enforced
if (!ignorePermissions && userId) { if (!ignorePermissions && userId) {
const accessiblePages = pageMentionIds =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( await this.pagePermissionRepo.filterAccessiblePageIds({
pageMentionIds, pageIds: pageMentionIds,
userId, userId,
); });
pageMentionIds = accessiblePages.map((p) => p.id);
} }
const pages = const pages =
@@ -485,20 +484,14 @@ export class ExportService {
): Promise<Page[]> { ): Promise<Page[]> {
if (pages.length === 0) return []; if (pages.length === 0) return [];
// skip heavy filtering if no restrictions exist in this space
const hasRestrictions =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
if (!hasRestrictions) {
return pages;
}
const pageIds = pages.map((p) => p.id); const pageIds = pages.map((p) => p.id);
const accessiblePages = const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions( await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds, pageIds,
userId, userId,
); spaceId,
const accessibleSet = new Set(accessiblePages.map((p) => p.id)); });
const accessibleSet = new Set(accessibleIds);
const includedIds = new Set<string>(); const includedIds = new Set<string>();