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.
Roles aren't global. Every school owns its own copies, cloned from a template the moment the school exists. This page covers where those copies come from and the one part of a system role you're not allowed to change.
Roles are per-school
Here's a fact that surprises people: roles are per-school. RoleEntity is @TenantScoped() with a NOT-NULL school_id and a unique constraint on (school_id, name). Every tenant owns its own copy of "Staff", its own "Head teacher", and so on — there is no single shared "Staff" row.
@Entity('role')
@TenantScoped()
@Unique(['schoolId', 'name'])
@WithTimestamps()
export class RoleEntity extends DatabaseEntity implements Role {
@Column({ name: 'school_id', type: 'uuid' }) schoolId: string;
@Column() name: string;
@Column({ name: 'is_system', default: false }) isSystem: boolean;
// ...
}The template
So where do those per-school copies come from? They're cloned from a template the moment a school exists. The template lives in seeds/system-roles.ts — a plain array of SystemRole objects, each with a name, a description, and a default permission bundle. A small sample:
export const SYSTEM_ROLES: SystemRole[] = [
{
name: SYSTEM_ROLE_NAMES.SUPER_ADMIN,
description: 'Full system access across all campuses',
isSystem: true,
permissions: [
{ action: Action.MANAGE, resource: Resource.ALL },
{ action: Action.MANAGE, resource: Resource.CAMPUS },
{ action: Action.IMPERSONATE, resource: Resource.USER },
],
},
// School Admin, Head teacher, Class Teacher, Staff, … each with its own bundle
];cloneSystemRolesForSchool
The cloning itself is one shared helper, cloneSystemRolesForSchool. It's deliberately idempotent by (school, name) — it reads the school's existing roles first and skips any name already present, so running it twice never duplicates or resets anything:
export async function cloneSystemRolesForSchool(manager: EntityManager, schoolId: string) {
const existing = await roleRepo.find({ where: { schoolId } });
const existingNames = new Set(existing.map((r) => r.name));
for (const seed of SYSTEM_ROLES) {
if (existingNames.has(seed.name)) continue; // already cloned → skip
const role = await roleRepo.save(roleRepo.create({
name: seed.name, description: seed.description, isSystem: true, schoolId,
}));
// …then save a role_x_permission row per seed.permissions entry
}
}Where it fires
That helper runs in exactly two places. In staging and production, SchoolProvisioningService calls it for every newly-created school, so a brand-new tenant arrives with a full set of editable roles. In local development only, RoleSeedService (an OnModuleInit that returns early unless nodeEnv === 'development') clones the set for the fixed demo tenant. Worth reading once is what the dev seed does after cloning — it additively re-adds any seeded permission missing from an existing role, but never removes extras you added:
private async reconcileRole(role: RoleEntity, seed: SystemRole): Promise<number> {
const missing = seed.permissions.filter(
(perm) => !role.permissions?.some(
(existing) => existing.action === perm.action && existing.resource === perm.resource,
),
);
// …save only the missing rows; never deletes, never touches name/description
}Re-seeding is additive, never a reset
The dev seed adds back missing default permissions and leaves everything else alone. If you've edited a role in dev and restart the server, your edits survive — but any default permission you deliberately removed gets re-added. To remove a default permanently, edit the role through the API, not by deleting a row and hoping it stays gone.
Frozen names
A role cloned from the template has isSystem: true. That flag does not make the role read-only. Schools are expected to tune what "Head teacher" can do for them. What isSystem protects is the name and the existence of the role — not its permission set.
You can see both guards in RoleService. Renaming a system role is forbidden:
if (dto.name && dto.name !== role.name) {
if (role.isSystem) {
throw new ForbiddenException('Cannot rename a system role');
}
role.name = dto.name;
}And deleting one is forbidden:
if (role.isSystem) {
throw new ForbiddenException('Cannot delete a system role');
}The reason names are frozen: other code matches roles by name. The SystemRoleGuard (via @RequireSystemRole) checks against SYSTEM_ROLE_NAMES in role.constants.ts, and automatic assignment looks up 'Staff' and 'Super Admin' by literal string. Rename "Staff" to "Employee" in one school and those lookups silently miss. So: edit a system role's permissions freely, but its name is part of the system's contract.
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.
Where to go next
Managing Roles
Now that the roles exist, how an admin creates new ones and edits their permissions.
Assigning Roles
How a cloned role lands on a staff user, scoped to a campus.
Multi-Tenancy
Per-school provisioning, super-admin allow-lists, and where role cloning fires.
Roles & Permissions Admin
Back to the overview: the permission registry and the role chain.
Roles & Permissions Admin
The management side of authorization — the permission registry, plus how roles are created, cloned, edited, and assigned to users.
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.