Skip to content

Security Model

Modern APIs need three things from their security stack:

  • Pluggable authentication — swap Keycloak for Entra ID or Cognito without touching application code.
  • Fine-grained authorization — control access at the resource-action level without per-user permission assignments that spiral into unmanageable matrices.
  • Compliance-ready audit trails — every authenticated action must be traceable for ISO 27001 and GDPR.

Most frameworks punt on this: they give you [Authorize] and leave the rest to you. Granit provides a layered security stack where each layer has a single responsibility and can be replaced independently.

The authentication pipeline is built from a base package with provider-specific modules layered on top. Each provider module adds one concern: claims transformation from the provider’s JWT format to standard .NET role claims.

graph TD
    S["Granit.Security<br/>ICurrentUserService abstraction"] --> C[Granit.Core]
    JWT["Granit.Authentication.JwtBearer<br/>Generic OIDC JWT Bearer"] --> S
    KC["Granit.Authentication.Keycloak<br/>Keycloak claims transformation"] --> JWT
    EID["Granit.Authentication.EntraId<br/>Entra ID roles parsing"] --> JWT
    CG["Granit.Authentication.Cognito<br/>Cognito groups → roles"] --> JWT

ICurrentUserService is the sole abstraction for “who is calling.” It lives in Granit.Security with zero dependency on ASP.NET Core, so domain services and background jobs can consume it without pulling in the HTTP stack.

public interface ICurrentUserService
{
string? UserId { get; }
string? UserName { get; }
string? Email { get; }
bool IsAuthenticated { get; }
ActorKind ActorKind { get; }
IReadOnlyList<string> GetRoles();
bool IsInRole(string role);
}

ActorKind distinguishes User (human), ExternalSystem (API key or service account), and System (background jobs). The AuditedEntityInterceptor uses this to fill CreatedBy/ModifiedBy — when no user is authenticated, it falls back to "system".

Granit.Authentication.JwtBearer — generic OIDC

Section titled “Granit.Authentication.JwtBearer — generic OIDC”

This module registers ASP.NET Core JWT Bearer authentication, a CurrentUserService backed by HttpContext.User, and an IRevokedSessionStore for back-channel logout. It also registers the "Authenticated" authorization policy, which requires a valid JWT with no further claims.

Any OIDC-compliant provider works out of the box:

{
"Authentication": {
"Authority": "https://idp.example.com",
"Audience": "my-api",
"RequireHttpsMetadata": true,
"NameClaimType": "sub"
}
}

Granit.Authentication.Keycloak — PostConfigure pattern

Section titled “Granit.Authentication.Keycloak — PostConfigure pattern”

The Keycloak module does not register its own authentication scheme. Instead, it post-configures JwtBearerOptions to add Keycloak-specific claims transformation. This is the key design choice: the consumer declares a single [DependsOn], and the module wires itself in automatically.

[DependsOn(typeof(GranitAuthenticationKeycloakModule))]
public class AppModule : GranitModule { }

Behind the scenes, the module:

  1. Reads realm_access.roles and resource_access.{clientId}.roles from the JWT payload.
  2. Maps them to standard ClaimTypes.Role claims.
  3. Registers an "Admin" authorization policy matching the configured admin role.

No manual PostConfigure<JwtBearerOptions> calls, no claims transformation boilerplate.

Granit.Authentication.EntraId — Microsoft Entra ID

Section titled “Granit.Authentication.EntraId — Microsoft Entra ID”

For Azure-based deployments, the Entra ID module parses roles from both the v1.0 roles claim and the v2.0 wids claim. Like the Keycloak module, it post-configures JwtBearerOptions and maps provider-specific claims to standard ClaimTypes.Role.

Granit.Authentication.Cognito — AWS Cognito

Section titled “Granit.Authentication.Cognito — AWS Cognito”

For AWS-based deployments, the Cognito module extracts groups from the cognito:groups claim and maps them to standard ClaimTypes.Role. Cognito has no native “roles” concept — groups serve as roles.

[DependsOn(typeof(GranitAuthenticationKeycloakModule))]
[DependsOn(typeof(GranitAuthorizationModule))]
public class AppModule : GranitModule { }

Granit enforces a strict rule: all permissions are attached to roles, never to individual users. This is not a suggestion — the framework provides no API for user-level permission grants. The reason is practical: user-level permissions create an N-users x M-permissions matrix that becomes unmanageable within months.

Permissions follow the pattern [Module].[Resource].[Action]:

ActionMeaningExample
ReadView resourcesInvoices.Invoices.Read
CreateCreate new resourcesInvoices.Invoices.Create
UpdateModify existing resourcesInvoices.Invoices.Update
DeleteRemove resourcesInvoices.Invoices.Delete
ManageFull CRUD (implies all four above)Invoices.Invoices.Manage
ExecuteNon-CRUD actionDataExchange.Imports.Execute

Modules declare their permissions via IPermissionDefinitionProvider:

public class InvoicePermissionDefinitionProvider : IPermissionDefinitionProvider
{
public void DefinePermissions(IPermissionDefinitionContext context)
{
var group = context.AddGroup("Invoices", "Invoice management");
group.AddPermission("Invoices.Invoices.Read", "View invoices");
group.AddPermission("Invoices.Invoices.Create", "Create invoices");
group.AddPermission("Invoices.Invoices.Update", "Edit invoices");
group.AddPermission("Invoices.Invoices.Delete", "Delete invoices");
}
}

ASP.NET Core requires a named AuthorizationPolicy for every [Authorize(Policy = "...")]. Registering hundreds of policies at startup is wasteful. Instead, DynamicPermissionPolicyProvider creates policies on-the-fly from the permission name. When ASP.NET Core asks for a policy named Invoices.Invoices.Read, the provider builds one that delegates to IPermissionChecker.

Permission checks are cached by role, not by user. With K roles and M permissions, the cache holds K x M entries. Compare this to N users x M permissions in a user-level scheme — for a system with 10,000 users, 5 roles, and 200 permissions, that is 1,000 cache entries instead of 2,000,000.

Cache key format: perm:{tenantId}:{roleName}:{permissionName}

Default TTL: 5 minutes, configurable via Authorization:CacheDuration.

Roles listed in Authorization:AdminRoles bypass all permission checks entirely. This is a configured list, not a hardcoded constant. The bypass is evaluated before the cache lookup, so admin requests never touch the permission store.

{
"Authorization": {
"AdminRoles": ["admin"],
"CacheDuration": "00:05:00"
}
}
flowchart LR
    A[Incoming request] --> B{AlwaysAllow?}
    B -->|yes| C[Granted]
    B -->|no| D{AdminRole?}
    D -->|yes| C
    D -->|no| E{Cache hit?}
    E -->|yes| F{Granted?}
    E -->|no| G[IPermissionGrantStore]
    G --> H[Cache result by role]
    H --> F
    F -->|yes| C
    F -->|no| I[Denied — 403]

The PermissionChecker evaluates in strict order:

  1. AlwaysAllow — development-only escape hatch. The option validator rejects it outside Development environment.
  2. AdminRole bypass — users with any role in AdminRoles skip all further checks.
  3. Per-role cache — checks all roles the user holds, grants if any role has the permission.
  4. IPermissionGrantStore — queries the backing store (EF Core or custom). Result is cached for subsequent requests.

Granit implements the OIDC Back-Channel Logout specification, provider-agnostic.

The flow:

  1. User logs out from the identity provider.
  2. The IdP POSTs a logout_token to your API at /auth/back-channel-logout.
  3. The endpoint validates the token: signature (against IdP JWKS), issuer, audience.
  4. The sid (session ID) claim is extracted and stored in IDistributedCache with key granit:revoked-session:{sid}.
  5. Subsequent requests carrying a JWT with a revoked sid are rejected by the JWT Bearer events handler.
// In OnApplicationInitialization
app.MapBackChannelLogout(); // POST /auth/back-channel-logout (anonymous)
{
"Authentication": {
"BackChannelLogout": {
"Enabled": true,
"EndpointPath": "/auth/back-channel-logout",
"SessionRevocationTtl": "01:00:00"
}
}
}

The complete security pipeline for an authenticated request:

sequenceDiagram
    participant Client
    participant JWT as JWT Bearer Middleware
    participant CT as ClaimsTransformation
    participant DPP as DynamicPolicyProvider
    participant PC as PermissionChecker
    participant Cache as IDistributedCache
    participant Store as IPermissionGrantStore

    Client->>JWT: GET /invoices (Bearer token)
    JWT->>JWT: Validate signature, issuer, audience
    JWT->>JWT: Check sid not in revoked sessions
    JWT->>CT: Transform claims
    CT->>CT: Extract roles (Keycloak/EntraID/Cognito/generic)
    CT-->>JWT: ClaimsPrincipal with Role claims

    Note over DPP: [Permission("Invoices.Invoices.Read")]
    DPP->>PC: Check permission for user roles
    PC->>PC: AdminRole? Skip if yes
    PC->>Cache: Lookup perm:{tenantId}:{role}:{permission}
    alt Cache miss
        Cache-->>PC: miss
        PC->>Store: Query grants for role
        Store-->>PC: granted/denied
        PC->>Cache: Store result (TTL 5 min)
    else Cache hit
        Cache-->>PC: granted/denied
    end
    PC-->>Client: 200 OK or 403 Forbidden

Why no user-level permissions? Permission creep. In every system we have built, user-level overrides start as “just one exception” and end as an unauditable mess. Role-based grants are reviewable: you can answer “what can role X do?” in one query. “What can user Y do?” across 50 individual grants is a different story.

Why cache by role, not by user? A role’s permissions change rarely (admin action). A user’s role membership changes more often (HR onboarding). By caching at the role level, a permission grant change invalidates K cache entries (one per role), not N entries (one per user).

Why PostConfigure instead of a separate auth scheme? ASP.NET Core’s default authentication scheme is the one that runs on [Authorize]. If the Keycloak module registered its own scheme, every endpoint would need [Authorize(AuthenticationSchemes = "Keycloak")]. PostConfigure modifies the default JWT Bearer scheme in place, so [Authorize] just works.