Authorization Guide
Wallow uses role-based access control (RBAC) with permission expansion. ASP.NET Core Identity with OpenIddict manages authentication; the API expands roles into granular permissions at request time.
A role is granted by an organization, not by the platform. One person has one identity and a membership in each organization they belong to, and their roles hang off that membership — so the same person can be admin in one organization and user in another. Which set applies is decided when a token is issued, from the organization the OIDC client is bound to; IMembershipRoleResolver resolves it and only an active membership counts.
How It Works
JWT with role claims
│
▼
┌─────────────────────────────┐
│ PermissionExpansionMiddleware │
│ Reads roles from token │
│ Expands to permission claims │
└─────────────┬───────────────┘
▼
┌─────────────────────────────┐
│ [HasPermission] attribute │
│ Checks permission claims │
└─────────────────────────────┘
- User authenticates through an OIDC client. A third-party client is bound to one organization; a first-party client names one through the
organizationauthorize parameter, or falls back to the user's single membership — either way the same enrollment policy decides admission (see Authentication § Organization context) - The token carries the roles that user holds in that organization (e.g.,
admin,manager,user), plus itsorg_id. A first-party token issued without a hint to a user with several (or no) memberships carries noorg_id: it holds no roles, andTenantResolutionMiddlewareanswers403on every tenant-scoped endpoint, letting through only actions marked[AllowWithoutOrganization](profile, my organizations, create organization, accept invitation) PermissionExpansionMiddlewarereads the roles and adds permission claims to the request identity- Controller actions decorated with
[HasPermission]check for specific permissions
A role earns nothing outside the organization that granted it: on a cross-tenant request (a global-admin or operator override via X-Tenant-Id) the middleware expands no role and no scope, so the only grant that crosses an organization boundary is the seeded global-admin flag.
Adding Permissions to Routes
Step 1: Choose or Add a Permission
Permissions are defined as string constants in:
api/src/Shared/Wallow.Shared.Kernel/Identity/Authorization/PermissionType.cs
Naming convention: {Domain}{Action} — e.g., InquiriesRead, InquiriesWrite, WebhooksManage.
Step 2: Map Permission to Roles
Edit the role-to-permission mapping in:
api/src/Shared/Wallow.Shared.Kernel/Identity/Authorization/RolePermissionMapping.cs
The mapping uses a FrozenDictionary<string, string[]> keyed by role name (case-insensitive). Each role maps to an explicit array of PermissionType constants.
Step 3: Apply to Controller or Action
Add the [HasPermission] attribute:
using Wallow.Shared.Kernel.Identity.Authorization;
[ApiController]
[Route("v{version:apiVersion}/inquiries")]
[Authorize]
public partial class InquiriesController : ControllerBase
{
[HttpGet]
[HasPermission(PermissionType.InquiriesRead)]
public async Task<IActionResult> GetAll([FromQuery] string? status, CancellationToken cancellationToken) { /* ... */ }
[HttpPost]
[HasPermission(PermissionType.InquiriesWrite)]
public async Task<IActionResult> Submit([FromBody] SubmitInquiryRequest request, CancellationToken cancellationToken) { /* ... */ }
}
The version segment is substituted from the default API version, so these actions are served at
/v1/inquiries. Routes carry no api/ prefix.
You can apply [HasPermission] at the controller level (all actions) or individual action level.
Step 4: Add Project Reference (if needed)
HasPermissionAttribute and PermissionType both live in Wallow.Shared.Kernel, which all modules already reference. No additional project references are needed.
Adding New Roles
Step 1: Define the Role
Add the role through the Identity module's role management API or seed it in a database migration.
Step 2: Map Permissions to the Role
Add the role to RolePermissionMapping.cs with an explicit array of PermissionType constants.
Step 3: Assign the Role in an Organization
Roles are assigned per membership, so an assignment names both the user and the organization:
IUserManagementService.AssignRoleAsync(userId, organizationId, roleName) and its RemoveRoleAsync
counterpart. The organization comes from the caller's own tenant context, so an admin grants roles
only where they are an admin. A user with no active membership in that organization cannot be
granted a role there.
Service Account Permissions
Service accounts (machine-to-machine) and API keys use OAuth2 scopes instead of roles. The middleware detects service accounts by the client_id prefix (sa- for operator service accounts, app- for developer apps) and maps their scopes to permissions.
Scope-to-permission mapping is defined in:
api/src/Shared/Wallow.Shared.Kernel/Identity/Authorization/ScopePermissionMapper.cs
Scope naming convention: {domain}.{action} — e.g., inquiries.read, inquiries.write.
For regular user tokens, the middleware first expands roles to permissions, then supplements with any granted OAuth2 scopes (covering cases where role claims are absent from the token).
Quick Reference
Files to Edit
| Task | File |
|---|---|
| Add permission | Shared/Wallow.Shared.Kernel/Identity/Authorization/PermissionType.cs |
| Map permission to role | Shared/Wallow.Shared.Kernel/Identity/Authorization/RolePermissionMapping.cs |
| Map scope to permission | Shared/Wallow.Shared.Kernel/Identity/Authorization/ScopePermissionMapper.cs |
| Apply to route | Your controller with [HasPermission(...)] |
Existing Roles
Every role below is held within one organization. The same catalog is used everywhere; who holds which entry is a property of the membership, not of the user.
| Role | Description |
|---|---|
admin |
All permissions (explicitly listed) |
manager |
User read, organization management, API keys, configuration, inquiries read |
user |
Organization read, messaging, notifications, announcements read, storage, API key read/create, inquiries write |
Note:
PermissionTypeis a static class with string constants (not a numeric enum). Permissions are grouped by domain area. The current active modules are: Identity, Storage, Notifications, Announcements, Inquiries, ApiKeys, and Branding.
Multi-Tenancy Authorization
Wallow uses JWT claims for multi-tenancy. The TenantResolutionMiddleware extracts the tenant ID from standard JWT claims (via ClaimsPrincipalExtensions.GetTenantId()) and populates ITenantContext.
How Tenant Resolution Works
JWT with tenant claims
│
▼
┌─────────────────────────────────────┐
│ TenantResolutionMiddleware │
│ - Reads tenant ID/name from claims │
│ - Sets ITenantContext via setter │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ EF Core Global Query Filters │
│ - Automatically filter by TenantId │
└─────────────────────────────────────┘
Admin Tenant Override
Only callers whose token carries the non-assignable is_global_admin claim or the is_operator platform-operator claim (ClaimsPrincipalExtensions.IsGlobalAdmin() / IsOperator()) can switch tenant context using the X-Tenant-Id header:
curl -H "Authorization: Bearer $TOKEN" \
-H "X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000" \
http://localhost:5001/v1/inquiries
This allows global admins and platform operators to view data across tenants for support scenarios. The admin role does not qualify: it is granted tenant-side by one organization, so it must never reach another tenant — TenantResolutionMiddleware ignores the header for role-holders. The sa-/app- client-ID prefixes play no part here either; prefix detection belongs only to the scope-to-permission mapping in PermissionExpansionMiddleware.
Accessing Tenant Context in Code
Inject ITenantContext to access the current tenant:
public class InvoiceService(ITenantContext tenantContext)
{
public async Task<List<Invoice>> GetInvoicesAsync()
{
// TenantId is already set by middleware
// EF Core global query filters handle filtering automatically
}
}
MFA Lockout
MFA lockout is a separate mechanism from ASP.NET Core Identity's password lockout. Both can be active simultaneously — they protect different authentication stages.
How It Works
MFA code submitted
│
▼
┌─────────────────────────────────────────┐
│ Check IsMfaLockedOut() │
│ - Active lockout? → 423 immediately │
└─────────────────────────────────────────┘
│ Not locked out
▼
┌─────────────────────────────────────────┐
│ Validate TOTP / backup code │
│ - Valid? → complete login, reset count │
│ - Invalid? → RecordFailure() │
└─────────────────────────────────────────┘
│ Invalid
▼
┌─────────────────────────────────────────┐
│ IMfaLockoutService.RecordFailureAsync() │
│ - Increment MfaFailedAttempts │
│ - On 5th failure: lock + escalate │
└─────────────────────────────────────────┘
Lockout Thresholds and Durations
A lockout triggers after 5 consecutive failed MFA attempts. WallowUser.RecordMfaFailure computes the duration as 15 * 2^MfaLockoutCount minutes, so each subsequent lockout doubles until it hits the 24-hour cap on the eighth:
| Lockout # | Duration |
|---|---|
| 1st | 15 minutes |
| 2nd | 30 minutes |
| 3rd | 1 hour |
| 4th | 2 hours |
| 5th | 4 hours |
| 6th | 8 hours |
| 7th | 16 hours |
| 8th+ | 24 hours (the cap; the doubling would give 32 hours) |
The MfaLockoutCount on the user record tracks how many times the user has been locked out. It is only reset by an admin clear (not by a successful login), so repeat offenders accumulate progressively longer lockouts.
Error Response
When a user is locked out, the API returns HTTP 423 Locked:
{ "succeeded": false, "error": "mfa_locked_out" }
The lockout end time is not included in the response body — clients should display a generic "too many attempts" message and not expose the exact unlock time to callers.
A UserMfaLockedOutEvent is published on the Wolverine bus when a lockout occurs, allowing the Notifications module to email the user.
Relationship to Password Lockout
ASP.NET Core Identity's built-in password lockout (failed SignInManager.PasswordSignInAsync calls) operates independently of MFA lockout:
- Password lockout triggers during the password step and uses Identity's
LockoutEnd/AccessFailedCountfields. - MFA lockout triggers during the MFA step and uses
MfaLockoutEnd/MfaFailedAttemptsfields onWallowUser.
A user could be subject to both simultaneously. The admin clear-lockout endpoint (below) resets both.
Admin Clear-Lockout Endpoint
Admins can clear all lockout state for a user — both password lockout and MFA lockout — via:
POST /v1/identity/mfa/admin/{userId}/clear-lockout
Requires: Authorization: Bearer <admin-token> (caller must have the admin role).
What it clears:
LockoutEndandAccessFailedCount(Identity password lockout)MfaLockoutEnd,MfaFailedAttempts, andMfaLockoutCount(MFA lockout, including the escalation counter)- The Valkey cache entry for the MFA lockout
A UserMfaLockoutClearedEvent is published after a successful clear, recording which admin performed the action.
Example:
curl -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:5001/v1/identity/mfa/admin/550e8400-e29b-41d4-a716-446655440000/clear-lockout
Response:
{ "succeeded": true }
Troubleshooting MFA lockout
423on MFA submit but lockout time has passed — the Valkey cache entry may outlive the DB record in edge cases; an admin clear resolves this- Admin clear returns
404— verify the user ID is correct; the endpoint looks up by Identity user ID (GUID), not email
Access Token Format
Access tokens are plain signed JWTs, not encrypted ones. OpenIddict
encrypts access tokens into JWEs by default; Wallow deliberately turns that off
(DisableAccessTokenEncryption() in IdentityInfrastructureExtensions).
This is a considered trade, not an omission:
- Why it is safe today: the only resource server is the API itself — the same process that issued the token. Encryption would protect claims from a resource server that should not read them, and no such party exists. Under the BFF pattern the browser never holds the token at all.
- What it buys: tokens are debuggable (
jwt.io, as the troubleshooting sections here assume), and a fork that adds a separate resource server can validate tokens with nothing but the public JWKS — encrypted tokens would force it to share the private encryption certificate with every resource server. - What it costs: any holder of a token can read its claims. Claims are therefore not a place for secrets — nothing may go into a token claim that the authenticated client itself must not see.
- When to revisit: if tokens ever start carrying claims that some audience must not read, re-enable encryption for those audiences rather than trying to scrub claims per-client.
Refresh tokens and authorization codes remain encrypted with the OpenIddict encryption certificate — see Key Rotation.
Middleware Pipeline Order
The authorization middleware must be registered in the correct order in Program.cs:
1. UseAuthentication() - OpenIddict JWT validation
2. TenantResolutionMiddleware - Reads tenant claims → ITenantContext
3. PermissionExpansionMiddleware - Expands roles/scopes → permission claims
4. UseAuthorization() - Enforces [HasPermission] attributes
Warning: Reordering these middlewares will break authorization. PermissionExpansionMiddleware requires an authenticated user to have claims to expand.
Authorization in the Frontend
The API is the only enforcement point. The browser helpers exist to decide what to render, and they are deliberately built to answer the same way the server does — a control the UI shows but the next request refuses is a broken screen.
@bc-solutions-coder/auth is the single import site for both:
| Helper | Reads | Comparison |
|---|---|---|
hasRole(user, role) |
CurrentUser.roles |
case-INsensitive |
hasPermission(user, permission) |
CurrentUser.permissions |
case-SENSITIVE |
isAdmin(user) |
CurrentUser.roles |
hasRole(user, "admin"), named |
The asymmetry is not an oversight — it mirrors this document:
- Roles are compared case-insensitively because
ClaimsPrincipalExtensions.GetRoles()deduplicates withStringComparer.OrdinalIgnoreCase. - Permissions are compared case-sensitively because
PermissionAuthorizationHandlerdecides with a plain ordinalpermissions.Contains(requirement.Permission).
The user itself comes from useCurrentUser(client), or from ensureCurrentUser({ queryClient, client })
in a route's beforeLoad. See packages/auth/CLAUDE.md for the full export table.
Troubleshooting
403 Forbidden but user has the role
- Check the user holds that role in the organization the token names — decode the token and compare its
roleclaims against itsorg_id; a role held in another organization is invisible here - Check the membership is active; a pending one grants nothing
- Check
RolePermissionMappingincludes the permission for that role - Verify the role name matches (comparison is case-insensitive)
- Check the JWT contains the role claim (decode at jwt.io)
Permission not being checked
- Ensure
[Authorize]is on the controller (authentication required first) - Verify
[HasPermission]attribute is applied HasPermissionAttributeis inWallow.Shared.Kernel— all modules reference this already
Service account getting 403
- Verify the scope is in the token
- Check
ScopePermissionMapper.MapScopeToPermissionincludes the mapping - Confirm the client ID prefix is correct (
sa-orapp-)
A control renders but the request is refused
- Check the casing:
hasPermissionis case-sensitive and must match thePermissionTypeconstant exactly - Confirm the permission actually reached the token —
PermissionExpansionMiddlewareexpands role claims, so a permission granted by no role never appears
Related Documentation
- Authentication — how the token the permissions ride on is issued
- Module Creation — adding a permission-guarded controller to a new module
- Service Accounts — scopes and the machine-to-machine path
packages/auth/CLAUDE.md— the browser-sidehasRole/hasPermissionlayer