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 · Path | operationId | Guard |
|---|---|---|
POST /users/:userId/roles | assignRole | MANAGE ROLE |
GET /users/:userId/roles | listUserRoles | READ ROLE |
DELETE /users/:userId/roles/:assignmentId | removeRoleAssignment | MANAGE 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:
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:
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:
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:
// 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:
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 assignment — POST /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
RBAC & Scopes
The enforcement half: how that campus scope becomes a CASL condition and SQL WHERE clause.
Managing Roles
Create the role you're about to assign, and edit its permissions.
Multi-Tenancy
Per-school provisioning and the super-admin allow-lists that drive automatic assignment.
Auditing
Every assignment is audited — here's how the snapshots work.