Designing Roles and Permissions for Business Software Without Painting Yourself Into a Corner
All Articles
CRMBusiness SoftwareSecurity

Designing Roles and Permissions for Business Software Without Painting Yourself Into a Corner

HR
Hassan Raza
Tech Lead
Jul 19, 2026 11 min read

The Feature That Gets Designed Last and Costs the Most

Permissions are the part of a business application that everyone assumes will be simple. There is an admin, there are users, and admins can do more. Two weeks before launch someone asks whether a team leader should be able to see another department's invoices, and the entire model falls apart.

We have built role and permission systems into CRMs, workforce platforms, booking back-offices, and multi-tenant SaaS products, and the pattern is consistent: access control is easy to add badly at any point and hard to add properly late. This article is about getting it right early, without over-engineering it into something nobody can reason about.

Roles Are Not Permissions

The most common structural mistake is checking the role in the code.

A conditional that says "if the user is a manager, show the delete button" reads clearly and works fine, right up until the business creates a senior manager role, or decides regional managers should not delete, or adds a new role that needs one specific manager capability. Now every one of those checks has to be found and edited, and inevitably one is missed. The one that is missed is a security bug.

The alternative is to check capabilities, not identities. Roles are bundles of permissions; code asks whether the user can perform an action, never who they are:

  • The code asks: can this user delete an invoice.
  • A role, say Finance Manager, holds a set of permissions including invoice.delete.
  • A user has one or more roles, and possibly direct permissions on top.

Adding a role becomes configuration. Adding a permission to an existing role becomes a data change. Neither requires touching the code that enforces it. In a Laravel codebase this maps naturally onto policies and gates; the framework detail matters less than the discipline of never branching on a role name in business logic.

The Second Dimension Everyone Forgets

Knowing that a user can delete invoices is only half the question. The other half is which invoices.

This is the distinction between what a user can do and what they can do it to, and it is where most permission systems become tangled. A team leader can view tasks — but only for their own team. A project manager can edit projects — but only ones they are assigned to. A client can see invoices — but only their own.

Handle these as two separate layers and both stay comprehensible:

  • The capability check answers whether this kind of action is available to this user at all.
  • The scope check answers whether this specific record is within their reach.

Scoping approaches, in increasing order of complexity:

  • Ownership. The record has a user or client identifier and the user must match it. Simple and covers a surprising amount of ground.
  • Team or department membership. The record belongs to a group and the user belongs to the same group.
  • Assignment. The user is explicitly attached to the record, as with a project manager assigned to a project.
  • Hierarchy. The user can reach anything belonging to themselves or anyone beneath them in an organisational tree. This is the expensive one, and it needs a data structure that makes ancestor queries cheap rather than recursive lookups on every request.

Pick the simplest one that expresses the real business rule. Teams that reach for a full hierarchy on day one usually discover that ownership and assignment would have covered every actual requirement.

Enforce in One Place

A permission that is checked in the user interface only is not a permission, it is a suggestion. Hiding a button prevents an honest mistake; it does not prevent anyone who can open developer tools or call the API directly.

The rule we apply without exception: authorisation is enforced on the server, at the point where the action happens, and the user interface merely reflects those decisions.

That means:

  • Every endpoint authorises, including the ones only reachable from a screen that is already protected.
  • Every list query is scoped at the query level, so a user simply cannot retrieve records outside their scope. This is more robust than filtering after retrieval, and it is faster.
  • The interface receives the user's effective permissions and hides what they cannot do, purely for usability.
  • Nested and related data is scoped too. An endpoint that returns a project with its tasks, comments, and attachments must scope every one of those, not just the project.

The most common real-world leak we find in audits is not an unprotected endpoint. It is a well-protected endpoint that returns related records nobody thought to filter, or an autocomplete search that quietly returns every user in the system because it was written as a convenience.

Multi-Tenancy Changes the Threat Model

If your application serves multiple organisations from one database, tenant isolation is a different class of concern from role permissions. A user seeing something above their pay grade inside their own company is an internal problem. A user seeing another company's data is a breach, a notification obligation, and possibly the end of the product.

The controls that make this safe:

  • Enforce the tenant boundary globally rather than per query, using a mechanism that applies by default — a global scope, a middleware-bound context, or row-level security in the database. Anything that requires each developer to remember a where clause will eventually be forgotten.
  • Make the failure mode loud. If tenant context is missing, queries should fail rather than silently returning everything.
  • Never accept a tenant identifier from the client. Derive it from the authenticated session, always.
  • Test it explicitly. A test that authenticates as tenant A and asserts a 404 rather than a 403 when requesting tenant B's record belongs in every suite. A 403 confirms the record exists, which is itself a small leak.

We treated this as a first-class concern when building BookFlow Pro as a multi-tenant platform, and it is the one area where we deliberately accept extra defensive machinery.

Designing the Role Set

The technical model is only as good as the roles the business actually needs. A useful process:

  • List the real job functions, in the customer's own words. Not "admin" and "user" but "branch manager", "call centre agent", "finance", "field engineer".
  • For each, list the tasks they perform daily. This produces the permission list far more reliably than trying to enumerate permissions abstractly.
  • Look for functions that differ only in scope rather than capability. A regional manager and a branch manager often do identical things to different sets of records, which is a scope difference, not a role difference.
  • Design for the smallest sensible number of roles. Every additional role multiplies testing and support burden, and organisations rarely need as many as they first request.
  • Provide a mechanism for exceptions. There is always one person who needs one extra capability. A direct per-user permission override handles this without inventing a bespoke role for one individual.

Resist the temptation to build a fully generic permission designer where customers compose arbitrary roles from atomic permissions. It sounds flexible and in practice produces misconfigured roles, support tickets, and a permission matrix nobody can audit. Well-named preset roles with a small number of toggles serve real organisations better.

Approvals, Delegation and Time

Real businesses have access rules that a static role model cannot express on its own.

  • Approval thresholds. A manager can approve expenses up to a limit; beyond it, it escalates. This is business logic informed by permissions, not a permission itself, and conflating the two makes both harder to change.
  • Delegation. Someone goes on leave and hands their approvals to a colleague for two weeks. Model this as an explicit, time-bounded delegation record rather than by temporarily editing the colleague's role, which everyone forgets to undo.
  • Temporary elevation. A contractor needs access for the duration of a project. Access with an expiry date is far safer than access granted indefinitely with a reminder to remove it.
  • Break-glass access. Support staff occasionally need to see a customer's data to resolve a problem. Where this exists it should require a reason, be time-limited, and be conspicuously logged.

That last point generalises. Any capability that can view data beyond the normal scope should leave a trail that someone can review.

Audit Logging That Is Actually Useful

Most audit logs are written once and never read, because they record the wrong thing. A log entry saying that a record was updated at 14:32 answers no question anyone asks.

Log what will be needed in a dispute:

  • Who performed the action, including the real user behind any impersonation.
  • What changed, with before and after values on the fields that matter.
  • When, in UTC, with the source such as web, API, or an automated job.
  • Failed authorisation attempts, which are a genuine early warning signal.
  • Permission and role changes themselves, which are the most security-relevant events in the system and the ones most often omitted.

Keep the log append-only and outside the reach of the application's normal delete paths. An audit log that a sufficiently privileged user can edit is not evidence.

Impersonation Without Losing Accountability

Support teams need to see what a user sees. Impersonation is the right feature and a dangerous one if built casually.

The controls that make it safe: restrict it to a specific permission rather than to administrators generally, record both the real and the effective user on every action taken during the session, display a persistent and unmistakable banner so nobody forgets which context they are in, time-limit sessions, and block genuinely destructive actions while impersonating. Being able to see a customer's dashboard is support; being able to delete their account while wearing their identity is a liability.

Testing Access Control

Permissions are the area where tests pay for themselves fastest, because the failure mode is silent.

The tests worth writing:

  • For each role, one test per protected endpoint asserting both allowed and denied outcomes. This is tedious and it is the whole point.
  • Scope tests that authenticate as a user and assert they cannot reach a record belonging to a peer.
  • Tenant isolation tests, as described above.
  • A test asserting that every route has an authorisation check, which can often be automated by inspecting the route list. This catches the endpoint someone added at 6pm on a Friday.
  • Tests for the interface's permission payload, so the screen and the server agree.

When a new role is added, this suite is what tells you whether it accidentally inherited something it should not have.

Retrofitting a System That Grew Without a Model

Most of the permission work we do is not greenfield. It is untangling an application where role checks accumulated in the code over years and nobody can now say with confidence who can do what.

The sequence that works:

  • Inventory the reality first. Grep the codebase for every role check and every place a query is scoped, and write down what is actually enforced today. This list is always longer and stranger than anyone expects.
  • Derive the permission set from that inventory rather than designing an ideal one in isolation. You are documenting a system that exists before you improve it.
  • Introduce the capability layer alongside the old checks, mapping current roles onto the new permissions so behaviour is unchanged on day one.
  • Migrate call sites incrementally, replacing role checks with capability checks, with tests asserting identical outcomes.
  • Only then change the actual access rules, once the mechanism is trustworthy.

The temptation is to redesign the roles and the mechanism at the same time. Doing both at once means that when something breaks, you cannot tell whether it is the new plumbing or an intended change in policy, and in access control that ambiguity is expensive.

The Short Version

Check capabilities, never role names. Separate what a user can do from which records they can do it to. Enforce on the server, scope at the query level, and treat the interface as a convenience layer. Make tenant isolation global and loud. Keep the role set small and named after real jobs. Model delegation and temporary access explicitly rather than by editing roles. Log the things you would need in a dispute, including permission changes themselves. Test every role against every endpoint.

We build business software where this model is the backbone rather than an afterthought, from CRMs to workforce platforms like Binary Time Station. If you are designing a permission model, or you have inherited one that has become unmanageable, our contact page is a good place to start.

Share this article