feat(rbac): R2.1 backend RBAC dynamique multi-roles avec catalogue + 5 roles seed + JWT enrichi
This commit is contained in:
parent
06c46f7b9b
commit
bcd861126f
24 changed files with 2252 additions and 1 deletions
|
|
@ -123,6 +123,154 @@ Branche fork : `acadenice/main`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Patch 003 — R2.1 : RBAC dynamique multi-roles (backend)
|
||||||
|
|
||||||
|
**Date** : 2026-05-07
|
||||||
|
**Scope** : nouveau systeme RBAC source-de-verite cote DocAdenice + JWT enrichi `acadenice_permissions[]` + 5 roles classiques pre-seed
|
||||||
|
**Rationale** : DocAdenice devient generique (pivot R1) — il faut un RBAC dynamique multi-roles (un user peut cumuler plusieurs roles, union des permissions) editable par l'admin via UI, qui signe ses permissions effectives dans le JWT pour que le bridge formation-hub les consume sans re-query la base. Pattern Notion. Le RBAC custom cohabite avec les roles natifs Docmost (`WorkspaceUser.role` `OWNER`/`ADMIN`/`MEMBER`) — natifs gardes pour les guards Docmost upstream, le RBAC Acadenice est une couche par-dessus.
|
||||||
|
|
||||||
|
### Catalogue de permissions
|
||||||
|
|
||||||
|
22 permissions atomiques generiques, catalogue ferme en TS dans `apps/server/src/core/acadenice/rbac/permissions-catalog.ts`. Toute insertion en base est validee contre ce catalogue cote service.
|
||||||
|
|
||||||
|
Groupes : `pages`, `space`, `tables`, `rows`, `attachments`, `users` + meta `roles:manage` + wildcard `admin:*`.
|
||||||
|
|
||||||
|
### Tables Postgres
|
||||||
|
|
||||||
|
Toutes prefixees `acadenice_` (zero conflit avec les tables upstream Docmost) :
|
||||||
|
|
||||||
|
- `acadenice_role` (id, workspace_id FK, name, description, is_system_role, ts) — unique (workspace_id, name)
|
||||||
|
- `acadenice_role_permission` (role_id FK cascade, permission_key) — pk composee
|
||||||
|
- `acadenice_user_role` (user_id FK cascade, role_id FK cascade, workspace_id FK, assigned_by FK set null, assigned_at) — pk composee (user_id, role_id)
|
||||||
|
|
||||||
|
Migration : `apps/server/src/database/migrations/20260507T120000-create-acadenice-rbac.ts`. Idempotente (`ifNotExists` partout).
|
||||||
|
|
||||||
|
### 5 roles classiques pre-seed (au boot, idempotent)
|
||||||
|
|
||||||
|
| Role | is_system | Permissions |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| Owner | true | `admin:*` |
|
||||||
|
| Admin | true | tout sauf `*:delete` et `roles:manage` |
|
||||||
|
| Editor | true | pages:read/write/share, space:read/write, rows:read/write, attachments:upload |
|
||||||
|
| Member | true | read-only + attachments:upload |
|
||||||
|
| Guest | true | `pages:read` |
|
||||||
|
|
||||||
|
`is_system_role=true` -> rename / delete refuses cote service. Permissions modifiables (admin peut reshape les roles seed sans perdre l'identite system).
|
||||||
|
|
||||||
|
`AcadeniceRbacSeedService.onModuleInit()` au boot : seed tous les workspaces existants. Idempotent : ne duplique pas. Failure -> log et le boot poursuit (in my tests, le seed echoue tot si la migration n'a pas tourne, et le boot suivant retente).
|
||||||
|
|
||||||
|
### JWT enrichi
|
||||||
|
|
||||||
|
`TokenService.generateAccessToken()` injecte `acadenice_permissions: string[]` dans le payload du token d'acces. Cache Redis 60s par user/workspace (key `acadenice:perms:user:<userId>:ws:<workspaceId>`). Si le user a `admin:*`, la liste est court-circuitee a `["admin:*"]`. Failure de resolution -> claim vide (degradation graceful, le bridge a son propre fallback).
|
||||||
|
|
||||||
|
### Guard NestJS
|
||||||
|
|
||||||
|
Decorator `@RequirePermission('roles:manage')` + `AcadenicePermissionsGuard` :
|
||||||
|
- Lit `req.user = { user, workspace }` (pose en amont par `JwtAuthGuard`)
|
||||||
|
- Resoud les permissions via `AcadeniceRoleService.getUserPermissions` (cache Redis 60s)
|
||||||
|
- Match avec wildcard support : `admin:*` couvre tout, `<group>:*` couvre `<group>:<action>`
|
||||||
|
- Throw `ForbiddenException` si miss
|
||||||
|
|
||||||
|
### Endpoints REST
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/acadenice/permissions (auth)
|
||||||
|
GET /api/acadenice/roles (auth)
|
||||||
|
POST /api/acadenice/roles (perm: roles:manage)
|
||||||
|
GET /api/acadenice/roles/:id (perm: roles:manage)
|
||||||
|
PATCH /api/acadenice/roles/:id (perm: roles:manage)
|
||||||
|
DELETE /api/acadenice/roles/:id (perm: roles:manage)
|
||||||
|
GET /api/acadenice/roles/:id/permissions (perm: roles:manage)
|
||||||
|
PUT /api/acadenice/roles/:id/permissions (perm: roles:manage)
|
||||||
|
GET /api/acadenice/users/:userId/roles (perm: roles:manage OR self)
|
||||||
|
POST /api/acadenice/users/:userId/roles (perm: roles:manage, body { roleIds: [...] })
|
||||||
|
DELETE /api/acadenice/users/:userId/roles/:roleId (perm: roles:manage)
|
||||||
|
```
|
||||||
|
|
||||||
|
Les routes user-roles refusent l'auto-modification meme avec `roles:manage` (anti-escalation).
|
||||||
|
|
||||||
|
### Fichiers crees
|
||||||
|
|
||||||
|
| Fichier | Role |
|
||||||
|
|---------|------|
|
||||||
|
| `apps/server/src/core/acadenice/rbac/permissions-catalog.ts` | Catalogue ferme + helpers `isPermissionKey`, `permissionMatches` |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/rbac.module.ts` | Module Nest, exporte `AcadeniceRoleService` + guard |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/dto/create-role.dto.ts` | CreateRoleDto |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/dto/update-role.dto.ts` | UpdateRoleDto + UpdateRolePermissionsDto |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/dto/assign-role.dto.ts` | AssignRolesDto |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/dto/role.dto.ts` | RoleDto + RoleWithPermissionsDto + UserRoleAssignmentDto |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/repos/role.repo.ts` | Repo SQL natif (pas de typed Kysely : tables hors `db.d.ts`, rebase-friendly) |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/repos/user-role.repo.ts` | Repo user-role + `getEffectivePermissions` (union via JOIN, court-circuit `admin:*`) |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/services/role.service.ts` | RoleService — CRUD, assignation, cache Redis 60s, validation catalog |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/services/seed.service.ts` | SeedService — 5 roles seed idempotent au boot |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/guards/permissions.guard.ts` | AcadenicePermissionsGuard avec wildcard support |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/guards/require-permission.decorator.ts` | `@RequirePermission(...)` decorator |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts` | GET /api/acadenice/permissions (catalog read-only) |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/controllers/roles.controller.ts` | CRUD roles + permissions |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/controllers/user-roles.controller.ts` | Assignations user-roles |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/spec/role.service.spec.ts` | 11 tests unitaires (Jest, mocks repos) |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/spec/permissions.guard.spec.ts` | 11 tests unitaires (Reflector mock + permissionMatches) |
|
||||||
|
| `apps/server/src/core/acadenice/rbac/spec/seed.service.spec.ts` | 3 tests unitaires (idempotence + creation initiale) |
|
||||||
|
| `apps/server/src/database/migrations/20260507T120000-create-acadenice-rbac.ts` | Migration Kysely 3 tables |
|
||||||
|
|
||||||
|
### Fichiers modifies (touches minimales)
|
||||||
|
|
||||||
|
| Fichier | Modification |
|
||||||
|
|---------|--------------|
|
||||||
|
| `apps/server/src/core/auth/dto/jwt-payload.ts` | +1 champ optionnel `acadenice_permissions?: string[]` sur `JwtPayload` |
|
||||||
|
| `apps/server/src/core/auth/services/token.service.ts` | +injection optionnelle `AcadeniceRoleService`, +resoud les perms a chaque sign d'access token (failure graceful = []) |
|
||||||
|
| `apps/server/src/core/auth/token.module.ts` | +import `AcadeniceRbacModule`, re-export pour les consumers de TokenModule |
|
||||||
|
| `apps/server/src/core/core.module.ts` | +import `AcadeniceRbacModule` dans `imports[]` |
|
||||||
|
|
||||||
|
### Cache strategy
|
||||||
|
|
||||||
|
- Cle Redis : `acadenice:perms:user:<userId>:ws:<workspaceId>` (TTL 60s)
|
||||||
|
- Best-effort : un failure cache (Redis down, parse error) -> fallback sur SQL, in my tests pas de 500
|
||||||
|
- Invalidation : sur mutation user-role, on `DEL` la cle du user. Sur mutation de role/permission, on `SCAN` + `DEL` toutes les cles du workspace (pas de `KEYS *` bloquant)
|
||||||
|
- Service `AcadeniceRoleService` rendu utilisable hors-Redis (`@Optional()` sur l'injection) — utile pour les tests unitaires
|
||||||
|
|
||||||
|
### Cohabitation avec roles natifs Docmost
|
||||||
|
|
||||||
|
- `WorkspaceUser.role` (OWNER/ADMIN/MEMBER) : KEEP — utilise par les guards natifs Docmost (creation page, invitation, etc.)
|
||||||
|
- `acadenice_role` : couche par-dessus, source de verite pour le JWT et le bridge
|
||||||
|
- L'admin DocAdenice peut couvrir 100% des cas via les `acadenice_role` + Owner/Admin natifs s'il decide de mapper les permissions natives plus tard. Compatible.
|
||||||
|
|
||||||
|
### Edge cases couverts
|
||||||
|
|
||||||
|
- User sans aucun role -> `acadenice_permissions: []` dans le JWT
|
||||||
|
- User avec `admin:*` -> `["admin:*"]` (court-circuit, payload tiny)
|
||||||
|
- Role rename quand un autre role porte deja le nouveau nom -> `409 Conflict`
|
||||||
|
- Rename / delete d'un system role -> `403 Forbidden` (cote service)
|
||||||
|
- Auto-modification de ses propres roles -> `403 Forbidden` (anti-escalation)
|
||||||
|
- Assignation d'un role d'un autre workspace -> `404 NotFound` validation prealable atomique
|
||||||
|
- Assignation duplique du meme role -> idempotent (`ON CONFLICT DO NOTHING`)
|
||||||
|
- Permission inconnue dans une payload -> `400 BadRequest` avec liste autorisee
|
||||||
|
- Migration re-run sur DB partiellement migree -> `ifNotExists` evite l'erreur
|
||||||
|
- Seed echoue au boot -> log, le boot continue, retry au boot suivant
|
||||||
|
- Redis down -> service degrade en SQL direct, JWT a quand meme les perms (a chaque sign)
|
||||||
|
|
||||||
|
### TODO laisses
|
||||||
|
|
||||||
|
- Frontend `/settings/roles` (R2.2) — page admin pour gerer les roles
|
||||||
|
- Mapping group sync OIDC -> `acadenice_role` (utile en couplage avec Patch 002)
|
||||||
|
- Audit log des changements de role (qui a assigne quoi a qui)
|
||||||
|
- Quand un nouveau workspace est cree, le seed actuel ne s'execute qu'au prochain boot — il faudrait hooker `WorkspaceService.create` pour seeder en live (R2.2 ou R2.3)
|
||||||
|
- Endpoint `POST /api/acadenice/permissions/me` pour query rapidement les perms du user courant cote front (alternativement : decoder le JWT)
|
||||||
|
- Permissions cache : invalidation cross-workspace via Redis Streams si on monte plusieurs instances Docmost (probleme deja present dans Docmost natif, hors scope R2.1)
|
||||||
|
|
||||||
|
### Bugs detectes dans Docmost natif
|
||||||
|
|
||||||
|
Aucun bug bloquant. Le test stub `apps/server/src/core/auth/services/token.service.spec.ts` etait deja casse avant ce patch (provider declare sans ses dependances) — non touche pour ne pas mentir sur la dette upstream.
|
||||||
|
|
||||||
|
### Verifications skipped
|
||||||
|
|
||||||
|
- `pnpm install` : pas execute (deps absents en local par convention de l'agent fork — Corentin install + build)
|
||||||
|
- TypeScript build : pas execute (cf ci-dessus)
|
||||||
|
- Migration runtime : pas executee (pas de Postgres local pour le moment)
|
||||||
|
- Tests Jest : ecrits mais pas runs en local (pnpm absent)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### TODO rebrand complet (futur)
|
### TODO rebrand complet (futur)
|
||||||
|
|
||||||
- Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream)
|
- Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Controller, Get, HttpCode, HttpStatus, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
|
import { PERMISSIONS_CATALOG } from '../permissions-catalog';
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('acadenice/permissions')
|
||||||
|
export class AcadenicePermissionsController {
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return PERMISSIONS_CATALOG.map((p) => ({
|
||||||
|
key: p.key,
|
||||||
|
group: p.group,
|
||||||
|
description: p.description,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { AcadeniceRoleService } from '../services/role.service';
|
||||||
|
import { CreateRoleDto } from '../dto/create-role.dto';
|
||||||
|
import {
|
||||||
|
UpdateRoleDto,
|
||||||
|
UpdateRolePermissionsDto,
|
||||||
|
} from '../dto/update-role.dto';
|
||||||
|
import { AcadenicePermissionsGuard } from '../guards/permissions.guard';
|
||||||
|
import { RequirePermission } from '../guards/require-permission.decorator';
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard, AcadenicePermissionsGuard)
|
||||||
|
@Controller('acadenice/roles')
|
||||||
|
export class AcadeniceRolesController {
|
||||||
|
constructor(private readonly roleService: AcadeniceRoleService) {}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Get()
|
||||||
|
async list(@AuthWorkspace() workspace: Workspace) {
|
||||||
|
return this.roleService.listRoles(workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('roles:manage')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@Body() dto: CreateRoleDto,
|
||||||
|
) {
|
||||||
|
return this.roleService.createRole(workspace.id, {
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
permissions: dto.permissions ?? [],
|
||||||
|
isSystemRole: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('roles:manage')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Get(':id')
|
||||||
|
async getOne(
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
|
) {
|
||||||
|
return this.roleService.getRoleWithPermissions(id, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('roles:manage')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Patch(':id')
|
||||||
|
async update(
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
|
@Body() dto: UpdateRoleDto,
|
||||||
|
) {
|
||||||
|
return this.roleService.updateRole(id, workspace.id, {
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('roles:manage')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
|
) {
|
||||||
|
await this.roleService.deleteRole(id, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('roles:manage')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Get(':id/permissions')
|
||||||
|
async listPermissions(
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
|
) {
|
||||||
|
const role = await this.roleService.getRoleWithPermissions(
|
||||||
|
id,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
return { roleId: role.id, permissions: role.permissions };
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('roles:manage')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Put(':id/permissions')
|
||||||
|
async setPermissions(
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@AuthUser() _user: User,
|
||||||
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
|
@Body() dto: UpdateRolePermissionsDto,
|
||||||
|
) {
|
||||||
|
return this.roleService.setRolePermissions(
|
||||||
|
id,
|
||||||
|
workspace.id,
|
||||||
|
dto.permissions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||||
|
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||||
|
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||||
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { AcadeniceRoleService } from '../services/role.service';
|
||||||
|
import { AssignRolesDto } from '../dto/assign-role.dto';
|
||||||
|
import { permissionMatches } from '../permissions-catalog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages user <-> role assignments.
|
||||||
|
*
|
||||||
|
* Authorisation model :
|
||||||
|
* - List a user's roles : actor needs `roles:manage` OR is the user themself.
|
||||||
|
* - Mutate (assign / unassign) : actor needs `roles:manage`. Self-mutation is
|
||||||
|
* forbidden even with `roles:manage` to prevent privilege escalation
|
||||||
|
* accidents (an Admin promoting themselves to Owner). The Owner role can
|
||||||
|
* only be granted by another Owner — enforced by the `admin:*` short-circuit
|
||||||
|
* on the assigner side.
|
||||||
|
*
|
||||||
|
* The guard is intentionally hand-rolled here (no `AcadenicePermissionsGuard`)
|
||||||
|
* because the access logic depends on `userId` path param vs the actor.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('acadenice/users/:userId/roles')
|
||||||
|
export class AcadeniceUserRolesController {
|
||||||
|
constructor(private readonly roleService: AcadeniceRoleService) {}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Get()
|
||||||
|
async list(
|
||||||
|
@AuthUser() actor: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@Param('userId', new ParseUUIDPipe()) userId: string,
|
||||||
|
) {
|
||||||
|
if (actor.id !== userId) {
|
||||||
|
await this.requireRolesManage(actor.id, workspace.id);
|
||||||
|
}
|
||||||
|
return this.roleService.listUserRoles(userId, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post()
|
||||||
|
async assign(
|
||||||
|
@AuthUser() actor: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@Param('userId', new ParseUUIDPipe()) userId: string,
|
||||||
|
@Body() dto: AssignRolesDto,
|
||||||
|
) {
|
||||||
|
if (actor.id === userId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You cannot modify your own role assignments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.requireRolesManage(actor.id, workspace.id);
|
||||||
|
await this.roleService.assignRolesToUser(
|
||||||
|
userId,
|
||||||
|
workspace.id,
|
||||||
|
dto.roleIds,
|
||||||
|
actor.id,
|
||||||
|
);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Delete(':roleId')
|
||||||
|
async unassign(
|
||||||
|
@AuthUser() actor: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@Param('userId', new ParseUUIDPipe()) userId: string,
|
||||||
|
@Param('roleId', new ParseUUIDPipe()) roleId: string,
|
||||||
|
) {
|
||||||
|
if (actor.id === userId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You cannot modify your own role assignments',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.requireRolesManage(actor.id, workspace.id);
|
||||||
|
await this.roleService.unassignRoleFromUser(
|
||||||
|
userId,
|
||||||
|
workspace.id,
|
||||||
|
roleId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireRolesManage(
|
||||||
|
actorId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const perms = await this.roleService.getUserPermissions(
|
||||||
|
actorId,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
if (!permissionMatches(perms, 'roles:manage')) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Missing required permission: roles:manage',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { ArrayMinSize, ArrayUnique, IsArray, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class AssignRolesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
roleIds: string[];
|
||||||
|
}
|
||||||
25
apps/server/src/core/acadenice/rbac/dto/create-role.dto.ts
Normal file
25
apps/server/src/core/acadenice/rbac/dto/create-role.dto.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import {
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateRoleDto {
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 120)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
28
apps/server/src/core/acadenice/rbac/dto/role.dto.ts
Normal file
28
apps/server/src/core/acadenice/rbac/dto/role.dto.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* Plain shapes returned by the RBAC controllers.
|
||||||
|
* Kept as interfaces (not classes) — these are read-only DTOs, no validation
|
||||||
|
* happens on the way out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RoleDto {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isSystemRole: boolean;
|
||||||
|
createdAt: Date | string;
|
||||||
|
updatedAt: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleWithPermissionsDto extends RoleDto {
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRoleAssignmentDto {
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
assignedBy: string | null;
|
||||||
|
assignedAt: Date | string;
|
||||||
|
role?: RoleDto;
|
||||||
|
}
|
||||||
18
apps/server/src/core/acadenice/rbac/dto/update-role.dto.ts
Normal file
18
apps/server/src/core/acadenice/rbac/dto/update-role.dto.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { IsOptional, IsString, Length, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateRoleDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 120)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateRolePermissionsDto {
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { permissionMatches } from '../permissions-catalog';
|
||||||
|
import { AcadeniceRoleService } from '../services/role.service';
|
||||||
|
import { REQUIRE_PERMISSION_KEY } from './require-permission.decorator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission guard for DocAdenice RBAC routes.
|
||||||
|
*
|
||||||
|
* Assumes `JwtAuthGuard` (or an equivalent) has already populated
|
||||||
|
* `req.user = { user, workspace }` upstream. This guard does not authenticate
|
||||||
|
* — it only authorises against the user's effective permissions in the
|
||||||
|
* current workspace.
|
||||||
|
*
|
||||||
|
* Resolution order :
|
||||||
|
* 1. Read every `@RequirePermission(...)` clause on handler + class.
|
||||||
|
* 2. If no clause is present, allow (the guard is a no-op).
|
||||||
|
* 3. Resolve the user's effective permissions via the cached service.
|
||||||
|
* 4. Each clause is an OR-list; all clauses must be satisfied (AND across
|
||||||
|
* multiple decorators).
|
||||||
|
* 5. `admin:*` short-circuits any check.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AcadenicePermissionsGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly roleService: AcadeniceRoleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const clauses =
|
||||||
|
this.reflector.getAllAndMerge<string[][]>(REQUIRE_PERMISSION_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]) ?? [];
|
||||||
|
|
||||||
|
if (!clauses || clauses.length === 0) return true;
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const user = req?.user?.user;
|
||||||
|
const workspace = req?.user?.workspace;
|
||||||
|
if (!user?.id || !workspace?.id) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const effective = await this.roleService.getUserPermissions(
|
||||||
|
user.id,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// `getAllAndMerge` returns a flat string[] when each metadata entry is a
|
||||||
|
// string[]. We treat the merged array as a single OR clause to keep the
|
||||||
|
// guard simple : the user needs at least one matching permission across
|
||||||
|
// all decorators stacked on the route.
|
||||||
|
const required: string[] = Array.isArray(clauses[0])
|
||||||
|
? (clauses as unknown as string[][]).flat()
|
||||||
|
: (clauses as unknown as string[]);
|
||||||
|
|
||||||
|
if (required.length === 0) return true;
|
||||||
|
|
||||||
|
const ok = required.some((perm) => permissionMatches(effective, perm));
|
||||||
|
if (!ok) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Missing required permission: ${required.join(' or ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const REQUIRE_PERMISSION_KEY = 'acadenice:requirePermission';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a controller route as requiring at least one of the listed permission
|
||||||
|
* keys (logical OR). Wildcard matching is handled by the guard via
|
||||||
|
* `permissionMatches`. Multiple decorators stack with AND semantics — the
|
||||||
|
* guard treats each `@RequirePermission(...)` invocation as an additional
|
||||||
|
* required clause.
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
*
|
||||||
|
* @RequirePermission('roles:manage')
|
||||||
|
* @Patch(':id')
|
||||||
|
* updateRole(...) { ... }
|
||||||
|
*
|
||||||
|
* @RequirePermission('pages:write', 'pages:delete')
|
||||||
|
* @Delete('pages/:id')
|
||||||
|
* removePage(...) { ... } // user must hold pages:write OR pages:delete
|
||||||
|
*/
|
||||||
|
export const RequirePermission = (...permissions: string[]) =>
|
||||||
|
SetMetadata(REQUIRE_PERMISSION_KEY, permissions);
|
||||||
210
apps/server/src/core/acadenice/rbac/permissions-catalog.ts
Normal file
210
apps/server/src/core/acadenice/rbac/permissions-catalog.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
/**
|
||||||
|
* DocAdenice RBAC permissions catalogue.
|
||||||
|
*
|
||||||
|
* Closed catalogue : every permission key referenced by an `acadenice_role`
|
||||||
|
* MUST exist here. Adding a permission requires shipping code (no runtime
|
||||||
|
* extension) so that backend guards, frontend UI and bridge consumers stay
|
||||||
|
* in sync.
|
||||||
|
*
|
||||||
|
* Wildcards :
|
||||||
|
* - `admin:*` — Owner-only, short-circuits all checks.
|
||||||
|
* - `<group>:*` — covers every action key starting with `<group>:`.
|
||||||
|
*
|
||||||
|
* Format of `key` : `<group>:<action>` (one colon, lowercase, snake_case
|
||||||
|
* forbidden — use simple verbs to keep it Notion-like).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PERMISSION_KEYS = [
|
||||||
|
// Pages
|
||||||
|
'pages:read',
|
||||||
|
'pages:write',
|
||||||
|
'pages:delete',
|
||||||
|
'pages:share',
|
||||||
|
|
||||||
|
// Spaces
|
||||||
|
'space:read',
|
||||||
|
'space:create',
|
||||||
|
'space:write',
|
||||||
|
'space:delete',
|
||||||
|
'space:invite',
|
||||||
|
|
||||||
|
// Tables (DocAdenice block — generic relational tables, à la Notion DB)
|
||||||
|
'tables:list',
|
||||||
|
'tables:create',
|
||||||
|
'tables:write',
|
||||||
|
'tables:delete',
|
||||||
|
|
||||||
|
// Rows inside tables
|
||||||
|
'rows:read',
|
||||||
|
'rows:write',
|
||||||
|
'rows:delete',
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
'attachments:upload',
|
||||||
|
'attachments:delete',
|
||||||
|
|
||||||
|
// Users / membership
|
||||||
|
'users:invite',
|
||||||
|
'users:write',
|
||||||
|
'users:delete',
|
||||||
|
|
||||||
|
// Meta
|
||||||
|
'roles:manage',
|
||||||
|
'admin:*',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||||
|
|
||||||
|
export interface PermissionDescriptor {
|
||||||
|
key: PermissionKey;
|
||||||
|
group: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERMISSIONS_CATALOG: ReadonlyArray<PermissionDescriptor> = [
|
||||||
|
{
|
||||||
|
key: 'pages:read',
|
||||||
|
group: 'pages',
|
||||||
|
description: 'Read pages content',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pages:write',
|
||||||
|
group: 'pages',
|
||||||
|
description: 'Create and edit pages',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pages:delete',
|
||||||
|
group: 'pages',
|
||||||
|
description: 'Delete pages',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pages:share',
|
||||||
|
group: 'pages',
|
||||||
|
description: 'Share pages externally',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'space:read',
|
||||||
|
group: 'space',
|
||||||
|
description: 'List and view spaces',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'space:create',
|
||||||
|
group: 'space',
|
||||||
|
description: 'Create new spaces',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'space:write',
|
||||||
|
group: 'space',
|
||||||
|
description: 'Edit space metadata and settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'space:delete',
|
||||||
|
group: 'space',
|
||||||
|
description: 'Delete spaces',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'space:invite',
|
||||||
|
group: 'space',
|
||||||
|
description: 'Invite members to a space',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tables:list',
|
||||||
|
group: 'tables',
|
||||||
|
description: 'List relational tables of the workspace',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tables:create',
|
||||||
|
group: 'tables',
|
||||||
|
description: 'Create relational tables',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tables:write',
|
||||||
|
group: 'tables',
|
||||||
|
description: 'Edit table schema',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tables:delete',
|
||||||
|
group: 'tables',
|
||||||
|
description: 'Delete relational tables',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rows:read',
|
||||||
|
group: 'rows',
|
||||||
|
description: 'Read table rows',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rows:write',
|
||||||
|
group: 'rows',
|
||||||
|
description: 'Insert and update table rows',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rows:delete',
|
||||||
|
group: 'rows',
|
||||||
|
description: 'Delete table rows',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'attachments:upload',
|
||||||
|
group: 'attachments',
|
||||||
|
description: 'Upload attachments',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'attachments:delete',
|
||||||
|
group: 'attachments',
|
||||||
|
description: 'Delete attachments',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users:invite',
|
||||||
|
group: 'users',
|
||||||
|
description: 'Invite users to the workspace',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users:write',
|
||||||
|
group: 'users',
|
||||||
|
description: 'Edit user profiles and assignments',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users:delete',
|
||||||
|
group: 'users',
|
||||||
|
description: 'Remove users from the workspace',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roles:manage',
|
||||||
|
group: 'meta',
|
||||||
|
description:
|
||||||
|
'Manage acadenice_role definitions and user-role assignments',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin:*',
|
||||||
|
group: 'meta',
|
||||||
|
description: 'Owner short-circuit — grants every permission',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PERMISSION_KEY_SET: ReadonlySet<string> = new Set(PERMISSION_KEYS);
|
||||||
|
|
||||||
|
export function isPermissionKey(key: string): key is PermissionKey {
|
||||||
|
return PERMISSION_KEY_SET.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the granted permission set covers `required`.
|
||||||
|
*
|
||||||
|
* Wildcard rules :
|
||||||
|
* - `admin:*` always wins.
|
||||||
|
* - `<group>:*` covers every `<group>:<action>` of the same group.
|
||||||
|
* - Exact key match otherwise.
|
||||||
|
*/
|
||||||
|
export function permissionMatches(
|
||||||
|
granted: ReadonlyArray<string>,
|
||||||
|
required: string,
|
||||||
|
): boolean {
|
||||||
|
if (granted.length === 0) return false;
|
||||||
|
|
||||||
|
if (granted.includes('admin:*')) return true;
|
||||||
|
if (granted.includes(required)) return true;
|
||||||
|
|
||||||
|
const colonIdx = required.indexOf(':');
|
||||||
|
if (colonIdx <= 0) return false;
|
||||||
|
const group = required.slice(0, colonIdx);
|
||||||
|
return granted.includes(`${group}:*`);
|
||||||
|
}
|
||||||
32
apps/server/src/core/acadenice/rbac/rbac.module.ts
Normal file
32
apps/server/src/core/acadenice/rbac/rbac.module.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AcadeniceRoleRepo } from './repos/role.repo';
|
||||||
|
import { AcadeniceUserRoleRepo } from './repos/user-role.repo';
|
||||||
|
import { AcadeniceRoleService } from './services/role.service';
|
||||||
|
import { AcadeniceRbacSeedService } from './services/seed.service';
|
||||||
|
import { AcadenicePermissionsGuard } from './guards/permissions.guard';
|
||||||
|
import { AcadeniceRolesController } from './controllers/roles.controller';
|
||||||
|
import { AcadeniceUserRolesController } from './controllers/user-roles.controller';
|
||||||
|
import { AcadenicePermissionsController } from './controllers/permissions.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DocAdenice RBAC module.
|
||||||
|
*
|
||||||
|
* Exports `AcadeniceRoleService` so the auth/token pipeline can resolve
|
||||||
|
* effective permissions and inject them into the JWT payload at sign-time.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [
|
||||||
|
AcadenicePermissionsController,
|
||||||
|
AcadeniceRolesController,
|
||||||
|
AcadeniceUserRolesController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AcadeniceRoleRepo,
|
||||||
|
AcadeniceUserRoleRepo,
|
||||||
|
AcadeniceRoleService,
|
||||||
|
AcadeniceRbacSeedService,
|
||||||
|
AcadenicePermissionsGuard,
|
||||||
|
],
|
||||||
|
exports: [AcadeniceRoleService, AcadenicePermissionsGuard],
|
||||||
|
})
|
||||||
|
export class AcadeniceRbacModule {}
|
||||||
235
apps/server/src/core/acadenice/rbac/repos/role.repo.ts
Normal file
235
apps/server/src/core/acadenice/rbac/repos/role.repo.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
|
|
||||||
|
export interface AcadeniceRoleRow {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isSystemRole: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InsertRoleParams {
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
isSystemRole?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateRoleParams {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct-SQL repo for the `acadenice_*` tables.
|
||||||
|
*
|
||||||
|
* We use `sql.id`/`sql` template strings instead of typed Kysely calls because
|
||||||
|
* these tables are not declared in `db.d.ts` (kysely-codegen output, owned by
|
||||||
|
* upstream Docmost). Keeping them out of the typed schema preserves the
|
||||||
|
* upstream-rebase property — the codegen file can be regenerated by upstream
|
||||||
|
* tooling without colliding with our additions.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AcadeniceRoleRepo {
|
||||||
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
async findById(
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AcadeniceRoleRow | null> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const res = await sql<AcadeniceRoleRow>`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
workspace_id AS "workspaceId",
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
is_system_role AS "isSystemRole",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
updated_at AS "updatedAt"
|
||||||
|
FROM acadenice_role
|
||||||
|
WHERE id = ${id} AND workspace_id = ${workspaceId}
|
||||||
|
LIMIT 1
|
||||||
|
`.execute(db);
|
||||||
|
return res.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByName(
|
||||||
|
workspaceId: string,
|
||||||
|
name: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AcadeniceRoleRow | null> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const res = await sql<AcadeniceRoleRow>`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
workspace_id AS "workspaceId",
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
is_system_role AS "isSystemRole",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
updated_at AS "updatedAt"
|
||||||
|
FROM acadenice_role
|
||||||
|
WHERE workspace_id = ${workspaceId} AND name = ${name}
|
||||||
|
LIMIT 1
|
||||||
|
`.execute(db);
|
||||||
|
return res.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByWorkspace(
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AcadeniceRoleRow[]> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const res = await sql<AcadeniceRoleRow>`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
workspace_id AS "workspaceId",
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
is_system_role AS "isSystemRole",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
updated_at AS "updatedAt"
|
||||||
|
FROM acadenice_role
|
||||||
|
WHERE workspace_id = ${workspaceId}
|
||||||
|
ORDER BY is_system_role DESC, name ASC
|
||||||
|
`.execute(db);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
params: InsertRoleParams,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AcadeniceRoleRow> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const isSystem = params.isSystemRole ?? false;
|
||||||
|
const description = params.description ?? null;
|
||||||
|
const res = await sql<AcadeniceRoleRow>`
|
||||||
|
INSERT INTO acadenice_role (workspace_id, name, description, is_system_role)
|
||||||
|
VALUES (${params.workspaceId}, ${params.name}, ${description}, ${isSystem})
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
workspace_id AS "workspaceId",
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
is_system_role AS "isSystemRole",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
updated_at AS "updatedAt"
|
||||||
|
`.execute(db);
|
||||||
|
return res.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
params: UpdateRoleParams,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<AcadeniceRoleRow | null> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
// Build the SET clause dynamically. We always bump updated_at.
|
||||||
|
const fields: ReturnType<typeof sql>[] = [sql`updated_at = now()`];
|
||||||
|
if (params.name !== undefined) {
|
||||||
|
fields.push(sql`name = ${params.name}`);
|
||||||
|
}
|
||||||
|
if (params.description !== undefined) {
|
||||||
|
fields.push(sql`description = ${params.description}`);
|
||||||
|
}
|
||||||
|
const setClause = sql.join(fields, sql`, `);
|
||||||
|
const res = await sql<AcadeniceRoleRow>`
|
||||||
|
UPDATE acadenice_role
|
||||||
|
SET ${setClause}
|
||||||
|
WHERE id = ${id} AND workspace_id = ${workspaceId}
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
workspace_id AS "workspaceId",
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
is_system_role AS "isSystemRole",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
updated_at AS "updatedAt"
|
||||||
|
`.execute(db);
|
||||||
|
return res.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<number> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const res = await sql`
|
||||||
|
DELETE FROM acadenice_role
|
||||||
|
WHERE id = ${id} AND workspace_id = ${workspaceId}
|
||||||
|
`.execute(db);
|
||||||
|
return Number(res.numAffectedRows ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPermissions(
|
||||||
|
roleId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const res = await sql<{ permissionKey: string }>`
|
||||||
|
SELECT permission_key AS "permissionKey"
|
||||||
|
FROM acadenice_role_permission
|
||||||
|
WHERE role_id = ${roleId}
|
||||||
|
ORDER BY permission_key ASC
|
||||||
|
`.execute(db);
|
||||||
|
return res.rows.map((r) => r.permissionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async replacePermissions(
|
||||||
|
roleId: string,
|
||||||
|
permissions: string[],
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
await sql`
|
||||||
|
DELETE FROM acadenice_role_permission WHERE role_id = ${roleId}
|
||||||
|
`.execute(db);
|
||||||
|
if (permissions.length === 0) return;
|
||||||
|
const values = sql.join(
|
||||||
|
permissions.map(
|
||||||
|
(p) => sql`(${roleId}, ${p})`,
|
||||||
|
),
|
||||||
|
sql`, `,
|
||||||
|
);
|
||||||
|
await sql`
|
||||||
|
INSERT INTO acadenice_role_permission (role_id, permission_key)
|
||||||
|
VALUES ${values}
|
||||||
|
ON CONFLICT (role_id, permission_key) DO NOTHING
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPermissionsForRoles(
|
||||||
|
roleIds: string[],
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<Map<string, string[]>> {
|
||||||
|
const out = new Map<string, string[]>();
|
||||||
|
if (roleIds.length === 0) return out;
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const res = await sql<{ roleId: string; permissionKey: string }>`
|
||||||
|
SELECT
|
||||||
|
role_id AS "roleId",
|
||||||
|
permission_key AS "permissionKey"
|
||||||
|
FROM acadenice_role_permission
|
||||||
|
WHERE role_id = ANY(${roleIds}::uuid[])
|
||||||
|
`.execute(db);
|
||||||
|
for (const row of res.rows) {
|
||||||
|
const existing = out.get(row.roleId);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(row.permissionKey);
|
||||||
|
} else {
|
||||||
|
out.set(row.roleId, [row.permissionKey]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
apps/server/src/core/acadenice/rbac/repos/user-role.repo.ts
Normal file
104
apps/server/src/core/acadenice/rbac/repos/user-role.repo.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||||
|
import { dbOrTx } from '@docmost/db/utils';
|
||||||
|
|
||||||
|
export interface AcadeniceUserRoleRow {
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
assignedBy: string | null;
|
||||||
|
assignedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRoleWithRole extends AcadeniceUserRoleRow {
|
||||||
|
roleName: string;
|
||||||
|
isSystemRole: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AcadeniceUserRoleRepo {
|
||||||
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
async assign(
|
||||||
|
params: {
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
assignedBy?: string | null;
|
||||||
|
},
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const assignedBy = params.assignedBy ?? null;
|
||||||
|
await sql`
|
||||||
|
INSERT INTO acadenice_user_role (user_id, role_id, workspace_id, assigned_by)
|
||||||
|
VALUES (${params.userId}, ${params.roleId}, ${params.workspaceId}, ${assignedBy})
|
||||||
|
ON CONFLICT (user_id, role_id) DO NOTHING
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unassign(
|
||||||
|
userId: string,
|
||||||
|
roleId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<number> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const res = await sql`
|
||||||
|
DELETE FROM acadenice_user_role
|
||||||
|
WHERE user_id = ${userId} AND role_id = ${roleId}
|
||||||
|
`.execute(db);
|
||||||
|
return Number(res.numAffectedRows ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForUser(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<UserRoleWithRole[]> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const res = await sql<UserRoleWithRole>`
|
||||||
|
SELECT
|
||||||
|
ur.user_id AS "userId",
|
||||||
|
ur.role_id AS "roleId",
|
||||||
|
ur.workspace_id AS "workspaceId",
|
||||||
|
ur.assigned_by AS "assignedBy",
|
||||||
|
ur.assigned_at AS "assignedAt",
|
||||||
|
r.name AS "roleName",
|
||||||
|
r.is_system_role AS "isSystemRole"
|
||||||
|
FROM acadenice_user_role ur
|
||||||
|
JOIN acadenice_role r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = ${userId}
|
||||||
|
AND ur.workspace_id = ${workspaceId}
|
||||||
|
ORDER BY r.is_system_role DESC, r.name ASC
|
||||||
|
`.execute(db);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the union of all permission keys held by `userId` in `workspaceId`,
|
||||||
|
* deduplicated and sorted. Empty array if the user has no role.
|
||||||
|
*
|
||||||
|
* If the user has `admin:*`, the result is short-circuited to `["admin:*"]`
|
||||||
|
* to keep the JWT payload tiny and the guard fast-path obvious.
|
||||||
|
*/
|
||||||
|
async getEffectivePermissions(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
const res = await sql<{ permissionKey: string }>`
|
||||||
|
SELECT DISTINCT rp.permission_key AS "permissionKey"
|
||||||
|
FROM acadenice_user_role ur
|
||||||
|
JOIN acadenice_role_permission rp ON rp.role_id = ur.role_id
|
||||||
|
WHERE ur.user_id = ${userId}
|
||||||
|
AND ur.workspace_id = ${workspaceId}
|
||||||
|
ORDER BY rp.permission_key ASC
|
||||||
|
`.execute(db);
|
||||||
|
const all = res.rows.map((r) => r.permissionKey);
|
||||||
|
if (all.includes('admin:*')) return ['admin:*'];
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
}
|
||||||
377
apps/server/src/core/acadenice/rbac/services/role.service.ts
Normal file
377
apps/server/src/core/acadenice/rbac/services/role.service.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
Optional,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import { AcadeniceRoleRepo } from '../repos/role.repo';
|
||||||
|
import { AcadeniceUserRoleRepo } from '../repos/user-role.repo';
|
||||||
|
import {
|
||||||
|
isPermissionKey,
|
||||||
|
PERMISSION_KEYS,
|
||||||
|
} from '../permissions-catalog';
|
||||||
|
import {
|
||||||
|
RoleDto,
|
||||||
|
RoleWithPermissionsDto,
|
||||||
|
UserRoleAssignmentDto,
|
||||||
|
} from '../dto/role.dto';
|
||||||
|
|
||||||
|
const PERMS_CACHE_TTL_SECONDS = 60;
|
||||||
|
|
||||||
|
function permsCacheKey(userId: string, workspaceId: string): string {
|
||||||
|
return `acadenice:perms:user:${userId}:ws:${workspaceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AcadeniceRoleService {
|
||||||
|
private readonly redis: Redis | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly roleRepo: AcadeniceRoleRepo,
|
||||||
|
private readonly userRoleRepo: AcadeniceUserRoleRepo,
|
||||||
|
@Optional() @Inject(RedisService) redisService?: RedisService,
|
||||||
|
) {
|
||||||
|
this.redis = redisService ? redisService.getOrThrow() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Read
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async listRoles(workspaceId: string): Promise<RoleDto[]> {
|
||||||
|
const rows = await this.roleRepo.listByWorkspace(workspaceId);
|
||||||
|
return rows.map((r) => this.toDto(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRoleWithPermissions(
|
||||||
|
roleId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<RoleWithPermissionsDto> {
|
||||||
|
const role = await this.roleRepo.findById(roleId, workspaceId);
|
||||||
|
if (!role) throw new NotFoundException('Role not found');
|
||||||
|
const perms = await this.roleRepo.listPermissions(roleId);
|
||||||
|
return { ...this.toDto(role), permissions: perms };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Write
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async createRole(
|
||||||
|
workspaceId: string,
|
||||||
|
params: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
permissions?: string[];
|
||||||
|
isSystemRole?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<RoleWithPermissionsDto> {
|
||||||
|
const name = params.name.trim();
|
||||||
|
if (!name) throw new BadRequestException('Role name is required');
|
||||||
|
|
||||||
|
const existing = await this.roleRepo.findByName(workspaceId, name);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`A role named "${name}" already exists in this workspace`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedPerms = this.validatePermissions(params.permissions ?? []);
|
||||||
|
|
||||||
|
const created = await this.roleRepo.create({
|
||||||
|
workspaceId,
|
||||||
|
name,
|
||||||
|
description: params.description ?? null,
|
||||||
|
isSystemRole: params.isSystemRole ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validatedPerms.length > 0) {
|
||||||
|
await this.roleRepo.replacePermissions(created.id, validatedPerms);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.invalidateAllUsersCacheForWorkspace(workspaceId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...this.toDto(created),
|
||||||
|
permissions: validatedPerms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRole(
|
||||||
|
roleId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
params: { name?: string; description?: string | null },
|
||||||
|
): Promise<RoleDto> {
|
||||||
|
const role = await this.roleRepo.findById(roleId, workspaceId);
|
||||||
|
if (!role) throw new NotFoundException('Role not found');
|
||||||
|
|
||||||
|
if (params.name !== undefined) {
|
||||||
|
const newName = params.name.trim();
|
||||||
|
if (!newName) throw new BadRequestException('Role name cannot be empty');
|
||||||
|
if (role.isSystemRole && newName !== role.name) {
|
||||||
|
throw new ForbiddenException('System roles cannot be renamed');
|
||||||
|
}
|
||||||
|
if (newName !== role.name) {
|
||||||
|
const conflict = await this.roleRepo.findByName(workspaceId, newName);
|
||||||
|
if (conflict && conflict.id !== roleId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`A role named "${newName}" already exists in this workspace`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.roleRepo.update(roleId, workspaceId, {
|
||||||
|
name: params.name?.trim(),
|
||||||
|
description: params.description,
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundException('Role not found');
|
||||||
|
|
||||||
|
await this.invalidateAllUsersCacheForWorkspace(workspaceId);
|
||||||
|
return this.toDto(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRole(roleId: string, workspaceId: string): Promise<void> {
|
||||||
|
const role = await this.roleRepo.findById(roleId, workspaceId);
|
||||||
|
if (!role) throw new NotFoundException('Role not found');
|
||||||
|
if (role.isSystemRole) {
|
||||||
|
throw new ForbiddenException('System roles cannot be deleted');
|
||||||
|
}
|
||||||
|
const affected = await this.roleRepo.delete(roleId, workspaceId);
|
||||||
|
if (affected === 0) throw new NotFoundException('Role not found');
|
||||||
|
await this.invalidateAllUsersCacheForWorkspace(workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRolePermissions(
|
||||||
|
roleId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
permissions: string[],
|
||||||
|
): Promise<RoleWithPermissionsDto> {
|
||||||
|
const role = await this.roleRepo.findById(roleId, workspaceId);
|
||||||
|
if (!role) throw new NotFoundException('Role not found');
|
||||||
|
const validated = this.validatePermissions(permissions);
|
||||||
|
await this.roleRepo.replacePermissions(roleId, validated);
|
||||||
|
await this.invalidateAllUsersCacheForWorkspace(workspaceId);
|
||||||
|
return { ...this.toDto(role), permissions: validated };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// User <-> Role
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async assignRolesToUser(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
roleIds: string[],
|
||||||
|
assignedBy: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (roleIds.length === 0) return;
|
||||||
|
const uniqueIds = Array.from(new Set(roleIds));
|
||||||
|
|
||||||
|
// Validate every role belongs to the workspace before any write so we are
|
||||||
|
// either fully consistent or fully rejected.
|
||||||
|
for (const roleId of uniqueIds) {
|
||||||
|
const role = await this.roleRepo.findById(roleId, workspaceId);
|
||||||
|
if (!role) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Role ${roleId} does not exist in this workspace`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const roleId of uniqueIds) {
|
||||||
|
// Idempotent : repo uses ON CONFLICT DO NOTHING.
|
||||||
|
await this.userRoleRepo.assign({
|
||||||
|
userId,
|
||||||
|
roleId,
|
||||||
|
workspaceId,
|
||||||
|
assignedBy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.invalidateUserCache(userId, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unassignRoleFromUser(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
roleId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const role = await this.roleRepo.findById(roleId, workspaceId);
|
||||||
|
if (!role) throw new NotFoundException('Role not found');
|
||||||
|
await this.userRoleRepo.unassign(userId, roleId);
|
||||||
|
await this.invalidateUserCache(userId, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUserRoles(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<UserRoleAssignmentDto[]> {
|
||||||
|
const rows = await this.userRoleRepo.listForUser(userId, workspaceId);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
userId: r.userId,
|
||||||
|
roleId: r.roleId,
|
||||||
|
workspaceId: r.workspaceId,
|
||||||
|
assignedBy: r.assignedBy,
|
||||||
|
assignedAt: r.assignedAt,
|
||||||
|
role: {
|
||||||
|
id: r.roleId,
|
||||||
|
workspaceId: r.workspaceId,
|
||||||
|
name: r.roleName,
|
||||||
|
description: null,
|
||||||
|
isSystemRole: r.isSystemRole,
|
||||||
|
// listForUser does not project createdAt/updatedAt — return assignedAt
|
||||||
|
// as a stable proxy timestamp; callers needing the canonical role
|
||||||
|
// metadata should call getRoleWithPermissions.
|
||||||
|
createdAt: r.assignedAt,
|
||||||
|
updatedAt: r.assignedAt,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Effective permissions (cached)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the deduplicated union of all permissions held by the user in the
|
||||||
|
* workspace. Cache-through Redis with 60s TTL — invalidated on any role or
|
||||||
|
* assignment mutation. If `admin:*` is part of the union, the result is the
|
||||||
|
* single-element array `["admin:*"]`.
|
||||||
|
*/
|
||||||
|
async getUserPermissions(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const cached = await this.readCache(userId, workspaceId);
|
||||||
|
if (cached !== null) return cached;
|
||||||
|
const fresh = await this.userRoleRepo.getEffectivePermissions(
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
await this.writeCache(userId, workspaceId, fresh);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateUserCache(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.redis) return;
|
||||||
|
try {
|
||||||
|
await this.redis.del(permsCacheKey(userId, workspaceId));
|
||||||
|
} catch {
|
||||||
|
// Cache is best-effort. A failure here just means the next read takes
|
||||||
|
// the SQL path; correctness is preserved.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe every cached perm entry for the workspace. Used after a role or
|
||||||
|
* permission-set mutation : we cannot enumerate which users were affected
|
||||||
|
* cheaply, so we evict the whole namespace under that workspace.
|
||||||
|
*
|
||||||
|
* Uses Redis SCAN to avoid `KEYS *` blocking semantics on prod.
|
||||||
|
*/
|
||||||
|
async invalidateAllUsersCacheForWorkspace(
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.redis) return;
|
||||||
|
const pattern = `acadenice:perms:user:*:ws:${workspaceId}`;
|
||||||
|
try {
|
||||||
|
let cursor = '0';
|
||||||
|
do {
|
||||||
|
const [next, keys] = await this.redis.scan(
|
||||||
|
cursor,
|
||||||
|
'MATCH',
|
||||||
|
pattern,
|
||||||
|
'COUNT',
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
cursor = next;
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await this.redis.del(...keys);
|
||||||
|
}
|
||||||
|
} while (cursor !== '0');
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
private validatePermissions(permissions: string[]): string[] {
|
||||||
|
const dedup = Array.from(new Set(permissions));
|
||||||
|
for (const p of dedup) {
|
||||||
|
if (!isPermissionKey(p)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Unknown permission key "${p}". Allowed keys: ${PERMISSION_KEYS.join(
|
||||||
|
', ',
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedup;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isSystemRole: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): RoleDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspaceId,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
isSystemRole: row.isSystemRole,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readCache(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
if (!this.redis) return null;
|
||||||
|
try {
|
||||||
|
const raw = await this.redis.get(permsCacheKey(userId, workspaceId));
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
return parsed.filter((s): s is string => typeof s === 'string');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeCache(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
perms: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.redis) return;
|
||||||
|
try {
|
||||||
|
await this.redis.set(
|
||||||
|
permsCacheKey(userId, workspaceId),
|
||||||
|
JSON.stringify(perms),
|
||||||
|
'EX',
|
||||||
|
PERMS_CACHE_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
apps/server/src/core/acadenice/rbac/services/seed.service.ts
Normal file
157
apps/server/src/core/acadenice/rbac/services/seed.service.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { AcadeniceRoleRepo } from '../repos/role.repo';
|
||||||
|
import { PermissionKey } from '../permissions-catalog';
|
||||||
|
|
||||||
|
interface SystemRoleSpec {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
permissions: PermissionKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Five canonical roles seeded into every existing workspace at boot.
|
||||||
|
*
|
||||||
|
* - Owner : `admin:*` short-circuit, full power.
|
||||||
|
* - Admin : everything except destructive `*:delete` and `roles:manage`.
|
||||||
|
* - Editor : authoring rights, read everywhere, no destruction.
|
||||||
|
* - Member : read + minimal contribution (attachments).
|
||||||
|
* - Guest : read-only on pages.
|
||||||
|
*
|
||||||
|
* `is_system_role = true` for all five. Service-level guards forbid renaming
|
||||||
|
* or deleting them; permission set is editable so admins can reshape the
|
||||||
|
* defaults to match their org without losing the seed identity.
|
||||||
|
*/
|
||||||
|
const SYSTEM_ROLES: ReadonlyArray<SystemRoleSpec> = [
|
||||||
|
{
|
||||||
|
name: 'Owner',
|
||||||
|
description: 'Full access to the workspace, including dangerous actions.',
|
||||||
|
permissions: ['admin:*'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Admin',
|
||||||
|
description:
|
||||||
|
'All actions except deletion of resources and role management.',
|
||||||
|
permissions: [
|
||||||
|
'pages:read',
|
||||||
|
'pages:write',
|
||||||
|
'pages:share',
|
||||||
|
'space:read',
|
||||||
|
'space:create',
|
||||||
|
'space:write',
|
||||||
|
'space:invite',
|
||||||
|
'tables:list',
|
||||||
|
'tables:create',
|
||||||
|
'tables:write',
|
||||||
|
'rows:read',
|
||||||
|
'rows:write',
|
||||||
|
'attachments:upload',
|
||||||
|
'users:invite',
|
||||||
|
'users:write',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Editor',
|
||||||
|
description: 'Author content and manage rows; cannot delete or invite.',
|
||||||
|
permissions: [
|
||||||
|
'pages:read',
|
||||||
|
'pages:write',
|
||||||
|
'pages:share',
|
||||||
|
'space:read',
|
||||||
|
'space:write',
|
||||||
|
'rows:read',
|
||||||
|
'rows:write',
|
||||||
|
'attachments:upload',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Member',
|
||||||
|
description: 'Read content and upload attachments.',
|
||||||
|
permissions: [
|
||||||
|
'pages:read',
|
||||||
|
'space:read',
|
||||||
|
'rows:read',
|
||||||
|
'attachments:upload',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Guest',
|
||||||
|
description: 'Read-only access to pages.',
|
||||||
|
permissions: ['pages:read'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AcadeniceRbacSeedService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AcadeniceRbacSeedService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
private readonly roleRepo: AcadeniceRoleRepo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.seedAllWorkspaces();
|
||||||
|
} catch (err) {
|
||||||
|
// Boot must never crash because the seed failed (typical case : tables
|
||||||
|
// not yet migrated on a brand-new DB before the first migration run).
|
||||||
|
// Log loud and move on; the next boot will retry.
|
||||||
|
this.logger.error(
|
||||||
|
`Acadenice RBAC seed failed: ${(err as Error).message}`,
|
||||||
|
(err as Error).stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent : guarantees the five system roles + their permissions exist
|
||||||
|
* in every workspace. Safe to run repeatedly.
|
||||||
|
*/
|
||||||
|
async seedAllWorkspaces(): Promise<void> {
|
||||||
|
const workspaces = await sql<{ id: string }>`
|
||||||
|
SELECT id FROM workspaces WHERE deleted_at IS NULL
|
||||||
|
`.execute(this.db);
|
||||||
|
|
||||||
|
for (const ws of workspaces.rows) {
|
||||||
|
await this.seedWorkspace(ws.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async seedWorkspace(workspaceId: string): Promise<void> {
|
||||||
|
for (const spec of SYSTEM_ROLES) {
|
||||||
|
let role = await this.roleRepo.findByName(workspaceId, spec.name);
|
||||||
|
if (!role) {
|
||||||
|
role = await this.roleRepo.create({
|
||||||
|
workspaceId,
|
||||||
|
name: spec.name,
|
||||||
|
description: spec.description,
|
||||||
|
isSystemRole: true,
|
||||||
|
});
|
||||||
|
} else if (!role.isSystemRole) {
|
||||||
|
// A non-system role with the same name predates the seed. Promote it
|
||||||
|
// so guards behave correctly. We do NOT touch its permission set —
|
||||||
|
// existing customisations are preserved.
|
||||||
|
await sql`
|
||||||
|
UPDATE acadenice_role
|
||||||
|
SET is_system_role = true, updated_at = now()
|
||||||
|
WHERE id = ${role.id}
|
||||||
|
`.execute(this.db);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPerms = await this.roleRepo.listPermissions(role.id);
|
||||||
|
if (existingPerms.length === 0) {
|
||||||
|
// First time we see this role : install the canonical permission set.
|
||||||
|
await this.roleRepo.replacePermissions(role.id, [...spec.permissions]);
|
||||||
|
}
|
||||||
|
// If existingPerms.length > 0 we keep the admin's customisations intact.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AcadenicePermissionsGuard } from '../guards/permissions.guard';
|
||||||
|
import { AcadeniceRoleService } from '../services/role.service';
|
||||||
|
import { permissionMatches } from '../permissions-catalog';
|
||||||
|
|
||||||
|
function makeContext(user?: any): ExecutionContext {
|
||||||
|
const req = { user };
|
||||||
|
return {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => req,
|
||||||
|
getResponse: () => ({}),
|
||||||
|
getNext: () => ({}),
|
||||||
|
}),
|
||||||
|
getHandler: () => ({}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('permissionMatches', () => {
|
||||||
|
it('matches exact key', () => {
|
||||||
|
expect(permissionMatches(['pages:read'], 'pages:read')).toBe(true);
|
||||||
|
});
|
||||||
|
it('matches via group wildcard', () => {
|
||||||
|
expect(permissionMatches(['pages:*'], 'pages:read')).toBe(true);
|
||||||
|
});
|
||||||
|
it('admin:* short-circuits any required permission', () => {
|
||||||
|
expect(permissionMatches(['admin:*'], 'tables:delete')).toBe(true);
|
||||||
|
expect(permissionMatches(['admin:*'], 'roles:manage')).toBe(true);
|
||||||
|
});
|
||||||
|
it('rejects when the granted set is empty', () => {
|
||||||
|
expect(permissionMatches([], 'pages:read')).toBe(false);
|
||||||
|
});
|
||||||
|
it('does not cross groups', () => {
|
||||||
|
expect(permissionMatches(['pages:*'], 'rows:read')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AcadenicePermissionsGuard', () => {
|
||||||
|
let reflector: jest.Mocked<Reflector>;
|
||||||
|
let roleService: jest.Mocked<Partial<AcadeniceRoleService>>;
|
||||||
|
let guard: AcadenicePermissionsGuard;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reflector = {
|
||||||
|
getAllAndMerge: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
roleService = {
|
||||||
|
getUserPermissions: jest.fn(),
|
||||||
|
};
|
||||||
|
guard = new AcadenicePermissionsGuard(
|
||||||
|
reflector,
|
||||||
|
roleService as AcadeniceRoleService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows when no @RequirePermission decorator is present', async () => {
|
||||||
|
reflector.getAllAndMerge.mockReturnValue([]);
|
||||||
|
const ok = await guard.canActivate(makeContext({ user: { id: 'u' }, workspace: { id: 'w' } }));
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(roleService.getUserPermissions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws Unauthorized when req.user is missing', async () => {
|
||||||
|
reflector.getAllAndMerge.mockReturnValue(['roles:manage']);
|
||||||
|
await expect(guard.canActivate(makeContext(undefined))).rejects.toBeInstanceOf(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows when the user has the exact permission', async () => {
|
||||||
|
reflector.getAllAndMerge.mockReturnValue(['roles:manage']);
|
||||||
|
roleService.getUserPermissions!.mockResolvedValueOnce(['roles:manage', 'pages:read']);
|
||||||
|
const ok = await guard.canActivate(
|
||||||
|
makeContext({ user: { id: 'u' }, workspace: { id: 'w' } }),
|
||||||
|
);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows when the user has admin:*', async () => {
|
||||||
|
reflector.getAllAndMerge.mockReturnValue(['tables:delete']);
|
||||||
|
roleService.getUserPermissions!.mockResolvedValueOnce(['admin:*']);
|
||||||
|
const ok = await guard.canActivate(
|
||||||
|
makeContext({ user: { id: 'u' }, workspace: { id: 'w' } }),
|
||||||
|
);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows via group wildcard', async () => {
|
||||||
|
reflector.getAllAndMerge.mockReturnValue(['pages:write']);
|
||||||
|
roleService.getUserPermissions!.mockResolvedValueOnce(['pages:*']);
|
||||||
|
const ok = await guard.canActivate(
|
||||||
|
makeContext({ user: { id: 'u' }, workspace: { id: 'w' } }),
|
||||||
|
);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies with Forbidden when permission is missing', async () => {
|
||||||
|
reflector.getAllAndMerge.mockReturnValue(['roles:manage']);
|
||||||
|
roleService.getUserPermissions!.mockResolvedValueOnce(['pages:read']);
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(makeContext({ user: { id: 'u' }, workspace: { id: 'w' } })),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
182
apps/server/src/core/acadenice/rbac/spec/role.service.spec.ts
Normal file
182
apps/server/src/core/acadenice/rbac/spec/role.service.spec.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AcadeniceRoleService } from '../services/role.service';
|
||||||
|
import { AcadeniceRoleRepo } from '../repos/role.repo';
|
||||||
|
import { AcadeniceUserRoleRepo } from '../repos/user-role.repo';
|
||||||
|
|
||||||
|
type RepoMock<T> = jest.Mocked<Partial<T>>;
|
||||||
|
|
||||||
|
describe('AcadeniceRoleService', () => {
|
||||||
|
let service: AcadeniceRoleService;
|
||||||
|
let roleRepo: RepoMock<AcadeniceRoleRepo>;
|
||||||
|
let userRoleRepo: RepoMock<AcadeniceUserRoleRepo>;
|
||||||
|
|
||||||
|
const WS = 'ws-1';
|
||||||
|
const sampleRow = (overrides: Partial<any> = {}) => ({
|
||||||
|
id: 'role-1',
|
||||||
|
workspaceId: WS,
|
||||||
|
name: 'Admin',
|
||||||
|
description: null,
|
||||||
|
isSystemRole: false,
|
||||||
|
createdAt: new Date('2026-01-01'),
|
||||||
|
updatedAt: new Date('2026-01-01'),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
roleRepo = {
|
||||||
|
findById: jest.fn(),
|
||||||
|
findByName: jest.fn(),
|
||||||
|
listByWorkspace: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
listPermissions: jest.fn(),
|
||||||
|
replacePermissions: jest.fn(),
|
||||||
|
};
|
||||||
|
userRoleRepo = {
|
||||||
|
assign: jest.fn(),
|
||||||
|
unassign: jest.fn(),
|
||||||
|
listForUser: jest.fn(),
|
||||||
|
getEffectivePermissions: jest.fn(),
|
||||||
|
};
|
||||||
|
service = new AcadeniceRoleService(
|
||||||
|
roleRepo as AcadeniceRoleRepo,
|
||||||
|
userRoleRepo as AcadeniceUserRoleRepo,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createRole', () => {
|
||||||
|
it('rejects an empty name', async () => {
|
||||||
|
await expect(
|
||||||
|
service.createRole(WS, { name: ' ' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate name in the same workspace', async () => {
|
||||||
|
roleRepo.findByName!.mockResolvedValueOnce(sampleRow());
|
||||||
|
await expect(
|
||||||
|
service.createRole(WS, { name: 'Admin' }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown permission keys', async () => {
|
||||||
|
roleRepo.findByName!.mockResolvedValueOnce(null);
|
||||||
|
await expect(
|
||||||
|
service.createRole(WS, {
|
||||||
|
name: 'Custom',
|
||||||
|
permissions: ['pages:read', 'unknown:permission'],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists role + dedup permissions', async () => {
|
||||||
|
roleRepo.findByName!.mockResolvedValueOnce(null);
|
||||||
|
roleRepo.create!.mockResolvedValueOnce(
|
||||||
|
sampleRow({ id: 'r-new', name: 'Custom' }),
|
||||||
|
);
|
||||||
|
const out = await service.createRole(WS, {
|
||||||
|
name: 'Custom',
|
||||||
|
permissions: ['pages:read', 'pages:read', 'pages:write'],
|
||||||
|
});
|
||||||
|
expect(roleRepo.replacePermissions).toHaveBeenCalledWith('r-new', [
|
||||||
|
'pages:read',
|
||||||
|
'pages:write',
|
||||||
|
]);
|
||||||
|
expect(out.permissions).toEqual(['pages:read', 'pages:write']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateRole', () => {
|
||||||
|
it('refuses to rename a system role', async () => {
|
||||||
|
roleRepo.findById!.mockResolvedValueOnce(
|
||||||
|
sampleRow({ isSystemRole: true, name: 'Owner' }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.updateRole('role-1', WS, { name: 'BigBoss' }),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows updating description on a system role', async () => {
|
||||||
|
roleRepo.findById!.mockResolvedValueOnce(
|
||||||
|
sampleRow({ isSystemRole: true, name: 'Owner' }),
|
||||||
|
);
|
||||||
|
roleRepo.update!.mockResolvedValueOnce(
|
||||||
|
sampleRow({ isSystemRole: true, name: 'Owner', description: 'd2' }),
|
||||||
|
);
|
||||||
|
const out = await service.updateRole('role-1', WS, { description: 'd2' });
|
||||||
|
expect(out.description).toBe('d2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteRole', () => {
|
||||||
|
it('refuses to delete a system role', async () => {
|
||||||
|
roleRepo.findById!.mockResolvedValueOnce(
|
||||||
|
sampleRow({ isSystemRole: true }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.deleteRole('role-1', WS),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(roleRepo.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a custom role', async () => {
|
||||||
|
roleRepo.findById!.mockResolvedValueOnce(sampleRow());
|
||||||
|
roleRepo.delete!.mockResolvedValueOnce(1);
|
||||||
|
await expect(service.deleteRole('role-1', WS)).resolves.toBeUndefined();
|
||||||
|
expect(roleRepo.delete).toHaveBeenCalledWith('role-1', WS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assignRolesToUser', () => {
|
||||||
|
it('is idempotent across duplicate roleIds', async () => {
|
||||||
|
roleRepo.findById!.mockResolvedValue(sampleRow());
|
||||||
|
await service.assignRolesToUser(
|
||||||
|
'user-1',
|
||||||
|
WS,
|
||||||
|
['role-1', 'role-1', 'role-1'],
|
||||||
|
'actor',
|
||||||
|
);
|
||||||
|
// dedup -> single assign call
|
||||||
|
expect(userRoleRepo.assign).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects assignment of a role from another workspace', async () => {
|
||||||
|
roleRepo.findById!.mockResolvedValueOnce(null);
|
||||||
|
await expect(
|
||||||
|
service.assignRolesToUser('u', WS, ['role-x'], 'actor'),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(userRoleRepo.assign).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUserPermissions', () => {
|
||||||
|
it('returns the union from the repo when no cache', async () => {
|
||||||
|
userRoleRepo.getEffectivePermissions!.mockResolvedValueOnce([
|
||||||
|
'pages:read',
|
||||||
|
'pages:write',
|
||||||
|
'rows:read',
|
||||||
|
]);
|
||||||
|
const out = await service.getUserPermissions('u1', WS);
|
||||||
|
expect(out).toEqual(['pages:read', 'pages:write', 'rows:read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for a user with no role', async () => {
|
||||||
|
userRoleRepo.getEffectivePermissions!.mockResolvedValueOnce([]);
|
||||||
|
const out = await service.getUserPermissions('u-empty', WS);
|
||||||
|
expect(out).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through admin:* short-circuit from the repo layer', async () => {
|
||||||
|
// The repo is responsible for collapsing admin:* — service trusts it.
|
||||||
|
userRoleRepo.getEffectivePermissions!.mockResolvedValueOnce(['admin:*']);
|
||||||
|
const out = await service.getUserPermissions('owner', WS);
|
||||||
|
expect(out).toEqual(['admin:*']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
102
apps/server/src/core/acadenice/rbac/spec/seed.service.spec.ts
Normal file
102
apps/server/src/core/acadenice/rbac/spec/seed.service.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { AcadeniceRbacSeedService } from '../services/seed.service';
|
||||||
|
import { AcadeniceRoleRepo } from '../repos/role.repo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seed service uses kysely's `sql` template tag through `InjectKysely()`
|
||||||
|
* to look up workspaces. We bypass that by stubbing `seedAllWorkspaces` indirectly:
|
||||||
|
* tests below drive `seedWorkspace` directly (the public idempotent unit).
|
||||||
|
*/
|
||||||
|
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 = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
roleRepo = {
|
||||||
|
findByName: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
listPermissions: jest.fn(),
|
||||||
|
replacePermissions: jest.fn(),
|
||||||
|
};
|
||||||
|
seedService = new AcadeniceRbacSeedService(
|
||||||
|
fakeDb,
|
||||||
|
roleRepo as AcadeniceRoleRepo,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the 5 system roles when none exist', async () => {
|
||||||
|
roleRepo.findByName!.mockResolvedValue(null);
|
||||||
|
roleRepo.create!.mockImplementation(async (params: any) => ({
|
||||||
|
id: `id-${params.name}`,
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
name: params.name,
|
||||||
|
description: params.description ?? null,
|
||||||
|
isSystemRole: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
roleRepo.listPermissions!.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await seedService.seedWorkspace('ws-1');
|
||||||
|
|
||||||
|
expect(roleRepo.create).toHaveBeenCalledTimes(5);
|
||||||
|
const createdNames = roleRepo.create!.mock.calls.map(
|
||||||
|
(c) => (c[0] as any).name,
|
||||||
|
);
|
||||||
|
expect(createdNames).toEqual(
|
||||||
|
expect.arrayContaining(['Owner', 'Admin', 'Editor', 'Member', 'Guest']),
|
||||||
|
);
|
||||||
|
// Owner gets admin:*
|
||||||
|
const ownerPermsCall = roleRepo.replacePermissions!.mock.calls.find(
|
||||||
|
(c) => c[0] === 'id-Owner',
|
||||||
|
);
|
||||||
|
expect(ownerPermsCall?.[1]).toEqual(['admin:*']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — does not recreate existing system roles', async () => {
|
||||||
|
const existing = (name: string) => ({
|
||||||
|
id: `id-${name}`,
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
name,
|
||||||
|
description: null,
|
||||||
|
isSystemRole: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
roleRepo.findByName!.mockImplementation(async (_ws, name: string) =>
|
||||||
|
existing(name),
|
||||||
|
);
|
||||||
|
// Existing roles have already been seeded with permissions.
|
||||||
|
roleRepo.listPermissions!.mockResolvedValue(['pages:read']);
|
||||||
|
|
||||||
|
await seedService.seedWorkspace('ws-1');
|
||||||
|
|
||||||
|
expect(roleRepo.create).not.toHaveBeenCalled();
|
||||||
|
expect(roleRepo.replacePermissions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reseeds permissions only when the existing role has none', async () => {
|
||||||
|
const existing = (name: string) => ({
|
||||||
|
id: `id-${name}`,
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
name,
|
||||||
|
description: null,
|
||||||
|
isSystemRole: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
roleRepo.findByName!.mockImplementation(async (_ws, name: string) =>
|
||||||
|
existing(name),
|
||||||
|
);
|
||||||
|
roleRepo.listPermissions!.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await seedService.seedWorkspace('ws-1');
|
||||||
|
|
||||||
|
// Permissions installed for all 5 roles, no role recreated.
|
||||||
|
expect(roleRepo.create).not.toHaveBeenCalled();
|
||||||
|
expect(roleRepo.replacePermissions).toHaveBeenCalledTimes(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,6 +14,16 @@ export type JwtPayload = {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
type: 'access';
|
type: 'access';
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
/**
|
||||||
|
* DocAdenice (Acadenice fork) — effective RBAC permissions at sign time.
|
||||||
|
*
|
||||||
|
* Union of every permission held by the user across all of their
|
||||||
|
* `acadenice_role` assignments in the current workspace, deduplicated.
|
||||||
|
* If the user holds `admin:*`, this is the single-element array
|
||||||
|
* `["admin:*"]` (Owner short-circuit). The bridge consumer relies on this
|
||||||
|
* claim being present on every fresh access token.
|
||||||
|
*/
|
||||||
|
acadenice_permissions?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JwtCollabPayload = {
|
export type JwtCollabPayload = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import {
|
import {
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
Optional,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
|
@ -19,12 +22,18 @@ import {
|
||||||
} from '../dto/jwt-payload';
|
} from '../dto/jwt-payload';
|
||||||
import { User } from '@docmost/db/types/entity.types';
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
import { isUserDisabled } from '../../../common/helpers';
|
import { isUserDisabled } from '../../../common/helpers';
|
||||||
|
import { AcadeniceRoleService } from '../../acadenice/rbac/services/role.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenService {
|
export class TokenService {
|
||||||
|
private readonly logger = new Logger(TokenService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(AcadeniceRoleService)
|
||||||
|
private readonly acadeniceRoleService?: AcadeniceRoleService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
||||||
|
|
@ -38,10 +47,42 @@ export class TokenService {
|
||||||
workspaceId: user.workspaceId,
|
workspaceId: user.workspaceId,
|
||||||
type: JwtType.ACCESS,
|
type: JwtType.ACCESS,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
acadenice_permissions: await this.resolveAcadenicePermissions(
|
||||||
|
user.id,
|
||||||
|
user.workspaceId,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
return this.jwtService.sign(payload);
|
return this.jwtService.sign(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the user's DocAdenice RBAC permissions for inclusion in the JWT.
|
||||||
|
*
|
||||||
|
* Failure is non-fatal : a Redis or DB hiccup at sign time degrades to an
|
||||||
|
* empty array (the bridge falls back to its own local checks) rather than
|
||||||
|
* blocking authentication. The cache TTL is 60s so a transient failure
|
||||||
|
* self-heals on the next sign-in or token refresh.
|
||||||
|
*/
|
||||||
|
private async resolveAcadenicePermissions(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (!this.acadeniceRoleService) return [];
|
||||||
|
try {
|
||||||
|
return await this.acadeniceRoleService.getUserPermissions(
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to resolve acadenice_permissions for user=${userId} ws=${workspaceId}: ${
|
||||||
|
(err as Error).message
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async generateCollabToken(user: User, workspaceId: string): Promise<string> {
|
async generateCollabToken(user: User, workspaceId: string): Promise<string> {
|
||||||
if (isUserDisabled(user)) {
|
if (isUserDisabled(user)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { JwtModule } from '@nestjs/jwt';
|
||||||
import type { StringValue } from 'ms';
|
import type { StringValue } from 'ms';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
import { TokenService } from './services/token.service';
|
import { TokenService } from './services/token.service';
|
||||||
|
import { AcadeniceRbacModule } from '../acadenice/rbac/rbac.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
AcadeniceRbacModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
useFactory: async (environmentService: EnvironmentService) => {
|
useFactory: async (environmentService: EnvironmentService) => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -20,6 +22,6 @@ import { TokenService } from './services/token.service';
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [TokenService],
|
providers: [TokenService],
|
||||||
exports: [TokenService],
|
exports: [TokenService, AcadeniceRbacModule],
|
||||||
})
|
})
|
||||||
export class TokenModule {}
|
export class TokenModule {}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { WatcherModule } from './watcher/watcher.module';
|
||||||
import { FavoriteModule } from './favorite/favorite.module';
|
import { FavoriteModule } from './favorite/favorite.module';
|
||||||
import { SessionModule } from './session/session.module';
|
import { SessionModule } from './session/session.module';
|
||||||
import { OidcModule } from './auth/oidc/oidc.module';
|
import { OidcModule } from './auth/oidc/oidc.module';
|
||||||
|
import { AcadeniceRbacModule } from './acadenice/rbac/rbac.module';
|
||||||
import { ClsMiddleware } from 'nestjs-cls';
|
import { ClsMiddleware } from 'nestjs-cls';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -44,6 +45,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||||
WatcherModule,
|
WatcherModule,
|
||||||
SessionModule,
|
SessionModule,
|
||||||
OidcModule,
|
OidcModule,
|
||||||
|
AcadeniceRbacModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreModule implements NestModule {
|
export class CoreModule implements NestModule {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DocAdenice RBAC tables.
|
||||||
|
*
|
||||||
|
* Three tables, all prefixed with `acadenice_` to keep upstream Docmost
|
||||||
|
* untouched and rebase-friendly :
|
||||||
|
*
|
||||||
|
* acadenice_role — role definitions, scoped per workspace
|
||||||
|
* acadenice_role_permission — m2m role -> permission_key
|
||||||
|
* acadenice_user_role — m2m user -> role (workspace-scoped)
|
||||||
|
*
|
||||||
|
* The catalogue of valid permission_key values is owned by
|
||||||
|
* `apps/server/src/core/acadenice/rbac/permissions-catalog.ts`. We do not
|
||||||
|
* enforce the catalogue at the SQL level (varchar) so that re-running the
|
||||||
|
* migration on an older app build never breaks; the service layer validates
|
||||||
|
* every write against the TS catalogue.
|
||||||
|
*
|
||||||
|
* Idempotence : every CREATE uses `ifNotExists` so this migration is safe to
|
||||||
|
* apply on a partially-migrated database (typical when a deploy retries).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('acadenice_role')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('id', 'uuid', (col) =>
|
||||||
|
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||||
|
)
|
||||||
|
.addColumn('workspace_id', 'uuid', (col) =>
|
||||||
|
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('name', 'varchar(120)', (col) => col.notNull())
|
||||||
|
.addColumn('description', 'text')
|
||||||
|
.addColumn('is_system_role', 'boolean', (col) =>
|
||||||
|
col.notNull().defaultTo(false),
|
||||||
|
)
|
||||||
|
.addColumn('created_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.addUniqueConstraint('acadenice_role_workspace_name_unique', [
|
||||||
|
'workspace_id',
|
||||||
|
'name',
|
||||||
|
])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_acadenice_role_workspace')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('acadenice_role')
|
||||||
|
.column('workspace_id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('acadenice_role_permission')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('role_id', 'uuid', (col) =>
|
||||||
|
col.notNull().references('acadenice_role.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('permission_key', 'varchar(120)', (col) => col.notNull())
|
||||||
|
.addPrimaryKeyConstraint('acadenice_role_permission_pk', [
|
||||||
|
'role_id',
|
||||||
|
'permission_key',
|
||||||
|
])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_acadenice_role_permission_key')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('acadenice_role_permission')
|
||||||
|
.column('permission_key')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('acadenice_user_role')
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn('user_id', 'uuid', (col) =>
|
||||||
|
col.notNull().references('users.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('role_id', 'uuid', (col) =>
|
||||||
|
col.notNull().references('acadenice_role.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('workspace_id', 'uuid', (col) =>
|
||||||
|
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('assigned_by', 'uuid', (col) =>
|
||||||
|
col.references('users.id').onDelete('set null'),
|
||||||
|
)
|
||||||
|
.addColumn('assigned_at', 'timestamptz', (col) =>
|
||||||
|
col.notNull().defaultTo(sql`now()`),
|
||||||
|
)
|
||||||
|
.addPrimaryKeyConstraint('acadenice_user_role_pk', ['user_id', 'role_id'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_acadenice_user_role_user_ws')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('acadenice_user_role')
|
||||||
|
.columns(['user_id', 'workspace_id'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('idx_acadenice_user_role_role')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('acadenice_user_role')
|
||||||
|
.column('role_id')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('acadenice_user_role').ifExists().execute();
|
||||||
|
await db.schema
|
||||||
|
.dropTable('acadenice_role_permission')
|
||||||
|
.ifExists()
|
||||||
|
.execute();
|
||||||
|
await db.schema.dropTable('acadenice_role').ifExists().execute();
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue