40227-vm/backend/docs/permissions.md
2026-06-10 18:27:19 +02:00

6.3 KiB

Permissions Backend

Purpose

permissions is the catalog of named permission strings that authorize API access. Each permission is a single name (e.g. CREATE_USERS, READ_DOCUMENTS). Permissions are attached to roles (a role's permissions) and to individual users (a user's custom_permissions); the authorization middleware checks them per request. This slice manages the permission catalog itself (CRUD); the consumption of permission names happens in middlewares/check-permissions.ts.

Slice Files (by layer)

  • Route: src/routes/permissions.ts (CRUD plus bulk-import, count, autocomplete, deleteByIds; applies checkCrudPermissions('permissions') to every route).
  • Controller: src/api/controllers/permissions.controller.ts (CSV export, file upload for bulk import).
  • Service (BLL): src/services/permissions.ts (transactional writes; CSV buffer parsing).
  • Repository (DAL): src/db/api/permissions.ts.
  • Model: src/db/models/permissions.ts.
  • Consumer middleware: src/middlewares/check-permissions.ts (how permission names are read and enforced).
  • Shared used: db/api/shared/repository.ts (removeRecord, deleteRecordsByIds), db/utils.ts (Utils.uuid, Utils.ilike), shared/constants/pagination.ts (resolvePagination), shared/constants/database.ts (BULK_IMPORT_TIMESTAMP_STEP_MS), shared/constants/roles.ts (SPECIAL_ROLE_NAMES), shared/csv.ts (toCsv), middlewares/upload.ts (processFile), db/api/roles.ts (RolesDBApi, used by the middleware), shared/object.ts (isRecord), shared/logger.ts, shared/errors/validation.ts.

API

All routes are mounted under /api/permissions and require JWT authentication (src/index.ts). Every route passes checkCrudPermissions('permissions'), requiring ${METHOD}_PERMISSIONS.

  • POST /api/permissions -> 200 true. Request body: { data: <PermissionInput> }.
  • POST /api/permissions/bulk-import -> 200 true. Multipart CSV upload. Missing file raises ValidationError('importer.errors.invalidFileEmpty').
  • PUT /api/permissions/:id -> 200 true. The controller calls Service.update(req.body.data, req.body.id, ...) (reads req.body.id, not req.params.id).
  • DELETE /api/permissions/:id -> 200 true.
  • POST /api/permissions/deleteByIds -> 200 true. Request body: { data: string[] }.
  • GET /api/permissions -> 200 { rows, count }. When ?filetype=csv, responds with a CSV attachment of fields id, name.
  • GET /api/permissions/count -> 200 { rows: [], count }.
  • GET /api/permissions/autocomplete -> 200 array of { id, label } (label is the permission name, ordered by name ASC).
  • GET /api/permissions/:id -> 200, a single plain record.

Access Rules

CRUD on the permissions catalog is gated solely by checkCrudPermissions('permissions') (${METHOD}_PERMISSIONS). There is no tenant scope or additional role-name gate inside the service or repository.

How permission names are consumed (check-permissions.ts)

checkCrudPermissions(name) derives a permission string from the HTTP method and entity name: ${METHOD_MAP[req.method]}_${name.toUpperCase()} where METHOD_MAP is POST→CREATE, GET→READ, PUT→UPDATE, PATCH→UPDATE, DELETE→DELETE. For example a GET on the users router requires READ_USERS; a POST on documents requires CREATE_DOCUMENTS. It then delegates to checkPermissions(permissionName).

checkPermissions(permission) allows the request when any of the following holds, in order:

  1. Self-access bypass: currentUser.id === req.params.id or currentUser.id === req.body.id.
  2. Custom (per-user) permissions: the current user's custom_permissions contains a record whose name equals the required permission.
  3. Effective-role permissions: the effective role is the user's app_role if present, otherwise the Public role (SPECIAL_ROLE_NAMES.PUBLIC), which is fetched once at module load via RolesDBApi.findBy and cached (publicRoleCache); if the cache is empty it is fetched synchronously as a fallback. resolveRolePermissions reads the role's permission names from an eager-loaded permissions array when present, otherwise calls getPermissions(). Access is granted when that list includes(permission).

On denial the middleware logs the role name and the denied permission and calls next(new ValidationError('auth.forbidden')). A role object lacking both a permissions array and a getPermissions() method, or a missing/unfetchable Public role, surfaces an Internal Server Error via next(new Error(...)).

Tenant Scope

None. The permission catalog is global; findAll applies no organization filter.

Data Contract

Model columns (src/db/models/permissions.ts): id (UUID PK), name (text), importHash (unique), createdAt, updatedAt, deletedAt (paranoid soft-delete), createdById, updatedById. Associations: belongsTo users as createdBy/updatedBy only. The model itself declares no association to roles or users for the permission grants; those join tables are declared on the roles and users models (e.g. users.belongsToMany(permissions) through usersCustom_permissionsPermissions).

List filters (findAll): id (uuid), name (ILIKE), active, createdAtRange, plus field/sort (default createdAt desc) and limit/page pagination.

Behavior / Notes

  • All mutations run inside a manual Sequelize transaction (commit on success, rollback on error).
  • update raises ValidationError('permissionsNotFound') when the record does not exist.
  • Bulk import parses the uploaded CSV (csv-parser), then bulkCreates with ignoreDuplicates: true and staggered createdAt (BULK_IMPORT_TIMESTAMP_STEP_MS).
  • The Public role permission cache in check-permissions.ts is loaded at application startup; a startup failure to load it is logged but does not stop boot (the synchronous fallback covers per-request misses).

Tests

None yet (no permissions unit/e2e test under src/).

  • Backend slices: the roles entity (a role's permissions provide the effective-role grants) and users.md (a user's custom_permissions and assigned app_role, eager-loaded in UsersDBApi.findBy for per-request authorization).
  • auth-profile.md (the profile DTO carries app_role_permissions and custom_permissions, the same permission names enforced here).