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 { 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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue