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
AsyncLocalthat follows the flow - You touch it when
- A handler needs the user or a check
Two questions, two types
Section titled “Two questions, two types”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:
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);}How they get resolved
Section titled “How they get resolved”Each resolver returns what its ambient holder already has, or builds the value; only a user identity costs a database query.
IIdentity, unless ICurrentIdentity already holds oneIPrincipalUserPrincipalFactory reads the database| 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. |
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;}The membership check
Section titled “The membership check”Building a UserPrincipal is what checks that the user belongs to the company in the X-Company-Id header.
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.
Four entry points, four seams
Section titled “Four entry points, four seams”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:
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.
options.Policies.Add<TenantContextPolicy>();options.Policies.Add<DomainEventContextPolicy>();options.Policies.Add<IdentityContextPolicy>();options.Policies.Add<PrincipalContextPolicy>();options.Policies.Add<CompanyContextPolicy>();In a handler: ask for them as parameters
Section titled “In a handler: ask for them as parameters”Declare IIdentity, IPrincipal or CompanyId as a handler parameter and the generated middleware supplies it.
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?.
Authentication: cookie or bearer, Keycloak or Entra
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.
.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.
Extend it
Section titled “Extend it”| 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. |
Gotchas
Section titled “Gotchas”| 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.