40227-vm/backend/docs/permissions.md
2026-06-12 06:55:35 +02:00

133 lines
7.5 KiB
Markdown

# 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` (`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 `assessments` requires `CREATE_ASSESSMENTS`. It
then delegates to `checkPermissions(permissionName)`.
`checkPermissions(permission)` allows the request when any of the following holds, in order:
1. Self-access bypass: read-only — a `GET` whose `currentUser.id === req.params.id` (the
`req.body.id` bypass was removed; profile self-edits go through `/api/auth/profile`).
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 `guest` role (`ROLE_NAMES.GUEST`), 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(...))`.
## Product-feature permissions (§3.2)
Besides the `${METHOD}_${ENTITY}` CRUD permissions, the catalog includes product-feature
permissions defined once in `shared/constants/product-permissions.ts`: a `READ_<MODULE>` per
product page and the three action permissions `FILL_ATTENDANCE` / `TAKE_QUIZ` / `ACK_READ_RECEIPT`.
The role seeder grants them per role (full-access roles get all; campus staff get their page set;
external roles get the external pages). The feature routes enforce them with the **same**
`checkPermissions(name)` middleware: page reads and the special actions call it directly
(e.g. `GET /api/frame_entries``READ_FRAME`, `PUT /api/campus_attendance/summaries/...`
`FILL_ATTENDANCE`, `POST /api/safety_quiz_results``TAKE_QUIZ`). Because that middleware honors
`custom_permissions` (step 2 above), a director can extend a single user's feature access by
granting one of these names. Manager-only writes (FRAME/walkthrough/communications/content-catalog
editing, the staff/attendance reports) remain gated by role inside their services until dedicated
`MANAGE_*` permissions are introduced.
## 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 `bulkCreate`s 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/`).
## Related
- 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).