128 lines
7.4 KiB
Markdown
128 lines
7.4 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), `scope` (ENUM
|
|
`system | organization | campus | external | guest`, NOT NULL),
|
|
`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 roles**: The seeder (`20200430130760-user-roles.ts`) creates the 11 first-class roles from
|
|
`ROLE_DEFINITIONS` (`shared/constants/roles.ts`), each with its `scope`. `globalAccess: true` is set
|
|
for the two system-scope roles (`super_admin`, `system_admin`); their requests bypass both
|
|
per-permission checks (`check-permissions.ts`) and the `organizationId` filter. Org/campus roles
|
|
(`owner`, `superintendent`, `director`, `office_manager`, `teacher`, `support_staff`) are constrained
|
|
to their tenant/campus by scoping; `student`, `guardian`, and the unauthenticated-fallback `guest`
|
|
have no entity-CRUD permissions. The seeder also assigns roles to the seeded users and writes the
|
|
preset role→permission matrix. Services use `getOrganizationIdOrGlobal` / `hasGlobalAccess`
|
|
(`services/shared/access.ts`) to 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).
|