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

121 lines
6.9 KiB
Markdown

# Roles Backend
## Purpose
`roles` is the catalog of access roles. It is wired through the shared generic-CRUD router,
controller, and service factories, but its repository is **custom**: a role is not a plain record,
it carries a many-to-many link to `permissions`, and the repository manages that link on every
create/update and eager-loads it on reads. Roles are what `users` reference as their app role, so
this slice underpins the permission checks documented in `permissions.md` and `auth-profile.md`.
## Slice Files (by layer)
- Route: `src/routes/roles.ts``createCrudRouter(controller, { permission: 'roles' })`.
- Controller: `src/api/controllers/roles.controller.ts``createCrudController(service, { csvFields: ['id', 'name'] })`.
- Service (BLL): `src/services/roles.ts``createCrudService(DbApi, { notFoundCode: 'rolesNotFound' })`.
- Repository (DAL): `src/db/api/roles.ts` (`RolesDBApi`) — custom; see Behavior. `remove`/`deleteByIds`
delegate to `db/api/shared/repository.ts`; `create`/`update`/`findBy`/`findAll`/`findAllAutocomplete`
are entity-specific (autocomplete is implemented inline, not via the shared `autocompleteByField`).
- Model: `src/db/models/roles.ts`.
- Shared used: CRUD factories (`services/shared/crud-service.ts`,
`api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts`), repository helpers
(`db/api/shared/repository.ts``removeRecord`, `deleteRecordsByIds`),
`shared/constants/pagination.ts` (`resolvePagination`), `shared/config` (`config.roles.super_admin`),
`db/utils` (`uuid`, `ilike`).
## API
The standard generic-CRUD surface (all under `/api/roles`, JWT + `${METHOD}_ROLES` permission, all
`200`) — see `backend-architecture.md` for the shared contract:
- `POST /` — body `{ data }`, returns `true`.
- `POST /bulk-import` — multipart CSV file, returns `true`.
- `PUT /:id` — body `{ data, id }` (the service reads the id from the **body**, not the path param),
returns `true`.
- `DELETE /:id` — returns `true`.
- `POST /deleteByIds` — body `{ data: string[] }`, returns `true`.
- `GET /` — query filters, returns `{ rows, count }`; `?filetype=csv` streams a CSV of `csvFields`.
- `GET /count` — returns `{ rows: [], count }`.
- `GET /autocomplete``?query&limit&offset`, returns `[{ id, label }]` where `label` is `name`.
- `GET /:id` — returns the record with eager associations (see Data Contract).
`csvFields`: `id`, `name`.
## Access Rules
- JWT required; the whole router is guarded by `checkCrudPermissions('roles')`, deriving
`READ_ROLES` / `CREATE_ROLES` / `UPDATE_ROLES` / `DELETE_ROLES` per HTTP method.
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
- Visibility gate on the super-admin role: in both `findAll` and `findAllAutocomplete`, when the
caller does **not** have `globalAccess`, the where clause is reset to
`name <> config.roles.super_admin`, so non-global callers never see the super-admin role.
## Tenant Scope
- `roles` has no `organizationId` column; the catalog is not tenant-scoped. The only access
partition is the super-admin visibility gate above (driven by `globalAccess`, not by organization).
## Data Contract
Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`):
- `id` (UUID PK), `name` (TEXT, nullable), `globalAccess` (BOOLEAN, not null, default `false`),
`importHash` (STRING(255), unique, nullable), `createdById`, `updatedById`,
`createdAt` / `updatedAt` / `deletedAt`.
Associations:
- `belongsToMany` `permissions` as `permissions` — through table `rolesPermissionsPermissions`,
foreign key `roles_permissionsId`, `constraints: false`.
- `belongsToMany` `permissions` as `permissions_filter` — same through table and foreign key
(`rolesPermissionsPermissions` / `roles_permissionsId`), used only for filtered list queries.
- `hasMany` `users` as `users_app_role` (foreign key `app_roleId`) — the users whose app role is
this role.
- `belongsTo` `users` as `createdBy` / `updatedBy`.
`findBy` (backing `GET /:id`) returns the plain role plus two eager-loaded keys fetched in a single
`Promise.all`: `users_app_role` (from `getUsers_app_role`) and `permissions` (from `getPermissions`).
List filters (`RolesFilter`): `id`, `name` (ilike), `active`, `globalAccess`, `permissions`
(`|`-separated; matched by permission id or permission name ilike via the `permissions_filter`
include), `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. Default order
is `createdAt desc`.
## Behavior / Notes
- Role <-> permission linkage is the defining custom behavior. `create` builds the role row, then
calls `roles.setPermissions(data.permissions || [])` to replace the through-table rows. `update`
only touches the through table when `data.permissions !== undefined`, calling
`roles.setPermissions(data.permissions)` (a full replace of the role's permission set). `findBy`
reads them back with `roles.getPermissions()`. So a role's permissions are passed as a flat
`string[]` of permission ids on create/update and returned as the eager `permissions` array on
read — this is the main way this slice differs from a plain CRUD entity, whose repository only
manages its own columns.
- `findAll` always eager-loads `permissions` (`required: false`). When the `permissions` filter is
present it additionally joins `permissions_filter` (the second alias over the same through table)
with an `Op.or` of permission id `Op.in` and permission name ilike, and the list uses
`distinct: true` to avoid row multiplication from the join.
- The super-admin gate (see Access Rules) **replaces** the accumulated where clause rather than
merging into it, so for a non-global caller other filters on the same query are overridden by the
`name <> super_admin` condition (kept here for source accuracy).
- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order; it
does **not** set permissions (only `create`/`update` do).
- List pagination uses the shared `resolvePagination` defaults; `findAllAutocomplete` orders by
`name ASC` and selects only `id`/`name`.
- Note: `RolesFilter` accepts an `active` flag and `findAll` filters on an `active` column the
`roles` model does not declare; it is currently inert (kept for source accuracy).
- **Seeded globalAccess roles**: The seeder (`20200430130760-user-roles.ts`) sets `globalAccess: true`
for both `Super Administrator` and `Administrator` roles. Users with these roles can access data
across all organizations without an `organizationId` filter. Services use `getOrganizationIdOrGlobal`
and `hasGlobalAccess` from `services/shared/access.ts` to check for and honor global access.
## Tests
None yet.
## Related
- Generic-CRUD contract: `backend-architecture.md`.
- Related slices: `permissions.md` (the linked entity), `users.md` (references a role via
`app_roleId`), `auth-profile.md` (how the signed-in user's role/permissions are resolved).