Technical Article

Multi-Tenant Access Control: Three Layers That Must Stay Separate

The tenant boundary, the permission and the record scope answer three different questions. Keeping them apart is what stops most permission bugs.

Share this article

Multi-tenant access control answers three different questions, and most permission bugs come from answering two of them with one mechanism. Which tenant’s data can this request reach at all? What is this user allowed to do? And of the records in their tenant, which ones may they see? In Omnitech CRM, a multi-tenant CRM I architected in 2026 (the code was written by a build agent working to that architecture and reviewed by me), each question has its own layer, its own code and its own tests. Where access control sits among the other platform decisions is covered in SaaS platform architecture: the decisions that come first.

Three questions, three layers

Each layer narrows what the one outside it allows, and none of them can widen the layer outside it.

Three nested boxes: the tenant boundary outermost, the permission inside it, the record scope innermost, with one example request passing through all three.
Tenant boundary, permission, record scope. A misconfigured role can show too much of one tenant, never another tenant.
LayerQuestionHow Omnitech answers it
1. Tenant boundary Which tenant? A global scope on every query, which fails closed
2. Permission What may the user do? Module and action, from one registry
3. Record scope Whose records? Assigned, own, team, department, all or none

The tenant boundary comes first and is not a permission

The tenant boundary is not something a role grants. Every tenant-owned model carries a global scope that filters by the current tenant, and if no tenant is bound the query throws instead of running. Roles never widen it. Code that genuinely needs every tenant, such as the platform super admin’s tools, must call an explicitly named escape hatch that stands out in review.

That ordering matters for everything below: record scope runs after the tenant filter. In the words of Omnitech’s own code, record scope “decides whose rows within the tenant, never which tenant”. A misconfigured role can at worst show a user too much of their own tenant; it can never show another tenant. How the boundary is built and tested is covered in Multi-tenant isolation.

Roles are per tenant too. Omnitech uses Spatie’s laravel-permission with its “teams” feature keyed on tenant_id, so a role called “Sales Manager” in one workspace is a different role from one with the same name in another. That setting is forced in code rather than left in the package’s config file, so a package upgrade can’t quietly turn it off.

Per-tenant roles, forced in a service provider rather than the published config. Simplified from Omnitech.
config([
    'permission.teams' => true,
    'permission.column_names.team_foreign_key' => 'tenant_id',
]);

Permissions from one registry

Every permission in Omnitech comes from a single registry class. The registry’s own comment states the reason: there is no second place to register a permission, which is how the legacy system ended up with capabilities that existed in code but never in the UI.

The registry has two kinds of entry:

A simplified module definition and the permissions it produces.
new Module('deals', 'Deals', 'Sales', extras: [
    'move_stage'    => 'Move stage',
    'reopen_closed' => 'Reopen closed deals',
]);

// deals.view, deals.create, deals.edit, deals.delete,
// deals.move_stage, deals.reopen_closed
  • Modules (22 of them, grouped by area: Sales, Service, Revenue and others). Each module gets the four standard actions (view, create, edit, delete) plus extras that only make sense for that module: converting a lead, merging companies, moving a deal’s stage, reopening a closed deal, logging time, sending a contract for signature.
  • Capabilities (12 of them): sensitive actions that cut across modules, each with a risk label. Revealing stored client credentials is labelled high risk. Placing calls and sending mass email are labelled metered because they cost money. Exports are logged and always stay within the user’s record scope. Permanent deletion is high risk because it bypasses the 30-day recycle bin.

The same registry feeds the seeder, the roles screen and the tests, so a permission can’t exist in one and be missing from another.

High-risk capabilities are meant to leave an audit trail. Today that trail is wired for one case, with more to follow: revealing a custom field whose value is protected by a capability. Each attempt is recorded with whether access was allowed, and the write is deliberately not wrapped in error handling. If the audit entry can’t be saved, the reveal fails. A reveal that couldn’t be recorded is treated as one that didn’t happen.

Record scope is its own dimension

This is the layer most systems skip, and it is the one that came directly from experience. Omnitech replaced a Perfex CRM installation. In Perfex, how much a user could see was baked into permission names: view, view_own, and the vague minimal_access. Because visibility lived inside the permission, giving someone edit rights could quietly change what they could read.

Omnitech makes scope a separate setting per role and per module, with six levels:

ScopeSees
Assigned Records they created or are assigned to
Own Records they own, plus ones assigned to them
Team Everything owned by anyone on their team
Department Everything owned by anyone in their departments
All Every record in the tenant
None Nothing, which is distinct from “not configured”
The six record scopes, narrowest to widest, with None as an explicit setting.

Rules that keep record scope predictable

  • Scopes are ranked, and the widest wins. A user with several roles gets the broadest scope any of them grants for that module. The tenant’s Administrator role always resolves to All.
  • “None” returns an empty list, not an error. A user with no access to a module sees an empty table, not a crash page.
  • One resolver answers “whose records” for both the list and the single record. The list query and the per-record permission check call the same code. When those two drift apart you get a row that appears in a table and then refuses to open, or the reverse. Sharing one resolver makes that drift impossible rather than unlikely.
  • Scope isn’t cached. Team and department membership is looked up per request, because a stale cache here would be a visibility bug and the lookups are cheap.
  • Legacy permissions translate cleanly. During the migration, Perfex’s view maps to All, view_own to Own and minimal_access to Assigned, so existing staff kept exactly the access they had (how the migration was reconciled).

How each layer is tested

Each layer has its own tests, so a change in one can’t silently weaken another.

  • Tenant boundary: cross-tenant reads blocked, writes stamped with the tenant, queries without a tenant throw, and one test that discovers every tenant-owned model automatically and checks each is isolated, so new models are covered the day they are written.
  • Permissions: roles and permissions tests, plus policy tests per area (deals, tickets, pipelines, custom fields).
  • Record scope: scoping tests per model (leads, deals, companies and contacts, tickets, activities) and a dedicated test for the assignee side of the scope model.

A permission registry template

Fill in the module rows before you write the first policy. If a permission can’t be placed in a row, it doesn’t belong in the code yet. The table is for modules, and its example rows show the shape; capabilities get a sheet of their own, described below it.

ModuleAreaExtras beyond view, create, edit, deleteScopeableDefault scope per role
deals Sales move_stage, reopen_closed Yes Sales rep: Own · Manager: Team · Admin: All
tickets Service Your extras Yes Your defaults
settings Admin None No Not scoped
Modules: one row each. “Scopeable” means record scope applies to it.

Capabilities get their own sheet with three columns: the capability, its risk label (high, metered, logged, commercial or scoped) and one sentence on why it is separate from the module actions. Permanent deletion, for example, is high risk because it bypasses the 30-day recycle bin.

When to get help

If your platform mixes these layers, for example visibility encoded in permission names or roles that are global across tenants, the Platform Change-Readiness Scorecard will show it under tenant isolation and permission model. For a full review with a tenancy and security risk register, see SaaS architecture.

Share this article

References

  1. OWASP Authorization Cheat Sheet
  2. Spatie laravel-permission: Teams permissions

Evidence and further reading

All insights →
Case studyOmnitech CRMMulti-tenant CRM and revenue platform with fail-closed tenant isolation, scoped permissions, configurable pipelines, lead conversion, and legacy-data migration. InsightSaaS Platform Architecture: The Decisions That Come FirstSeven decisions a multi-tenant platform has to make before features, in the order they bind each other, with what each one cost on four platforms I run.

Facing a similar problem?

Platform & Integration Review

For SaaS founders and engineering leads whose platform is getting slower and riskier to change.

Request an assessment

Let's build what's next

Have a complex system that needs to be built right?

Whether you are starting from an idea, replacing an existing platform, or scaling a system, let's talk.

Better Technology.
Brighter Possibilities.