From bcd861126f93f1ee5d6d07aee5de1b8a3aef528e Mon Sep 17 00:00:00 2001 From: Corentin Date: Thu, 7 May 2026 22:26:21 +0200 Subject: [PATCH] feat(rbac): R2.1 backend RBAC dynamique multi-roles avec catalogue + 5 roles seed + JWT enrichi --- ACADENICE_PATCHES.md | 148 +++++++ .../controllers/permissions.controller.ts | 17 + .../rbac/controllers/roles.controller.ts | 117 ++++++ .../rbac/controllers/user-roles.controller.ts | 112 ++++++ .../acadenice/rbac/dto/assign-role.dto.ts | 9 + .../acadenice/rbac/dto/create-role.dto.ts | 25 ++ .../src/core/acadenice/rbac/dto/role.dto.ts | 28 ++ .../acadenice/rbac/dto/update-role.dto.ts | 18 + .../rbac/guards/permissions.guard.ts | 75 ++++ .../guards/require-permission.decorator.ts | 23 ++ .../acadenice/rbac/permissions-catalog.ts | 210 ++++++++++ .../src/core/acadenice/rbac/rbac.module.ts | 32 ++ .../core/acadenice/rbac/repos/role.repo.ts | 235 +++++++++++ .../acadenice/rbac/repos/user-role.repo.ts | 104 +++++ .../acadenice/rbac/services/role.service.ts | 377 ++++++++++++++++++ .../acadenice/rbac/services/seed.service.ts | 157 ++++++++ .../rbac/spec/permissions.guard.spec.ts | 105 +++++ .../acadenice/rbac/spec/role.service.spec.ts | 182 +++++++++ .../acadenice/rbac/spec/seed.service.spec.ts | 102 +++++ apps/server/src/core/auth/dto/jwt-payload.ts | 10 + .../src/core/auth/services/token.service.ts | 41 ++ apps/server/src/core/auth/token.module.ts | 4 +- apps/server/src/core/core.module.ts | 2 + .../20260507T120000-create-acadenice-rbac.ts | 120 ++++++ 24 files changed, 2252 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts create mode 100644 apps/server/src/core/acadenice/rbac/controllers/roles.controller.ts create mode 100644 apps/server/src/core/acadenice/rbac/controllers/user-roles.controller.ts create mode 100644 apps/server/src/core/acadenice/rbac/dto/assign-role.dto.ts create mode 100644 apps/server/src/core/acadenice/rbac/dto/create-role.dto.ts create mode 100644 apps/server/src/core/acadenice/rbac/dto/role.dto.ts create mode 100644 apps/server/src/core/acadenice/rbac/dto/update-role.dto.ts create mode 100644 apps/server/src/core/acadenice/rbac/guards/permissions.guard.ts create mode 100644 apps/server/src/core/acadenice/rbac/guards/require-permission.decorator.ts create mode 100644 apps/server/src/core/acadenice/rbac/permissions-catalog.ts create mode 100644 apps/server/src/core/acadenice/rbac/rbac.module.ts create mode 100644 apps/server/src/core/acadenice/rbac/repos/role.repo.ts create mode 100644 apps/server/src/core/acadenice/rbac/repos/user-role.repo.ts create mode 100644 apps/server/src/core/acadenice/rbac/services/role.service.ts create mode 100644 apps/server/src/core/acadenice/rbac/services/seed.service.ts create mode 100644 apps/server/src/core/acadenice/rbac/spec/permissions.guard.spec.ts create mode 100644 apps/server/src/core/acadenice/rbac/spec/role.service.spec.ts create mode 100644 apps/server/src/core/acadenice/rbac/spec/seed.service.spec.ts create mode 100644 apps/server/src/database/migrations/20260507T120000-create-acadenice-rbac.ts diff --git a/ACADENICE_PATCHES.md b/ACADENICE_PATCHES.md index 802c2383..7d0a1e30 100644 --- a/ACADENICE_PATCHES.md +++ b/ACADENICE_PATCHES.md @@ -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::ws:`). 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, `:*` couvre `:` +- 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::ws:` (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) - Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream) diff --git a/apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts b/apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts new file mode 100644 index 00000000..e7faf80d --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts @@ -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, + })); + } +} diff --git a/apps/server/src/core/acadenice/rbac/controllers/roles.controller.ts b/apps/server/src/core/acadenice/rbac/controllers/roles.controller.ts new file mode 100644 index 00000000..eb06b4ea --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/controllers/roles.controller.ts @@ -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, + ); + } +} diff --git a/apps/server/src/core/acadenice/rbac/controllers/user-roles.controller.ts b/apps/server/src/core/acadenice/rbac/controllers/user-roles.controller.ts new file mode 100644 index 00000000..6265ce97 --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/controllers/user-roles.controller.ts @@ -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 { + const perms = await this.roleService.getUserPermissions( + actorId, + workspaceId, + ); + if (!permissionMatches(perms, 'roles:manage')) { + throw new ForbiddenException( + 'Missing required permission: roles:manage', + ); + } + } +} diff --git a/apps/server/src/core/acadenice/rbac/dto/assign-role.dto.ts b/apps/server/src/core/acadenice/rbac/dto/assign-role.dto.ts new file mode 100644 index 00000000..bf4e2849 --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/dto/assign-role.dto.ts @@ -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[]; +} diff --git a/apps/server/src/core/acadenice/rbac/dto/create-role.dto.ts b/apps/server/src/core/acadenice/rbac/dto/create-role.dto.ts new file mode 100644 index 00000000..541098f1 --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/dto/create-role.dto.ts @@ -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[]; +} diff --git a/apps/server/src/core/acadenice/rbac/dto/role.dto.ts b/apps/server/src/core/acadenice/rbac/dto/role.dto.ts new file mode 100644 index 00000000..7d31abae --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/dto/role.dto.ts @@ -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; +} diff --git a/apps/server/src/core/acadenice/rbac/dto/update-role.dto.ts b/apps/server/src/core/acadenice/rbac/dto/update-role.dto.ts new file mode 100644 index 00000000..1f951691 --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/dto/update-role.dto.ts @@ -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[]; +} diff --git a/apps/server/src/core/acadenice/rbac/guards/permissions.guard.ts b/apps/server/src/core/acadenice/rbac/guards/permissions.guard.ts new file mode 100644 index 00000000..291dbeba --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/guards/permissions.guard.ts @@ -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 { + const clauses = + this.reflector.getAllAndMerge(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; + } +} diff --git a/apps/server/src/core/acadenice/rbac/guards/require-permission.decorator.ts b/apps/server/src/core/acadenice/rbac/guards/require-permission.decorator.ts new file mode 100644 index 00000000..b6621dbb --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/guards/require-permission.decorator.ts @@ -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); diff --git a/apps/server/src/core/acadenice/rbac/permissions-catalog.ts b/apps/server/src/core/acadenice/rbac/permissions-catalog.ts new file mode 100644 index 00000000..9542c4ef --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/permissions-catalog.ts @@ -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. + * - `:*` — covers every action key starting with `:`. + * + * Format of `key` : `:` (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 = [ + { + 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 = 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. + * - `:*` covers every `:` of the same group. + * - Exact key match otherwise. + */ +export function permissionMatches( + granted: ReadonlyArray, + 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}:*`); +} diff --git a/apps/server/src/core/acadenice/rbac/rbac.module.ts b/apps/server/src/core/acadenice/rbac/rbac.module.ts new file mode 100644 index 00000000..5d231910 --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/rbac.module.ts @@ -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 {} diff --git a/apps/server/src/core/acadenice/rbac/repos/role.repo.ts b/apps/server/src/core/acadenice/rbac/repos/role.repo.ts new file mode 100644 index 00000000..5da7b03b --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/repos/role.repo.ts @@ -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 { + const db = dbOrTx(this.db, trx); + const res = await sql` + 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 { + const db = dbOrTx(this.db, trx); + const res = await sql` + 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 { + const db = dbOrTx(this.db, trx); + const res = await sql` + 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 { + const db = dbOrTx(this.db, trx); + const isSystem = params.isSystemRole ?? false; + const description = params.description ?? null; + const res = await sql` + 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 { + const db = dbOrTx(this.db, trx); + // Build the SET clause dynamically. We always bump updated_at. + const fields: ReturnType[] = [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` + 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 { + 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 { + 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 { + 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> { + const out = new Map(); + 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; + } +} diff --git a/apps/server/src/core/acadenice/rbac/repos/user-role.repo.ts b/apps/server/src/core/acadenice/rbac/repos/user-role.repo.ts new file mode 100644 index 00000000..4a7591b3 --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/repos/user-role.repo.ts @@ -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 { + 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 { + 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 { + const db = dbOrTx(this.db, trx); + const res = await sql` + 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 { + 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; + } +} diff --git a/apps/server/src/core/acadenice/rbac/services/role.service.ts b/apps/server/src/core/acadenice/rbac/services/role.service.ts new file mode 100644 index 00000000..ddebc70c --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/services/role.service.ts @@ -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 { + const rows = await this.roleRepo.listByWorkspace(workspaceId); + return rows.map((r) => this.toDto(r)); + } + + async getRoleWithPermissions( + roleId: string, + workspaceId: string, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if (!this.redis) return; + try { + await this.redis.set( + permsCacheKey(userId, workspaceId), + JSON.stringify(perms), + 'EX', + PERMS_CACHE_TTL_SECONDS, + ); + } catch { + // best-effort + } + } +} diff --git a/apps/server/src/core/acadenice/rbac/services/seed.service.ts b/apps/server/src/core/acadenice/rbac/services/seed.service.ts new file mode 100644 index 00000000..614c08d5 --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/services/seed.service.ts @@ -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 = [ + { + 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 { + 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 { + 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 { + 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. + } + } +} diff --git a/apps/server/src/core/acadenice/rbac/spec/permissions.guard.spec.ts b/apps/server/src/core/acadenice/rbac/spec/permissions.guard.spec.ts new file mode 100644 index 00000000..4536faa6 --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/spec/permissions.guard.spec.ts @@ -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; + let roleService: jest.Mocked>; + 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); + }); +}); diff --git a/apps/server/src/core/acadenice/rbac/spec/role.service.spec.ts b/apps/server/src/core/acadenice/rbac/spec/role.service.spec.ts new file mode 100644 index 00000000..15264526 --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/spec/role.service.spec.ts @@ -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 = jest.Mocked>; + +describe('AcadeniceRoleService', () => { + let service: AcadeniceRoleService; + let roleRepo: RepoMock; + let userRoleRepo: RepoMock; + + const WS = 'ws-1'; + const sampleRow = (overrides: Partial = {}) => ({ + 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:*']); + }); + }); +}); diff --git a/apps/server/src/core/acadenice/rbac/spec/seed.service.spec.ts b/apps/server/src/core/acadenice/rbac/spec/seed.service.spec.ts new file mode 100644 index 00000000..9afb44d8 --- /dev/null +++ b/apps/server/src/core/acadenice/rbac/spec/seed.service.spec.ts @@ -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>; + // 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); + }); +}); diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index b3ccda70..303fc1dc 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -14,6 +14,16 @@ export type JwtPayload = { workspaceId: string; type: 'access'; 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 = { diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index 1cc10a07..1dad8ac6 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -1,6 +1,9 @@ import { ForbiddenException, + Inject, Injectable, + Logger, + Optional, UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; @@ -19,12 +22,18 @@ import { } from '../dto/jwt-payload'; import { User } from '@docmost/db/types/entity.types'; import { isUserDisabled } from '../../../common/helpers'; +import { AcadeniceRoleService } from '../../acadenice/rbac/services/role.service'; @Injectable() export class TokenService { + private readonly logger = new Logger(TokenService.name); + constructor( private jwtService: JwtService, private environmentService: EnvironmentService, + @Optional() + @Inject(AcadeniceRoleService) + private readonly acadeniceRoleService?: AcadeniceRoleService, ) {} async generateAccessToken(user: User, sessionId: string): Promise { @@ -38,10 +47,42 @@ export class TokenService { workspaceId: user.workspaceId, type: JwtType.ACCESS, sessionId, + acadenice_permissions: await this.resolveAcadenicePermissions( + user.id, + user.workspaceId, + ), }; 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 { + 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 { if (isUserDisabled(user)) { throw new ForbiddenException(); diff --git a/apps/server/src/core/auth/token.module.ts b/apps/server/src/core/auth/token.module.ts index 01ae8cd7..33ac3eae 100644 --- a/apps/server/src/core/auth/token.module.ts +++ b/apps/server/src/core/auth/token.module.ts @@ -3,9 +3,11 @@ import { JwtModule } from '@nestjs/jwt'; import type { StringValue } from 'ms'; import { EnvironmentService } from '../../integrations/environment/environment.service'; import { TokenService } from './services/token.service'; +import { AcadeniceRbacModule } from '../acadenice/rbac/rbac.module'; @Module({ imports: [ + AcadeniceRbacModule, JwtModule.registerAsync({ useFactory: async (environmentService: EnvironmentService) => { return { @@ -20,6 +22,6 @@ import { TokenService } from './services/token.service'; }), ], providers: [TokenService], - exports: [TokenService], + exports: [TokenService, AcadeniceRbacModule], }) export class TokenModule {} diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index 8fbc0c88..5670b2ac 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -23,6 +23,7 @@ import { WatcherModule } from './watcher/watcher.module'; import { FavoriteModule } from './favorite/favorite.module'; import { SessionModule } from './session/session.module'; import { OidcModule } from './auth/oidc/oidc.module'; +import { AcadeniceRbacModule } from './acadenice/rbac/rbac.module'; import { ClsMiddleware } from 'nestjs-cls'; @Module({ @@ -44,6 +45,7 @@ import { ClsMiddleware } from 'nestjs-cls'; WatcherModule, SessionModule, OidcModule, + AcadeniceRbacModule, ], }) export class CoreModule implements NestModule { diff --git a/apps/server/src/database/migrations/20260507T120000-create-acadenice-rbac.ts b/apps/server/src/database/migrations/20260507T120000-create-acadenice-rbac.ts new file mode 100644 index 00000000..499ebe29 --- /dev/null +++ b/apps/server/src/database/migrations/20260507T120000-create-acadenice-rbac.ts @@ -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): Promise { + 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): Promise { + 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(); +}