diff --git a/apps/server/src/core/acadenice/rbac/services/seed.service.ts b/apps/server/src/core/acadenice/rbac/services/seed.service.ts index 37c2ac03..bf5a171e 100644 --- a/apps/server/src/core/acadenice/rbac/services/seed.service.ts +++ b/apps/server/src/core/acadenice/rbac/services/seed.service.ts @@ -7,12 +7,19 @@ import { InjectKysely } from 'nestjs-kysely'; import { sql } from 'kysely'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { AcadeniceRoleRepo } from '../repos/role.repo'; +import { AcadeniceUserRoleRepo } from '../repos/user-role.repo'; import { PermissionKey } from '../permissions-catalog'; interface SystemRoleSpec { name: string; description: string; permissions: PermissionKey[]; + /** + * If true, the seed REPLACES the role's permissions on every boot, even + * when the role already has perms in DB. Used for roles whose default set + * evolves with the product (e.g. Member after the OSS opening of v1.x). + */ + forceResync?: boolean; } /** @@ -92,18 +99,44 @@ const SYSTEM_ROLES: ReadonlyArray = [ }, { name: 'Member', - description: 'Read content and upload attachments.', + description: + 'Default role: full content authoring (pages, tables, templates, comments). Admin actions reserved to Owner/Admin roles.', + forceResync: true, permissions: [ + // Pages — full CRUD + share by page (Outline-style granular access) 'pages:read', + 'pages:write', + 'pages:delete', + 'pages:share', + // Spaces — create + edit (visibility public/private + member mgmt) 'space:read', + 'space:create', + 'space:write', + 'space:invite', + // Tables — full CRUD via bridge (every user can spin up databases) + 'tables:list', + 'tables:create', + 'tables:write', + 'tables:delete', 'rows:read', + 'rows:write', + 'rows:delete', + // Attachments 'attachments:upload', - // R3.6 — Members can browse templates + 'attachments:delete', + // Templates — read + create + manage own 'templates:read', - // R4.2 — Members can create and edit sync blocks + 'templates:create', + 'templates:manage', + // Comments — full participation (resolve included) + 'comments:read', + 'comments:write', + 'comments:resolve', + // Sync blocks 'sync_blocks:create', 'sync_blocks:edit', - // R4.3 — Members can use the Web Clipper + 'sync_blocks:delete', + // Web Clipper 'clipper:use', ], }, @@ -125,6 +158,7 @@ export class AcadeniceRbacSeedService implements OnModuleInit { constructor( @InjectKysely() private readonly db: KyselyDB, private readonly roleRepo: AcadeniceRoleRepo, + private readonly userRoleRepo: AcadeniceUserRoleRepo, ) {} async onModuleInit(): Promise { @@ -178,11 +212,44 @@ export class AcadeniceRbacSeedService implements OnModuleInit { } const existingPerms = await this.roleRepo.listPermissions(role.id); - if (existingPerms.length === 0) { - // First time we see this role : install the canonical permission set. + if (existingPerms.length === 0 || spec.forceResync) { + // Install the canonical permission set. forceResync=true ALWAYS + // overrides existing perms — used for roles whose default contract + // changes between releases (e.g. Member after OSS-opening). Local + // customisations on a forceResync role are not supported by design; + // create a custom role instead. await this.roleRepo.replacePermissions(role.id, [...spec.permissions]); } - // If existingPerms.length > 0 we keep the admin's customisations intact. + // For non-forceResync roles with existing perms, we preserve them. + } + + // Ensure every workspace member has at least one Acadenice role assigned. + // Without this the permission guards return an empty permission set, + // making the entire UI 403 silently. Workspace owners (users.role='owner') + // get the Owner role; everyone else gets Member as a sane default. + await this.assignDefaultUserRoles(workspaceId); + } + + private async assignDefaultUserRoles(workspaceId: string): Promise { + const ownerRole = await this.roleRepo.findByName(workspaceId, 'Owner'); + const memberRole = await this.roleRepo.findByName(workspaceId, 'Member'); + if (!ownerRole || !memberRole) return; + + const users = await sql<{ id: string; role: string }>` + SELECT id, role FROM users + WHERE workspace_id = ${workspaceId} AND deleted_at IS NULL + `.execute(this.db); + + for (const u of users.rows) { + const existing = await this.userRoleRepo.listForUser(u.id, workspaceId); + if (existing.length > 0) continue; + const targetRoleId = u.role === 'owner' ? ownerRole.id : memberRole.id; + await this.userRoleRepo.assign({ + userId: u.id, + roleId: targetRoleId, + workspaceId, + assignedBy: null, + }); } } } diff --git a/apps/server/src/core/acadenice/rbac/spec/seed.service.spec.ts b/apps/server/src/core/acadenice/rbac/spec/seed.service.spec.ts index 7b9d73d0..9f0dc749 100644 --- a/apps/server/src/core/acadenice/rbac/spec/seed.service.spec.ts +++ b/apps/server/src/core/acadenice/rbac/spec/seed.service.spec.ts @@ -1,5 +1,6 @@ import { AcadeniceRbacSeedService } from '../services/seed.service'; import { AcadeniceRoleRepo } from '../repos/role.repo'; +import { AcadeniceUserRoleRepo } from '../repos/user-role.repo'; /** * The seed service uses kysely's `sql` template tag through `InjectKysely()` @@ -9,10 +10,12 @@ import { AcadeniceRoleRepo } from '../repos/role.repo'; describe('AcadeniceRbacSeedService.seedWorkspace', () => { let seedService: AcadeniceRbacSeedService; let roleRepo: jest.Mocked>; - // Stub the kysely DB — only `sql\`...\`.execute(this.db)` calls the DB and - // seedWorkspace doesn't use it directly (only seedAllWorkspaces does). We - // pass an arbitrary placeholder. - const fakeDb: any = {}; + let userRoleRepo: jest.Mocked>; + // Stub the kysely DB. seedWorkspace itself runs a `sql\`...\`.execute(db)` + // for the default-role assignment loop, so the stub responds with no users. + const fakeDb: any = { + execute: jest.fn().mockResolvedValue({ rows: [] }), + }; beforeEach(() => { roleRepo = { @@ -21,9 +24,14 @@ describe('AcadeniceRbacSeedService.seedWorkspace', () => { listPermissions: jest.fn(), replacePermissions: jest.fn(), }; + userRoleRepo = { + listForUser: jest.fn(), + assign: jest.fn(), + }; seedService = new AcadeniceRbacSeedService( fakeDb, roleRepo as unknown as AcadeniceRoleRepo, + userRoleRepo as unknown as AcadeniceUserRoleRepo, ); });