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:
parent
11e003e71e
commit
f2e9d2205c
2 changed files with 86 additions and 11 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue