Skip to main content

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

FileRole
App/src/lib/team-member-hierarchy.tsPure helpers: getHierarchyLevelForRole, getActorHierarchyLevelForTeam, canActOnHierarchyLevel
App/src/services/roles/getUserRoleHierarchyLevelForTeam.tsCached 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.