Four layers of permissions, and why three of them are not enough
Multi-tenant B2B software fails on access control before it fails on features. The permission model SiteLeader uses, and the reasoning behind each layer.
- Published
- 6 Jul 2026
- Reading time
- 9 minutes
- Category
- Architecture
- Topics
- Multi-tenancy, RBAC, SaaS
Most B2B platforms start with a role column: admin, manager, staff. It survives until the first customer says their site supervisor should see labour attendance but not vendor rates, and only for the two sites they run. At that point a role enum cannot express what the business means.
Layer one — the coarse role
Still useful, just not sufficient. It answers what kind of user this is: platform operator, tenant owner, staff, external client. It decides which application they see at all.
Layer two — module grants per tenant
Not every customer buys every module. A contractor on a basic plan should not see equipment management at all — not a disabled button, not an empty page. Grants live on the tenant, so the platform operator controls what a customer's whole organisation can reach.
Layer three — granular custom roles
This is where most systems stop, and it is the layer customers actually want to configure. The tenant owner builds their own roles from individual permissions, because only they know that at their company the storekeeper approves material issue but not purchase.
Layer four — row scope and field visibility
The layer people forget, and the one that causes data leaks. Two supervisors may have identical permissions and still must not see each other's sites. That is not a permission question, it is a data-scope question — which rows, not which screens.
Field visibility is the same idea one level down. A supervisor may need the labour attendance record but not the wage rate on it. Same row, different columns.
// Tenant isolation belongs in middleware, never in each handler.
// One forgotten filter in one endpoint leaks another customer's data.
app.use(builderScope);
function builderScope(req, res, next) {
req.scope = { builderId: req.user.builderId };
next();
}Tenant isolation must be enforced in one place. If every query author has to remember it, one of them eventually will not — and that is a breach, not a bug.
The order matters
Each layer narrows the one above it and can never widen it. A custom role cannot grant a module the tenant has not bought; row scope cannot expose another tenant's data. Enforce that direction strictly and the model stays reasonable to audit as it grows.
The system behind this article
SiteLeader — Multi-tenant SaaS platform
Multi-tenant SaaS for Indian building contractors — labour, materials, finance, GST billing and AI-assisted design in one tenant-isolated platform.