Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 20269 min read

Identity & principal

AppointMe splits the current user into an identity (who you are) and a principal (what you may do in this company), and resolves both before your .NET handler runs.

Identity
Who you are: IIdentity
Principal
What you may do here: IPrincipal
Held in
An AsyncLocal that follows the flow
You touch it when
A handler needs the user or a check

IIdentity says who is calling; IPrincipal says what that caller may do in the active company.

Business rules need the first (“you can’t remove yourself”). Authorization needs the second (“may this caller remove employees?”). Each identity has exactly one kind of principal:

Identity Principal Used for HasPermission
UserIdentity(Id, Name, Email) UserPrincipal(CompanyId, Roles, Permissions) A signed-in user Looks in its resolved set
SystemIdentity SystemPrincipal Hangfire jobs and domain-event handlers Always true
AnonymousIdentity AnonymousPrincipal No authenticated user Always false

The identity types live in AppointMe.Shared/Authentication, the principal types in AppointMe.Shared/Authorization/Principals. A UserPrincipal is always scoped to one company:

src/AppointMe.Shared/Authorization/Principals/UserPrincipal.csView source ↗
public sealed record UserPrincipal(CompanyId CompanyId, IReadOnlySet<Role> Roles, IReadOnlySet<Permission> Permissions)
: IPrincipal
{
public bool HasRole(Role role) => Roles.Contains(role);
public bool HasPermission(Permission permission) => Permissions.Contains(permission);
}

Each resolver returns what its ambient holder already has, or builds the value; only a user identity costs a database query.

Entry pointHTTP, Wolverine, Hangfire or a domain event
CurrentIdentityResolverclaims → IIdentity, unless ICurrentIdentity already holds one
CurrentPrincipalResolveridentity + company → IPrincipal
AnonymousPrincipalbuilt in memory
SystemPrincipalbuilt in memory
UserPrincipalUserPrincipalFactory reads the database
Figure 1. Identity first, then the principal. A pushed system context short-circuits both.
Piece Where What it does
ICurrentIdentity AppointMe.Shared/Authentication Holds the identity in a static AsyncLocal. Change(identity) returns an IDisposable that restores the previous value.
CurrentIdentityResolver AppointMe.Api/Authentication Returns the held identity if there is one. Otherwise it builds one with HttpIdentityFactory and calls Change(...).
HttpIdentityFactory AppointMe.Api/Authentication No authenticated HttpContext.User gives AnonymousIdentity. Otherwise it maps the claims and calls IUserIdentityRegistry.GetOrRegister.
UserIdentityRegistry AppointMe.Identity/Users Finds the User by the identity provider’s subject, or registers one on first sign-in.
ICurrentPrincipal AppointMe.Shared/Authorization/Principals The same AsyncLocal holder, for the principal.
CurrentPrincipalResolver AppointMe.Organizations/Infrastructure Turns the identity into a principal (below).
UserPrincipalFactory AppointMe.Organizations/Infrastructure Loads the user’s roles in the current company and resolves their permissions.
src/Organizations/AppointMe.Organizations/Infrastructure/CurrentPrincipalResolver.csView source ↗
public async ValueTask<IPrincipal> Resolve(CancellationToken cancellationToken)
{
if (currentPrincipal.Principal is { } resolved)
{
return resolved;
}
var identity = await identityResolver.Resolve(cancellationToken);
IPrincipal principal = identity switch
{
AnonymousIdentity => new AnonymousPrincipal(),
SystemIdentity => new SystemPrincipal(),
UserIdentity user => await userPrincipalFactory.Create(user, cancellationToken),
_ => throw new NotSupportedException($"Unknown identity type: {identity.GetType().Name}")
};
currentPrincipal.Change(principal);
return principal;
}

Building a UserPrincipal is what checks that the user belongs to the company in the X-Company-Id header.

src/Organizations/AppointMe.Organizations/Infrastructure/UserPrincipalFactory.csView source ↗
public async ValueTask<UserPrincipal> Create(UserIdentity user, CancellationToken cancellationToken)
{
var companyId = currentCompany.CompanyId
?? throw new AccessDeniedException("Active company was not specified.");
var roles = await dbContext.Employees
.Where(employee => employee.UserId == user.Id)
.Select(employee => employee.Roles)
.SingleOrDefaultAsync(cancellationToken);
if (roles is null || roles.Count == 0)
{
throw new AccessDeniedException("User is not authorized to access requested company.");
}
var permissionOverrides = await permissionOverridesCache.GetAsync(companyId, cancellationToken);
var userRoles = roles.ToHashSet();
return new UserPrincipal(
CompanyId: companyId,
Roles: userRoles,
Permissions: permissionResolver.Resolve(userRoles, permissionOverrides));
}

The query has no CompanyId predicate because Employees carries the named company query filter, so it only sees employees of the current company (see Multi-tenancy). Both exceptions become 403 through AccessDeniedExceptionHandler. How roles turn into permissions is on the Permissions page.

Each way into the app has its own framework hook that fills the holders before the handler runs.

Seam Where Strategy What it does
HTTP authorization RegisteredUserAuthorizationHandler in AppointMe.Api/Authorization Pull The fallback policy requires RegisteredUserRequirement. The handler resolves the identity and succeeds only for a UserIdentity.
Wolverine handlers IdentityContextPolicy, PrincipalContextPolicy in AppointMe.Api/Wolverine/HandlerContext Pull When handler code is generated, adds a Load middleware to every chain whose handler takes IIdentity or IPrincipal. Other chains get nothing.
Hangfire jobs SystemContextJobFilter in AppointMe.Api/Hangfire Push OnPerforming sets SystemIdentity and SystemPrincipal. OnPerformed disposes both scopes.
Domain-event handlers DomainEventContextPolicy in AppointMe.Api/Wolverine/HandlerContext Push Inserts SystemContextBehavior first on every chain whose message is an IDomainEvent.

The domain-event policy puts the system context at the front of the chain:

src/AppointMe.Api/Wolverine/HandlerContext/DomainEventContextPolicy.csView source ↗
public void Apply(IReadOnlyList<HandlerChain> chains, GenerationRules rules, IServiceContainer container)
{
foreach (var chain in chains
.Where(handlerChain => handlerChain.MessageType.IsAssignableTo(typeof(IDomainEvent))))
{
chain.Middleware.Insert(0, new MethodCall(typeof(SystemContextBehavior),
nameof(SystemContextBehavior.Apply)));
}
}

All five handler policies are registered together. Order matters: the tenant is restored before anything resolves a principal.

src/AppointMe.Api/Wolverine/WolverineHostBuilderExtensions.csView source ↗
options.Policies.Add<TenantContextPolicy>();
options.Policies.Add<DomainEventContextPolicy>();
options.Policies.Add<IdentityContextPolicy>();
options.Policies.Add<PrincipalContextPolicy>();
options.Policies.Add<CompanyContextPolicy>();

Declare IIdentity, IPrincipal or CompanyId as a handler parameter and the generated middleware supplies it.

src/Organizations/AppointMe.Organizations/Employees/DeleteEmployee/DeleteEmployeeCommandHandler.csView source ↗
public async Task HandleAsync(DeleteEmployeeCommand command, CompanyId companyId, IPrincipal principal,
IIdentity identity, CancellationToken cancellationToken)
{
principal.Require(EmployeePermissions.Remove);
var employee = await dbContext.Employees.LoadAsync(new EmployeeId(command.Id), companyId, cancellationToken);
if (identity is UserIdentity currentUser && employee.UserId == currentUser.Id)
{
throw new ValidationException("You cannot remove yourself from the company.");
}
employee.Delete();
await dbContext.SaveChangesAsync(cancellationToken);
}
Parameter Supplied by Can fail with
IIdentity identity IdentityContextBehavior.Load Nothing. It may be AnonymousIdentity, so pattern-match.
IPrincipal principal PrincipalContextBehavior.LoadAsync 403 from UserPrincipalFactory: no company, or not an employee
CompanyId companyId CompanyContextBehavior.Load 403 Active company was not specified.

HandlerChain.Uses<T>() looks at the handler method’s parameters and its constructor’s. For a nullable user id, identity.UserId (in IdentityExtensions) returns UserId?.

Section titled “Authentication: cookie or bearer, Keycloak or Entra”

One policy scheme sends requests with a Bearer header to JWT validation and everything else to the auth cookie.

src/AppointMe.Api/Authentication/AuthenticationExtensions.csView source ↗
.AddPolicyScheme(HybridAuthenticationDefaults.AuthenticationScheme, "Cookie or Bearer", options =>
{
options.ForwardDefaultSelector = context => context.Request.HasBearerTokenHeader()
? JwtBearerDefaults.AuthenticationScheme
: CookieAuthenticationDefaults.AuthenticationScheme;
})

The SPA signs in through GET /api/v1/login, an OpenID Connect code flow with its callback at /signin-oidc. The result is the appointme.auth cookie: HttpOnly, SameSite=Lax, a 7-day sliding expiry. API clients send a bearer token instead, and get 401 rather than a redirect.

Keycloak Entra External ID
Authentication:Provider Keycloak (the default) EntraExternalId
Options section Authentication:Keycloak Authentication:EntraExternalId
Claims mapping KeycloakClaimsTransformer EntraExternalIdClaimsTransformer
Where it runs Local development, started by Aspire The Azure deployment (appsettings.Devtest.json)

Each transformer maps the provider’s claims onto the standard subject, name and email types that ToAuthenticatedUser() reads. The Identity module reads the same key to pick its admin client for invitations. See the Identity module and Entra External ID.

You want to Where What
Use the current user Your handler Take IIdentity identity, then if (identity is UserIdentity user).
Check access Your handler Take IPrincipal principal and call principal.Require(...) first.
Add an entry point (a gRPC service, another job runner) Its own filter or middleware Before the handler runs, call ICurrentIdentity.Change(...) and ICurrentPrincipal.Change(...) from synchronous code, then dispose both scopes afterwards, as SystemContextJobFilter does.
Add an identity type (an API key, say) AppointMe.Shared/Authentication + CurrentPrincipalResolver Add a record that implements IIdentity, produce it in your seam, and add a case to the switch.
Symptom Cause Fix
403 Active company was not specified. on a handler with no CompanyId It takes IPrincipal, and a user principal needs a company Send X-Company-Id, or take only IIdentity in company-less handlers, as OnboardingCommandHandler does.
403 Missing email claim (or subject, or name) The token lacks a claim that ToAuthenticatedUser() requires Add the scope or claim in the identity provider, or map it in the provider’s claims transformer.
Require(...) always passes in an event handler Domain-event handlers run as SystemPrincipal Expected. Check permissions in the command handler that raised the event.
NotSupportedException: Unknown identity type A new IIdentity has no case in CurrentPrincipalResolver Add the case.
A later step sees the wrong identity A custom seam called Change(...) without disposing the result using var scope = currentIdentity.Change(...);
Why not read HttpContext.User inside handlers?2 min · optional

The same handler runs from an HTTP request, a Hangfire job and a queued message, and only one of those has an HttpContext. A ClaimsPrincipal also knows nothing about company roles, which live in the Organizations database.

That database is the other reason. Wolverine opens a transaction around a handler that uses a DbContext, and resolving the principal mid-handler would query OrganizationsDbContext from inside, say, a Booking transaction. So resolution has to finish before the handler starts, which is what the seams do.

The full argument is in two blog posts: Identity vs principal in .NET and Resolving identity and principal across 4 entry points.

Why an AsyncLocal holder, not a scoped service?1 min · optional

Jobs and message handlers don’t share an HTTP request scope, but every flow has an async context. An AsyncLocal value follows that context into everything it awaits. Change(...) returns a scope, so a nested context can set a value and restore the old one.

One .NET rule matters here: a value set inside an async method is discarded when that method returns. The push seams call Change(...) from synchronous code (OnPerforming, SystemContextBehavior.Apply), so the handler that runs next sees it.

The cost is that correctness rests on convention: each entry point must be wired. Add a fifth entry point without a seam and its handlers fall back to HttpIdentityFactory, which returns AnonymousIdentity when there’s no HttpContext.