Skip to content

Authorization

The Arc provides enhanced authorization capabilities that build upon ASP.NET Core’s built-in authorization system. It offers role-based authorization through specialized attributes and integrates authorization state into command and query results across controllers, model-bound commands, and queries.

Ensure that authentication and authorization are enabled in your application pipeline:

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

Note: If you’re interested in leveraging the Microsoft Identity way of working with identity, read more about Microsoft Identity integration

By default, ASP.NET Core endpoints are accessible to anonymous users unless explicitly protected with authorization attributes. You can change this behavior to require authentication for all endpoints by setting a fallback authorization policy.

The fallback policy applies to all endpoints that don’t have an explicit authorization policy:

builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());

With this configuration:

  • All endpoints require authentication by default - No anonymous access unless explicitly allowed
  • Use [AllowAnonymous] to opt specific endpoints out of the requirement
  • Explicit [Authorize] attributes still work - They override the fallback policy with their own requirements

Note: Fallback policies are a standard ASP.NET Core authorization feature. For more details on authorization policies, policy requirements, and advanced scenarios, refer to the ASP.NET Core authorization documentation.

Allowing Anonymous Access with Fallback Policy

Section titled “Allowing Anonymous Access with Fallback Policy”

When using a fallback policy, use [AllowAnonymous] to make specific endpoints publicly accessible:

// This command requires authentication (from fallback policy)
[Command]
public record ProcessOrder(OrderId Id)
{
public void Handle(IOrderService orders) => orders.Process(Id);
}
// This command is publicly accessible despite the fallback policy
[Command]
[AllowAnonymous]
public record GetPublicCatalog()
{
public Catalog Handle(ICatalogService catalog) => catalog.GetPublic();
}

The [AllowAnonymous] attribute can be applied at different levels and follows specific inheritance rules:

ScenarioResult
[AllowAnonymous] on typeAll methods inherit anonymous access
[AllowAnonymous] on methodMethod allows anonymous access
[Authorize] on method with [AllowAnonymous] on typeMethod requires authorization (overrides type)
Both [AllowAnonymous] and [Authorize] on same memberError - throws AmbiguousAuthorizationLevel
// Type-level AllowAnonymous - all methods allow anonymous access
[AllowAnonymous]
public record PublicQueries
{
public static IEnumerable<Product> GetProducts() => /* ... */;
public static IEnumerable<Category> GetCategories() => /* ... */;
}
// Method-level authorization overrides type-level AllowAnonymous
[AllowAnonymous]
public record MixedQueries
{
// Inherits [AllowAnonymous] from type
public static IEnumerable<Product> GetPublicProducts() => /* ... */;
// Requires authorization despite type having [AllowAnonymous]
[Authorize]
public static IEnumerable<Product> GetInternalProducts() => /* ... */;
}
// ERROR: This will throw AmbiguousAuthorizationLevel at startup
[AllowAnonymous]
[Authorize] // Cannot have both on the same member!
public record InvalidCommand
{
public void Handle() { }
}

Warning: Applying both [AllowAnonymous] and [Authorize] to the same type or method will result in an AmbiguousAuthorizationLevel exception. This prevents accidental security misconfigurations.

You can create more specific fallback policies with custom requirements:

// Require a specific role for all endpoints by default
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireRole("User")
.Build());

Or create a named policy and set it as the fallback:

builder.Services.AddAuthorizationBuilder()
.AddPolicy("RequireUserRole", policy => policy
.RequireAuthenticatedUser()
.RequireRole("User"))
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());

ASP.NET Core distinguishes between two policies:

PolicyDescription
Default PolicyApplied when [Authorize] is used without parameters
Fallback PolicyApplied to endpoints without any authorization attributes
builder.Services.AddAuthorizationBuilder()
// Default policy: what [Authorize] means
.SetDefaultPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build())
// Fallback policy: applied when no [Authorize] attribute is present
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());

Recommendation: For most secure applications, set a fallback policy that requires authentication. This follows the principle of “secure by default” - developers must explicitly opt-in to anonymous access rather than accidentally leaving endpoints unprotected.

The Arc provides two convenient ways to implement role-based authorization:

  1. Standard ASP.NET Core [Authorize] attribute - Works with all scenarios
  2. Convenient [Roles] attribute - Simplifies multi-role scenarios with cleaner syntax

The RolesAttribute is a wrapper around ASP.NET Core’s AuthorizeAttribute that eliminates the need to manually format role strings. Instead of writing [Authorize(Roles = "Admin,Manager")], you can use the more readable [Roles("Admin", "Manager")].

Standard ASP.NET Core authorization works across all scenarios:

using Microsoft.AspNetCore.Authorization;
// Single role
[Authorize(Roles = "Admin")]
public class AdminController : ControllerBase { }
// Multiple roles (user needs at least one)
[Authorize(Roles = "Admin,Manager")]
public record DeleteUser(string UserId);

The RolesAttribute provides cleaner syntax for multiple roles:

using Cratis.Arc.Authorization;
// Equivalent to [Authorize(Roles = "Admin,Manager")]
[Roles("Admin", "Manager")]
public class UserManagementController : ControllerBase
{
[HttpPost("create")]
public async Task<IActionResult> CreateUser(CreateUserCommand command)
{
// Only users with "Admin" or "Manager" roles can access this endpoint
// ...
}
[HttpDelete("{id}")]
[Roles("Admin")] // Override controller-level roles for specific actions
public async Task<IActionResult> DeleteUser(string id)
{
// Only users with "Admin" role can delete users
// ...
}
}

Users must have at least one of the specified roles to access the resource.

Apply authorization to an entire controller to protect all actions:

[Roles("Admin")]
public class AdminController : ControllerBase
{
// All actions in this controller require "Admin" role
}

Apply authorization to specific actions for fine-grained control:

public class ProductController : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetProducts()
{
// No authorization required - public endpoint
}
[HttpPost]
[Roles("Editor", "Admin")]
public async Task<IActionResult> CreateProduct(CreateProductCommand command)
{
// Requires "Editor" or "Admin" role
}
[HttpDelete("{id}")]
[Roles("Admin")]
public async Task<IActionResult> DeleteProduct(string id)
{
// Requires "Admin" role only
}
}

Action-level authorization overrides controller-level settings:

[Route("api/management")]
[Roles("Manager")]
public class ManagementController : ControllerBase
{
[HttpGet("reports")]
public async Task<IActionResult> GetReports()
{
// Requires "Manager" role (from controller)
}
[HttpGet("sensitive-data")]
[Roles("Admin")] // Overrides controller-level authorization
public async Task<IActionResult> GetSensitiveData()
{
// Requires "Admin" role only, not "Manager"
}
}

Model-bound commands support authorization through both standard ASP.NET Core authorization attributes and the convenient [Roles] attribute.

[Command]
[Authorize]
public record DeleteUser(string UserId)
{
public void Handle(IUserService userService)
{
userService.DeleteUser(UserId);
}
}

For role-based authorization with the standard attribute:

[Command]
[Authorize(Roles = "Admin,Manager")]
public record ApproveRequest(int RequestId)
{
public void Handle(IRequestService requestService)
{
requestService.ApproveRequest(RequestId);
}
}

The [Roles] attribute provides cleaner syntax for model-bound commands:

[Command]
[Roles("Admin", "Manager")]
public record ApproveRequest(int RequestId)
{
public void Handle(IRequestService requestService)
{
requestService.ApproveRequest(RequestId);
}
}
[Command]
[Roles("System", "Admin")]
public record CreateUser(
string Name,
string Email,
int Age)
{
public void Handle(IUserService userService)
{
// Command implementation
}
}

When authorization fails, the command pipeline automatically returns an unauthorized result. The command’s Handle() method will not be executed:

public class Users(ICommandPipeline commandPipeline)
{
public async Task DeleteUser(string user)
{
var result = await commandPipeline.Execute(new DeleteUserCommand(user));
if (!result.IsAuthorized)
{
// Handle unauthorized access - command was not executed
}
if (result.IsSuccess)
{
// Command executed successfully
}
}
}

Queries also support both authorization approaches for data protection:

[ReadModel]
[Authorize(Roles = "Admin,Manager")]
public record UserAuditLog(string UserId, DateTime Occurred, string Action)
{
public static IEnumerable<UserAuditLog> GetUserAuditLog(
IMongoCollection<UserAuditLog> collection,
string userId,
DateTime fromDate,
DateTime toDate) =>
collection.Find(entry =>
entry.UserId == userId &&
entry.Occurred >= fromDate &&
entry.Occurred <= toDate).ToList();
}
[ReadModel]
[Roles("Manager", "Admin", "Auditor")]
public record UserAuditLog(string UserId, DateTime Occurred, string Action)
{
public static IEnumerable<UserAuditLog> GetUserAuditLog(
IMongoCollection<UserAuditLog> collection,
string userId,
DateTime fromDate,
DateTime toDate) =>
collection.Find(entry =>
entry.UserId == userId &&
entry.Occurred >= fromDate &&
entry.Occurred <= toDate).ToList();
}
[ReadModel]
[Roles("Viewer", "Editor", "Admin")]
public record ProductDetails(string ProductId, string Name, decimal Price)
{
public static ProductDetails? GetProductDetails(
IMongoCollection<ProductDetails> collection,
string productId) =>
collection.Find(product => product.ProductId == productId).FirstOrDefault();
}

Model-bound queries are invoked by calling their static method directly. The authorization attributes are enforced by the query pipeline before the method runs, so a caller that lacks the required roles never reaches the query logic:

var auditLog = UserAuditLog.GetUserAuditLog(
collection,
"user123",
DateTime.Now.AddDays(-30),
DateTime.Now);
// Use the returned data
foreach (var entry in auditLog)
{
// Process each audit log entry
}

The Arc integrates authorization state into command and query results, allowing you to handle authorization failures gracefully.

For more complex authorization scenarios, you can use standard ASP.NET Core policy-based authorization alongside the Arc:

[Command]
[Authorize(Policy = "RequireAdminOrOwner")]
public record UpdateResource(string ResourceId, ResourceData Data)
{
public void Handle()
{
// Custom policy can check multiple claims, roles, and requirements
}
}

You can define custom authorization policies in your service configuration:

builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireAdminOrOwner", policy =>
policy.RequireAssertion(context =>
context.User.IsInRole("Admin") ||
context.User.HasClaim("resource", "owner")));
});

The Arc includes authorization filters that integrate with the command and query pipeline:

// Custom authorization logic can be implemented through command filters
public class CustomAuthorizationFilter : ICommandFilter
{
public Task<CommandResult> OnExecution(CommandContext context)
{
// Custom authorization logic
if (!IsAuthorized(context))
{
return Task.FromResult(CommandResult.Error(context.CorrelationId, "Unauthorized"));
}
return Task.FromResult(CommandResult.Success(context.CorrelationId));
}
}

For queries, you can implement custom authorization through query filters:

public class QueryAuthorizationFilter : IQueryFilter
{
public Task<QueryResult> OnPerform(QueryContext context)
{
// Custom authorization logic for queries
if (!IsAuthorized(context))
{
return Task.FromResult(QueryResult.Unauthorized(context.CorrelationId));
}
return Task.FromResult(QueryResult.Success(context.CorrelationId));
}
}

The Arc provides a built-in AuthorizationFilter that automatically handles both [Authorize] and [Roles] attributes for commands and queries:

  • Authentication: Verifies user is authenticated
  • Role-based authorization: Checks required roles if specified
  • Policy-based authorization: Evaluates custom policies
  • Automatic result handling: Returns appropriate unauthorized results

This filter is automatically registered and executes before command handlers and query renderers.

  • Use descriptive role names that reflect business functions (e.g., “AccountManager”, “ContentEditor”)
  • Avoid generic names like “User1”, “Level2”
  • Consider using a consistent naming convention across your application
  • Apply authorization at the appropriate level (controller vs. action vs. command/query)
  • Use action-level and command/query-level authorization for fine-grained control
  • Consider the principle of least privilege
  • Always check authorization status in your command/query results
  • Provide meaningful error messages while avoiding information disclosure
  • Log authorization failures for security monitoring
  • Use controller-level authorization for protecting entire API surfaces
  • Use model-bound command/query authorization for business logic protection
  • Combine both approaches when you need different authorization rules for different access patterns

Authorization works seamlessly with the Identity system. User roles are automatically extracted from the identity token and made available for authorization decisions. The identity provider context includes role information that can be used for authorization:

public class IdentityDetailsProvider : IProvideIdentityDetails
{
public Task<IdentityDetails> Provide(IdentityProviderContext context)
{
var userRoles = context.Claims
.Where(c => c.Key == ClaimTypes.Role)
.Select(c => c.Value)
.ToList();
var isAuthorized = userRoles.Contains("Admin") || userRoles.Contains("User");
return Task.FromResult(new IdentityDetails(isAuthorized, new { Roles = userRoles }));
}
}

Authorization attributes work seamlessly with the proxy generator, which automatically creates TypeScript proxies for your commands and queries. The generated proxies provide:

  • Authorization status handling in command and query results
  • Consistent error handling for unauthorized access
  • Integration with frontend authentication systems
  • Type-safe authorization checking