# Modelling roles you will not regret when the admin panel grows

> A role column, a ranked vocabulary in one file, and permission checks that name the action, not a boolean isAdmin scattered across forty components.

Every app starts with two kinds of people: users, and you. So the first version
of authorisation is a boolean.

```ts
if (user.email === "me@example.com") { /* admin things */ }
```

Then a colleague joins. Then support needs to read customer records but not
issue refunds. Then a contractor needs the analytics page for six weeks. By the
time you notice, `isAdmin` appears in forty components and nobody can answer
"what can a support agent actually do?" without grepping.

Here is a model that survives that growth without turning into a permissions
framework you have to maintain.

## Store one role per user, in a NOT NULL column

```ts
role: text("role").notNull().default("user"),
```

Two properties matter more than they look:

**`NOT NULL` with a default.** A nullable role means every check has to decide
what `null` means, and eventually one of them decides wrong. A row can never
exist in an unknown privilege state.

**A string, not a boolean or an enum type.** Adding `"support"` to a Postgres
enum is a migration with a lock; adding it to a string column is a deploy. You
lose database-level validation, which the next section replaces with something
better.

## Put the vocabulary in exactly one file

```ts
// src/lib/auth/roles.ts
export const ROLES = ["user", "support", "admin"] as const;
export type Role = (typeof ROLES)[number];

const RANK: Record<Role, number> = { user: 0, support: 1, admin: 2 };

export function isRole(value: unknown): value is Role {
  return typeof value === "string" && (ROLES as readonly string[]).includes(value);
}

export function hasRole(actual: unknown, required: Role): boolean {
  if (!isRole(actual)) return false;   // unknown fails closed
  return RANK[actual] >= RANK[required];
}
```

The ranking is what stops the OR lists. Without it, every check that admins
should also pass becomes `role === "support" || role === "admin"`, and the day
you add `"owner"` you must find all of them. With it, `hasRole(role, "support")`
is true for an admin forever.

`hasRole` takes `unknown` deliberately. Roles arrive from a database column that
was added after some rows existed, from a session cache that may predate a
config change, and from JSON. An unrecognised value must be least-privileged,
never most.

## Rank works until it does not

Ranking assumes privileges nest: everything support can do, an admin can do.
That is true for the overwhelming majority of internal tools, and it is worth
staying inside it for as long as you can.

The moment it stops being true (a "billing" role that can issue refunds but
must not read support tickets, a "read-only auditor" who outranks support in one
dimension and not another) add a capability map beside the ranks rather than
inventing more roles:

```ts
const CAPABILITIES: Record<Role, readonly Capability[]> = {
  user: [],
  support: ["ticket:read", "ticket:reply", "user:read"],
  admin: ["ticket:read", "ticket:reply", "user:read", "user:write", "refund:issue"],
};

export function can(role: Role, capability: Capability): boolean {
  return CAPABILITIES[role].includes(capability);
}
```

Now the call site says what it needs (`can(user.role, "refund:issue")`) rather
than which title happens to have it today. Reading the table answers "what can
support do?" in one screen, which is the question an auditor, a new hire and
your future self all ask.

Do not start here. A capability map with three roles and two capabilities is
ceremony. Move to it the first time a role does not fit the ladder.

## Never let a user write their own role

The sign-up path must not accept a role, from a form field, a query parameter,
an invite payload or an OAuth profile. The column default is the only thing that
sets it. Promotion happens through an admin-only path that checks the *actor's*
role first:

```ts
"use server";
export async function setRole(targetUserId: string, role: Role) {
  await requireRole("admin");           // the actor, from the session
  if (!isRole(role)) throw new Error("Unknown role");
  // ...
}
```

Two failure modes hide here. Trusting `role` from the body without `isRole`
writes an arbitrary string that every ranked check then fails closed on:
confusing, but safe. Forgetting `requireRole` lets anyone promote themselves,
not safe at all, and it looks completely normal in review because the function
name says "admin".

## Sessions cache the role

Better Auth's cookie cache keeps a signed snapshot of the user for a few
minutes, so most requests answer without a database read. A role changed with
raw SQL is therefore invisible for up to that long. Two consequences:

- Demotion is not immediate. If a demotion must take effect now (a
  compromised account, a departure) revoke that user's sessions as well as
  changing the row.
- Promote through the API rather than SQL where you can, so the cache is
  refreshed as part of the change.

## The admin panel side

The panel's route group checks the role once, in its layout, and pages inside it
do not repeat it. That is fine for rendering. It is not enough for actions: a
server action called from an admin page is a public endpoint, so it calls
`requireRole` itself. The layout controls what is *shown*; the action controls
what is *done*.

## Checking your work

- `grep -rn "=== \"admin\"" src/` returns nothing outside `roles.ts`.
- Every server action that mutates something privileged has a `requireRole` on
  its first line.
- Setting a user's role to `"wizard"` by hand denies everything rather than
  granting everything.
- Demote yourself in the database, wait for the cookie cache to expire, and
  confirm `/admin` becomes a 404.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
