Skip to content

Authorization

Arc.Core provides authorization capabilities through attributes that protect your commands and queries. This allows you to control access based on authentication status and user roles.

Authorization in Arc.Core is attribute-based and supports:

  • Authentication Requirements - Require users to be authenticated
  • Role-Based Authorization - Restrict access to specific roles
  • Anonymous Access - Explicitly allow unauthenticated access
  • Flexible Application - Apply at class or method level

Arc.Core provides authorization through attributes:

  • [Authorize] - Requires authentication and optionally specifies roles or policies
  • [Roles] - Convenience attribute for role-based authorization
  • [AllowAnonymous] - Explicitly allows unauthenticated access

Require users to be authenticated without specifying roles — put the attribute on the [Command] record, and its Handle() only runs for an authenticated user:

using Cratis.Arc.Commands;
using Cratis.Arc.Authorization;
[Authorize]
[Command]
public record UpdateProfile(ProfileId Id, ProfileName Name)
{
public ProfileRenamed Handle() => new(Name);
}

Restrict access to specific roles:

// Using the Authorize attribute
[Authorize(Roles = "Admin")]
[Command]
public record DeleteUser(UserId Id)
{
public UserDeleted Handle() => new();
}
// Using the Roles attribute (more readable for multiple roles)
[Roles("Admin", "Manager")]
[Command]
public record ApproveRequest(RequestId Id)
{
public RequestApproved Handle() => new();
}

Explicitly allow anonymous access (useful when you have a fallback policy requiring authentication) — on a model-bound query, the attribute goes on the static query method:

[ReadModel]
public record PublicData(DataId Id, string Value)
{
[AllowAnonymous]
public static IEnumerable<PublicData> All(IMongoCollection<PublicData> collection) =>
collection.Find(_ => true).ToList();
}

Authorization attributes can be applied at different levels:

Apply to all commands or queries in a type:

[Authorize]
[Command]
public record UpdateSettings(SettingKey Key, string Value)
{
public SettingChanged Handle() => new(Key, Value);
}
[Roles("Admin")]
[Command]
public record DeleteAccount(AccountId Id)
{
public AccountDeleted Handle() => new();
}

Apply an attribute to a single operation rather than the whole type. On a model-bound command the attribute goes on the [Command] record itself, and its Handle() only runs once the attribute’s requirements are met:

[Authorize]
[Command]
public record SecureCommand(string Data)
{
public SecureOperationCompleted Handle() => new(Data);
}
// User must have the "Admin" role
[Roles("Admin")]
[Command]
public record CreateAdmin(string Username)
{
public AdminCreated Handle() => new(Username);
}

Users need at least one of the specified roles:

// User must have either "Admin" OR "Manager" role
[Roles("Admin", "Manager")]
[ReadModel]
public record AuditLogEntry(AuditLogId Id, string Action)
{
public static IEnumerable<AuditLogEntry> All(IMongoCollection<AuditLogEntry> collection) =>
collection.Find(_ => true).ToList();
}

You can mix [Authorize] and [Roles] if needed:

// Requires authentication via specific scheme AND a role
[Authorize(AuthenticationSchemes = "Bearer")]
[Roles("Admin")]
[Command]
public record SecureAdminCommand(string Data)
{
public SecureOperationCompleted Handle() => new(Data);
}

The [AllowAnonymous] attribute explicitly allows unauthenticated access. On a model-bound query the attribute goes on the static query method:

[ReadModel]
public record CatalogItem(CatalogItemId Id, string Name)
{
// Anyone can access this, even without authentication
[AllowAnonymous]
public static IEnumerable<CatalogItem> All(IMongoCollection<CatalogItem> collection) =>
collection.Find(_ => true).ToList();
}

Authorization attributes follow specific inheritance rules:

ScenarioResult
[Authorize] on classRequires authentication for all operations
[Roles] on classRequires specified roles for all operations
[AllowAnonymous] on classAllows anonymous access for all operations
Method attribute overrides classMethod-level attribute takes precedence
Both [Authorize] and [AllowAnonymous] on same targetError - throws AmbiguousAuthorizationLevel

A read model inherits authorization from the type, and a query method can override it:

// Authentication required for every query method on the read model
[Authorize]
[ReadModel]
public record Profile(ProfileId Id, ProfileName Name)
{
// Inherits [Authorize] from the record — requires authentication
public static IEnumerable<Profile> All(IMongoCollection<Profile> collection) =>
collection.Find(_ => true).ToList();
// Overrides with a more specific role requirement
[Roles("Admin")]
public static IEnumerable<Profile> AllForAdmins(IMongoCollection<Profile> collection) =>
collection.Find(_ => true).ToList();
}

A command record can override an [AllowAnonymous] default by requiring authentication on a single command:

// Anonymous access by default
[AllowAnonymous]
[Command]
public record TrackPageView(string Path)
{
public PageViewTracked Handle() => new(Path);
}
// Authentication required for this command
[Authorize]
[Command]
public record SubmitFeedback(string Message)
{
public FeedbackSubmitted Handle() => new(Message);
}

Applying both [Authorize] and [AllowAnonymous] to the same target is an error:

// ERROR: This will throw AmbiguousAuthorizationLevel at startup
[AllowAnonymous]
[Authorize] // Cannot have both!
[Command]
public record InvalidCommand(string Data)
{
public OperationCompleted Handle() => new(Data);
}

When a request is authenticated, the ClaimsPrincipal is available from the current HttpContext. Inject IHttpContextAccessor into a model-bound query method (or a command’s Handle()) and read HttpContext?.User:

using System.Security.Claims;
using Microsoft.AspNetCore.Http;
[ReadModel]
public record UserProfile(UserId Id, string DisplayName)
{
// Returns the profile for whichever user is currently authenticated
public static UserProfile? Mine(
IHttpContextAccessor httpContextAccessor,
IMongoCollection<UserProfile> collection)
{
var user = httpContextAccessor.HttpContext?.User;
var userId = user?.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
return null;
}
return collection.Find(profile => profile.Id == userId).FirstOrDefault();
}
}

The same httpContextAccessor.HttpContext?.User gives you a ClaimsPrincipal — use FindFirstValue(ClaimTypes.NameIdentifier) for the user id, FindFirstValue(ClaimTypes.Name) for the name, and IsInRole("Admin") for role checks.

When authorization fails, Arc.Core automatically returns appropriate HTTP status codes:

ScenarioHTTP Status CodeDescription
Not Authenticated401 UnauthorizedUser is not authenticated
Not Authorized403 ForbiddenUser is authenticated but doesn’t have required permissions

The [Authorize] and [Roles] attributes handle coarse-grained access — whether the user is authenticated and in the right role. For row-level checks (for example, “you can only update your own orders”) put the logic inside Handle(): inject IHttpContextAccessor, read the current user’s claims, and return a ValidationResult.Error(...) when the guard fails. Make the return type Result<ValidationResult, TEvent> so the framework knows the command can fail validation:

using System.Security.Claims;
using Cratis.Arc.Validation;
using Microsoft.AspNetCore.Http;
[Authorize]
[Command]
public record UpdateOrder(OrderId Id, string Data)
{
public Result<ValidationResult, OrderUpdated> Handle(
IHttpContextAccessor httpContextAccessor,
IOrderRepository orders)
{
var user = httpContextAccessor.HttpContext?.User;
var userId = user?.FindFirstValue(ClaimTypes.NameIdentifier);
var order = orders.GetById(Id);
// Custom authorization: a user can only update their own orders
if (order.OwnerId != userId)
{
return ValidationResult.Error("You can only update your own orders.");
}
return new OrderUpdated(Data);
}
}

A returned ValidationResult.Error surfaces to the caller as a failed CommandResult with validation errors — the command does not append its event.

While Arc.Core focuses on attribute-based authorization, you can implement policy-based logic inside Handle() by inspecting the current user’s roles and returning a ValidationResult.Error(...) when the policy fails:

using System.Security.Claims;
using Cratis.Arc.Validation;
using Microsoft.AspNetCore.Http;
[Authorize]
[Command]
public record ApproveExpense(ExpenseId Id)
{
public Result<ValidationResult, ExpenseApproved> Handle(
IHttpContextAccessor httpContextAccessor,
IExpenseRepository expenses)
{
var user = httpContextAccessor.HttpContext?.User;
var expense = expenses.GetById(Id);
// Custom policy: managers can approve up to $1000, directors unlimited
var isManager = user?.IsInRole("Manager") ?? false;
var isDirector = user?.IsInRole("Director") ?? false;
if (!isManager && !isDirector)
{
return ValidationResult.Error("Only managers and directors can approve expenses.");
}
if (expense.Amount > 1000 && !isDirector)
{
return ValidationResult.Error("Only directors can approve expenses over $1000.");
}
return new ExpenseApproved(user?.FindFirstValue(ClaimTypes.Name) ?? "Unknown");
}
}

Apply authorization at the broadest scope possible and override only when necessary. Put [Authorize] on the read model so every query method requires authentication, then opt specific methods out with [AllowAnonymous]:

// Good: secure by default, explicit opt-out
[Authorize]
[ReadModel]
public record Resource(ResourceId Id, string Name)
{
// Inherits [Authorize] — requires authentication
public static IEnumerable<Resource> Mine(IMongoCollection<Resource> collection) =>
collection.Find(_ => true).ToList();
// Explicitly allow anonymous access for this method
[AllowAnonymous]
public static IEnumerable<Resource> Public(IMongoCollection<Resource> collection) =>
collection.Find(_ => true).ToList();
}

Use [Roles] for better readability when specifying multiple roles:

// More readable
[Roles("Admin", "Manager", "Supervisor")]
[Command]
public record ReviewApplication(ApplicationId Id)
{
public ApplicationReviewed Handle() => new();
}
// Less readable
[Authorize(Roles = "Admin,Manager,Supervisor")]
[Command]
public record ReviewApplication(ApplicationId Id)
{
public ApplicationReviewed Handle() => new();
}

Never apply both [Authorize] and [AllowAnonymous] to the same target:

// ERROR: Will throw AmbiguousAuthorizationLevel
[Authorize]
[AllowAnonymous]
[Command]
public record AmbiguousCommand(string Data)
{
public OperationCompleted Handle() => new(Data);
}

Add XML documentation to clarify authorization requirements:

/// <summary>
/// Deletes a user account. Requires Admin role.
/// </summary>
[Roles("Admin")]
[Command]
public record DeleteUser(UserId Id)
{
public UserDeleted Handle() => new();
}

For complex authorization logic, validate claims inside Handle() by injecting IHttpContextAccessor and returning a ValidationResult.Error(...) when a required claim is missing:

using System.Security.Claims;
using Cratis.Arc.Validation;
using Microsoft.AspNetCore.Http;
[Authorize]
[Command]
public record SecureCommand(string Data)
{
public Result<ValidationResult, SecureOperationCompleted> Handle(
IHttpContextAccessor httpContextAccessor)
{
var user = httpContextAccessor.HttpContext?.User;
var userId = user?.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
return ValidationResult.Error("A user identifier claim is required.");
}
// Continue with business logic
return new SecureOperationCompleted(Data);
}
}

Authorization works hand-in-hand with authentication. See the Authentication documentation for how to implement custom authentication handlers that provide the claims used by authorization.

Authorization runs as part of the real command pipeline, so you test it with CommandScenario<TCommand> — the same class used for testing validation and handler behavior. Instantiate the scenario, run the command with Execute, and assert on the resulting CommandResult:

  • ShouldNotBeAuthorized() — the command was rejected by an [Authorize] or [Roles] attribute.
  • ShouldBeAuthorized() — the command passed authorization.
  • ShouldHaveValidationErrors() / ShouldHaveValidationErrorFor("message") — an in-Handle guard returned a ValidationResult.Error.

For the full scenario API, the assertion reference, and a worked authorization example, see Command Scenarios and the wider Testing guide.

  • Authentication - Implement custom authentication handlers
  • Getting Started - Learn more about Arc.Core basics
  • Identity - Integrate with Arc’s identity system
  • Commands - Learn about command patterns and authorization
  • Queries - Discover query features with authorization