Naalya Handbook
Roles & Permissions Admin

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 · PathoperationIdGuard
POST /rolescreateRoleMANAGE ROLE
GET /roleslistRolesstaff (@RequireUserType(STAFF))
GET /roles/:idgetRolestaff
PATCH /roles/:idupdateRole (name / description only)MANAGE ROLE
PUT /roles/:id/permissionsupdateRolePermissions (replace all)MANAGE ROLE
DELETE /roles/:iddeleteRoleMANAGE 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:

apps/server/src/app/role/role.service.ts
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:

apps/server/src/app/role/role.service.ts
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:

terminal
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:

terminal
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:

terminal
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

On this page