Cybersecurity · September 17, 2026 · intSignal Team

The Permission That Quietly Grants Admin: Role Assignment Done Right

Share this article

A permission audit of our own customer portal turned up a delegated permission that did more than it said. A user who could manage other users — invite people, change their roles, the sort of thing you hand to an office manager — could assign the built-in Administrator role. To a new invitation, or to themselves.

The endpoint wasn't careless. It validated the roles being assigned:

// The check that was there: do these roles exist in this tenant?
await assertRolesInTenant(tenantId, roleIds);

That's the check most systems have, and it's a real one: it stops a caller referencing a role from someone else's tenant. It just answers the wrong question. The question isn't does this role exist. It's may this person hand out this role.

What is privilege escalation through role assignment?

It's when the permission to manage users is, in practice, the permission to become an administrator — because the system checks that a role is valid without checking that the person granting it holds that level of access themselves. Nothing is exploited and no bug is triggered: the user calls a documented endpoint with valid input, and the system grants exactly what it was asked for.

It usually reaches production because the two ideas are separated in the code. The permission check at the door asks "may you call this endpoint?" and the validation inside asks "are these roles real?" Nobody asks the question that sits between them.

Where it hides

Look for every path that can put a role on an account. In our case there were two, and both needed the same fix:

  • Changing an existing user's roles — the obvious one.
  • Creating an invitation — an invitation is a grant that hasn't been redeemed yet. If invites aren't checked, the escalation is one round trip longer: invite an address you control, redeem it, sign in as an administrator.

Two more paths are worth checking in any system:

  • Editing a role's definition. If a user can add permissions to a role, they never need to assign a more powerful role — they upgrade the one they already hold. Gate role editing on its own permission, and apply the same "you cannot grant what you don't hold" rule to the permissions being added.
  • Group or team membership, where a group carries roles. Adding yourself to a group is a role grant wearing a different hat.

And note how the privilege is usually derived: our isAdmin is computed from role membership on every request, not stored on the user. That's good design — it means a revoked role takes effect immediately — but it also means a self-granted admin role is effective on the very next request.

The six checks

Here's the complete rule set we landed on. The first three are the fix; the rest are what stops the same hole reappearing at a different angle.

1. The actor must hold the delegation permission. The gate at the door. Necessary, not sufficient.

2. Only an administrator may grant or remove an administrator role. The admin role is special because it implies everything, including future permissions nobody has invented yet.

3. You cannot hand out what you do not hold. A non-admin may only grant roles whose permissions are a subset of their own. It generalizes check 2 and closes lateral escalation: without it, an office manager can grant the "billing" role and then invite themselves to it.

4. Nobody edits their own roles, except an administrator. Self-grant is the shortest path, and there's no legitimate case for a delegated manager editing their own access. Admins keep the ability because someone has to be able to change the last account.

5. Removals are changes too. Stripping an administrator is an attack, not a courtesy. Check the roles being removed by the same rules as the ones being added.

6. The tenant keeps at least one administrator. A rule about availability rather than escalation, but it belongs in the same function: it's the only place that sees the whole change.

The implementation

One pure function, no framework, no database calls. Load the tenant's roles, call it, and act on the answer:

// role-grants.mjs: decide whether one user may change another user's roles.
// Pure functions, no dependencies — call it from your API layer before writing anything.

export const DENY = {
  NOT_PERMITTED: "not_permitted", // the actor can't manage users at all
  UNKNOWN_ROLE: "unknown_role", // role doesn't exist in this tenant
  ADMIN_ROLE: "admin_role", // only an admin may grant or remove an admin role
  ESCALATION: "escalation", // the role carries permissions the actor doesn't hold
  SELF: "self", // nobody edits their own roles except an admin
  LAST_ADMIN: "last_admin", // the tenant must keep at least one admin
};

/** A permission matches if it's held exactly, or covered by a "resource:*" or "*" wildcard. */
function covers(held, permission) {
  if (held.has("*") || held.has(permission)) return true;
  const resource = permission.split(":")[0];
  return held.has(`${resource}:*`);
}

/** Every permission carried by a set of roles. */
export function permissionsOf(roleIds, roles) {
  const held = new Set();
  for (const id of roleIds) for (const p of roles.get(id)?.permissions ?? []) held.add(p);
  return held;
}

/** True when any of these roles is flagged as an administrator role. */
export function isAdminRoleSet(roleIds, roles) {
  return roleIds.some((id) => roles.get(id)?.isAdmin === true);
}

/**
 * May `actor` change `target`'s roles from `currentRoleIds` to `nextRoleIds`?
 *
 * Returns { allowed: true } or { allowed: false, reason, roleId? }. Both additions AND removals
 * are checked: being able to strip an administrator is its own attack.
 *
 * roles: Map<roleId, { isAdmin?: boolean, permissions?: string[] }> for this tenant.
 * adminUserIds: ids of users who currently hold an admin role (optional; enables the last-admin rule).
 */
export function checkRoleChange({
  actor,
  target,
  currentRoleIds = [],
  nextRoleIds = [],
  roles,
  requiredPermission = "users:update",
  adminUserIds,
}) {
  if (actor?.isSystem) return { allowed: true }; // trusted internal caller, e.g. tenant provisioning

  const actorRoleIds = actor?.roleIds ?? [];
  const actorIsAdmin = actor?.isAdmin ?? isAdminRoleSet(actorRoleIds, roles);
  const actorPermissions = permissionsOf(actorRoleIds, roles);
  if (!actorIsAdmin && !covers(actorPermissions, requiredPermission)) {
    return { allowed: false, reason: DENY.NOT_PERMITTED };
  }

  // Existence first: an unknown id must never fall through to a permission comparison.
  const current = [...new Set(currentRoleIds)];
  const next = [...new Set(nextRoleIds)];
  for (const id of [...current, ...next]) {
    if (!roles.has(id)) return { allowed: false, reason: DENY.UNKNOWN_ROLE, roleId: id };
  }

  const changed = [
    ...next.filter((id) => !current.includes(id)),
    ...current.filter((id) => !next.includes(id)),
  ];
  if (changed.length === 0) return { allowed: true }; // nothing to decide

  if (!actorIsAdmin) {
    if (actor?.id && actor.id === target?.id) return { allowed: false, reason: DENY.SELF };
    for (const id of changed) {
      const role = roles.get(id);
      if (role.isAdmin) return { allowed: false, reason: DENY.ADMIN_ROLE, roleId: id };
      // You cannot hand out what you do not hold.
      for (const permission of role.permissions ?? []) {
        if (!covers(actorPermissions, permission)) {
          return { allowed: false, reason: DENY.ESCALATION, roleId: id, permission };
        }
      }
    }
  }

  // The tenant must not be left without an administrator.
  if (adminUserIds && target?.id && isAdminRoleSet(current, roles) && !isAdminRoleSet(next, roles)) {
    const others = adminUserIds.filter((id) => id !== target.id);
    if (others.length === 0) return { allowed: false, reason: DENY.LAST_ADMIN };
  }

  return { allowed: true };
}

/** Same rules for an invitation: an invite is a grant that hasn't been redeemed yet. */
export function checkInviteRoles(args) {
  return checkRoleChange({ ...args, target: null, currentRoleIds: [], nextRoleIds: args.roleIds ?? [] });
}

Wiring it into an endpoint:

const roles = await loadTenantRoles(tenantId);              // Map<roleId, { isAdmin, permissions }>
const verdict = checkRoleChange({
  actor: req.user,                                          // { id, roleIds }
  target: { id: params.userId },
  currentRoleIds: await currentRolesOf(params.userId),
  nextRoleIds: body.roleIds,
  roles,
  adminUserIds: await adminIdsOf(tenantId),
});
if (!verdict.allowed) {
  audit.record({ action: "user.roles.denied", actor: req.user.id, reason: verdict.reason });
  throw new ForbiddenException(messageFor(verdict.reason));
}

Two details worth copying. Denials return a reason code, not just false, so the API can give an accurate message and your audit log records why. And trusted internal callers are explicit (actor.isSystem), rather than implicitly trusted because they happen to bypass the permission layer: tenant provisioning, your own seed scripts and support tooling all need to grant an admin role, and they should say so.

How to check your own system

You don't need a code review to find out. Three steps, in order of cost:

1. List the permissions that can lead to a role grant. In most systems that's anything like users:update, users:create, members:manage, invites:create, roles:update, plus group management. Then list which non-admin roles carry them, and which people hold those roles. In our case the answer was "more people than anyone expected", which is normal: delegating user management is the whole point of having it.

2. Read the assignment path for its checks. Search for where role ids are validated and ask whether the actor's own level is considered:

rg -n 'roleIds|assignRoles|setRoles|addRole|inviteUser' --type ts

If the only validation is existence or tenant scope, you have this bug.

3. Prove it in a test environment. Create a user with the delegated permission and no admin role. Sign in as them and try, through the API, to: assign an administrator role to another user; assign it to themselves; create an invitation carrying it; and remove an administrator from someone else. All four should be refused with a clear reason. This is the check worth keeping as an automated test afterwards, because it tests the property rather than the implementation.

How we tested this implementation

38 tests cover each rule and its boundaries: a manager granting admin to someone else, to themselves, and through an invitation; granting a role with permissions the actor lacks; granting a role the actor does fully hold, which must be allowed; wildcard permissions (users:* covers users:update but not tickets:read); removals; an actor with no permissions at all; the last-admin rule, including that it doesn't fire when granting; unknown role ids rejected before anything else; no-op changes and reordered lists allowed; and explicit system callers.

Then we checked the tests were worth having: weakening any of 16 details in the module — dropping the self-edit rule, checking additions but not removals, treating every caller as an admin, letting a resource wildcard match another resource — makes the suite fail.

Frequently asked questions

Isn't "you cannot grant what you don't hold" too strict?

It's the standard rule in delegated administration, and it matches what people expect: you can't give away access you were never given. Where teams do need something looser — a helpdesk role that can assign a specific support role without holding it — model that explicitly as an allow-list of assignable roles per role, and review that list like any other privilege. What you don't want is the implicit version, where everything is assignable because nobody checked.

What about role hierarchies?

A hierarchy is a compact way to express the same subset rule: a level may grant levels below it. If your system has one, enforce it in the same place, and keep the admin case explicit rather than relying on the hierarchy to sort it out. Hierarchies drift when new roles are added off to the side.

Does an approval workflow fix this instead?

It helps with intent but not with authority: an approver who is themselves a delegated manager can approve an escalation. Keep the authority check, and add approvals if you want the paper trail.

Should the API say why it refused?

Yes, at this level of detail: "only an administrator can grant the Administrator role" tells a legitimate manager exactly what to do next, and tells an attacker nothing they couldn't learn by trying. Log the denial with the actor, target and reason — repeated denials on this endpoint are a genuine signal.

We use an external identity provider. Are we covered?

Only if the roles live there too. Single sign-on proves who someone is; the roles inside your application are usually your own. If you map identity-provider groups to application roles, the same question applies to whoever can edit those groups, and that's often a different team with different expectations.

How do we stop it coming back?

Put the rule in one function, call it from every path that writes roles, and keep the four-part attempt above as a test. A check that lives in one place is the only kind that survives a new endpoint being added by someone who wasn't in this conversation.

This came out of the same review as credentials leaking into logs from HTTP client errors and two payment bugs with database fixes. For the evidence side of access control, see our SOC 2 user access review guide, and for the sign-in layer in front of it, Entra ID single sign-on for multi-tenant apps. If you'd like this reviewed in your own environment, that's part of our identity and access management service.

Share this article