feat(ee): page-level access/permissions (#1971)

* Add page_hierarchy table

* feat(ee): page-level permissions

* pagination

* rename migration
fixes

* fix

* tabs

* fix theme

* cleanup

* sync

* page permissions notification
* other fixes

* sharing disbled

* fix column nodes

* toggle error handling
This commit is contained in:
Philip Okugbe
2026-02-26 19:49:10 +00:00
committed by GitHub
parent 22f33bab7c
commit 59e945562d
75 changed files with 4235 additions and 363 deletions
@@ -15,6 +15,7 @@ import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PageRepo } from './repos/page/page.repo';
import { PagePermissionRepo } from './repos/page/page-permission.repo';
import { CommentRepo } from './repos/comment/comment.repo';
import { PageHistoryRepo } from './repos/page/page-history.repo';
import { AttachmentRepo } from './repos/attachment/attachment.repo';
@@ -76,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
SpaceRepo,
SpaceMemberRepo,
PageRepo,
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
@@ -94,6 +96,7 @@ import { normalizePostgresUrl } from '../common/helpers';
SpaceRepo,
SpaceMemberRepo,
PageRepo,
PagePermissionRepo,
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
@@ -0,0 +1,90 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_access')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_id', 'uuid', (col) =>
col.notNull().unique().references('pages.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('space_id', 'uuid', (col) =>
col.notNull().references('spaces.id').onDelete('cascade'),
)
.addColumn('access_level', 'varchar', (col) => col.notNull())
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createTable('page_permissions')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_access_id', 'uuid', (col) =>
col.notNull().references('page_access.id').onDelete('cascade'),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade'),
)
.addColumn('group_id', 'uuid', (col) =>
col.references('groups.id').onDelete('cascade'),
)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('added_by_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('page_access_user_unique', [
'page_access_id',
'user_id',
])
.addUniqueConstraint('page_access_group_unique', [
'page_access_id',
'group_id',
])
.addCheckConstraint(
'allow_either_user_id_or_group_id_check',
sql`((user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL))`,
)
.execute();
await db.schema
.createIndex('idx_page_access_space')
.on('page_access')
.column('space_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_user')
.on('page_permissions')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_group')
.on('page_permissions')
.column('group_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('page_permissions').ifExists().execute();
await db.schema.dropTable('page_access').ifExists().execute();
}
@@ -306,6 +306,21 @@ export function defaultEncodeCursor<
return Buffer.from(cursor.toString(), 'utf8').toString('base64url');
}
export function emptyCursorPaginationResult<T>(
limit: number,
): CursorPaginationResult<T> {
return {
items: [],
meta: {
limit,
hasNextPage: false,
hasPrevPage: false,
nextCursor: null,
prevCursor: null,
},
};
}
export function defaultDecodeCursor<
DB,
TB extends keyof DB,
@@ -175,4 +175,14 @@ export class GroupUserRepo {
.where('groupId', '=', groupId)
.execute();
}
async getUserGroupIds(userId: string): Promise<string[]> {
const results = await this.db
.selectFrom('groupUsers')
.select('groupId')
.where('userId', '=', userId)
.execute();
return results.map((r) => r.groupId);
}
}
File diff suppressed because it is too large Load Diff
@@ -175,11 +175,13 @@ export class PageRepo {
.selectFrom('pages')
.select(['id'])
.where('id', '=', pageId)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select(['p.id'])
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId'),
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_descendants')
@@ -197,6 +199,7 @@ export class PageRepo {
deletedAt: currentDate,
})
.where('id', 'in', pageIds)
.where('deletedAt', 'is', null)
.execute();
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
@@ -472,4 +475,75 @@ export class PageRepo {
.selectAll()
.execute();
}
/**
* Get page and all descendants, excluding restricted pages and their subtrees.
* More efficient than getPageAndDescendants + filtering because:
* 1. Single DB query (no separate restricted IDs query)
* 2. Stops traversing at restricted pages (doesn't fetch data to discard)
* 3. No in-memory filtering needed
*/
async getPageAndDescendantsExcludingRestricted(
parentPageId: string,
opts: { includeContent: boolean },
) {
return (
this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
.leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.position',
'pages.parentPageId',
'pages.spaceId',
'pages.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('pages.content'))
.where('pages.id', '=', parentPageId)
.where('pages.deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.leftJoin('pageAccess', 'pageAccess.pageId', 'p.id')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.position',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
sql<boolean>`page_access.id IS NOT NULL`.as('isRestricted'),
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
.where('p.deletedAt', 'is', null)
// Only recurse into children of non-restricted pages
.where('ph.isRestricted', '=', false),
),
)
.selectFrom('page_hierarchy')
.select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'workspaceId',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
// Filter out restricted pages from the result
.where('isRestricted', '=', false)
.execute()
);
}
}
@@ -0,0 +1,23 @@
type PagePermissionUserMember = {
id: string;
name: string;
email: string;
avatarUrl: string | null;
type: 'user';
role: string;
createdAt: Date;
};
type PagePermissionGroupMember = {
id: string;
name: string;
memberCount: number;
isDefault: boolean;
type: 'group';
role: string;
createdAt: Date;
};
export type PagePermissionMember =
| PagePermissionUserMember
| PagePermissionGroupMember;
+25
View File
@@ -390,6 +390,28 @@ export interface Watchers {
createdAt: Generated<Timestamp>;
}
export interface PageAccess {
id: Generated<string>;
pageId: string;
workspaceId: string;
spaceId: string;
accessLevel: string;
creatorId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PagePermissions {
id: Generated<string>;
pageAccessId: string;
userId: string | null;
groupId: string | null;
role: string;
addedById: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
@@ -402,7 +424,10 @@ export interface DB {
groups: Groups;
groupUsers: GroupUsers;
notifications: Notifications;
pageAccess: PageAccess;
pageHierarchy: PageHierarchy;
pageHistory: PageHistory;
pagePermissions: PagePermissions;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
@@ -4,6 +4,8 @@ import {
Comments,
Groups,
Notifications,
PageAccess as _PageAccess,
PagePermissions as _PagePermissions,
Pages,
Spaces,
Users,
@@ -143,3 +145,13 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
// Page Access
export type PageAccess = Selectable<_PageAccess>;
export type InsertablePageAccess = Insertable<_PageAccess>;
export type UpdatablePageAccess = Updateable<Omit<_PageAccess, 'id'>>;
// Page Permission
export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;