There is a second question every internal app eventually has to answer: who is allowed to do this?
Not in the abstract. In the moment. Can this person approve the request? Can they edit the price? Can they see the vendor cost? Can they void a shipment that already left the dock? If the answer lives in someone’s head, a shared admin password, or a permissions spreadsheet that has not been updated since last spring, you do not have access control. You have folklore.
I have written about the five features every internal app needs on day one, and recently about why audit trails are not optional. Roles sit between those two ideas. Authentication tells you who signed in. Audit trails tell you what they did. Roles decide what they were allowed to do in the first place.
## The Spreadsheet Trap
The most common failure mode looks responsible at first. Someone opens Excel and builds a matrix: users down the left, features across the top, checkboxes everywhere. Then the business adds a new exception. Then another. Then a temporary override for one plant. Six months later, nobody trusts the matrix, nobody wants to touch it, and the app ends up with two real roles anyway: “admin” and “everyone else.”
That is not because permissions are hard. It is because the model was built around features instead of jobs. People do not think in checkboxes. They think in responsibilities. Purchasing creates requests. Managers approve them. QA reviews them. Admins clean up messes. If your roles do not map to those sentences, you will keep adding exceptions until the system is unmaintainable.
The rule I use: if you need a spreadsheet to explain your permissions model, you have already overcomplicated it.
## Start With Jobs, Not Features
Before you invent canEditLineItemCostOverrideAfterApproval, sit with the people who do the work and name the jobs the app supports. Most internal apps I build land on three or four roles:
- Requester / Operator — creates and edits their own work
- Approver / Manager — moves items through workflow steps
- Specialist — QA, finance, compliance, or another gate that exists in the real process
- Admin — configuration, cleanup, and rare overrides
That set is boring on purpose. Boring scales. Fifteen custom roles do not. When a new hire asks, “what access do I need?” the answer should be a job title the business already understands, not a tour of your permission catalog.
If two roles always get the same permissions, merge them. If one person needs two jobs, give them two roles—or give them the higher role if that is how the company already works. Do not invent a hybrid role to avoid a conversation with the business.
## Prefer Entra Groups Over App-Only Role Tables
If your company runs Microsoft 365, you already have an identity system and a place to manage membership. Use it.
I usually map app roles to Entra ID security groups: App-Purchasing-Requesters, App-Purchasing-Approvers, App-Purchasing-Admins. Auth.js / Entra login brings those group claims into the session. The app decides what each role can do. IT manages who belongs where when someone joins, leaves, or changes departments.
That matters more than it sounds. Access that only lives in your app database becomes another “one guy system.” When someone leaves the company, Entra can cut off the identity. When someone changes roles, the group membership can move with them. Your app should not become a second HR system just because you wanted a UserRole table on day one.
I still keep a local user record for app-specific data—preferences, last login, audit foreign keys. But the source of truth for “what job does this person have?” should sit with the identity provider whenever you can get away with it.
## Check Permissions Near the Business Action
Hiding a button is not authorization. It is UI courtesy.
If the approve function lives in a service layer, that function should refuse the action when the user is not an approver. Same for voids, deletes, price edits, and exports. Put the check next to the business rule, the same way I put audit writes next to the business action. If the important behavior is scattered across pages and form handlers, permissions become inconsistent the same way audit trails do.
A simple pattern is enough for most apps:
function assertCanApprove(user: SessionUser) {
if (!user.roles.includes("approver") && !user.roles.includes("admin")) {
throw new Error("Not authorized to approve");
}
}
Call that before the status change. Wrap the status change and the audit entry in the same transaction. Now the system has one story: only the right people can approve, and every approval leaves a trail.
## Page Access Is Not Record Access
There are two different questions, and they get mashed together constantly:
- Can this role open this screen?
- Can this user act on this record?
Page-level access is usually role-based. Approvers can open the approval queue. Operators can open the request form. Admins can open configuration.
Record-level access is usually ownership or assignment. Operators edit their own drafts. Approvers act on items in their queue. Managers see their team’s requests. You do not need a full attribute-based access control engine for that. You need honest queries: filter by createdBy, assignedTo, plant, department, or whatever the business already uses to decide “mine” versus “theirs.”
Start with role checks for actions and simple ownership filters for lists. Add finer record rules only when the business can describe them in one sentence. “Managers can see everything in their plant” is a rule. “Unless it is a special vendor, except on Tuesdays, and not if Bob is covering” is a process problem wearing a permissions costume.
## Approvals Are Permissions With a Workflow
In internal apps, the highest-value permission is rarely “can view report.” It is “can move this thing from one state to another.”
That is why roles and workflows belong together. In a real approval system, a role is often just a name for who is allowed to take a transition: submit, approve, reject, return, void. If you already modeled the process as explicit states—as I described in building approval workflows—permissions become almost obvious. Requesters submit. Approvers approve or reject. Specialists handle the exception path. Admins unlock stuck items.
Resist the urge to encode every exception as a new role. Exceptions are usually workflow branches, not new identities. One QA role with a clear transition beats five overlapping “power user” roles that exist because nobody wanted to model the real process.
## Log Role and Permission Changes
If you change what someone can do, that is as important as changing a price or an approval status. Maybe more important.
When membership is managed in Entra, the identity side has its own logs. Still, if your app stores local overrides, temporary elevations, or admin grants, write audit events for those changes. Who got admin. Who lost approver. Who received a temporary override and when it expires.
Access drift is quiet until it is not. The first time someone asks why a former contractor can still approve purchase requests, you want a better answer than “I think we added them during go-live.”
## What You Do Not Need
Most internal tools do not need:
- A custom permission builder UI with drag-and-drop scopes
- Hundreds of fine-grained capabilities named after every button
- Per-field ACLs on every form
- A second spreadsheet that mirrors the roles in the database
What they need is a small set of roles the business recognizes, checks enforced next to real actions, lists filtered to the right records, and an audit trail when access changes. That is enough to replace shared passwords, hallway exceptions, and “just make me an admin so I can finish this.”
## A Practical Starter Model
If you are shipping the first version of an internal app, this is the shape I reach for:
- Authenticate with Entra through Auth.js so every action ties to a real person
- Map three or four Entra groups to app roles
- Store roles on the session after login
- Enforce role checks in the service functions that mutate state
- Filter list queries by ownership, assignment, or plant where it matters
- Hide UI controls that the user cannot use, but never trust the UI alone
- Audit approvals, deletes, overrides, and role changes
That is not enterprise IAM theater. It is a maintainable system for a team that has real work to do and no appetite for a permissions science project.
## Final Thought
Good access control feels invisible when it is right. People can do their jobs. They cannot casually do someone else’s. When something sensitive happens, the system can explain who was allowed to do it and who actually did.
The goal is not the cleverest permission model. The goal is a model the business can still explain six months from now without opening a spreadsheet. Roles should describe jobs. Checks should sit next to actions. History should catch the rest.
If authentication answers who are you? and audit trails answer what happened?, roles answer the question that keeps the other two honest: should you have been able to do that at all?