Naalya Handbook
Roles & Permissions Admin

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.

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

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

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

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

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

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

On this page