Security Model
The problem
Section titled “The problem”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.
Authentication stack
Section titled “Authentication stack”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
Granit.Security — the foundation
Section titled “Granit.Security — the foundation”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:
- Reads
realm_access.rolesandresource_access.{clientId}.rolesfrom the JWT payload. - Maps them to standard
ClaimTypes.Roleclaims. - 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 { }[DependsOn(typeof(GranitAuthenticationEntraIdModule))][DependsOn(typeof(GranitAuthorizationModule))]public class AppModule : GranitModule { }[DependsOn(typeof(GranitAuthenticationCognitoModule))][DependsOn(typeof(GranitAuthorizationModule))]public class AppModule : GranitModule { }[DependsOn(typeof(GranitJwtBearerModule))][DependsOn(typeof(GranitAuthorizationModule))]public class AppModule : GranitModule { }Authorization — RBAC strict
Section titled “Authorization — RBAC strict”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.
Permission naming convention
Section titled “Permission naming convention”Permissions follow the pattern [Module].[Resource].[Action]:
| Action | Meaning | Example |
|---|---|---|
Read | View resources | Invoices.Invoices.Read |
Create | Create new resources | Invoices.Invoices.Create |
Update | Modify existing resources | Invoices.Invoices.Update |
Delete | Remove resources | Invoices.Invoices.Delete |
Manage | Full CRUD (implies all four above) | Invoices.Invoices.Manage |
Execute | Non-CRUD action | DataExchange.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"); }}DynamicPermissionPolicyProvider
Section titled “DynamicPermissionPolicyProvider”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.
Per-role caching
Section titled “Per-role caching”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.
AdminRoles bypass
Section titled “AdminRoles bypass”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" }}Permission checking pipeline
Section titled “Permission checking pipeline”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:
- AlwaysAllow — development-only escape hatch. The option validator rejects it outside
Developmentenvironment. - AdminRole bypass — users with any role in
AdminRolesskip all further checks. - Per-role cache — checks all roles the user holds, grants if any role has the permission.
- IPermissionGrantStore — queries the backing store (EF Core or custom). Result is cached for subsequent requests.
Back-channel logout
Section titled “Back-channel logout”Granit implements the OIDC Back-Channel Logout specification, provider-agnostic.
The flow:
- User logs out from the identity provider.
- The IdP POSTs a
logout_tokento your API at/auth/back-channel-logout. - The endpoint validates the token: signature (against IdP JWKS), issuer, audience.
- The
sid(session ID) claim is extracted and stored inIDistributedCachewith keygranit:revoked-session:{sid}. - Subsequent requests carrying a JWT with a revoked
sidare rejected by the JWT Bearer events handler.
// In OnApplicationInitializationapp.MapBackChannelLogout(); // POST /auth/back-channel-logout (anonymous){ "Authentication": { "BackChannelLogout": { "Enabled": true, "EndpointPath": "/auth/back-channel-logout", "SessionRevocationTtl": "01:00:00" } }}Request flow
Section titled “Request flow”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
Design decisions
Section titled “Design decisions”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.
Next steps
Section titled “Next steps”- Security reference — full API surface, configuration tables, EF Core permission store
- Compliance concept — how the security model supports GDPR and ISO 27001
- Identity reference — user management via Keycloak Admin API or Cognito User Pool API