Files
docmost/apps/server/src/database/repos/page/page-permission.repo.ts
T

1093 lines
33 KiB
TypeScript

import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
InsertablePageAccess,
InsertablePagePermission,
PageAccess,
PagePermission,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { ExpressionBuilder, sql, SqlBool } from 'kysely';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { DB } from '@docmost/db/types/db';
import {
CursorPaginationResult,
executeWithCursorPagination,
} from '@docmost/db/pagination/cursor-pagination';
import { PagePermissionMember } from './types/page-permission.types';
import { withCache } from '../../../common/helpers/with-cache';
import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
export { PagePermissionMember } from './types/page-permission.types';
@Injectable()
export class PagePermissionRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {}
async findPageAccessByPageId(
pageId: string,
trx?: KyselyTransaction,
): Promise<PageAccess | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('pageAccess')
.selectAll()
.where('pageId', '=', pageId)
.executeTakeFirst();
}
async insertPageAccess(
data: InsertablePageAccess,
trx?: KyselyTransaction,
): Promise<PageAccess> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('pageAccess')
.values(data)
.returningAll()
.executeTakeFirst();
}
async deletePageAccess(
pageId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db.deleteFrom('pageAccess').where('pageId', '=', pageId).execute();
}
async insertPagePermissions(
permissions: InsertablePagePermission[],
trx?: KyselyTransaction,
): Promise<void> {
if (permissions.length === 0) return;
const db = dbOrTx(this.db, trx);
await db.insertInto('pagePermissions').values(permissions).execute();
}
async findPagePermissionByUserId(
pageAccessId: string,
userId: string,
trx?: KyselyTransaction,
): Promise<PagePermission | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('pagePermissions')
.selectAll()
.where('pageAccessId', '=', pageAccessId)
.where('userId', '=', userId)
.executeTakeFirst();
}
async findPagePermissionByGroupId(
pageAccessId: string,
groupId: string,
trx?: KyselyTransaction,
): Promise<PagePermission | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('pagePermissions')
.selectAll()
.where('pageAccessId', '=', pageAccessId)
.where('groupId', '=', groupId)
.executeTakeFirst();
}
async deletePagePermissionByUserId(
pageAccessId: string,
userId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pagePermissions')
.where('pageAccessId', '=', pageAccessId)
.where('userId', '=', userId)
.execute();
}
async deletePagePermissionByGroupId(
pageAccessId: string,
groupId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pagePermissions')
.where('pageAccessId', '=', pageAccessId)
.where('groupId', '=', groupId)
.execute();
}
async deletePagePermissionsByUserIds(
pageAccessId: string,
userIds: string[],
trx?: KyselyTransaction,
): Promise<void> {
if (userIds.length === 0) return;
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pagePermissions')
.where('pageAccessId', '=', pageAccessId)
.where('userId', 'in', userIds)
.execute();
}
async deletePagePermissionsByGroupIds(
pageAccessId: string,
groupIds: string[],
trx?: KyselyTransaction,
): Promise<void> {
if (groupIds.length === 0) return;
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pagePermissions')
.where('pageAccessId', '=', pageAccessId)
.where('groupId', 'in', groupIds)
.execute();
}
async updatePagePermissionRole(
pageAccessId: string,
role: string,
opts: { userId?: string; groupId?: string },
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
let query = db
.updateTable('pagePermissions')
.set({ role, updatedAt: new Date() })
.where('pageAccessId', '=', pageAccessId);
if (opts.userId) {
query = query.where('userId', '=', opts.userId);
} else if (opts.groupId) {
query = query.where('groupId', '=', opts.groupId);
}
await query.execute();
}
async countWritersByPageAccessId(
pageAccessId: string,
opts?: { trx?: KyselyTransaction },
): Promise<number> {
const db = dbOrTx(this.db, opts?.trx);
const result = await db
.selectFrom('pagePermissions')
.select((eb) => eb.fn.count('id').as('count'))
.where('pageAccessId', '=', pageAccessId)
.where('role', '=', 'writer')
.executeTakeFirst();
return Number(result?.count ?? 0);
}
async getPagePermissionsPaginated(
pageAccessId: string,
pagination: PaginationOptions,
): Promise<CursorPaginationResult<PagePermissionMember>> {
let baseQuery = this.db
.selectFrom('pagePermissions')
.leftJoin('users', 'users.id', 'pagePermissions.userId')
.leftJoin('groups', 'groups.id', 'pagePermissions.groupId')
.select([
'pagePermissions.id',
'pagePermissions.role',
'pagePermissions.createdAt',
'users.id as userId',
'users.name as userName',
'users.avatarUrl as userAvatarUrl',
'users.email as userEmail',
'groups.id as groupId',
'groups.name as groupName',
'groups.isDefault as groupIsDefault',
])
.select((eb) => this.groupRepo.withMemberCount(eb))
.select((eb) =>
eb
.case()
.when('groups.id', 'is not', null)
.then(1)
.else(0)
.end()
.as('isGroup'),
)
.where('pageAccessId', '=', pageAccessId);
if (pagination.query) {
baseQuery = baseQuery.where((eb) =>
eb(
sql`f_unaccent(users.name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
)
.or(
sql`users.email`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
)
.or(
sql`f_unaccent(groups.name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
),
);
}
const query = this.db.selectFrom(baseQuery.as('sub')).selectAll('sub');
const result = await executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'sub.isGroup', direction: 'desc', key: 'isGroup' },
{ expression: 'sub.id', direction: 'asc', key: 'id' },
],
parseCursor: (cursor) => ({
isGroup: parseInt(cursor.isGroup, 10),
id: cursor.id,
}),
});
const items: PagePermissionMember[] = result.items.map((member) => {
if (member.userId) {
return {
id: member.userId,
name: member.userName,
email: member.userEmail,
avatarUrl: member.userAvatarUrl,
type: 'user' as const,
role: member.role,
createdAt: member.createdAt,
};
} else {
return {
id: member.groupId,
name: member.groupName,
memberCount: member.memberCount as number,
isDefault: member.groupIsDefault,
type: 'group' as const,
role: member.role,
createdAt: member.createdAt,
};
}
});
return { items, meta: result.meta };
}
async getUserPagePermission(
userId: string,
pageId: string,
): Promise<{ role: string } | undefined> {
const result = await this.db
.selectFrom('pageAccess')
.innerJoin(
'pagePermissions',
'pagePermissions.pageAccessId',
'pageAccess.id',
)
.select(['pagePermissions.role'])
.where('pageAccess.pageId', '=', pageId)
.where('pagePermissions.userId', '=', userId)
.unionAll(
this.db
.selectFrom('pageAccess')
.innerJoin(
'pagePermissions',
'pagePermissions.pageAccessId',
'pageAccess.id',
)
.innerJoin(
'groupUsers',
'groupUsers.groupId',
'pagePermissions.groupId',
)
.select(['pagePermissions.role'])
.where('pageAccess.pageId', '=', pageId)
.where('groupUsers.userId', '=', userId),
)
.executeTakeFirst();
return result;
}
async findRestrictedAncestor(pageId: string): Promise<
| {
pageAccessId: string;
pageId: string;
accessLevel: string;
depth: number;
}
| undefined
> {
return this.db
.withRecursive('ancestors', (qb) =>
qb
.selectFrom('pages')
.select([
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`0`.as('depth'),
])
.where('pages.id', '=', pageId)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
.select([
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`ancestors.depth + 1`.as('depth'),
]),
),
)
.selectFrom('ancestors')
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
.select([
'pageAccess.id as pageAccessId',
'pageAccess.pageId',
'pageAccess.accessLevel',
'ancestors.depth',
])
.orderBy('ancestors.depth', 'asc')
.executeTakeFirst();
}
/**
* Check if user can access a page by verifying they have permission on ALL restricted ancestors.
*/
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
const { canAccess } = await this.canUserEditPage(userId, pageId);
return canAccess;
}
/**
* Check if user can edit a page.
* Single query: builds ancestor chain once, checks both traversal and nearest-restricted writer.
* - bool_and(pp.id IS NOT NULL): false if any restricted ancestor has no permission (traversal denied)
* - array_agg(role ORDER BY depth)[1]: role on the nearest restricted ancestor
* - Zero rows (no restricted ancestors): both NULL → defer to space permissions (true)
*/
async canUserEditPage(
userId: string,
pageId: string,
): Promise<{
hasAnyRestriction: boolean;
canAccess: boolean;
canEdit: boolean;
}> {
return withCache(
this.cacheManager,
CacheKey.PAGE_CAN_EDIT(userId, pageId),
PERMISSION_CACHE_TTL_MS,
async () => {
const result = await sql<{
canAccess: boolean | null;
canEdit: boolean | null;
}>`
WITH RECURSIVE ancestors AS (
SELECT id AS ancestor_id, parent_page_id, 0 AS depth
FROM pages
WHERE id = ${pageId}::uuid
UNION ALL
SELECT p.id, p.parent_page_id, a.depth + 1
FROM pages p
JOIN ancestors a ON a.parent_page_id = p.id
)
SELECT
bool_and(pp.id IS NOT NULL) AS "canAccess",
-- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
(array_agg(pp.role ORDER BY a.depth ASC, pp.role DESC NULLS LAST))[1] = 'writer' AS "canEdit"
FROM ancestors a
JOIN page_access pa ON pa.page_id = a.ancestor_id
LEFT JOIN page_permissions pp ON pp.page_access_id = pa.id
AND (
pp.user_id = ${userId}::uuid
OR pp.group_id IN (
SELECT gu.group_id FROM group_users gu WHERE gu.user_id = ${userId}::uuid
)
)
`.execute(this.db);
const row = result.rows[0];
if (!row || row.canAccess === null) {
return { hasAnyRestriction: false, canAccess: true, canEdit: true };
}
return {
hasAnyRestriction: true,
canAccess: row.canAccess,
canEdit: row.canAccess && (row.canEdit ?? false),
};
},
);
}
/**
* Get user's access level for a page.
* Returns:
* - hasDirectRestriction: whether this specific page has restrictions
* - hasInheritedRestriction: whether any ancestor (not self) has restrictions
* - hasAnyRestriction: hasDirectRestriction || hasInheritedRestriction
* - canAccess: user has permission on all restricted ancestors (always true if no restrictions)
* - canEdit: user has writer on nearest restricted ancestor (always true if no restrictions)
*/
async getUserPageAccessLevel(
userId: string,
pageId: string,
): Promise<{
hasDirectRestriction: boolean;
hasInheritedRestriction: boolean;
hasAnyRestriction: boolean;
canAccess: boolean;
canEdit: boolean;
}> {
const result = await this.db
.withRecursive('ancestors', (qb) =>
qb
.selectFrom('pages')
.select([
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`0`.as('depth'),
])
.where('pages.id', '=', pageId)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
.select([
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`ancestors.depth + 1`.as('depth'),
]),
),
)
.selectFrom('pages')
.select((eb) => [
// hasDirectRestriction: this page itself has page_access entry
eb
.case()
.when(
eb.exists(
eb
.selectFrom('pageAccess')
.select('pageAccess.id')
.whereRef('pageAccess.pageId', '=', 'pages.id'),
),
)
.then(true)
.else(false)
.end()
.as('hasDirectRestriction'),
// hasInheritedRestriction: any ancestor (depth > 0) has page_access entry
eb
.case()
.when(
eb.exists(
eb
.selectFrom('ancestors')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'ancestors.ancestorId',
)
.select('pageAccess.id')
.where('ancestors.depth', '>', 0),
),
)
.then(true)
.else(false)
.end()
.as('hasInheritedRestriction'),
// canAccess: no restricted ancestor without ANY permission
eb
.case()
.when(
eb.not(
eb.exists(
eb
.selectFrom('ancestors')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'ancestors.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef(
'pagePermissions.pageAccessId',
'=',
'pageAccess.id',
)
.on((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
this.userGroupIdsSubquery(eb2, userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pagePermissions.id', 'is', null),
),
),
)
.then(true)
.else(false)
.end()
.as('canAccess'),
// canEdit: nearest restricted ancestor determines edit capability
eb
.case()
// traversal denied: any restricted ancestor without any permission
.when(
eb.exists(
eb
.selectFrom('ancestors')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'ancestors.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
this.userGroupIdsSubquery(eb2, userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pagePermissions.id', 'is', null),
),
)
.then(false)
// no restricted ancestors at all → defer to space permissions
.when(
eb.not(
eb.exists(
eb
.selectFrom('ancestors')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'ancestors.ancestorId',
)
.select('pageAccess.id'),
),
),
)
.then(true)
// nearest restricted ancestor has writer for this user
.when(
eb.exists(
eb
.selectFrom('pagePermissions')
.select('pagePermissions.id')
.where('pagePermissions.role', '=', 'writer')
.where(
'pagePermissions.pageAccessId',
'=',
sql<string>`(
SELECT pa.id FROM ancestors a_nr
JOIN page_access pa ON pa.page_id = a_nr.ancestor_id
ORDER BY a_nr.depth ASC
LIMIT 1
)`,
)
.where((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
this.userGroupIdsSubquery(eb2, userId),
),
]),
),
),
)
.then(true)
.else(false)
.end()
.as('canEdit'),
])
.where('pages.id', '=', pageId)
.executeTakeFirst();
const hasDirectRestriction = Boolean(result?.hasDirectRestriction);
const hasInheritedRestriction = Boolean(result?.hasInheritedRestriction);
return {
hasDirectRestriction,
hasInheritedRestriction,
hasAnyRestriction: hasDirectRestriction || hasInheritedRestriction,
canAccess: Boolean(result?.canAccess),
canEdit: Boolean(result?.canEdit),
};
}
/**
* Filter a list of page IDs to only those the user can access.
* 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,
): Promise<Array<{ id: string; canEdit: boolean }>> {
if (pageIds.length === 0) return [];
const results = await this.db
.withRecursive('allAncestors', (qb) =>
qb
.selectFrom('pages')
.select([
'pages.id as pageId',
'pages.id as ancestorId',
'pages.parentPageId',
sql<number>`0`.as('depth'),
])
.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',
sql<number>`all_ancestors.depth + 1`.as('depth'),
]),
),
)
.selectFrom('pages')
.select('pages.id')
.select((eb) =>
eb
.case()
// no restricted ancestors for this page → defer to space
.when(
eb.not(
eb.exists(
eb
.selectFrom('allAncestors')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'allAncestors.ancestorId',
)
.select('pageAccess.id')
.whereRef('allAncestors.pageId', '=', 'pages.id'),
),
),
)
.then(true)
// nearest restricted ancestor has writer for this user
.when(
eb.exists(
eb
.selectFrom('pagePermissions')
.select('pagePermissions.id')
.where('pagePermissions.role', '=', 'writer')
.where(
'pagePermissions.pageAccessId',
'=',
sql<string>`(
SELECT pa.id FROM all_ancestors aa
JOIN page_access pa ON pa.page_id = aa.ancestor_id
WHERE aa.page_id = pages.id
ORDER BY aa.depth ASC
LIMIT 1
)`,
)
.where((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
this.userGroupIdsSubquery(eb2, userId),
),
]),
),
),
)
.then(true)
.else(false)
.end()
.as('canEdit'),
)
.where(sql<SqlBool>`pages.id = ANY(${pageIds}::uuid[])`)
// view filter: no restricted ancestor without any permission
.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) => ({ id: r.id, canEdit: Boolean(r.canEdit) }));
}
/**
* 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<boolean> {
const result = await this.db
.withRecursive('ancestors', (qb) =>
qb
.selectFrom('pages')
.select(['pages.id as ancestorId', 'pages.parentPageId'])
.where('pages.id', '=', pageId)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin('ancestors', 'ancestors.parentPageId', 'pages.id')
.select(['pages.id as ancestorId', 'pages.parentPageId']),
),
)
.selectFrom('ancestors')
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.ancestorId')
.select('pageAccess.id')
.executeTakeFirst();
return !!result;
}
/**
* Check if any page in a space has restrictions.
* Used as a quick check to skip heavy permission filtering when no restrictions exist.
*/
async hasRestrictedPagesInSpace(spaceId: string): Promise<boolean> {
const result = await this.db
.selectNoFrom((eb) =>
eb
.exists(
eb
.selectFrom('pageAccess')
.select(sql`1`.as('one'))
.where('pageAccess.spaceId', '=', spaceId),
)
.as('exists'),
)
.executeTakeFirst();
return Boolean(result?.exists);
}
/**
* Given a list of parent page IDs, return which ones have at least one accessible child.
* Efficient batch query for sidebar hasChildren calculation.
*/
async getParentIdsWithAccessibleChildren(
parentIds: string[],
userId: string,
): Promise<string[]> {
if (parentIds.length === 0) return [];
const results = await this.db
.withRecursive('childAncestors', (qb) =>
qb
.selectFrom('pages as child')
.select([
'child.id as childId',
'child.id as ancestorId',
'child.parentPageId as ancestorParentId',
])
.where('child.parentPageId', 'in', parentIds)
.where('child.deletedAt', 'is', null)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin(
'childAncestors',
'childAncestors.ancestorParentId',
'pages.id',
)
.select([
'childAncestors.childId',
'pages.id as ancestorId',
'pages.parentPageId as ancestorParentId',
]),
),
)
.selectFrom('pages as child')
.select('child.parentPageId')
.distinct()
.where('child.parentPageId', 'in', parentIds)
.where('child.deletedAt', 'is', null)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('childAncestors')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'childAncestors.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('childAncestors.childId', '=', 'child.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.execute();
return results.map((r) => r.parentPageId);
}
/**
* Get all page IDs within a subtree that are restricted OR are descendants of restricted pages.
* Used to filter pages from public shares - if a page is restricted, it and all its
* children should be hidden.
*/
async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> {
const results = await this.db
.withRecursive('descendants', (qb) =>
qb
.selectFrom('pages')
.select(['pages.id as descendantId', 'pages.parentPageId'])
.where('pages.id', '=', rootPageId)
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin(
'descendants',
'descendants.descendantId',
'pages.parentPageId',
)
.select(['pages.id as descendantId', 'pages.parentPageId'])
.where('pages.deletedAt', 'is', null),
),
)
.withRecursive('descendantAncestors', (qb) =>
qb
.selectFrom('descendants')
.innerJoin('pages', 'pages.id', 'descendants.descendantId')
.select([
'descendants.descendantId',
'pages.id as ancestorId',
'pages.parentPageId as ancestorParentId',
])
.unionAll((eb) =>
eb
.selectFrom('pages')
.innerJoin(
'descendantAncestors',
'descendantAncestors.ancestorParentId',
'pages.id',
)
.select([
'descendantAncestors.descendantId',
'pages.id as ancestorId',
'pages.parentPageId as ancestorParentId',
]),
),
)
.selectFrom('descendantAncestors')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'descendantAncestors.ancestorId',
)
.select('descendantAncestors.descendantId')
.distinct()
.execute();
return results.map((r) => r.descendantId);
}
/**
* Given a pageId and a set of candidate userIds, return the subset who can
* access the page (have permission on ALL restricted ancestors).
* Returns all userIds if the page has no restricted ancestors.
*/
async getUserIdsWithPageAccess(
pageId: string,
userIds: string[],
): Promise<string[]> {
if (userIds.length === 0) return [];
const results = await sql<{ userId: string }>`
WITH RECURSIVE ancestors AS (
SELECT id AS ancestor_id, parent_page_id
FROM pages
WHERE id = ${pageId}::uuid
UNION ALL
SELECT p.id, p.parent_page_id
FROM pages p
JOIN ancestors a ON a.parent_page_id = p.id
)
SELECT cu.user_id AS "userId"
FROM unnest(${userIds}::uuid[]) AS cu(user_id)
WHERE NOT EXISTS (
SELECT 1
FROM ancestors a
JOIN page_access pa ON pa.page_id = a.ancestor_id
LEFT JOIN page_permissions pp ON pp.page_access_id = pa.id
AND (
pp.user_id = cu.user_id
OR pp.group_id IN (
SELECT gu.group_id FROM group_users gu WHERE gu.user_id = cu.user_id
)
)
WHERE pp.id IS NULL
)
`.execute(this.db);
return results.rows.map((r) => r.userId);
}
private userGroupIdsSubquery(
eb: ExpressionBuilder<any, keyof DB>,
userId: string,
) {
return eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId);
}
}