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,
rootPage.id,
userId,
rootPage.spaceId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
@@ -389,6 +390,7 @@ export class PageService {
allPages,
rootPage.id,
authUser.id,
rootPage.spaceId,
);
const pageMap = new Map<string, CopyPageMapEntry>();
@@ -683,12 +685,13 @@ export class PageService {
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
spaceId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
@@ -703,12 +706,12 @@ export class PageService {
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
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));
}
@@ -727,12 +730,13 @@ export class PageService {
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
spaceId,
});
const accessibleSet = new Set(accessibleIds);
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
@@ -806,19 +810,18 @@ export class PageService {
pages: T[],
rootPageId: string,
userId: string,
spaceId?: string,
): Promise<T[]> {
if (pages.length === 0) return [];
const pageIds = pages.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
// Build a map for quick lookup
const pageMap = new Map(pages.map((p) => [p.id, p]));
spaceId,
});
const accessibleSet = new Set(accessibleIds);
// Prune: include a page only if it's accessible AND its parent chain to root is included
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)
if (opts.userId && results.length > 0) {
const pageIds = results.map((r: any) => r.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
opts.userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
userId: opts.userId,
spaceId: searchParams.spaceId,
});
const accessibleSet = new Set(accessibleIds);
results = results.filter((r: any) => accessibleSet.has(r.id));
}
@@ -225,12 +226,13 @@ export class SearchService {
// Filter by page-level permissions
if (pages.length > 0) {
const pageIds = pages.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
spaceId: suggestion?.spaceId,
});
const accessibleSet = new Set(accessibleIds);
pages = pages.filter((p) => accessibleSet.has(p.id));
}
}
@@ -612,6 +612,83 @@ export class PagePermissionRepo {
* Returns page IDs with their permission level (canEdit).
* 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(
pageIds: string[],
userId: string,
@@ -645,7 +722,6 @@ export class PagePermissionRepo {
)
.selectFrom('pages')
.select('pages.id')
// Check if user lacks writer permission on any restricted ancestor
.select((eb) =>
eb
.case()
@@ -690,7 +766,6 @@ export class PagePermissionRepo {
.as('canEdit'),
)
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
// Filter: user must have access (any permission on all restricted ancestors)
.where(({ not, exists, selectFrom }) =>
not(
exists(
@@ -380,12 +380,11 @@ export class ExportService {
// Filter to only accessible pages if permissions are enforced
if (!ignorePermissions && userId) {
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageMentionIds,
pageMentionIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: pageMentionIds,
userId,
);
pageMentionIds = accessiblePages.map((p) => p.id);
});
}
const pages =
@@ -485,20 +484,14 @@ export class ExportService {
): Promise<Page[]> {
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 accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
spaceId,
});
const accessibleSet = new Set(accessibleIds);
const includedIds = new Set<string>();