Naalya Handbook
Roles & Permissions Admin

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.

A role is inert until it's assigned to a user. This page covers how an assignment carries its scope, how some assignments happen automatically, and why you never encode a campus into a permission directly.

Assignment endpoints

Roles attach to users through the user_x_role table, never to permissions directly. The endpoints hang under the user:

Method · PathoperationIdGuard
POST /users/:userId/rolesassignRoleMANAGE ROLE
GET /users/:userId/roleslistUserRolesREAD ROLE
DELETE /users/:userId/roles/:assignmentIdremoveRoleAssignmentMANAGE ROLE

Scope on the assignment

The body that creates an assignment carries the scope — and this is the crux of the whole design. An assignment isn't just "user has role"; it's "user has role at this level". ScopeLevel has exactly two values, SCHOOL and CAMPUS:

apps/server/src/app/role/dto/assign-role.dto.ts
export class AssignRoleDto {
  @IsUUID() @IsNotEmpty() roleId: string;
  @IsEnum(ScopeLevel) @IsNotEmpty() scopeLevel: ScopeLevel;   // SCHOOL | CAMPUS
  @IsUUID() @IsOptional() campusId?: string;
}

UserRoleService.assignRole enforces a tight set of rules before it writes the row. The target user must be STAFF — roles never go on students or guardians. The role must exist. And the scope/campus pairing must be consistent:

apps/server/src/app/role/user-x-role.service.ts
if (user.type !== UserType.STAFF) {
  throw new ForbiddenException('Roles can only be assigned to staff users');
}
// …role must exist…
if (dto.scopeLevel === ScopeLevel.CAMPUS) {
  if (!dto.campusId) {
    throw new BadRequestException('campusId is required when scopeLevel is campus');
  }
  const campus = await this.campusService.findOne(dto.campusId);
  if (!campus) throw new NotFoundException('Campus not found');
}

For a CAMPUS assignment a real campusId is required; for SCHOOL it's omitted. The uniqueness constraint is on (userId, roleId, campusId), so a duplicate for the same scope gets a ConflictException, and the assigner is recorded as assignedBy for the audit trail.

Scope lives on the assignment — that's how 'this campus only' happens

A CAMPUS-scoped assignment is what makes a role's permissions apply to only one campus's rows. The permissions on the role stay generic (READ STUDENT); the assignment supplies the campus. At request time the ability factory turns that campusId into a { campusId: X } condition — see RBAC & Scopes. Nothing about the campus is stored on the permission itself.

Removal is guarded by SuperAdminProtectionService, which keeps the system from locking itself out: you can't remove your own Super Admin role, and the system always keeps at least one user holding Super Admin. Try to drop the last one and you get a ConflictException.

Automatic assignment

Not every assignment is an admin clicking a button. UserRoleService.assignDefaultStaffRole(userId, campusHint?, isAdmin?) runs when a staff user is created. It attaches 'Staff' by default, or 'Super Admin' when isAdmin is set, and — neatly — if a campusHint resolves to a real campus it makes the assignment CAMPUS-scoped; otherwise SCHOOL:

apps/server/src/app/role/user-x-role.service.ts
const roleName = isAdmin ? 'Super Admin' : 'Staff';
const role = await this.roleRepository.findOne({ where: { name: roleName } });
// …
let scopeLevel = ScopeLevel.SCHOOL;
let campusId: string | undefined;
if (campusHint) {
  const campus = await this.campusRepository.findByLooseName(campusHint);
  if (campus) { scopeLevel = ScopeLevel.CAMPUS; campusId = campus.id; }
}

This is the by-name lookup that makes system-role name immutability matter: if the school's 'Staff' role were renamed, this would log a warning and skip — leaving a new staff member with no role at all. (Whether a staff member becomes a Super Admin on login is decided by the allow-lists covered in Multi-Tenancy; this method is just the mechanism that writes the assignment.)

Conditions, not campuses

A natural question: "how do I give someone read students in campus X?" You don't attach a campus to a permission. You assign READ STUDENT to a role and grant that role at CAMPUS scope with campusId: X. The { campusId: X } condition is derived at request time, by the ability factory, from the assignment's scope. That's why role_x_permission doesn't store a campus — the scope machinery does it, and storing it twice would only let the two disagree.

There is one narrow exception, and it's deliberately fenced in. A role permission may carry explicit conditions (a RoleConditionTriple[] stored as jsonb) for a small, code-defined allow-list of filterable fields — currently just filtering STUDENT by department. Every condition is validated server-side by validateRoleConditions against FILTERABLE_FIELDS, and that allow-list intentionally excludes campus fields because campus is already handled by the assignment:

libs/shared/src/permission/role-conditions/filterable-fields.ts
// Do NOT list a resource's campus-scope field here (campus is scoped via the
// assignment; a stored filter on it would collide).
export const FILTERABLE_FIELDS: ReadonlyMap<Resource, readonly FieldSpec[]> = new Map([
  [Resource.STUDENT, [{ field: 'department', operators: ['eq', 'in'], valueType: 'string' }]],
]);

The takeaway: scope (campus) comes from the assignment; the rare structural filter (like department) is an explicit, allow-listed condition on the permission. Both keep the assignable surface small — pick actions × resources, plus a scope — while still producing rich, row-level access.

Assign to a campus

Now grant a role to a staff user. To restrict it to one campus, send scopeLevel: "campus" and a real campusId:

terminal
curl -X POST https://<host>/api/v1/users/<userId>/roles \
  -H "Authorization: Bearer <staff-token-with-manage-role>" \
  -H "Content-Type: application/json" \
  -d '{
    "roleId": "<librarian-role-id>",
    "scopeLevel": "campus",
    "campusId": "<campus-id>"
  }'

For a school-wide grant, drop campusId and send scopeLevel: "school". The service checks the user is STAFF, the role exists, and (for campus scope) the campus exists. A second identical assignment for the same (userId, roleId, campusId) returns 409. From here, the user's READ STUDENT applies only to students in that campus — and you never typed "campusId" into a permission.

Gotchas

A handful of things that trip people up, all grounded in the rules above.

System roles: editable permissions, frozen names

isSystem: true blocks renaming (ForbiddenException) and deleting the role — not editing its permissions. Other code (the SystemRoleGuard, automatic 'Staff' / 'Super Admin' assignment) matches roles by literal name, so a renamed system role silently breaks those lookups. Tune permissions freely; leave the name alone.

Roles are per-school — there is no global 'Staff'

Every tenant has its own cloned copy of each role, unique on (school_id, name). Edit "Head teacher" in one school and no other school is affected. Cloning is idempotent, so provisioning or re-seeding never duplicates or resets an existing role.

Scope goes on the assignment, never the permission

To limit access to a campus, set scopeLevel: "campus" + campusId on the assignmentPOST /users/:id/roles. The permission itself stays generic. campusId is required when scopeLevel is campus (else 400) and must point at a real campus (else 404). Don't try to encode a campus into a role_x_permission row; the field isn't there, and the ability factory derives it from the assignment.

MANAGE ROLE is a near-admin grant

Holding MANAGE ROLE lets a staff member assign any role at any scope — there is no check that the assigner already holds the permissions they're granting, or that they're confined to their own campus. Treat it as a privileged grant, close to admin.

Where to go next

On this page