Skip to content

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

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

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

jsonc
// 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" ]
    }
  }
}
csharp
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.

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

csharp
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 authorizer

Critical: 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).

csharp
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

Released under the MIT License.