← Notes

.NET · Authorization · Multi-tenancy

Permission-heavy multi-tenant systems: make the access check boring

Permission-heavy systems become risky when access rules are scattered across controllers, UI checks, repository filters, and background jobs. The aim is not a clever authorization framework. It is one rule path that is difficult to bypass.

9 min read by Anas Amin

Start with the access tuple

Every protected operation can be described with four values: the actor, the tenant, the action, and the resource. If one is unknown, the system does not yet have enough information to allow the operation.

This simple tuple prevents a common mistake: checking that a user has a permission somewhere, then loading a resource that belongs to another tenant. Permission and ownership need to be evaluated together.

public sealed record AccessRequest(
    Guid ActorId,
    Guid TenantId,
    string Action,
    string ResourceType,
    Guid? ResourceId);
The decision input

Resolve the tenant from trusted context

A route parameter or request body can name a tenant, but it cannot prove membership in that tenant. Resolve the active tenant from authenticated membership, then compare any requested tenant identifier with it.

Administrative cross-tenant work should use an explicit path with separate policy and audit rules. Hiding it inside the normal tenant resolver makes ordinary code much harder to reason about.

  • Create one request-scoped tenant context after authentication.
  • Reject a missing or ambiguous tenant before business logic starts.
  • Pass the tenant identifier into application and data access boundaries explicitly.
  • Do not accept a tenant identifier from a message or scheduled job without validating who created that work.

Keep roles and permissions separate

Roles are a management tool. Permissions are the actions the application actually protects. Code that asks for a role name tends to grow exceptions such as manager-or-owner-or-support. Code that asks for a permission can keep the business decision in one place.

Use stable permission names that describe an action, for example invoice.read or member.invite. Map tenant roles to those permissions in configuration or data. The application checks the permission; the administration UI decides which roles receive it.

  • Global policy. Rules such as account status, tenant membership, and support access that apply before a resource is loaded.
  • Permission policy. Whether the actor may attempt the action in the active tenant.
  • Resource policy. Ownership, state, department, or other facts that can only be checked against the resource.

Queries are part of the security boundary

An authorization check followed by an unscoped query is still a data leak. Put the tenant condition in the database query that loads or changes the resource. This also handles the case where a resource identifier is valid but belongs to another tenant.

Global query filters can reduce repetition in Entity Framework Core, but they should not become invisible magic. Raw SQL, background work, admin queries, and filter disabling all need deliberate review. Sensitive writes benefit from a repository or application method that requires a tenant ID rather than reading one from a global static value.

var order = await db.Orders
    .SingleOrDefaultAsync(
        x => x.Id == request.OrderId
          && x.TenantId == tenant.Id,
        cancellationToken);
Scope the resource in the query

Protect the whole write path

UI visibility is not authorization. Hiding a button helps the user understand the product, but the API or command handler must make the final decision. The same rule applies to bulk imports, message consumers, scheduled jobs, and internal tools.

For background work, store the tenant and initiating actor with the job. At execution time, rebuild a limited authorization context or use a clearly named service identity. Do not let a worker inherit broad access simply because it runs inside the backend.

  1. Resolve the actor and tenant.
  2. Check the action permission.
  3. Load the resource inside the tenant boundary.
  4. Apply resource-specific rules.
  5. Change state and write an audit event in the same application operation.

Permission caches need an invalidation story

Caching a permission set can remove repeated database reads, but a key based only on user ID is unsafe in a multi-tenant system. Include the tenant and a policy version, role assignment version, or another value that changes when access is edited.

Decide how quickly revocation must take effect. A short expiration may be acceptable for low-risk read access. Sensitive actions may need a fresh check or direct invalidation. This is a product and security choice, not only a cache setting.

permissions:{tenantId}:{actorId}:{assignmentVersion}
A safer cache key shape

Audit decisions that matter

A useful audit event says who attempted what, in which tenant, against which resource, and whether it was allowed. It also records the policy or permission involved. It should not copy tokens, request bodies, or sensitive fields into logs.

Denied access can be noisy, so aggregate routine denials and keep detailed records for sensitive operations. A support team needs enough context to investigate without receiving the protected data itself.

Test boundaries, not only happy paths

Authorization tests should prove that the boundary holds when identifiers are valid but belong to the wrong place. A test that uses only missing IDs cannot catch cross-tenant reads.

Minimum cases for a protected operation
CaseExpected result
Member, correct tenant, allowed permission, owned resourceAllow
Member, correct tenant, missing permissionDeny without loading protected data
Member, other tenant, valid resource IDDeny or return not found consistently
Permission was revoked after a cache entry was createdDeny within the agreed revocation window
Background job with removed initiator accessFollow the documented service or initiator policy
Administrator path used through a normal tenant endpointDeny

Boring is the target

A new endpoint should not require its author to invent an authorization sequence. Give it one tenant context, one permission name, one tenant-scoped resource query, and one place for the final decision. Reviewers can then look for missing steps instead of reverse-engineering a different security model in every feature.