Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho 147f734c97 POC 2026-05-12 22:56:22 +01:00
6 changed files with 122 additions and 125 deletions
@@ -1,11 +1,3 @@
export const CacheKey = { export const CacheKey = {
LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`, LICENSE_VALID: (workspaceId: string) => `license:valid:${workspaceId}`,
SPACE_ROLES: (userId: string, spaceId: string) =>
`perm:space-roles:${userId}:${spaceId}`,
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
`perm:can-edit:${userId}:${pageId}`,
}; };
// Permission caches dedupe repeated checks within and across short request bursts.
// 5s keeps staleness on revocations bounded.
export const PERMISSION_CACHE_TTL_MS = 5_000;
@@ -0,0 +1,21 @@
export function LogTiming() {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const original = descriptor.value;
descriptor.value = async function (...args: any[]) {
const start = performance.now();
try {
return await original.apply(this, args);
} finally {
const ms = performance.now() - start;
console.log(
`[perm-timing] ${target.constructor.name}.${propertyKey} ${ms.toFixed(2)}ms`,
);
}
};
return descriptor;
};
}
@@ -1,27 +0,0 @@
import { Cache } from 'cache-manager';
export async function withCache<T>(
cacheManager: Cache,
key: string,
ttlMs: number,
fn: () => Promise<T>,
): Promise<T> {
try {
const cached = await cacheManager.get<{ v: T }>(key);
if (cached !== undefined && cached !== null) {
return cached.v;
}
} catch (err) {
console.warn(`[withCache] get failed for "${key}", falling back to source`, err);
}
const value = await fn();
try {
await cacheManager.set(key, { v: value }, ttlMs);
} catch (err) {
console.warn(`[withCache] set failed for "${key}"`, err);
}
return value;
}
@@ -13,10 +13,12 @@ import {
SpaceCaslSubject, SpaceCaslSubject,
} from '../interfaces/space-ability.type'; } from '../interfaces/space-ability.type';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils'; import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { LogTiming } from '../../../common/helpers/log-timing';
@Injectable() @Injectable()
export default class SpaceAbilityFactory { export default class SpaceAbilityFactory {
constructor(private readonly spaceMemberRepo: SpaceMemberRepo) {} constructor(private readonly spaceMemberRepo: SpaceMemberRepo) {}
@LogTiming()
async createForUser(user: User, spaceId: string) { async createForUser(user: User, spaceId: string) {
const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles( const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles(
user.id, user.id,
@@ -1,6 +1,4 @@
import { Inject, Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx } from '@docmost/db/utils';
@@ -19,11 +17,7 @@ import {
executeWithCursorPagination, executeWithCursorPagination,
} from '@docmost/db/pagination/cursor-pagination'; } from '@docmost/db/pagination/cursor-pagination';
import { PagePermissionMember } from './types/page-permission.types'; import { PagePermissionMember } from './types/page-permission.types';
import { withCache } from '../../../common/helpers/with-cache'; import { LogTiming } from '../../../common/helpers/log-timing';
import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
export { PagePermissionMember } from './types/page-permission.types'; export { PagePermissionMember } from './types/page-permission.types';
@@ -32,7 +26,6 @@ export class PagePermissionRepo {
constructor( constructor(
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo, private readonly groupRepo: GroupRepo,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {} ) {}
async findPageAccessByPageId( async findPageAccessByPageId(
@@ -368,9 +361,42 @@ export class PagePermissionRepo {
/** /**
* Check if user can access a page by verifying they have permission on ALL restricted ancestors. * Check if user can access a page by verifying they have permission on ALL restricted ancestors.
*/ */
@LogTiming()
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> { async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
const { canAccess } = await this.canUserEditPage(userId, pageId); const deniedAncestor = await this.db
return canAccess; .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')
.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')
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !deniedAncestor;
} }
/** /**
@@ -380,6 +406,7 @@ export class PagePermissionRepo {
* - array_agg(role ORDER BY depth)[1]: role on the nearest restricted ancestor * - 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) * - Zero rows (no restricted ancestors): both NULL → defer to space permissions (true)
*/ */
@LogTiming()
async canUserEditPage( async canUserEditPage(
userId: string, userId: string,
pageId: string, pageId: string,
@@ -388,50 +415,43 @@ export class PagePermissionRepo {
canAccess: boolean; canAccess: boolean;
canEdit: boolean; canEdit: boolean;
}> { }> {
return withCache( const result = await sql<{
this.cacheManager, canAccess: boolean | null;
CacheKey.PAGE_CAN_EDIT(userId, pageId), canEdit: boolean | null;
PERMISSION_CACHE_TTL_MS, }>`
async () => { WITH RECURSIVE ancestors AS (
const result = await sql<{ SELECT id AS ancestor_id, parent_page_id, 0 AS depth
canAccess: boolean | null; FROM pages
canEdit: boolean | null; WHERE id = ${pageId}::uuid
}>` UNION ALL
WITH RECURSIVE ancestors AS ( SELECT p.id, p.parent_page_id, a.depth + 1
SELECT id AS ancestor_id, parent_page_id, 0 AS depth FROM pages p
FROM pages JOIN ancestors a ON a.parent_page_id = p.id
WHERE id = ${pageId}::uuid )
UNION ALL SELECT
SELECT p.id, p.parent_page_id, a.depth + 1 bool_and(pp.id IS NOT NULL) AS "canAccess",
FROM pages p -- nearest restricted ancestor's highest role wins (DESC: 'writer' > 'reader', NULLS LAST: no-permission after real roles)
JOIN ancestors a ON a.parent_page_id = p.id (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
) )
SELECT )
bool_and(pp.id IS NOT NULL) AS "canAccess", `.execute(this.db);
-- 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]; const row = result.rows[0];
if (!row || row.canAccess === null) { if (!row || row.canAccess === null) {
return { hasAnyRestriction: false, canAccess: true, canEdit: true }; return { hasAnyRestriction: false, canAccess: true, canEdit: true };
} }
return { return {
hasAnyRestriction: true, hasAnyRestriction: true,
canAccess: row.canAccess, canAccess: row.canAccess,
canEdit: row.canAccess && (row.canEdit ?? false), canEdit: row.canAccess && (row.canEdit ?? false),
}; };
},
);
} }
/** /**
@@ -443,6 +463,7 @@ export class PagePermissionRepo {
* - canAccess: user has permission on all restricted ancestors (always true if no restrictions) * - 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) * - canEdit: user has writer on nearest restricted ancestor (always true if no restrictions)
*/ */
@LogTiming()
async getUserPageAccessLevel( async getUserPageAccessLevel(
userId: string, userId: string,
pageId: string, pageId: string,
@@ -653,6 +674,7 @@ 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.
*/ */
@LogTiming()
async filterAccessiblePageIds(opts: { async filterAccessiblePageIds(opts: {
pageIds: string[]; pageIds: string[];
userId: string; userId: string;
@@ -730,6 +752,7 @@ export class PagePermissionRepo {
return results.map((r) => r.id); return results.map((r) => r.id);
} }
@LogTiming()
async filterAccessiblePageIdsWithPermissions( async filterAccessiblePageIdsWithPermissions(
pageIds: string[], pageIds: string[],
userId: string, userId: string,
@@ -860,6 +883,7 @@ export class PagePermissionRepo {
* Check if a page or any of its ancestors has restrictions. * Check if a page or any of its ancestors has restrictions.
* Used to determine if page-level permission checks are needed. * Used to determine if page-level permission checks are needed.
*/ */
@LogTiming()
async hasRestrictedAncestor(pageId: string): Promise<boolean> { async hasRestrictedAncestor(pageId: string): Promise<boolean> {
const result = await this.db const result = await this.db
.withRecursive('ancestors', (qb) => .withRecursive('ancestors', (qb) =>
@@ -886,6 +910,7 @@ export class PagePermissionRepo {
* Check if any page in a space has restrictions. * Check if any page in a space has restrictions.
* Used as a quick check to skip heavy permission filtering when no restrictions exist. * Used as a quick check to skip heavy permission filtering when no restrictions exist.
*/ */
@LogTiming()
async hasRestrictedPagesInSpace(spaceId: string): Promise<boolean> { async hasRestrictedPagesInSpace(spaceId: string): Promise<boolean> {
const result = await this.db const result = await this.db
.selectNoFrom((eb) => .selectNoFrom((eb) =>
@@ -907,6 +932,7 @@ export class PagePermissionRepo {
* Given a list of parent page IDs, return which ones have at least one accessible child. * Given a list of parent page IDs, return which ones have at least one accessible child.
* Efficient batch query for sidebar hasChildren calculation. * Efficient batch query for sidebar hasChildren calculation.
*/ */
@LogTiming()
async getParentIdsWithAccessibleChildren( async getParentIdsWithAccessibleChildren(
parentIds: string[], parentIds: string[],
userId: string, userId: string,
@@ -983,6 +1009,7 @@ export class PagePermissionRepo {
* Used to filter pages from public shares - if a page is restricted, it and all its * Used to filter pages from public shares - if a page is restricted, it and all its
* children should be hidden. * children should be hidden.
*/ */
@LogTiming()
async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> { async getRestrictedSubtreeIds(rootPageId: string): Promise<string[]> {
const results = await this.db const results = await this.db
.withRecursive('descendants', (qb) => .withRecursive('descendants', (qb) =>
@@ -1044,6 +1071,7 @@ export class PagePermissionRepo {
* access the page (have permission on ALL restricted ancestors). * access the page (have permission on ALL restricted ancestors).
* Returns all userIds if the page has no restricted ancestors. * Returns all userIds if the page has no restricted ancestors.
*/ */
@LogTiming()
async getUserIdsWithPageAccess( async getUserIdsWithPageAccess(
pageId: string, pageId: string,
userIds: string[], userIds: string[],
@@ -1,6 +1,4 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx } from '@docmost/db/utils';
@@ -15,11 +13,6 @@ import { MemberInfo, UserSpaceRole } from './types';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { withCache } from '../../../common/helpers/with-cache';
import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
@Injectable() @Injectable()
export class SpaceMemberRepo { export class SpaceMemberRepo {
@@ -27,7 +20,6 @@ export class SpaceMemberRepo {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo, private readonly groupRepo: GroupRepo,
private readonly spaceRepo: SpaceRepo, private readonly spaceRepo: SpaceRepo,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {} ) {}
async insertSpaceMember( async insertSpaceMember(
@@ -222,36 +214,25 @@ export class SpaceMemberRepo {
userId: string, userId: string,
spaceId: string, spaceId: string,
): Promise<UserSpaceRole[]> { ): Promise<UserSpaceRole[]> {
return withCache( const roles = await this.db
this.cacheManager, .selectFrom('spaceMembers')
CacheKey.SPACE_ROLES(userId, spaceId), .select(['userId', 'role'])
PERMISSION_CACHE_TTL_MS, .where('userId', '=', userId)
async () => { .where('spaceId', '=', spaceId)
const roles = await this.db .unionAll(
this.db
.selectFrom('spaceMembers') .selectFrom('spaceMembers')
.select(['userId', 'role']) .innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.where('userId', '=', userId) .select(['groupUsers.userId', 'spaceMembers.role'])
.where('spaceId', '=', spaceId) .where('groupUsers.userId', '=', userId)
.unionAll( .where('spaceMembers.spaceId', '=', spaceId),
this.db )
.selectFrom('spaceMembers') .execute();
.innerJoin(
'groupUsers',
'groupUsers.groupId',
'spaceMembers.groupId',
)
.select(['groupUsers.userId', 'spaceMembers.role'])
.where('groupUsers.userId', '=', userId)
.where('spaceMembers.spaceId', '=', spaceId),
)
.execute();
if (!roles || roles.length === 0) { if (!roles || roles.length === 0) {
return undefined; return undefined;
} }
return roles; return roles;
},
);
} }
async getUserIdsWithSpaceAccess( async getUserIdsWithSpaceAccess(