Skip to content

Authorization Pattern

Module-based access control beyond role-based auth.

Overview

flowchart TB
    A[Request] --> B{Authenticated?}
    B -->|No| X1[401]
    B -->|Yes| C{Has Role?}
    C -->|No| X2[403]
    C -->|Yes| D{Module Enabled?}
    D -->|No| X3[403]
    D -->|Yes| E[Handler]

Module Types

See Feature Modules for the available modules and their features.

Authorization Attribute

Applied to controllers to require module access:

[AuthorizeModule(ModuleType.Budget)]
public sealed class TransactionsController : ApiControllerBase

The attribute extends AuthorizeAttribute and sets a policy:

public sealed class AuthorizeModuleAttribute : AuthorizeAttribute
{
    public AuthorizeModuleAttribute(ModuleType module)
    {
        Policy = module.ToString();
    }
}

Policy Configuration

Policies are configured at startup for each module:

foreach (var module in Enum.GetValues<ModuleType>())
{
    builder.AddPolicy(module.ToString(), policy =>
        policy.RequireAssertion(context =>
        {
            var claim = context.User.FindFirst(AuthConstants.ModulesClaim)?.Value;
            if (string.IsNullOrEmpty(claim))
            {
                return true;
            }
            var modules = JsonSerializer.Deserialize<List<string>>(claim) ?? [];
            return modules.Contains(module.ToString());
        }));
}

Permission Version

When permissions change, the user's permission version increments:

user.PermissionVersion++;
await context.SaveChangesAsync();

JWT includes version claim:

new Claim("perms_version", user.PermissionVersion.ToString())

Middleware validates version matches - forces token refresh on mismatch.

Combining with Roles

[Authorize(Roles = "Admin")]
[AuthorizeModule(ModuleType.Budget)]
public sealed record DeleteCategoryCommand(...);

Both checks must pass.

Admin Bypass

Admins (configured via Security__AdminUserNames) bypass module checks but still require authentication.