mirror of
https://github.com/docmost/docmost.git
synced 2026-05-16 14:14:06 +08:00
refactoring
This commit is contained in:
@@ -72,7 +72,7 @@ export class AuthenticationExtension implements Extension {
|
||||
|
||||
// Check page-level permissions
|
||||
const { hasAnyRestriction, canAccess, canEdit } =
|
||||
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
|
||||
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
|
||||
|
||||
if (hasAnyRestriction) {
|
||||
if (!canAccess) {
|
||||
|
||||
@@ -28,16 +28,13 @@ export class PageAccessService {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const { hasAnyRestriction, canAccess } =
|
||||
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
|
||||
|
||||
if (hasAnyRestriction) {
|
||||
// Page has restrictions - use page-level permission
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
const canAccess = await this.pagePermissionRepo.canUserAccessPage(
|
||||
user.id,
|
||||
page.id,
|
||||
);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
// No restriction - space membership (checked above) is sufficient for view
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +51,7 @@ export class PageAccessService {
|
||||
}
|
||||
|
||||
const { hasAnyRestriction, canEdit } =
|
||||
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
|
||||
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
|
||||
|
||||
if (hasAnyRestriction) {
|
||||
// Page has restrictions - use page-level permission
|
||||
|
||||
@@ -229,27 +229,34 @@ export class PagePermissionService {
|
||||
const userIds = dto.userIds ?? [];
|
||||
const groupIds = dto.groupIds ?? [];
|
||||
|
||||
if (userIds.length > 0) {
|
||||
await this.pagePermissionRepo.deletePagePermissionsByUserIds(
|
||||
pageAccess.id,
|
||||
userIds,
|
||||
);
|
||||
}
|
||||
await executeTx(this.db, async (trx) => {
|
||||
if (userIds.length > 0) {
|
||||
await this.pagePermissionRepo.deletePagePermissionsByUserIds(
|
||||
pageAccess.id,
|
||||
userIds,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (groupIds.length > 0) {
|
||||
await this.pagePermissionRepo.deletePagePermissionsByGroupIds(
|
||||
pageAccess.id,
|
||||
groupIds,
|
||||
);
|
||||
}
|
||||
if (groupIds.length > 0) {
|
||||
await this.pagePermissionRepo.deletePagePermissionsByGroupIds(
|
||||
pageAccess.id,
|
||||
groupIds,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
const writerCount =
|
||||
await this.pagePermissionRepo.countWritersByPageAccessId(pageAccess.id);
|
||||
if (writerCount < 1) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one user with "Can edit" permission',
|
||||
);
|
||||
}
|
||||
const writerCount =
|
||||
await this.pagePermissionRepo.countWritersByPageAccessId(
|
||||
pageAccess.id,
|
||||
trx,
|
||||
);
|
||||
if (writerCount < 1) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one user with "Can edit" permission',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updatePagePermissionRole(
|
||||
@@ -486,7 +493,10 @@ export class PagePermissionService {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const canEdit = await this.canEditPage(user.id, page.id);
|
||||
const { canAccess, canEdit } = await this.canEditPage(user.id, page.id);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
if (canEdit) {
|
||||
return;
|
||||
}
|
||||
@@ -517,18 +527,22 @@ export class PagePermissionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can edit a page.
|
||||
* User must have WRITER permission on EVERY restricted ancestor.
|
||||
* Returns true if:
|
||||
* - No ancestors are restricted (defer to space permission)
|
||||
* - User has writer permission on all restricted ancestors
|
||||
* Check if user can edit a page based on page-level permissions.
|
||||
* Returns { hasAnyRestriction, canAccess, canEdit } from the nearest restricted ancestor logic.
|
||||
*/
|
||||
async canEditPage(userId: string, pageId: string): Promise<boolean> {
|
||||
async canEditPage(
|
||||
userId: string,
|
||||
pageId: string,
|
||||
): Promise<{
|
||||
hasAnyRestriction: boolean;
|
||||
canAccess: boolean;
|
||||
canEdit: boolean;
|
||||
}> {
|
||||
return this.pagePermissionRepo.canUserEditPage(userId, pageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has writer permission on ALL restricted ancestors of a page.
|
||||
* Check if user has writer permission on the nearest restricted ancestor.
|
||||
* Used for permission management operations.
|
||||
*/
|
||||
async hasWritePermission(userId: string, pageId: string): Promise<boolean> {
|
||||
@@ -539,7 +553,11 @@ export class PagePermissionService {
|
||||
return false; // no restrictions, defer to space permissions
|
||||
}
|
||||
|
||||
return this.pagePermissionRepo.canUserEditPage(userId, pageId);
|
||||
const { canEdit } = await this.pagePermissionRepo.canUserEditPage(
|
||||
userId,
|
||||
pageId,
|
||||
);
|
||||
return canEdit;
|
||||
}
|
||||
|
||||
async hasPageAccess(pageId: string): Promise<boolean> {
|
||||
|
||||
@@ -393,54 +393,60 @@ export class PagePermissionRepo {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can edit a page by verifying they have WRITER permission on ALL restricted ancestors.
|
||||
* 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<boolean> {
|
||||
const deniedAncestor = 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']),
|
||||
),
|
||||
async canUserEditPage(
|
||||
userId: string,
|
||||
pageId: string,
|
||||
): Promise<{ hasAnyRestriction: boolean; canAccess: boolean; canEdit: boolean }> {
|
||||
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
|
||||
)
|
||||
.selectFrom('ancestors')
|
||||
.innerJoin('pageAccess', 'pageAccess.pageId', 'ancestors.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',
|
||||
this.userGroupIdsSubquery(eb, userId),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select('pageAccess.pageId')
|
||||
.where('pagePermissions.id', 'is', null)
|
||||
.executeTakeFirst();
|
||||
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);
|
||||
|
||||
return !deniedAncestor;
|
||||
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, checking ALL restricted ancestors.
|
||||
* 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 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,
|
||||
@@ -550,9 +556,43 @@ export class PagePermissionRepo {
|
||||
.else(false)
|
||||
.end()
|
||||
.as('canAccess'),
|
||||
// canEdit: no restricted ancestor without WRITER permission
|
||||
// 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(
|
||||
@@ -563,31 +603,41 @@ export class PagePermissionRepo {
|
||||
'pageAccess.pageId',
|
||||
'ancestors.ancestorId',
|
||||
)
|
||||
.leftJoin('pagePermissions', (join) =>
|
||||
join
|
||||
.onRef(
|
||||
'pagePermissions.pageAccessId',
|
||||
'=',
|
||||
'pageAccess.id',
|
||||
)
|
||||
.on('pagePermissions.role', '=', 'writer')
|
||||
.on((eb2) =>
|
||||
eb2.or([
|
||||
eb2('pagePermissions.userId', '=', userId),
|
||||
eb2(
|
||||
'pagePermissions.groupId',
|
||||
'in',
|
||||
this.userGroupIdsSubquery(eb2, userId),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select('pageAccess.pageId')
|
||||
.where('pagePermissions.id', 'is', null),
|
||||
.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'),
|
||||
@@ -703,6 +753,7 @@ export class PagePermissionRepo {
|
||||
'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) =>
|
||||
@@ -717,6 +768,7 @@ export class PagePermissionRepo {
|
||||
'allAncestors.pageId',
|
||||
'pages.id as ancestorId',
|
||||
'pages.parentPageId',
|
||||
sql<number>`"allAncestors".depth + 1`.as('depth'),
|
||||
]),
|
||||
),
|
||||
)
|
||||
@@ -725,6 +777,7 @@ export class PagePermissionRepo {
|
||||
.select((eb) =>
|
||||
eb
|
||||
.case()
|
||||
// no restricted ancestors for this page → defer to space
|
||||
.when(
|
||||
eb.not(
|
||||
eb.exists(
|
||||
@@ -735,37 +788,49 @@ export class PagePermissionRepo {
|
||||
'pageAccess.pageId',
|
||||
'allAncestors.ancestorId',
|
||||
)
|
||||
.leftJoin('pagePermissions', (join) =>
|
||||
join
|
||||
.onRef(
|
||||
'pagePermissions.pageAccessId',
|
||||
'=',
|
||||
'pageAccess.id',
|
||||
)
|
||||
.on('pagePermissions.role', '=', 'writer')
|
||||
.on((eb2) =>
|
||||
eb2.or([
|
||||
eb2('pagePermissions.userId', '=', userId),
|
||||
eb2(
|
||||
'pagePermissions.groupId',
|
||||
'in',
|
||||
this.userGroupIdsSubquery(eb2, userId),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select('pageAccess.pageId')
|
||||
.whereRef('allAncestors.pageId', '=', 'pages.id')
|
||||
.where('pagePermissions.id', 'is', null),
|
||||
.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 "allAncestors" 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(
|
||||
|
||||
Reference in New Issue
Block a user