# 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: }`. - `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_` 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).