Role Hierarchy
In addition to permission keys, every role in the roles table has a numeric hierarchy_level. Higher numbers mean more senior. Hierarchy gates who can act on whom during member-management mutations: a non-super-admin actor can only assign or modify roles strictly below their own level.
Source files
| File | Role |
|---|---|
App/src/lib/team-member-hierarchy.ts | Pure helpers: getHierarchyLevelForRole, getActorHierarchyLevelForTeam, canActOnHierarchyLevel |
App/src/services/roles/getUserRoleHierarchyLevelForTeam.ts | Cached lookup of the actor's level for a given team |
Member-management actions in src/components/teams/actions/** | Apply the helpers before persisting any role change |
Helpers
getHierarchyLevelForRole(supabase, roleId)
const { data } = await supabase
.from("roles")
.select("hierarchy_level")
.eq("id", roleId)
.maybeSingle();
return typeof data?.hierarchy_level === "number" ? data.hierarchy_level : 0;
Returns 0 for null role IDs or roles without an explicit level. Used to score the target role in a mutation.
getActorHierarchyLevelForTeam(supabase, teamId, userId)
Reads team_users.role.hierarchy_level for (teamId, userId). Returns null when the actor is not a member of the team. Used inside actions when you already have a request-scoped Supabase client.
getUserRoleHierarchyLevelForTeam(teamId) (cached)
Same lookup, but for the currently signed-in user and wrapped in unstable_cache:
unstable_cache(
loader,
["user-role-hierarchy-level", userId, teamId],
{ revalidate: 3600, tags: [`user-role-level-${userId}`] },
);
Use this from server components (sidebar, member tables) when you don't already have the actor's level in scope.
canActOnHierarchyLevel(actorLevel, targetLevel, isSuperAdminPrimary)
if (isSuperAdminPrimary) return true; // super-admin team bypasses hierarchy
if (actorLevel === null) return false;
return actorLevel > targetLevel; // strictly greater
The super-admin team (defined by SUPER_ADMIN_TEAM_ID) is exempt — those operators can change anyone's role.
Mutation pattern
const supabase = createClient();
const isSuperAdminPrimary = teamId === SUPER_ADMIN_TEAM_ID;
const actorLevel = await getActorHierarchyLevelForTeam(
supabase,
teamId,
user.id,
);
const targetLevel = await getHierarchyLevelForRole(supabase, newRoleId);
if (!canActOnHierarchyLevel(actorLevel, targetLevel, isSuperAdminPrimary)) {
return createErrorResponse(
"You don't have permission to assign this role.",
);
}
// safe to proceed with the update
Why a separate dimension from permission keys?
Permission keys answer "can this user view/edit this resource?". Hierarchy answers "can this user manage other users?". Two team admins can have the same permission keys but different hierarchy_levels, so one cannot demote the other. This prevents the lateral-promotion problem you would otherwise hit with a flat key system.
Cache invalidation
When the actor's role changes (rare), invalidate user-role-level-${userId} along with the regular permissions-${userId} tag. In practice these are owner-managed transitions performed by a more senior admin or the super-admin team, so the cache is dropped naturally on the next session refresh.