Skip to content
GRUDGED Free audit
§ Field notes for developers ·

Adding a Permission Tier Above Admin Without Demoting Your Admins

A new top role usually breaks the role it sits above. Two things go wrong: exact-match role checks, and everyone's existing session cookie still says the old thing.

A site I build for had two tiers: team (anyone in the Discord server) and admin (a short allowlist of Discord ids). The owner wanted one page that only he could see. Four people were admins. He was one of them.

So: add an owner tier above admin. Perhaps twenty lines. It took longer than that, because a new top role reliably breaks the role directly beneath it in two ways that don’t show up until someone complains.

Trap 1: exact-match role checks silently demote

The existing check was the obvious one:

def is_admin(request):
    u = current_user(request)
    return bool(u and u[1] == "admin")     # <-- exact match

The moment his session resolves to "owner", that returns False. He keeps the new page and loses the console and the ratings page he already had.

The tier is meant to be additive. So it has to be written that way, everywhere the old role is tested:

def is_admin(request):
    """Owner counts as admin — granting someone owner
    must never cost them what they already had."""
    u = current_user(request)
    return bool(u and u[1] in ("owner", "admin"))

Grep for every comparison against the old role before you ship. Mine appeared in the guard, in the /api/me response that drives the nav, and in the client-side script that decides which links to render. Miss the third and the page is reachable but invisible — arguably the worst outcome, because it looks like a permissions bug rather than a missing link.

Sessions here last fourteen days. His browser had a signed cookie that said admin, issued before owner existed. Nothing about deploying new code changes what’s in it.

So the feature ships, he loads the page, and there’s nothing there — for two weeks, or until someone tells him to sign out and back in. Which is a support conversation you’ve created for yourself.

The fix is to promote the claim at verification time rather than reissue it:

# An owner who signed in before the tier existed still carries
# an "admin" cookie. Promote it in place — the owner allowlist
# is checked right here, so this grants nothing a fresh
# sign-in wouldn't.
if role == "admin" and user_id in owner_ids():
    return user_id, "owner"

This is safe because the allowlist is consulted on every request. The cookie isn’t being trusted to say who you are; it’s being trusted to say which id you are, and the id is then checked against the current list.

Keep the new tier its own list

The tempting shortcut is a flag on the admin list — admin:true,owner:true or a first-entry-wins convention. Don’t. Two separate env lists:

ADMIN_DISCORD_IDS=111…,222…,333…,444…
OWNER_DISCORD_IDS=444…

Widening the admin allowlist is a routine thing someone does in a hurry. If owner is derived from that list, a routine change hands out the top tier by accident. Separate lists make that impossible rather than unlikely.

Check both on every request, not just at login. Then dropping an id revokes access immediately instead of at cookie expiry — which is the behaviour people assume they already have.

Test the forgery, not just the happy path

The check that matters isn’t “can the owner get in”. It’s whether someone who is legitimately signed in can claim the new tier. I signed a cookie with the real key, with a valid signature, a real Discord id that was in the guild — and role=owner in the payload:

anonymous     → 403
team member   → 403
admin         → 403
forged owner  → 403   ← valid signature, id not on the owner list
bad signature → 403
OWNER         → 200

That fifth row is the one worth writing a test for. A valid signature only proves the payload wasn’t tampered with — it says nothing about whether the claim inside it is still true. The per-request allowlist check is what makes it false.


I’m Chris Moore — an independent developer and consultant in Henderson, NV, working under Grudged LLC. I build the internal tools small teams run on, and the access controls around them. If you’re adding a role and unsure what it will break, get in touch.

← All field notes Talk to Chris Moore →