How-to
Modeling roles and permissions
Authorisation models fail slowly. The first three roles are obvious, the next five are requests from customers, and by the twentieth nobody can say what a given role can do without reading the code. This guide is about the decisions that keep that from happening.
Model permissions, not roles
A role is a bundle of permissions with a name on it. If you start by defining roles, every new customer request becomes a new role, because there is nothing smaller to give them. If you start by defining permissions, a request becomes a rearrangement of things that already exist.
Permissions describe actions on resources: something a person can do, phrased so that it is still true if you redesign the interface. A permission named after a screen becomes a lie the moment the screen is redesigned.
The test for a good permission name: could you check it from a background job, an API call and a button, and have it mean the same thing in all three? If not, it is describing a screen rather than a capability.
A naming convention worth keeping
Use resource:action. It sorts sensibly, reads well in code, and groups naturally in an admin interface. Pick it before you have fifty permissions, because renaming them afterwards means touching every role in every customer's configuration.
| Permission | Means | Not |
|---|---|---|
| invoices:read | See invoices in this organization | See the billing page |
| invoices:write | Create and edit invoices | Click the save button |
| invoices:export | Take invoice data out of the product | Access the reports tab |
| members:invite | Add someone to the organization | See the team screen |
| members:manage | Change roles and remove members | Be an admin |
| settings:billing | Change payment details and plan | Be an owner |
How many roles
Three to start: a viewer who can read, a member who can do the ordinary work, and an admin who can manage the organization. Add an owner if billing needs to be separable from administration, which in most products it does.
Resist adding a role per customer request. When a customer asks for something between member and admin, the useful question is which permission they want moved, not which role they want created — and the answer often applies to everyone.
If you eventually need per-customer roles, add them as a deliberate feature with its own interface and its own limits, not by accumulating bespoke roles in a shared table.
Where to enforce
Enforce at the boundary where the action happens, which is the API, not the interface. Hiding a button is a courtesy to the user; it is not access control, and anybody with a network tab can see past it.
With permissions carried in the session, a check is a local array lookup, which is cheap enough to put on every route without thinking about it. That property is what makes consistent enforcement realistic rather than aspirational.
1import { Paycux } from "@paycux/node";2import { cookies } from "next/headers";34const paycux = new Paycux(process.env.PAYCUX_API_KEY);56export async function requirePermission(permission: string) {7 const jar = await cookies();8 const sealed = jar.get("px_session")?.value;9 if (!sealed) throw new Error("unauthenticated");1011 const session = paycux.userManagement.loadSealedSession({12 sessionData: sealed,13 cookiePassword: process.env.PAYCUX_COOKIE_PASSWORD,14 });1516 const result = await session.authenticate();17 if (!result.authenticated) throw new Error("unauthenticated");1819 // bilinmeyen izin de reddedilir — fail closed20 if (!result.permissions.includes(permission)) throw new Error("forbidden");2122 return { user: result.user, organizationId: result.organizationId };23}
Fail closed. An unknown permission slug — a typo, a permission that was renamed — must be a refusal. A model that treats unknown as allowed hides its own bugs until somebody finds them.
Roles are per organization
In a multi-tenant product the same person can be an admin in one workspace and a viewer in another. Permissions therefore belong to a membership, not to a user, and every check needs to know which organization it is asking about.
The commonest serious authorisation bug in tenanted products is not a missing permission check — it is a present check evaluated against the wrong organization. Take the organization from the session, never from a request parameter.
Changing somebody's access
A role change takes effect when the session next refreshes, not instantly. For a promotion that is fine. For a removal it is not, so revoke the sessions explicitly when you take access away.
- 1Update the membership's role.
- 2Revoke that user's sessions if the change reduces access.
- 3Emit an audit event naming the actor, the target and both roles.
- 4Let the interface reflect the change on the next request rather than caching permissions in your own layer, where they will go stale.
Keeping it comprehensible
Two habits do most of the work. First, keep a single list of permissions that is readable by someone who does not write code — the dashboard's role screen is that list, and it should match your slugs exactly. Second, review the role definitions whenever you add a feature, rather than only when a customer complains.
An authorisation model is a product surface. Customers read it during procurement, security reviewers read it during diligence, and support reads it every week. It is worth the same care as anything else people look at.