Authorization — Host/Tenant scope & multi-provider grants
Why this matters
Section titled “Why this matters”A SaaS application almost always carries two kinds of authorization concerns:
- Host-level concerns — managing tenants, reading cross-tenant audit logs, rotating platform-wide secrets. Relevant only in an admin context where no tenant is active.
- Tenant-level concerns — reading invoices, updating customers, approving workflows. Only meaningful inside a specific tenant’s scope.
Without an explicit marker, nothing stops an operator from granting Tenants.Manage to
a tenant user or from issuing a tenant-scoped check for an inherently host-level
permission. Granit 2026.05 adds two aligned features that close this gap:
- A
MultiTenancySidetag on everyPermissionDefinition(Host,Tenant,Both). - A multi-provider grant model (role / user / OIDC client) with a deterministic evaluation order.
Multi-tenancy remains optional: consumers that do not reference
Granit.MultiTenancy keep their current behavior — the absent module resolves to a
NullTenantContext, which is always “host-side” from the checker’s point of view.
MultiTenancySide
Section titled “MultiTenancySide”group.AddPermission( MyPermissions.Tenants.Manage, L("Permission:MyModule.Tenants.Manage"), MultiTenancySide.Host);
group.AddPermission( MyPermissions.Invoices.Delete, L("Permission:MyModule.Invoices.Delete"), MultiTenancySide.Tenant);
group.AddPermission( MyPermissions.Profile.Read, L("Permission:MyModule.Profile.Read")); // default = BothThe MultiTenancySide property on the definition is consulted by
IPermissionChecker.IsGrantedAsync before any store lookup:
- Permission is
HostandICurrentTenant.IsAvailable == true→ denied, no DB hit. - Permission is
TenantandICurrentTenant.IsAvailable == false→ denied, no DB hit. - Otherwise, normal grant evaluation proceeds.
At grant time, the built-in
MultiTenancySidePermissionGrantValidator refuses to persist a grant whose
TenantId contradicts the permission’s side:
| Permission side | Accepted TenantId on grant |
|---|---|
Host | null only (host-level grant) |
Tenant | non-null only (tenant-scoped grant) |
Both | any |
Custom validators can compose on top of the default chain via
TryAddEnumerable<IPermissionGrantValidator> for domain-specific rules (e.g. reject
if the target role does not exist in the IdP).
Grant providers
Section titled “Grant providers”Grants are no longer role-only. A PermissionGrant row now carries:
public sealed class PermissionGrant : AuditedEntity, IMultiTenant{ public string Name { get; init; } // "Invoices.Delete" public string ProviderName { get; init; } // "R" | "U" | "C" public string ProviderKey { get; init; } // role name | user id | client id public Guid? TenantId { get; set; } // null = host-level grant}The storage lives in the host schema (aligned with Identity and OpenIddict), so a host admin user without a tenant can receive grants on the same footing as a tenant-scoped user.
The canonical provider names are defined once and reused everywhere:
public static class PermissionGrantProviderNames{ public const string Role = "R"; public const string User = "U"; public const string Client = "C";}Evaluation order
Section titled “Evaluation order”Registered providers are consulted in the order they are added to the DI container. The default order is specific → generic:
User (U) ──▶ Role (R) ──▶ Client (C)A user-level grant short-circuits role and client lookups; a role-level grant short-circuits the client lookup. This ordering is deliberate:
- It matches the principle of least surprise — explicit user permissions win.
- It prepares a future
Denysemantic where the most specific entry takes precedence.
Writing a custom provider
Section titled “Writing a custom provider”A provider is a read-only projection of the current principal — it does not run the
actual grant query (the shared IPermissionGrantStore handles persistence and caching).
internal sealed class OrganizationPermissionGrantProvider : IPermissionGrantProvider{ public string Name => "O";
public IReadOnlyList<string> GetProviderKeys(PermissionGrantLookupContext context) => // Organization id is surfaced by a custom ICurrentUserService extension; // or consume IHttpContextAccessor / custom claims here. OrganizationResolver.Resolve(context) is { } orgId ? [orgId] : [];}builder.Services.TryAddEnumerable( ServiceDescriptor.Singleton<IPermissionGrantProvider, OrganizationPermissionGrantProvider>());Granting permissions
Section titled “Granting permissions”The HTTP endpoints under POST/DELETE /api/authorization/roles/{role}/{permission}
still target roles. Lower-level consumers can grant to users or clients via
IPermissionManagerWriter.SetAsync:
await permissionManagerWriter.SetAsync( permissionName: "Invoices.Delete", providerName: PermissionGrantProviderNames.User, providerKey: userId.ToString(), tenantId: currentTenant.Id, isGranted: true);The writer runs the IPermissionGrantValidator chain before persisting — including
the built-in side validator.
Cache key layout
Section titled “Cache key layout”Cache keys are scoped by (tenantId, providerName, providerKey, permissionName) so
that a role named "alice" and a user with id "alice" never collide:
perm:{tenantId|"global"}:{providerName}:{providerKey}:{permissionName}Cache invalidation (PermissionCacheInvalidationHandler) uses the exact same key
layout, driven by the updated PermissionGrantChangedEvent.
Storage placement
Section titled “Storage placement”The PermissionGrant table lives in the host schema reached via
GranitDbDefaults.HostDbSchema (with fallback to DbSchema for shared-database
setups). Wire it from the host context:
protected override void OnModelCreating(ModelBuilder modelBuilder){ base.OnModelCreating(modelBuilder); modelBuilder.ConfigureOpenIddictModule(); modelBuilder.ConfigureAuthorizationModule(); // new: grants are host-level}Tenant-scoped grants simply carry the target TenantId; host-level grants have
TenantId == null. A single table, a single migration — no per-tenant duplication of
authorization state.