Skip to content

Authorization — Host/Tenant scope & multi-provider grants

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:

  1. A MultiTenancySides tag on every PermissionDefinition (Host, Tenant, Both).
  2. 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.

MyModulePermissionDefinitionProvider.cs
group.AddPermission(
MyPermissions.Tenants.Manage,
L("Permission:MyModule.Tenants.Manage"),
MultiTenancySides.Host);
group.AddPermission(
MyPermissions.Invoices.Delete,
L("Permission:MyModule.Invoices.Delete"),
MultiTenancySides.Tenant);
group.AddPermission(
MyPermissions.Profile.Read,
L("Permission:MyModule.Profile.Read")); // default = Both

The MultiTenancySides property on the definition is consulted by IPermissionChecker.IsGrantedAsync before any store lookup:

  • Permission is Host and ICurrentTenant.IsAvailable == true → denied, no DB hit.
  • Permission is Tenant and ICurrentTenant.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).

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";
}

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 Deny semantic where the most specific entry takes precedence.

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).

OrganizationPermissionGrantProvider.cs
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] : [];
}
Program.cs
builder.Services.TryAddEnumerable(
ServiceDescriptor.Singleton<IPermissionGrantProvider, OrganizationPermissionGrantProvider>());

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 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.

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:

AppHostDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ConfigureOpenIddictModule();
modelBuilder.ConfigureAuthorizationModule(); // grants + role metadata, 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.

MultiTenancySides is not only a permission property — it is also a property of the role the permission is granted to. The RoleMetadata aggregate (introduced alongside this feature) carries the same Host / Tenant / Both enum plus an optional TenantId (required when the side is Tenant):

| Role side | TenantId | Usable from | Assignable to | | --- | --- | --- | --- | | Host | null | Host admin context only | Cross-tenant platform users | | Tenant | specific | Matching tenant context only | Users of that tenant | | Both | null | Host and tenant contexts | Defined by the platform, assigned per tenant |

The built-in MultiTenancySidePermissionGrantValidator looks up the target role via IRoleMetadataStore whenever ProviderName == "R" and rejects inconsistent grants with one of two reason codes:

  • role_side_forbidden — the role side forbids the grant’s scope (Host role + tenant grant, or Tenant role + host grant).
  • role_tenant_mismatch — the role belongs to a different tenant than the grant’s TenantId.

A role without metadata falls through the role-side check and is evaluated only against the permission side — preserving backward compatibility with roles created outside the Granit RoleMetadata surface (legacy rows, provider-native roles in early setups).

See Roles — RoleMetadata and /admin/roles CRUD for the full CRUD surface, the admin visibility matrix, and the system roles seeded by IdentityLocalRoleSeedContributor.