refactor(rbac): seed service updates and spec alignment

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-11 09:54:46 +00:00
parent 11e003e71e
commit f2e9d2205c
2 changed files with 86 additions and 11 deletions

View file

@ -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<SystemRoleSpec> = [
},
{
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<void> {
@ -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<void> {
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,
});
}
}
}

View file

@ -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<Partial<AcadeniceRoleRepo>>;
// 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<Partial<AcadeniceUserRoleRepo>>;
// 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,
);
});