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 string tickets:org:read exists 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 is Forbidden for 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.