RBAC Recipes
Worked examples for wiring ICritterWatchAuthorizer against common identity sources. Each recipe is self-contained — pick the one that matches your environment, paste, adjust the role / capability mappings, and register it.
For the interface contract, the capability catalog, and the deny-envelope shape, see RBAC. For per-tenant scoping that crosses HTTP / SignalR / MCP, see Multi-tenancy → Per-tenant scoping.
OIDC role-claim mapping
Use this when your IdP (Auth0, Okta, Entra ID, Google Workspace, Keycloak, …) issues role claims and you want the simplest possible mapping from "user has role" to "user can do capability."
using System.Security.Claims;
using CritterWatch.Services.Authorization;
public sealed class OidcRoleAuthorizer : ICritterWatchAuthorizer
{
// Capability -> set of roles that grant it.
private static readonly IReadOnlyDictionary<string, IReadOnlySet<string>> _grants =
new Dictionary<string, IReadOnlySet<string>>
{
// Read surfaces
[Capabilities.DashboardView] = new HashSet<string> { "viewer", "sre", "platform" },
[Capabilities.ServicesView] = new HashSet<string> { "viewer", "sre", "platform" },
[Capabilities.AlertsView] = new HashSet<string> { "viewer", "sre", "platform" },
// Day-to-day operator actions
[Capabilities.DlqReplay] = new HashSet<string> { "sre", "platform" },
[Capabilities.DlqDiscard] = new HashSet<string> { "platform" },
[Capabilities.AlertAcknowledge] = new HashSet<string> { "sre", "platform" },
[Capabilities.AlertClear] = new HashSet<string> { "sre", "platform" },
[Capabilities.ListenerPause] = new HashSet<string> { "sre", "platform" },
[Capabilities.ListenerRestart] = new HashSet<string> { "sre", "platform" },
// Higher-blast-radius actions
[Capabilities.ProjectionRebuild] = new HashSet<string> { "platform" },
[Capabilities.TenantHardDelete] = new HashSet<string> { "platform-admin" },
};
public Task<bool> IsAllowedAsync(
ClaimsPrincipal principal, string capability, string? resource, CancellationToken ct)
{
if (!_grants.TryGetValue(capability, out var allowedRoles)) return Task.FromResult(false);
var userRoles = principal.FindAll(ClaimTypes.Role).Select(c => c.Value);
return Task.FromResult(userRoles.Any(allowedRoles.Contains));
}
}Register it:
builder.Services.AddCritterWatchAuthorization<OidcRoleAuthorizer>();If your IdP issues role claims under a non-standard type (e.g. roles instead of ClaimTypes.Role), read from that type instead — principal.FindAll("roles").
Static config-based authorizer
Use this when there's no IdP — small internal deployments, ops-tool installs, regulated environments where ops users live in appsettings.json rather than an external directory.
// appsettings.json
{
"CritterWatchRbac": {
"RoleCapabilities": {
"viewer": [ "dashboard.view", "services.view", "alerts.view" ],
"sre": [ "dlq.replay", "alert.acknowledge", "alert.clear", "listener.pause", "listener.restart" ],
"platform": [ "dlq.discard", "projection.rebuild", "tenant.add", "tenant.remove" ]
},
"UserRoles": {
"alice@example.com": [ "platform" ],
"bob@example.com": [ "sre" ]
}
}
}public sealed class StaticConfigAuthorizer : ICritterWatchAuthorizer
{
private readonly IReadOnlyDictionary<string, IReadOnlySet<string>> _userRoles;
private readonly IReadOnlyDictionary<string, IReadOnlySet<string>> _roleCapabilities;
public StaticConfigAuthorizer(IConfiguration config)
{
var section = config.GetSection("CritterWatchRbac");
_roleCapabilities = section.GetSection("RoleCapabilities").GetChildren()
.ToDictionary(
c => c.Key,
c => (IReadOnlySet<string>)c.Get<string[]>()!.ToHashSet());
_userRoles = section.GetSection("UserRoles").GetChildren()
.ToDictionary(
c => c.Key,
c => (IReadOnlySet<string>)c.Get<string[]>()!.ToHashSet());
}
public Task<bool> IsAllowedAsync(
ClaimsPrincipal principal, string capability, string? resource, CancellationToken ct)
{
var email = principal.FindFirstValue(ClaimTypes.Email)
?? principal.Identity?.Name;
if (email is null || !_userRoles.TryGetValue(email, out var roles)) return Task.FromResult(false);
return Task.FromResult(roles
.Where(r => _roleCapabilities.TryGetValue(r, out _))
.Any(r => _roleCapabilities[r].Contains(capability)));
}
}Watch out for: appsettings is reloadable, but StaticConfigAuthorizer here snapshots on construction. For live edits, take IOptionsMonitor<TOptions> instead and read every call.
LDAP / Active Directory groups
Use this when your shop already federates Active Directory or LDAP groups into ASP.NET Core via Windows auth, Kerberos, or a SAML-to-claims bridge.
public sealed class LdapGroupAuthorizer : ICritterWatchAuthorizer
{
// Capability -> LDAP DNs (or sAMAccountName / CN as your bridge maps it).
private static readonly IReadOnlyDictionary<string, IReadOnlySet<string>> _grants =
new Dictionary<string, IReadOnlySet<string>>
{
[Capabilities.DlqReplay] = new HashSet<string> { "CN=SRE,OU=Groups,DC=corp,DC=example" },
[Capabilities.ProjectionRebuild] = new HashSet<string> { "CN=Platform,OU=Groups,DC=corp,DC=example" },
[Capabilities.TenantHardDelete] = new HashSet<string> { "CN=Platform-Admin,OU=Groups,DC=corp,DC=example" },
};
public Task<bool> IsAllowedAsync(
ClaimsPrincipal principal, string capability, string? resource, CancellationToken ct)
{
if (!_grants.TryGetValue(capability, out var allowedGroups)) return Task.FromResult(false);
// Group claims arrive as ClaimTypes.GroupSid (SID-form) or ClaimTypes.Role
// depending on your auth scheme. Adjust below to match the claim your
// bridge writes.
var userGroups = principal.FindAll(ClaimTypes.Role).Select(c => c.Value);
return Task.FromResult(userGroups.Any(allowedGroups.Contains));
}
}If your Windows auth setup emits SID claims rather than DNs, swap the dictionary values for SIDs and read ClaimTypes.GroupSid.
Reverse-proxy header trust
Use this when an upstream gateway (nginx, Envoy, Cloudflare Access, AWS ALB, ASP.NET ingress with oauth2-proxy) terminates auth and forwards the result via a signed header. The CritterWatch host trusts the header, mints a ClaimsPrincipal from it, and your authorizer reads roles off the principal exactly the same as the OIDC recipe.
The trust is established by an authentication handler, not the authorizer — the authorizer doesn't change.
public sealed class ProxyHeaderAuthenticationHandler
: AuthenticationHandler<AuthenticationSchemeOptions>
{
private const string UserHeader = "X-Forwarded-User";
private const string RolesHeader = "X-Forwarded-Roles";
public ProxyHeaderAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(UserHeader, out var user)) return Task.FromResult(AuthenticateResult.NoResult());
var claims = new List<Claim> { new(ClaimTypes.Name, user.ToString()) };
if (Request.Headers.TryGetValue(RolesHeader, out var roles))
claims.AddRange(roles.ToString().Split(',').Select(r => new Claim(ClaimTypes.Role, r.Trim())));
var identity = new ClaimsIdentity(claims, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(
new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
}
}
// Program.cs
builder.Services.AddAuthentication("ProxyHeader")
.AddScheme<AuthenticationSchemeOptions, ProxyHeaderAuthenticationHandler>("ProxyHeader", _ => { });
builder.Services.AddCritterWatchAuthorization<OidcRoleAuthorizer>(); // reuse the role authorizerCritical: only trust the headers if the request arrived via the proxy. Bind the host to the proxy's internal interface, use Kestrel's connection-restriction, or check the signed header (Cloudflare Access JWT, AWS ALB OIDC token) — don't leave the host listening on a public interface that accepts the headers from anyone.
Per-tenant scoping
Use this when an operator should only manage some tenants (e.g. each region's on-call team owns its own tenant set, but everyone shares the dashboard). The resource argument on IsAllowedAsync carries {serviceName}:{tenantId} for per-tenant projection commands; split it and look up grants by (tenant, capability).
public sealed class TenantScopedAuthorizer : ICritterWatchAuthorizer
{
// serviceName -> tenantId -> capabilities granted to the principal's roles
private readonly IReadOnlyDictionary<string, IReadOnlyDictionary<string, IReadOnlySet<string>>> _serviceTenantGrants;
public TenantScopedAuthorizer(/* inject your store */)
{
// populate from your config / database / IdP
}
public Task<bool> IsAllowedAsync(
ClaimsPrincipal principal, string capability, string? resource, CancellationToken ct)
{
// Service-wide path (no tenant scope) — same as the OIDC recipe.
if (resource is null || !resource.Contains(':'))
return Task.FromResult(IsAllowedServiceWide(principal, capability, resource));
var (serviceName, tenantId) = SplitScope(resource);
if (!_serviceTenantGrants.TryGetValue(serviceName, out var byTenant)) return Task.FromResult(false);
if (!byTenant.TryGetValue(tenantId, out var granted)) return Task.FromResult(false);
return Task.FromResult(granted.Contains(capability));
}
private static (string serviceName, string tenantId) SplitScope(string resource)
{
var i = resource.IndexOf(':');
return (resource[..i], resource[(i + 1)..]);
}
private bool IsAllowedServiceWide(ClaimsPrincipal principal, string capability, string? resource)
{
// fallback to your service-level rules
return false;
}
}This is the convention used across the three CritterWatch surfaces — HTTP API, SignalR-routed commands, and MCP action tools. One authorizer covers all three.
See also
- RBAC — the interface, capability catalog, deny-envelope shape, and SignalR / MCP gating.
- Multi-tenancy → Per-tenant scoping — when the
{serviceName}:{tenantId}resource scope applies. - MCP — Per-tenant scoping — same convention from the AI-agent integration's side.
