# 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.

Source page: https://appointme.dev/docs/concepts/identity-and-principal/

* **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

## 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:

src/AppointMe.Shared/Authorization/Principals/UserPrincipal.cs

```cs
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

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

*Figure 1. Identity first, then the principal. A pushed system context short-circuits both.*

1. **Entry point** — HTTP, Wolverine, Hangfire or a domain event

2. **CurrentIdentityResolver** — claims → `IIdentity`, unless `ICurrentIdentity` already holds one

3. **CurrentPrincipalResolver** — identity + company → `IPrincipal`

4. Then, in parallel:

   * **AnonymousPrincipal** — built in memory
   * **SystemPrincipal** — built in memory
   * **UserPrincipal** — `UserPrincipalFactory` 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.                                                                |

src/Organizations/AppointMe.Organizations/Infrastructure/CurrentPrincipalResolver.cs

```cs
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

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.cs

```cs
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](/docs/concepts/multi-tenancy/)). Both exceptions become `403` through `AccessDeniedExceptionHandler`. How roles turn into permissions is on the [Permissions](/docs/concepts/permissions/) page.

## 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:

src/AppointMe.Api/Wolverine/HandlerContext/DomainEventContextPolicy.cs

```cs
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.cs

```cs
options.Policies.Add<TenantContextPolicy>();
options.Policies.Add<DomainEventContextPolicy>();
options.Policies.Add<IdentityContextPolicy>();
options.Policies.Add<PrincipalContextPolicy>();
options.Policies.Add<CompanyContextPolicy>();
```

Only the company travels with a message

An envelope carries the company as its tenant id, never the user. Do permission checks in the handler the endpoint invokes, not in a handler further down a queue.

## In a handler: ask for them as parameters

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

src/Organizations/AppointMe.Organizations/Employees/DeleteEmployee/DeleteEmployeeCommandHandler.cs

```cs
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

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

src/AppointMe.Api/Authentication/AuthenticationExtensions.cs

```cs
.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](/docs/modules/identity/) and [Entra External ID](/docs/deploy/entra-external-id/).

## 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

| 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?

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](/blog/identity-vs-principal-in-dotnet) and [Resolving identity and principal across 4 entry points](/blog/resolving-identity-and-principal-in-dotnet).

### Why an AsyncLocal holder, not a scoped service?

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`.

## Next

* [Permissions](/docs/concepts/permissions/) (Concept)
* [Identity](/docs/modules/identity/) (Module)
* [Resolving identity & principal in .NET](/blog/resolving-identity-and-principal-in-dotnet) (Blog)
