Managing Roles
The role lifecycle — create, read, update, delete — plus why editing a role's permissions is a replace-all operation on its own endpoint.
Once a school's roles exist, admins create new ones and tune existing ones through the role controller. This page covers the CRUD surface and the one editing rule that bites people: permissions are replaced wholesale, never patched.
Role CRUD
The role lifecycle lives in role.controller.ts. Every write needs MANAGE ROLE; reads only need the caller to be staff. Note that permissions are split out onto their own endpoint — PATCH never touches them.
| Method · Path | operationId | Guard |
|---|---|---|
POST /roles | createRole | MANAGE ROLE |
GET /roles | listRoles | staff (@RequireUserType(STAFF)) |
GET /roles/:id | getRole | staff |
PATCH /roles/:id | updateRole (name / description only) | MANAGE ROLE |
PUT /roles/:id/permissions | updateRolePermissions (replace all) | MANAGE ROLE |
DELETE /roles/:id | deleteRole | MANAGE ROLE |
Creating a role
createRole takes a CreateRoleDto — a name, optional description, and a permissions array of { action, resource } (each pair may also carry optional conditions, more on those in Assigning Roles). The service rejects a duplicate name with ConflictException, validates every permission with isValidPermission, then writes the RoleEntity (isSystem: false, stamped with createdBy) and its role_x_permission rows in one transaction:
async create(dto: CreateRoleDto, createdBy: string): Promise<RoleEntity> {
const existing = await this.roleRepository.findOne({ where: { name: dto.name } });
if (existing) throw new ConflictException(`Role "${dto.name}" already exists`);
for (const perm of dto.permissions) {
if (!isValidPermission(perm.action, perm.resource)) {
throw new BadRequestException(`Invalid permission: ${perm.action} ${perm.resource}`);
}
validateRoleConditions(perm.resource, perm.conditions);
}
const schoolId = getTenantContext()?.schoolId;
if (!schoolId) throw new BadRequestException('A school context is required to create a role.');
// …transaction: save role, then save role_x_permission rows
}That getTenantContext() check is doing quiet but important work. Role creation runs inside a raw transaction (manager.getRepository), which bypasses BaseRepository's automatic school_id stamping. Because role.school_id is NOT NULL, the service resolves and sets the schoolId itself — and a platform operator with no school context gets a clean 400 rather than a database-level 500.
Deleting a role
deleteRole has two guards beyond the permission: it refuses to delete a system role (see System-Role Cloning), and it refuses to delete a role that still has assignments, returning ConflictException — you must unassign everyone first.
Editing permissions
Permissions are not edited through PATCH /roles/:id. That route only touches name and description. The permission set is replaced wholesale through PUT /roles/:id/permissions with an UpdatePermissionsDto. The service loads the role, checks access, validates every pair, then in a transaction deletes every existing role_x_permission row and recreates them from the payload:
await this.dataSource.transaction(async (manager) => {
const permRepo = manager.getRepository(RoleXPermissionEntity);
await permRepo.delete({ roleId: role.id }); // wipe…
if (dto.permissions.length > 0) {
const perms = dto.permissions.map((p) =>
permRepo.create({ roleId: role.id, action: p.action, resource: p.resource, conditions: p.conditions ?? null }),
);
await permRepo.save(perms); // …and recreate from the payload
}
});This is a replace-all operation, not a patch. The list you send becomes the role's complete permission set. Forget to include a permission the role already had and you've just removed it. So the frontend pattern is: fetch the current set, edit it in memory, and PUT the entire desired set back — every time.
Walkthrough: create
Roles are created with a name and an initial permission bundle in one call. Pick permissions only from the registry (the UI gets them from getPermissionsRegistry). This creates a "Librarian" role that can read and list students:
curl -X POST https://<host>/api/v1/roles \
-H "Authorization: Bearer <staff-token-with-manage-role>" \
-H "Content-Type: application/json" \
-d '{
"name": "Librarian",
"description": "Can view students and classes",
"permissions": [
{ "action": "read", "resource": "student" },
{ "action": "list", "resource": "student" },
{ "action": "read", "resource": "class" }
]
}'The caller needs MANAGE ROLE. Every pair runs through isValidPermission; an invalid one fails the whole request with 400. A name that already exists in this school fails with 409. On success you get the new role, isSystem: false, scoped to your school.
Walkthrough: add a permission
Remember: editing permissions is replace-all, so fetch the current set first, then PUT the full new set. Read the role:
curl https://<host>/api/v1/roles/<roleId> \
-H "Authorization: Bearer <staff-token>"Take the permissions it returns, add the new pair (here, update class), and send the entire list back:
curl -X PUT https://<host>/api/v1/roles/<roleId>/permissions \
-H "Authorization: Bearer <staff-token-with-manage-role>" \
-H "Content-Type: application/json" \
-d '{
"permissions": [
{ "action": "read", "resource": "student" },
{ "action": "list", "resource": "student" },
{ "action": "read", "resource": "class" },
{ "action": "update", "resource": "class" }
]
}'Omitting the three original pairs would have deleted them — replace-all takes the payload literally. This works on system roles too: their permissions are editable even though their names aren't.
Editing permissions is replace-all
PUT /roles/:id/permissions deletes every existing row and recreates from your payload. It is not a diff. Always send the complete desired set, or you'll silently drop the permissions you left out.
Where to go next
Assigning Roles
Now that the role exists, grant it to a staff user with a campus scope.
System-Role Cloning
Why system-role names are frozen even though their permissions are editable.
Roles & Permissions Admin
Back to the overview: the permission registry and the role chain.
Auditing
Every role write is audited — here's how the snapshots work.
System-Role Cloning
Roles are per-school copies cloned from a template — how cloneSystemRolesForSchool works, where it fires, and why system-role names are frozen.
Assigning Roles
Attaching a role to a staff user with a campus scope — the assign endpoint, automatic assignment, why scope never lives on a permission, and gotchas.