Articles · Getting Started
Sentinel meets Relay
Try out the running example
If your application runs on Relay —
commands, queries, pipelines — you already annotate messages with
[RequirePermission("tickets:org:close")]. Out of the box Relay checks that string
against whatever permission list your auth middleware loaded. The bridge package,
Nuvora.Nexus.Sentinel.Relay, swaps that lookup for the real thing: Sentinel
authenticates the request, Sentinel’s evaluator decides the attribute, and Sentinel’s
org becomes Relay’s tenant. One package, two registrations, one middleware call.
The wiring
// Sentinel first — any host from the first-login article works unchanged.
services.AddSentinel(o => { o.DefaultRealmId = RealmId; o.AllowDevelopmentDefaults = true; });
services.AddSentinelAuthentication(o => { o.Issuer = Issuer; o.Audience = Audience; o.DefaultRealmId = RealmId; });
// Relay over this assembly (commands + handlers), then the bridge.
services.AddRelay(typeof(ReadTicketsCommand).Assembly);
services.AddSentinelRelayAuthorization();
services.AddSentinelRelayTenancy();
// Documented order: authentication (the Sentinel handler stashes principal + snapshot) →
// the bridge projection (REPLACES Relay's UseRelayAuthContext — never run both) → tenant
// resolution (reads the projected principal) → endpoints.
app.UseAuthentication();
app.UseSentinelRelayAuthContext();
app.UseRelayTenantContext();
UseSentinelRelayAuthContext is the hinge. Per request it takes what the Sentinel
authentication handler already produced — the principal and the subject’s permission
snapshot — and projects them onto Relay’s AuthContext: UserId is the Sentinel
subject (same Guid, no mapping table), the claims dictionary carries sub/realm/org,
and the snapshot rides along for the authorization policy. Unauthenticated requests
flow through anonymous, so public commands keep working.
The attribute, upgraded
[RequireAuthentication]
[RequirePermission("tickets:org:read")]
[SkipTransaction] // no persistence in this host
public sealed record ReadTicketsCommand : ICommand<TicketObservation>;
[RequirePermission] is Relay’s attribute — nothing about your messages changes. What
changes is who answers. AddSentinelRelayAuthorization registers a permission catalog
that hands every check to AuthorizationEvaluator over the live snapshot, which means
the semantics are the full grammar, not
string equality:
- Wildcards work. Rita’s only grant is
tickets:*:*— the literal stringtickets:org:readexists nowhere in her grant set, yet the command executes. Only pattern evaluation can conclude that (Wildcard_grant_allows_because_the_engine_decides_not_string_equality). - Deny overrides. Nadia holds the same wildcard plus an explicit deny on
tickets:org:close— close isForbiddenfor her, wildcard notwithstanding. - Default deny. Ivan holds only
tickets:org:read; close fails with no deny rule anywhere in sight.
For checks that must reflect this instant — long-lived dispatch loops, background replays — the bridge also registers a named Relay policy:
[RequirePolicy(SentinelAuthorizationPolicy.PolicyName)] // "sentinel"
[RequirePermission("tickets:org:close")]
public sealed record CloseTicketCommand : ICommand<TicketObservation>;
Same evaluator, evaluated per-dispatch against the current snapshot rather than the one captured at the middleware.
Tenancy for free
AddSentinelRelayTenancy registers a tenant resolver that reads the org claim the
org-switch flow minted into the token — so
UseRelayTenantContext scopes every dispatch to the Sentinel organization:
public sealed record TicketObservation(Guid? UserId, string? Username, Guid? TenantId, string? OrgClaim)
{
public static TicketObservation Capture(AuthContext auth, TenantContext tenant) => new(
auth.UserId,
auth.Username,
tenant.TenantId,
auth.Claims.TryGetValue("org", out var org) ? org : null);
}
A handler observing tenant.TenantId == SupportOrgId didn’t parse a token or consult a
mapping table — the Sentinel org is the Relay tenant. Switch orgs, get a new token,
and the same command dispatches under the other tenant; Relay’s tenancy enforcement
(tenant-scoped stores, cache partitioning) keys off it from there.
What the handler sees
public sealed class ReadTicketsCommandHandler(IAuthContextAccessor auth, ITenantContextAccessor tenant)
: ICommandHandler<ReadTicketsCommand, TicketObservation>
{
public Task<TicketObservation> Handle(ReadTicketsCommand message, CancellationToken cancellationToken) =>
Task.FromResult(TicketObservation.Capture(auth.Current, tenant.Current));
}
No Sentinel types anywhere — the handler depends on Relay’s accessors and stays testable
with a hand-built AuthContext. The sample keeps the whole domain this way: the only
files that know Sentinel exists are the composition and Program.cs. That’s the bridge’s
actual promise — your CQRS layer keeps its own vocabulary, and the identity decisions
behind it stop being a string comparison.
The failure modes stay honest, too: no credential is Unauthorized, a failed check is
Forbidden (the sample maps Relay’s UnauthorizedException/ForbiddenException to
401/403 inline; UseRelayExceptionHandling does it for full Relay HTTP hosts), and
every denial flows through the same audited evaluator as the rest of your
Sentinel host.