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 plusbulk-import,count,autocomplete,deleteByIds; appliescheckCrudPermissions('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->200true. Request body:{ data: <PermissionInput> }.POST /api/permissions/bulk-import->200true. Multipart CSV upload. Missing file raisesValidationError('importer.errors.invalidFileEmpty').PUT /api/permissions/:id->200true. The controller callsService.update(req.body.data, req.body.id, ...)(readsreq.body.id, notreq.params.id).DELETE /api/permissions/:id->200true.POST /api/permissions/deleteByIds->200true. Request body:{ data: string[] }.GET /api/permissions->200{ rows, count }. When?filetype=csv, responds with a CSV attachment of fieldsid, name.GET /api/permissions/count->200{ rows: [], count }.GET /api/permissions/autocomplete->200array of{ id, label }(label is the permissionname, ordered byname 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:
- Self-access bypass:
currentUser.id === req.params.idorcurrentUser.id === req.body.id. - Custom (per-user) permissions: the current user's
custom_permissionscontains a record whosenameequals the required permission. - Effective-role permissions: the effective role is the user's
app_roleif present, otherwise thePublicrole (SPECIAL_ROLE_NAMES.PUBLIC), which is fetched once at module load viaRolesDBApi.findByand cached (publicRoleCache); if the cache is empty it is fetched synchronously as a fallback.resolveRolePermissionsreads the role's permission names from an eager-loadedpermissionsarray when present, otherwise callsgetPermissions(). Access is granted when that listincludes(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).
updateraisesValidationError('permissionsNotFound')when the record does not exist.- Bulk import parses the uploaded CSV (
csv-parser), thenbulkCreates withignoreDuplicates: trueand staggeredcreatedAt(BULK_IMPORT_TIMESTAMP_STEP_MS). - The
Publicrole permission cache incheck-permissions.tsis 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
rolesentity (a role'spermissionsprovide the effective-role grants) andusers.md(a user'scustom_permissionsand assignedapp_role, eager-loaded inUsersDBApi.findByfor per-request authorization). auth-profile.md(the profile DTO carriesapp_role_permissionsandcustom_permissions, the same permission names enforced here).