Designing Tenant-Scoped Permission Models

A permission model that does not carry the tenant boundary in its core data shape will eventually leak one customer's authority into another's account. This page sits under Role-Based Access Control Per Tenant, which governs how roles are assigned; here we design the underlying permission primitives — the tuples, the grant structure, and the storage — so that "can this principal do this thing here" is always answered inside exactly one tenant and answered fast.

Problem Framing

Most authorization bugs in multi-tenant SaaS are not missing checks; they are checks that ran against the wrong tenant. A user is a member of tenants acme and globex. They hold billing_admin in acme and viewer in globex. If your permission model stores "user 91 is billing_admin" without binding that fact to a tenant, the very first cache or join that forgets the tenant predicate grants acme's billing power inside globex. The tenant is not metadata bolted onto a permission record. It is part of the permission's identity.

The correct primitive is a tuple: (principal, tenant, resource, action, scope). A grant means "principal P, acting within tenant T, may perform action A on resource type R, constrained to scope S." Every field is mandatory. Drop tenant and you have a single-tenant system pretending to be multi-tenant. Collapse resource and action into one opaque string like can_edit_invoices and you lose the ability to reason about new resources without minting new permission names forever. Omit scope and you can only express coarse "all invoices in the tenant," never "invoices for the team this user leads."

Two structural choices then dominate the design. The first is roles versus direct grants. Roles are named bundles of tuples; a role assignment expands to many effective permissions. Direct grants attach a single tuple to a principal with no intermediary. Real systems need both: roles for the common shapes (admin, member, viewer) and direct grants for the exceptions ("this one contractor may export the audit log, nothing else"). Modeling everything as roles forces a combinatorial explosion of near-duplicate roles; modeling everything as direct grants makes onboarding a 40-row insert and makes "what can an admin do" unanswerable. Keep roles as the default and direct grants as the escape hatch, and resolve both into the same effective-tuple set at check time.

The second choice is wildcards and hierarchy, and this is where most models quietly break. A wildcard like invoice:* or a resource hierarchy where org > project > document is convenient to author but expensive and dangerous to evaluate. Once a grant can match by pattern or by ancestry, a permission check is no longer a lookup — it is a search over patterns and a walk up a tree, per request. Worse, a too-broad wildcard granted in one tenant is the single most common privilege-escalation root cause. The diagram below shows how a raw grant request resolves into the flattened, tenant-scoped effective set that a check actually reads.

The payoff for getting this right is that the runtime check stays O(1): a set-membership test against a precomputed effective-permission set, with no tree walks and no pattern matching on the hot path. The diff you write into this model is also exactly what you should record when auditing RBAC changes across tenants, because the audit log is only as precise as the permission shape it mirrors.

Step-by-Step Guide

1. Define the tuple as your atomic permission

Make the tuple a typed value, not a free-form string. A canonical string form (resource:action:scope) is fine for storage and comparison as long as it is generated from typed fields, never hand-written.

// permission.ts
export interface Permission {
  resource: string; // e.g. "invoice"
  action: string;   // e.g. "read" | "write" | "export"
  scope: string;    // e.g. "*" within a tenant, or "team:7"
}

export function toKey(p: Permission): string {
  return `${p.resource}:${p.action}:${p.scope}`;
}

// A grant is always tenant-bound. There is no tenant-less grant type.
export interface Grant {
  tenantId: string;
  principalId: string;
  permission: Permission;
  source: "role" | "direct";
}

2. Store roles as bundles, assign them per tenant

A role is a named set of permission tuples. A role assignment binds a principal to a role inside one tenant. Note the role definition itself is tenant-agnostic (the template), but every assignment carries tenant_id.

CREATE TABLE role (
  name        TEXT PRIMARY KEY,
  permissions JSONB NOT NULL  -- array of {resource, action, scope}
);

CREATE TABLE role_assignment (
  tenant_id    TEXT NOT NULL,
  principal_id TEXT NOT NULL,
  role_name    TEXT NOT NULL REFERENCES role(name),
  PRIMARY KEY (tenant_id, principal_id, role_name)
);

CREATE TABLE direct_grant (
  tenant_id    TEXT NOT NULL,
  principal_id TEXT NOT NULL,
  resource     TEXT NOT NULL,
  action       TEXT NOT NULL,
  scope        TEXT NOT NULL,
  PRIMARY KEY (tenant_id, principal_id, resource, action, scope)
);

3. Resolve roles and direct grants into one effective set

Expand role assignments through their definitions, union the direct grants, and key the result by (tenant_id, principal_id). Do this once per change, not once per request.

// resolve.ts
import { Permission, toKey } from "./permission";

export function resolveEffective(
  roleDefs: Map<string, Permission[]>,
  assignedRoles: string[],
  directGrants: Permission[],
): Set<string> {
  const set = new Set<string>();
  for (const roleName of assignedRoles) {
    for (const p of roleDefs.get(roleName) ?? []) set.add(toKey(p));
  }
  for (const p of directGrants) set.add(toKey(p));
  return set;
}

4. Flatten wildcards and hierarchy at write time, not read time

If you support invoice:* or a scope hierarchy, expand it into concrete keys when the effective set is built, so the runtime check never interprets patterns. Store both the literal tuple and its expansion.

# flatten.py
KNOWN_ACTIONS = {"invoice": ["read", "write", "export"]}

def flatten(resource: str, action: str, scope: str) -> list[str]:
    actions = KNOWN_ACTIONS.get(resource, []) if action == "*" else [action]
    return [f"{resource}:{a}:{scope}" for a in actions]

# A grant of invoice:*:team:7 becomes three concrete keys, all tenant-bound
# upstream by the (tenant_id, principal_id) the effective set is keyed on.

5. Persist the effective set for O(1) checks

Materialize the per-principal effective set keyed by tenant, in a store you can read in a single hop (a row of denormalized keys, or a cache entry). Recompute on grant, revoke, or role edit — the same events you audit.

# cache.py
import json

def cache_key(tenant_id: str, principal_id: str) -> str:
    return f"perm:{tenant_id}:{principal_id}"

def write_effective(redis, tenant_id, principal_id, keys: set[str]) -> None:
    redis.set(cache_key(tenant_id, principal_id), json.dumps(sorted(keys)))

def can(redis, tenant_id, principal_id, perm_key: str) -> bool:
    raw = redis.get(cache_key(tenant_id, principal_id))
    if raw is None:
        return False  # fail closed: no materialized set means no permission
    return perm_key in set(json.loads(raw))

The same effective set is the natural source of truth for the permission claims you embed when scoping access tokens; the rules for that live in tenant-aware JWT token management, where the trade-off between embedding permissions and looking them up per request is decided.

Verification

The model is correct when the same logical permission resolves differently per tenant for the same principal, and when a wildcard never matches outside its intended resource. Assert both.

# test_permission_model.py
def test_tenant_scoped_isolation():
    # Same principal, two tenants, different effective sets.
    acme = build_effective("u91", "acme", roles=["billing_admin"])
    globex = build_effective("u91", "globex", roles=["viewer"])
    assert "invoice:export:*" in acme
    assert "invoice:export:*" not in globex

def test_wildcard_does_not_overreach():
    # invoice:* must not grant any action on a different resource.
    eff = build_effective("u91", "acme", grants=[("invoice", "*", "*")])
    assert "invoice:write:*" in eff
    assert "payout:write:*" not in eff

A check that the runtime path stays O(1) is equally important: assert that can() issues exactly one key read and performs no role expansion. Log the resolver's expansion count and alert if a single check ever triggers expansion, because that means a stale or missing materialized set is forcing a synchronous rebuild on the hot path.

-- Confirm no grant exists without a tenant binding (the one fatal data bug).
SELECT 'role_assignment' AS tbl, count(*) FROM role_assignment WHERE tenant_id IS NULL
UNION ALL
SELECT 'direct_grant', count(*) FROM direct_grant WHERE tenant_id IS NULL;

Both counts must be zero. A non-zero result is a tenant-less grant, which is the data shape that causes cross-tenant authority leaks.

Failure Modes & Gotchas

FAQ

Should permissions live in the role definition or be computed per request? Define them in roles and direct grants, but compute the flattened effective set once per change and store it. Per-request computation reintroduces tree walks and wildcard matching, which is exactly the O(1) property you are trying to protect.

Do I need scopes if every tenant is already isolated? Yes. The tenant boundary separates customers, but scope separates principals within a tenant — team leads, project members, individual record owners. Without scope you can only express "all or nothing per tenant," which forces over-granting.

How do I add a new resource without exploding role count? Add the resource to the tuple vocabulary and grant it through existing roles by extending their permission arrays, then recompute effective sets. Because resource and action are separate tuple fields, you never mint a new permission name; you add rows, not enums.