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. The examples come from Shield, Zotic, CAS, Question Banking and Amlaak.

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.

The decision input

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

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.

Shield has five user types: admin, company, facility, guard and preventive security. Each type has its own permission set, and every new company gets an admin role of its own that holds the company set.

  • Global policy. Rules such as account status, tenant membership, and support access that apply before a resource is loaded. In Amlaak, an account whose role can manage users, roles or integrations, or export personal data, stays restricted until it enrols in TOTP MFA.
  • 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. In Question Banking, the ReadQuestionBank permission only gets a request as far as the bank; the bank then lets in its owner, anyone if it is public, or someone holding a share that has not expired. Everyone else gets not found.

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.

Zotic keeps tenant scoping out of individual queries: one query filter, registered for every entity that derives from its multi-tenant base class, and a SaveChanges interceptor that stamps the tenant on new rows. CAS scopes by person instead of tenant: a doctor’s course list holds only the courses they teach, and a receptionist’s only those of the doctors they are assigned to.

Scope the resource in the query

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

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.

Amlaak keeps a user’s permissions in memory for three minutes, under a key made of the user id and a hash of the security stamp in their token. A role change rotates the stamp, and a token with an old stamp is rejected, so a newly issued token never reads a stale entry.

A safer cache key shape

permissions:{tenantId}:{actorId}:{assignmentVersion} 

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.

Amlaak marks each audited operation with an attribute that names the action, such as User.Block or Inquiry.Export. A MediatR behaviour at the end of the pipeline writes the row only after the handler succeeds, and masks any property named like a password, token or secret before it is stored.

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

Case Expected result
Member, correct tenant, allowed permission, owned resource Allow
Member, correct tenant, missing permission Deny without loading protected data
Member, other tenant, valid resource ID Deny or return not found consistently
Permission was revoked after a cache entry was created Deny within the agreed revocation window
Background job with removed initiator access Follow the documented service or initiator policy
Administrator path used through a normal tenant endpoint Deny

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.

Where this runs

  • Shield gives each of its five user types (admin, company, facility, guard and preventive security) its own permission set, and every new company gets an admin role of its own. (Shield case study)
  • Zotic scopes its multi-tenant entities with one EF Core query filter and stamps the tenant on new rows in a SaveChanges interceptor, with 82 permissions across the organiser dashboard. (Zotic case study)
  • CAS has 85 permissions over five roles, and narrows each doctor’s and receptionist’s course list to the courses they teach or are assigned to. (CAS case study)
  • Question Banking lends a bank through five visibility levels and three share tiers (read only, read and write, admin), and a share can carry an expiry date that the database query itself checks. (Question Banking case study)
  • Amlaak restricts any account whose role can manage users, roles or integrations, or export personal data, until it enrols in TOTP MFA, and ties its permission cache to the security stamp in the token. (Amlaak case study)

Originally published at anasamin.dev. I'm Anas Amin, a software engineer in .NET and the founder of Arkedia.