Skip to main content

Authorization & Audit (A3)

Excalibur.A3 provides a unified Authentication, Authorization, and Audit (A3) system that integrates with the Dispatch pipeline. It supports activity-based authorization, fine-grained grants, token validation, and structured audit events.

Before You Start

  • .NET 8.0+ (or .NET 9/10 for latest features)
  • Install the required packages:
    # Full-stack (CQRS, Dispatch pipeline, authentication services)
    dotnet add package Excalibur.A3

    # Lightweight / standalone (grant management + authorization only)
    dotnet add package Excalibur.A3.Core
  • Full-stack: Familiarity with Dispatch pipeline and security concepts
  • Standalone: No prerequisites beyond basic .NET DI knowledge

Packages

PackageDependenciesPurpose
Excalibur.A3.CoreA3.Abstractions, Domain, Dispatch.AbstractionsLightweight core: in-memory stores, grant management, authorization evaluation
Excalibur.A3A3.Core + Application, EventSourcing, Dispatch, ...Full-stack: CQRS commands, Dispatch middleware, authentication HTTP services, audit pipeline
Excalibur.A3.Abstractions--Provider-neutral interfaces: IGrantStore, IActivityGroupStore, IA3Builder, Grant
Choose the Right Package
  • Building governance primitives, microservices, or lightweight tools? Use Excalibur.A3.Core -- 3 dependencies, no database required.
  • Full application with CQRS, event sourcing, and Dispatch pipeline? Use Excalibur.A3 -- includes everything in A3.Core plus the full stack.

Setup

Standalone Setup (A3.Core)

For standalone grant management and authorization without the Dispatch pipeline:

using Microsoft.Extensions.DependencyInjection;

// Minimal registration -- in-memory stores, no pipeline, no database
services.AddExcaliburA3Core();

This registers:

  • IGrantStoreInMemoryGrantStore (singleton, thread-safe, ConcurrentDictionary-backed)
  • IActivityGroupStoreInMemoryActivityGroupStore (singleton, thread-safe)
  • Returns IA3Builder for overriding stores with custom implementations

To override the default in-memory stores:

services.AddExcaliburA3Core()
.UseGrantStore<MyGrantStore>()
.UseActivityGroupStore<MyActivityGroupStore>();

What you get: Grant CRUD, activity group management, GetService(Type) ISP access to IGrantQueryStore and IActivityGroupGrantStore.

What you do NOT get: Dispatch pipeline, CQRS commands (AddGrantCommand, RevokeGrantCommand), authentication HTTP clients, audit middleware, event-sourced Grant aggregate.

Full-Stack Setup (A3)

Register full A3 services using the builder pattern. AddExcaliburA3() internally calls AddExcaliburA3Core(), then adds CQRS, Dispatch pipeline, and authentication:

using Microsoft.Extensions.DependencyInjection;

// SQL providers (connection configured via IDataRequest/IDomainDb)
services.AddExcaliburA3()
.UseSqlServer();

// Or PostgreSQL
services.AddExcaliburA3()
.UsePostgres();

// NoSQL providers (options configured inline)
services.AddExcaliburA3()
.UseCosmosDb(options => { options.DatabaseId = "mydb"; options.ContainerId = "grants"; });

services.AddExcaliburA3()
.UseMongoDB(options => { options.DatabaseName = "mydb"; });

services.AddExcaliburA3()
.UseDynamoDb(options => { options.TableName = "grants"; });

services.AddExcaliburA3()
.UseFirestore(options => { options.ProjectId = "my-project"; });

For custom store implementations:

services.AddExcaliburA3()
.UseGrantStore<MyGrantStore>()
.UseActivityGroupStore<MyActivityGroupStore>();

The builder also registers Dispatch pipeline integration (AddDispatchAuthorization()) automatically.

Single-Tenant Applications

You do not need to configure a tenant to use A3. When you call AddExcaliburA3(), it automatically registers ITenantId with the default value "Default" (via TenantDefaults.DefaultTenantId). All tenant-scoped features — grants, authorization policies, audit logging — work transparently.

For multi-tenant applications that serve multiple tenants from a single instance, use the factory overload:

// Resolve tenant per-request — A3 won't override it (TryAdd semantics)
services.TryAddTenantId(sp =>
{
var httpContext = sp.GetRequiredService<IHttpContextAccessor>().HttpContext;
return httpContext?.Request.Headers["X-Tenant-ID"].FirstOrDefault()
?? TenantDefaults.DefaultTenantId;
});
services.AddExcaliburA3()
.UsePostgres();

Authentication

Token Validation

Implement ITokenValidator to validate tokens from any provider (JWT, opaque, API keys):

using Excalibur.A3.Authentication;

public class JwtTokenValidator : ITokenValidator
{
public async Task<AuthenticationResult> ValidateAsync(
string token,
CancellationToken cancellationToken)
{
// Validate JWT and extract claims
var principal = new AuthenticatedPrincipal(
SubjectId: "user-123",
TenantId: "tenant-abc",
Claims: new Dictionary<string, string>
{
["role"] = "admin",
["email"] = "[email protected]"
});

return new AuthenticationResult(Succeeded: true, Principal: principal);
}
}

Access Token

IAccessToken unifies authentication and authorization into a single object that combines IAuthenticationToken and IAuthorizationPolicy:

using Excalibur.A3;

// IAccessToken provides both identity and authorization checks
IAccessToken token = ...;

// Authentication
string userId = token.UserId;
string tenantId = token.TenantId;

// Authorization
bool canCreate = token.IsAuthorized("Orders.Create");
bool hasGrant = token.HasGrant<CreateOrderActivity>();

Authorization

Activity-Based Authorization

Actions that require authorization implement IRequireAuthorization:

using Excalibur.A3.Authorization;

public class CreateOrderAction : IRequireAuthorization
{
public string ActivityName => "Orders.Create";
public Guid OrderId { get; set; }
public decimal Amount { get; set; }
}

For actions that carry an access token, implement IAmAuthorizable:

using Excalibur.A3.Authorization.Requests;

public class DeleteOrderAction : IAmAuthorizable
{
public string ActivityName => "Orders.Delete";
public IAccessToken? AccessToken { get; set; }
public Guid OrderId { get; set; }
}

Authorization Policies

IAuthorizationPolicy provides tenant-scoped, activity-based authorization checks:

using Excalibur.A3.Authorization;

// Check authorization against a policy
IAuthorizationPolicy policy = ...;

// Is the user authorized for this activity?
bool authorized = policy.IsAuthorized("Orders.Create");

// Does the user have a specific grant?
bool hasGrant = policy.HasGrant("Orders.Create");

// Type-safe grant check
bool hasTypedGrant = policy.HasGrant<CreateOrderActivity>();

// Resource-scoped grant check
bool hasResourceGrant = policy.HasGrant("Order", orderId.ToString());

Authorization Service

IDispatchAuthorizationService evaluates authorization using ASP.NET Core IAuthorizationRequirement and named policies:

using Excalibur.A3.Authorization;

public class OrderHandler : IActionHandler<CreateOrderAction>
{
private readonly IDispatchAuthorizationService _authService;

public OrderHandler(IDispatchAuthorizationService authService)
{
_authService = authService;
}

public async Task HandleAsync(CreateOrderAction action, CancellationToken ct)
{
// Check against requirements
var result = await _authService.AuthorizeAsync(
user, resource: null, new OrderCreationRequirement());

// Or check against a named policy
var policyResult = await _authService.AuthorizeAsync(
user, resource: null, "OrderCreationPolicy");

if (!result.IsAuthorized)
{
throw new UnauthorizedAccessException();
}
}
}

Authorization Evaluator

For provider-neutral evaluation, implement IAuthorizationEvaluator:

using Excalibur.A3.Authorization;

public class CustomEvaluator : IAuthorizationEvaluator
{
public async Task<AuthorizationDecision> EvaluateAsync(
AuthorizationSubject subject,
AuthorizationAction action,
AuthorizationResource resource,
CancellationToken cancellationToken)
{
// Evaluate subject + action + resource triple
return new AuthorizationDecision(AuthorizationEffect.Allow);
}
}

Grants

Grants are fine-grained permissions assigned to users, scoped by tenant and resource.

Grant Model

using Excalibur.A3.Authorization.Grants;

// Grant is an event-sourced aggregate
var grant = new Grant(
userId: "user-123",
fullName: "John Doe",
tenantId: "tenant-abc",
grantType: "activity-group",
qualifier: "OrderManagement",
expiresOn: DateTimeOffset.UtcNow.AddDays(90),
grantedBy: "admin-456",
grantedOn: DateTimeOffset.UtcNow);

Managing Grants

Use dispatch commands to add and revoke grants:

var correlationId = Guid.NewGuid();

// Add a grant
await dispatcher.DispatchAsync(new AddGrantCommand(
userId: "user-123",
fullName: "John Doe",
grantType: "activity-group",
qualifier: "OrderManagement",
expiresOn: DateTimeOffset.UtcNow.AddDays(90),
correlationId: correlationId,
tenantId: "tenant-abc"), cancellationToken);

// Revoke a specific grant
await dispatcher.DispatchAsync(new RevokeGrantCommand(
userId: "user-123",
grantType: "activity-group",
qualifier: "OrderManagement",
correlationId: correlationId,
tenantId: "tenant-abc"), cancellationToken);

// Revoke all grants for a user
await dispatcher.DispatchAsync(new RevokeAllGrantsCommand(
userId: "user-123",
fullName: "John Doe",
correlationId: correlationId,
tenantId: "tenant-abc"), cancellationToken);

Grant Events

Grant changes emit domain events for audit trails:

  • GrantAdded / IGrantAdded - Emitted when a grant is created
  • GrantRevoked / IGrantRevoked - Emitted when a grant is revoked

Wildcard Grants

Grant scopes support wildcard patterns for broad permission grants. A GrantScope is a wildcard if any segment is * or the qualifier ends with .* or /*.

Wildcard patterns:

PatternMatchesExample
*:*:*All grants across all tenantsGlobal admin
tenant-abc:*:*All grants within a tenantTenant admin
tenant-abc:activity:Orders.*All qualifier prefixes under Orders.Orders.Create, Orders.Delete
tenant-abc:activity:orders/*All qualifier prefixes under orders/orders/create, orders/delete

Validation:

Use GrantScope.Validate to check wildcard patterns before creating grants. Invalid patterns (such as **, *partial, or mid-qualifier wildcards) are rejected:

using Excalibur.A3.Authorization.Grants;

if (!GrantScope.Validate(tenantId: "*", grantType: "activity", qualifier: "Orders.*", out var error))
{
// error describes the validation failure
throw new ArgumentException(error);
}

// Valid -- create the wildcard grant
var scope = new GrantScope("*", "activity", "Orders.*");

Specificity and matching:

When multiple wildcard grants match a request, the most specific grant wins. The framework automatically ranks wildcards by specificity:

  • Each non-wildcard segment (TenantId, GrantType) is more specific than *
  • Exact qualifier beats suffix wildcards (prefix.*, prefix/*)
  • Suffix wildcards beat full wildcard (*)
  • Longer prefixes win tiebreakers between suffix patterns

The authorization policy uses a dual-index strategy: exact grants are checked first via O(1) hash lookup, then wildcard grants are evaluated in descending specificity order.

Audit

Conditional Authorization (When Expressions)

The [RequirePermission] attribute supports a When property for runtime conditional checks. Expressions are parsed at startup and cached as ASTs for zero-allocation evaluation.

Basic Usage

[RequirePermission("orders.approve", When = "resource.Amount <= 10000")]
public class ApproveOrderCommand : IDispatchAction
{
public decimal Amount { get; set; }
}

If the user has the orders.approve permission and the order amount is at most 10,000, authorization succeeds. Otherwise it fails even with a valid grant.

Expression Grammar

Expressions support three attribute categories: subject, action, and resource.

subject.Role == 'admin'
resource.Amount > 10000
subject.Department == 'finance' AND resource.Amount <= 50000
NOT subject.IsExternal
(subject.Role == 'admin' OR subject.Role == 'manager') AND resource.Status != 'archived'

Operators: ==, !=, >, <, >=, <=, contains, startsWith Logic: AND, OR, NOT, parentheses Values: string literals ('value'), numbers, true, false, null

Advanced Examples

// Time-based access
[RequirePermission("reports.view", When = "subject.Role == 'auditor'")]
public class ViewFinancialReport : IDispatchAction { }

// Multi-condition
[RequirePermission("transfers.execute",
When = "resource.Amount <= 50000 AND subject.Department == 'treasury'")]
public class ExecuteTransfer : IDispatchAction
{
public decimal Amount { get; set; }
public string Department { get; set; } = string.Empty;
}

How Attributes Are Resolved

CategorySource
subject.*Claims from the authenticated principal
action.*Properties of the dispatched message
resource.*Properties of the dispatched message (alias for action)

Audit Events

IAuditEvent captures structured audit data:

using Excalibur.A3.Abstractions.Auditing;

var auditEvent = new AuditEvent(
timestampUtc: DateTimeOffset.UtcNow,
tenantId: "tenant-abc",
actorId: "user-123",
action: "CreateOrder",
resource: "Order/order-456",
outcome: "Success",
correlationId: correlationId,
attributes: new Dictionary<string, string>
{
["amount"] = "99.99",
["currency"] = "USD"
});

Audit Sink

Implement IAuditSink to persist audit events to your chosen store:

using Excalibur.A3.Abstractions.Auditing;

public class SqlAuditSink : IAuditSink
{
public async ValueTask WriteAsync(
IAuditEvent auditEvent,
CancellationToken cancellationToken)
{
// Persist to database, send to log aggregator, etc.
}
}

Audit Message Publisher

IAuditMessagePublisher publishes audit messages to external systems:

using Excalibur.A3.Audit;

public class KafkaAuditPublisher : IAuditMessagePublisher
{
public async Task PublishAsync<TMessage>(
TMessage message,
IActivityContext context,
CancellationToken cancellationToken)
{
// Publish audit event to Kafka, Azure Event Hub, etc.
}
}

Store Pattern

A3 uses a store pattern modeled after ASP.NET Core Identity (IUserStore<T>, IRoleStore<T>, IdentityBuilder). Store interfaces live in Excalibur.A3.Abstractions and each database provider implements them in its own package.

Store Interfaces

InterfaceMethodsPurpose
IGrantStore5 + GetService(Type)Core grant CRUD (get, getAll, save, delete, exists)
IGrantQueryStore2ISP sub-interface for advanced queries (matching, find)
IActivityGroupStore4 + GetService(Type)Activity group operations (exists, findAll, deleteAll, create)
IActivityGroupGrantStore4Bridging ISP for activity-group grant operations

Advanced features are accessed via the GetService(Type) escape hatch rather than adding optional methods to the core interface:

// Access advanced query capabilities from IGrantStore
IGrantStore store = ...;
var queryStore = store.GetService(typeof(IGrantQueryStore)) as IGrantQueryStore;
if (queryStore is not null)
{
var grants = await queryStore.GetMatchingGrantsAsync(
userId, tenantId, grantType, qualifier, cancellationToken);
}

Builder Pattern (IA3Builder)

AddExcaliburA3() returns an IA3Builder that configures store providers via fluent Use*() methods:

public interface IA3Builder
{
IServiceCollection Services { get; }
IA3Builder UseGrantStore<TStore>() where TStore : class, IGrantStore;
IA3Builder UseActivityGroupStore<TStore>() where TStore : class, IActivityGroupStore;
}

Each provider package ships a single extension method (e.g., UseSqlServer(), UseCosmosDb(Action<CosmosDbAuthorizationOptions>)) that registers the appropriate store implementations. SQL providers use existing IDataRequest infrastructure for connection management; NoSQL providers accept an options callback with ValidateOnStart().

Available Providers

ProviderExtensionOptions
SQL Server.UseSqlServer()Connection via IDataRequest
PostgreSQL.UsePostgres()Connection via IDataRequest
Cosmos DB.UseCosmosDb(Action<CosmosDbAuthorizationOptions>)DatabaseId, ContainerId
MongoDB.UseMongoDB(Action<MongoDbAuthorizationOptions>)DatabaseName
DynamoDB.UseDynamoDb(Action<DynamoDbAuthorizationOptions>)TableName
Firestore.UseFirestore(Action<FirestoreAuthorizationOptions>)ProjectId

IAM Governance

The governance layer adds enterprise IAM capabilities (role management, access reviews, separation of duties, provisioning) on top of A3's grant infrastructure.

Governance Packages

PackageDependenciesPurpose
Excalibur.A3.Governance.AbstractionsA3.Abstractions onlyAll governance interfaces, enums, records, and options: roles, access reviews, SoD, orphaned access, provisioning, JIT, non-human identity, API keys, entitlement reporting
Excalibur.A3.GovernanceGovernance.Abstractions + A3.CoreAll governance implementations: 3 aggregates, in-memory stores, 3 background services, SoD middleware, entitlement provider. 8 builder extensions

Governance Setup

Add governance capabilities via the fluent AddGovernance() extension on IA3Builder:

services.AddExcaliburA3Core()
.AddGovernance(g => g
.AddRoles(opts =>
{
opts.MaxHierarchyDepth = 3;
opts.EnforceUniqueNames = true;
})
.AddAccessReviews(opts =>
{
opts.DefaultCampaignDuration = TimeSpan.FromDays(14);
opts.DefaultExpiryPolicy = AccessReviewExpiryPolicy.RevokeUnreviewed;
})
.AddSeparationOfDuties(opts =>
{
opts.MinimumEnforcementSeverity = SoDSeverity.Critical;
opts.DetectiveScanInterval = TimeSpan.FromHours(12);
})
.AddOrphanedAccessDetection(opts =>
{
opts.ScanIntervalHours = 12;
opts.AutoRevokeDeparted = true;
})
.AddProvisioning()
.AddNonHumanIdentity()
.AddApiKeyManagement(opts =>
{
opts.MaxKeysPerPrincipal = 5;
opts.DefaultExpirationDays = 90;
}));

AddRoles() registers:

  • IRoleStore -> InMemoryRoleStore (singleton fallback, TryAddSingleton)
  • RoleAwareAuthorizationEvaluator decorator (makes roles authorize)
  • RoleOptions with ValidateDataAnnotations

AddAccessReviews() registers:

  • IAccessReviewStore -> InMemoryAccessReviewStore (singleton fallback, TryAddSingleton)
  • AccessReviewOptions with ValidateDataAnnotations + ValidateOnStart
  • AccessReviewExpiryService background service for expired campaign processing
  • IAccessReviewNotifier -> NullAccessReviewNotifier fallback (TryAddSingleton)

AddSeparationOfDuties() registers:

  • ISoDPolicyStore -> InMemorySoDPolicyStore (singleton fallback, TryAddSingleton)
  • ISoDEvaluator -> DefaultSoDEvaluator (TryAddSingleton)
  • SoDPreventiveMiddleware as IDispatchMiddleware (blocks conflicting grant requests)
  • SoDDetectiveScanService as IHostedService (periodic scanning)
  • SoDOptions with ValidateDataAnnotations + ValidateOnStart

AddOrphanedAccessDetection() registers:

  • IOrphanedAccessDetector -> DefaultOrphanedAccessDetector (TryAddSingleton)
  • OrphanedAccessScanService as IHostedService (periodic scanning)
  • OrphanedAccessOptions with ValidateDataAnnotations + ValidateOnStart
  • Note: You must register IUserStatusProvider yourself -- no default is provided

AddProvisioning() registers:

  • IProvisioningStore -> InMemoryProvisioningStore (TryAddSingleton)
  • IProvisioningWorkflowConfiguration -> DefaultSingleApproverWorkflow (TryAddSingleton)
  • IGrantRiskAssessor -> DefaultGrantRiskAssessor (TryAddSingleton)
  • ProvisioningCompletionService for grant creation after approval
  • JitAccessExpiryService background service (when JIT enabled)
  • ProvisioningOptions and JitAccessOptions with ValidateDataAnnotations + ValidateOnStart

AddNonHumanIdentity() registers:

  • IPrincipalTypeProvider -> DefaultPrincipalTypeProvider (returns Human, TryAddSingleton)

AddApiKeyManagement() registers:

  • IApiKeyManager -> InMemoryApiKeyManager (SHA-256 hashed, TryAddSingleton)
  • ApiKeyOptions with ValidateDataAnnotations + ValidateOnStart

Role Management

Roles are event-sourced aggregates that map to one or more activity groups. Role assignment reuses the existing Grant infrastructure (GrantType = "Role", Qualifier = roleName).

Role lifecycle (state machine):

Active ←→ Inactive → Deprecated (one-way, audit-only)
  • Active: Can be assigned to users
  • Inactive: Temporarily suspended, can be reactivated
  • Deprecated: Permanently archived, exists for audit. Throws InvalidOperationException on modification

IRoleStore interface (5 methods + GetService):

MethodReturnsPurpose
GetRoleAsync(roleId, ct)RoleSummary?Get by ID
GetRolesAsync(tenantId?, ct)IReadOnlyList<RoleSummary>List (optional tenant filter)
SaveRoleAsync(role, ct)TaskUpsert
DeleteRoleAsync(roleId, ct)boolDelete, returns false if not found
GetService(Type)object?ISP escape hatch

Access Review Campaigns

Access reviews enable organizations to periodically verify that users still need their access -- required for compliance with SOC 2, FedRAMP, SOX, HIPAA, GDPR, and NIST 800-53.

Campaign lifecycle:

Created → InProgress → Completed (all items decided)
→ Expired (deadline passed, expiry policy applied)

Scoping reviews:

Reviews can target all grants or be scoped to a specific role, user, or tenant using AccessReviewScope:

// Review all grants system-wide
var scope = new AccessReviewScope(AccessReviewScopeType.AllGrants, null);

// Review grants for a specific role
var scope = new AccessReviewScope(AccessReviewScopeType.ByRole, "Admin");

// Review grants for a specific user
var scope = new AccessReviewScope(AccessReviewScopeType.ByUser, "user-123");

Review decisions:

Each grant item in a campaign receives one of three outcomes:

  • Approved -- access confirmed
  • Revoked -- access removed
  • Delegated -- decision forwarded to another reviewer

Expiry policies:

When a campaign expires with unreviewed items, the configured AccessReviewExpiryPolicy determines behavior:

PolicyBehavior
DoNothingMark expired for audit, leave access unchanged
RevokeUnreviewedAutomatically revoke unreviewed items (with retry + exponential backoff)
NotifyAndExtendNotify reviewers and extend the deadline

Configuration:

services.AddExcaliburA3Core()
.AddGovernance(g => g
.AddAccessReviews(opts =>
{
opts.DefaultCampaignDuration = TimeSpan.FromDays(14);
opts.DefaultExpiryPolicy = AccessReviewExpiryPolicy.RevokeUnreviewed;
opts.ExpiryCheckInterval = TimeSpan.FromMinutes(30);
opts.MaxRetryAttempts = 3;
opts.RetryBaseDelay = TimeSpan.FromSeconds(5);
opts.AutoStartOnCreation = false;
}));

Query store (IAccessReviewStore):

MethodPurpose
GetCampaignAsync(campaignId, ct)Retrieve a campaign summary
SaveCampaignAsync(campaign, ct)Save/update a campaign summary
GetCampaignsByStateAsync(state?, ct)List campaigns by state
DeleteCampaignAsync(campaignId, ct)Remove a campaign
GetService(Type)ISP escape hatch for extensions
Override the In-Memory Store

AddAccessReviews() registers InMemoryAccessReviewStore as a fallback via TryAddSingleton. Replace it with a persistent implementation by registering your own IAccessReviewStore before calling AddAccessReviews(), or by replacing the registration afterward.

Separation of Duties (SoD)

SoD policies prevent users from holding toxic permission combinations -- required for SOC 2, SOX Section 404, FedRAMP AC-5, and NIST 800-53.

Defining policies:

Policies reference either role names or activity names. N-way conflicts are supported (any 2 of N items is a violation):

var policy = new SoDPolicy(
PolicyId: "sod-treasury",
Name: "Treasury Segregation",
Description: "No user should approve and submit treasury transactions",
Severity: SoDSeverity.Critical,
PolicyScope: SoDPolicyScope.Role,
ConflictingItems: ["TreasuryApprover", "TreasurySubmitter"],
TenantId: null, // global policy
CreatedBy: "compliance-admin");

Enforcement modes:

ModeDescriptionEnabled By
PreventiveBlocks AddGrantCommand if granting access would create a conflictSoDOptions.EnablePreventiveEnforcement (default: true)
DetectiveBackground service periodically scans all users for existing violationsSoDOptions.EnableDetectiveScanning (default: true)

Severity levels:

SeverityBehavior
WarningLogged but allowed (below default enforcement threshold)
ViolationBlocked by default (matches MinimumEnforcementSeverity)
CriticalAlways blocked and escalated

Evaluating conflicts programmatically:

ISoDEvaluator evaluator = ...; // injected

// Check a user's current grants for conflicts
var conflicts = await evaluator.EvaluateCurrentAsync("user-123", cancellationToken);

// Check if granting a role would create a conflict
var hypothetical = await evaluator.EvaluateHypotheticalAsync(
"user-123", "TreasuryApprover", cancellationToken);

Policy store (ISoDPolicyStore):

MethodPurpose
GetPolicyAsync(policyId, ct)Retrieve a policy
GetAllPoliciesAsync(ct)List all policies
SavePolicyAsync(policy, ct)Save/update a policy
DeletePolicyAsync(policyId, ct)Remove a policy
GetService(Type)ISP escape hatch

Configuration:

services.AddExcaliburA3Core()
.AddGovernance(g => g
.AddSeparationOfDuties(opts =>
{
opts.EnablePreventiveEnforcement = true;
opts.EnableDetectiveScanning = true;
opts.MinimumEnforcementSeverity = SoDSeverity.Violation;
opts.DetectiveScanInterval = TimeSpan.FromHours(24);
}));
Override SoD Stores

Like access review stores, AddSeparationOfDuties() registers InMemorySoDPolicyStore as a fallback. Override with your persistent implementation via TryAddSingleton replacement.

Orphaned Access Detection

Detects grants held by users who are no longer active -- required for FedRAMP AC-2, SOC 2, and NIST 800-53.

Setup:

// You MUST register your own IUserStatusProvider -- no default is provided
services.AddSingleton<IUserStatusProvider, MyHrSystemStatusProvider>();

services.AddExcaliburA3Core()
.AddGovernance(g => g
.AddOrphanedAccessDetection(opts =>
{
opts.ScanIntervalHours = 12;
opts.InactiveGracePeriodDays = 30;
opts.AutoRevokeDeparted = true;
opts.AutoRevokeAfterGracePeriod = false;
}));

How it works:

  1. Background service scans all grants at the configured interval
  2. For each user, calls IUserStatusProvider.GetStatusAsync to check their status
  3. Maps user status to a recommended action:
User StatusRecommended Action
ActiveSkip (no action)
Inactive (within grace period)Flag for review
Inactive (past grace period)Revoke (if AutoRevokeAfterGracePeriod enabled)
DepartedRevoke (if AutoRevokeDeparted enabled)
Unknown or provider errorInvestigate
  1. Returns an OrphanedAccessReport with all findings
IUserStatusProvider Required

Unlike other governance features, orphaned access detection requires you to provide an IUserStatusProvider implementation that connects to your identity/HR system. No in-memory fallback exists because the detector needs real user status data to function.

Provisioning Workflows (Phase 3 Foundation)

Approval-based workflows for access requests with risk scoring.

Provisioning request lifecycle:

Pending → InReview → Approved → Provisioned
→ Denied → Failed

Key concepts:

  • ProvisioningRequest -- event-sourced aggregate managing the approval lifecycle
  • IProvisioningWorkflowConfiguration -- determines which approval steps apply based on scope and risk
  • IGrantRiskAssessor -- returns a risk score (0-100) for grant requests; default returns 0
  • IProvisioningStore -- read-model store for request summaries (4 methods + GetService)
  • ApprovalStep / ApprovalStepTemplate -- define who must approve and under what conditions

Setup:

services.AddExcaliburA3Core()
.AddGovernance(g => g
.AddProvisioning());

JIT (Just-In-Time) access:

Enable temporary role elevation with automatic revocation:

services.AddExcaliburA3Core()
.AddGovernance(g => g
.AddProvisioning()); // JIT configured via ProvisioningOptions.EnableJitAccess

JIT grants have a configurable duration (default: 4 hours, max: 24 hours). A background service (JitAccessExpiryService) automatically revokes expired JIT grants.

Non-Human Identity Governance

Classify and govern service accounts, bots, and API keys alongside human identities.

Principal classification:

services.AddExcaliburA3Core()
.AddGovernance(g => g
.AddNonHumanIdentity());

PrincipalType classifies identities as Human, ServiceAccount, Bot, or ApiKey. The default IPrincipalTypeProvider returns Human -- override for your identity system.

API key management:

services.AddExcaliburA3Core()
.AddGovernance(g => g
.AddNonHumanIdentity()
.AddApiKeyManagement(opts =>
{
opts.MaxKeysPerPrincipal = 5;
opts.DefaultExpirationDays = 90;
opts.MinimumExpirationDays = 1;
}));

IApiKeyManager provides full API key lifecycle:

MethodPurpose
CreateKeyAsync(request, ct)Create key (plaintext returned once, SHA-256 stored)
RevokeKeyAsync(keyId, ct)Revoke a key
ValidateKeyAsync(apiKey, ct)Validate plaintext key
GetKeysByPrincipalAsync(principalId, ct)List active keys for a principal
GetService(Type)ISP escape hatch
Security Properties

API keys are stored as SHA-256 hashes -- plaintext is never persisted. The plaintext key is returned exactly once at creation. Mandatory expiry is enforced, and the number of active keys per principal is bounded.

Entitlement Reporting

Generate compliance-ready snapshots of who has access to what -- required for SOC 2, FedRAMP, SOX, HIPAA, and NIST 800-53 audits.

Setup:

services.AddExcaliburA3Core()
.AddGovernance(g => g
.AddEntitlementReporting());

Generating reports:

IEntitlementReportProvider provider = ...; // injected

// User-scoped snapshot
var userSnapshot = await provider.GenerateUserSnapshotAsync("user-123", ct);

// Tenant-scoped snapshot
var tenantSnapshot = await provider.GenerateTenantSnapshotAsync("tenant-abc", ct);

// Specialized reports
var orphaned = await provider.GenerateReportAsync(
EntitlementReportType.OrphanedGrants, tenantId: null, ct);
var expiring = await provider.GenerateReportAsync(
EntitlementReportType.ExpiringGrants, tenantId: "tenant-abc", ct);
var sodViolations = await provider.GenerateReportAsync(
EntitlementReportType.SoDViolations, tenantId: null, ct);

Report types:

TypeDescription
UserEntitlementsAll entitlements for a specific user
TenantEntitlementsAll entitlements within a tenant
OrphanedGrantsGrants held by inactive/departed/unknown principals
ExpiringGrantsGrants expiring within a configurable window
SoDViolationsGrants violating separation-of-duties policies
UnreviewedGrantsGrants never reviewed in an access review campaign

Formatting reports:

IReportFormatter formatter = ...; // injected (JsonReportFormatter by default)

var bytes = await formatter.FormatAsync(snapshot, ct);
// formatter.ContentType == "application/json"

The built-in JsonReportFormatter uses System.Text.Json source generation for AOT safety. Implement IReportFormatter for custom formats (CSV, PDF, etc.).

Graceful Degradation

The entitlement report provider aggregates data from all governance subsystems. If an optional subsystem (e.g., orphaned access detection, SoD evaluator) is not registered, reports that need it return empty entries with a warning log -- they do not throw.

Package Comparison

CapabilityExcalibur.A3.CoreExcalibur.A3A3.Governance
Grant CRUD (IGrantStore)YesYesYes (via A3.Core)
Activity group management (IActivityGroupStore)YesYesYes (via A3.Core)
In-memory stores (dev/test/standalone)YesYesYes
ISP sub-interfaces (IGrantQueryStore, IActivityGroupGrantStore)YesYesYes
IA3Builder with UseGrantStore<T>() / UseActivityGroupStore<T>()YesYesYes
Role management (IRoleStore, AddRoles())----Yes
Access review campaigns (IAccessReviewStore, AddAccessReviews())----Yes
Separation of duties (ISoDEvaluator, AddSeparationOfDuties())----Yes
Orphaned access detection (IOrphanedAccessDetector, AddOrphanedAccessDetection())----Yes
Provisioning workflows (IProvisioningStore, AddProvisioning())----Yes
JIT access (temporary elevation with auto-revoke)----Yes
Non-human identity (IPrincipalTypeProvider, AddNonHumanIdentity())----Yes
API key management (IApiKeyManager, AddApiKeyManagement())----Yes
Entitlement reporting (IEntitlementReportProvider, AddEntitlementReporting())----Yes
CQRS commands (AddGrantCommand, RevokeGrantCommand)--Yes--
Dispatch pipeline middleware (auth, audit)--Yes--
Authentication HTTP services (ITokenValidator)--Yes--
Event-sourced Grant aggregate--Yes--
Audit message publishing--Yes--
NuGet transitive dependencies3 packages8+ packages4 packages

Dependency Graphs

Standalone (lightweight):
Your App → A3.Core → A3.Abstractions + Domain + Dispatch.Abstractions

Full stack (unchanged):
Your App → A3 → A3.Core + Application + EventSourcing + Dispatch + ...

Governance (lightweight):
Your App → A3.Governance → A3.Governance.Abstractions + A3.Core

External Policy Engines

For organizations using centralized policy engines, A3 supports delegation to OPA or Cedar via HTTP adapters.

Open Policy Agent (OPA)

<PackageReference Include="Excalibur.A3.Policy.Opa" />
services.AddExcaliburA3(a3 =>
{
a3.UseOpaPolicy(opa =>
{
opa.BaseUrl = "http://localhost:8181";
opa.PolicyPath = "/v1/data/excalibur/authz";
});
});

The OPA adapter sends authorization requests as JSON to your OPA server and maps the response back to A3's grant model. Uses IHttpClientFactory for connection management.

Cedar (AWS Verified Permissions)

<PackageReference Include="Excalibur.A3.Policy.Cedar" />
services.AddExcaliburA3(a3 =>
{
a3.UseCedarPolicy(cedar =>
{
cedar.Mode = CedarMode.AwsVerifiedPermissions;
cedar.BaseUrl = "https://verifiedpermissions.us-east-1.amazonaws.com";
cedar.PolicyStoreId = "ps-example123";
});
});

Cedar supports two modes:

  • CedarMode.Local -- Evaluate policies locally using a Cedar engine
  • CedarMode.AwsVerifiedPermissions -- Delegate to AWS Verified Permissions via HTTP
One evaluator at a time

UseOpaPolicy() and UseCedarPolicy() both register IAuthorizationEvaluator. Calling both replaces the previous registration -- the last call wins. Choose one policy engine per application.

What's Next

See Also