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 { sql } from 'kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { AcadeniceRoleRepo } from '../repos/role.repo'; import { AcadeniceRoleRepo } from '../repos/role.repo';
import { AcadeniceUserRoleRepo } from '../repos/user-role.repo';
import { PermissionKey } from '../permissions-catalog'; import { PermissionKey } from '../permissions-catalog';
interface SystemRoleSpec { interface SystemRoleSpec {
name: string; name: string;
description: string; description: string;
permissions: PermissionKey[]; 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', 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: [ permissions: [
// Pages — full CRUD + share by page (Outline-style granular access)
'pages:read', 'pages:read',
'pages:write',
'pages:delete',
'pages:share',
// Spaces — create + edit (visibility public/private + member mgmt)
'space:read', '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:read',
'rows:write',
'rows:delete',
// Attachments
'attachments:upload', 'attachments:upload',
// R3.6 — Members can browse templates 'attachments:delete',
// Templates — read + create + manage own
'templates:read', '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:create',
'sync_blocks:edit', 'sync_blocks:edit',
// R4.3 — Members can use the Web Clipper 'sync_blocks:delete',
// Web Clipper
'clipper:use', 'clipper:use',
], ],
}, },
@ -125,6 +158,7 @@ export class AcadeniceRbacSeedService implements OnModuleInit {
constructor( constructor(
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly roleRepo: AcadeniceRoleRepo, private readonly roleRepo: AcadeniceRoleRepo,
private readonly userRoleRepo: AcadeniceUserRoleRepo,
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@ -178,11 +212,44 @@ export class AcadeniceRbacSeedService implements OnModuleInit {
} }
const existingPerms = await this.roleRepo.listPermissions(role.id); const existingPerms = await this.roleRepo.listPermissions(role.id);
if (existingPerms.length === 0) { if (existingPerms.length === 0 || spec.forceResync) {
// First time we see this role : install the canonical permission set. // 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]); 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 { AcadeniceRbacSeedService } from '../services/seed.service';
import { AcadeniceRoleRepo } from '../repos/role.repo'; import { AcadeniceRoleRepo } from '../repos/role.repo';
import { AcadeniceUserRoleRepo } from '../repos/user-role.repo';
/** /**
* The seed service uses kysely's `sql` template tag through `InjectKysely()` * The seed service uses kysely's `sql` template tag through `InjectKysely()`
@ -9,10 +10,12 @@ import { AcadeniceRoleRepo } from '../repos/role.repo';
describe('AcadeniceRbacSeedService.seedWorkspace', () => { describe('AcadeniceRbacSeedService.seedWorkspace', () => {
let seedService: AcadeniceRbacSeedService; let seedService: AcadeniceRbacSeedService;
let roleRepo: jest.Mocked<Partial<AcadeniceRoleRepo>>; let roleRepo: jest.Mocked<Partial<AcadeniceRoleRepo>>;
// Stub the kysely DB — only `sql\`...\`.execute(this.db)` calls the DB and let userRoleRepo: jest.Mocked<Partial<AcadeniceUserRoleRepo>>;
// seedWorkspace doesn't use it directly (only seedAllWorkspaces does). We // Stub the kysely DB. seedWorkspace itself runs a `sql\`...\`.execute(db)`
// pass an arbitrary placeholder. // for the default-role assignment loop, so the stub responds with no users.
const fakeDb: any = {}; const fakeDb: any = {
execute: jest.fn().mockResolvedValue({ rows: [] }),
};
beforeEach(() => { beforeEach(() => {
roleRepo = { roleRepo = {
@ -21,9 +24,14 @@ describe('AcadeniceRbacSeedService.seedWorkspace', () => {
listPermissions: jest.fn(), listPermissions: jest.fn(),
replacePermissions: jest.fn(), replacePermissions: jest.fn(),
}; };
userRoleRepo = {
listForUser: jest.fn(),
assign: jest.fn(),
};
seedService = new AcadeniceRbacSeedService( seedService = new AcadeniceRbacSeedService(
fakeDb, fakeDb,
roleRepo as unknown as AcadeniceRoleRepo, roleRepo as unknown as AcadeniceRoleRepo,
userRoleRepo as unknown as AcadeniceUserRoleRepo,
); );
}); });