Files
vigilcare-clinical/docs/guides/14-rbac-dynamic-policies.md
T
2026-06-25 00:25:31 +08:00

15 KiB

Guide 14: Role-Based Access Control (RBAC) with Dynamic Policies

What is RBAC?

Authorization answers the question "what are you allowed to do?" Authentication (Guide 13) verifies identity — you're Dr. Smith. Authorization verifies permissions — Dr. Smith can acknowledge alerts, but can she modify threshold configurations?

Role-Based Access Control (RBAC) is the most common authorization model. Instead of assigning permissions directly to each user, you:

  1. Define roles (Nurse, Physician, Admin, Integration)
  2. Assign permissions to each role (Nurse can read patients, acknowledge alerts, record observations)
  3. Assign each user one role

When a user makes a request, the system checks: "Does this user's role have the required permission for this action?"

The advantage over assigning permissions directly to users: when you hire a new nurse, you assign the "Nurse" role once and they get all the right permissions. If you need to give all nurses a new permission, you change it in one place (the role definition).


How RBAC Works in This Project

HTTP Request
     │
     ▼
┌─────────────┐  JWT has claim:
│ JWT Bearer  │  "clinical_role": "Nurse"
│ Middleware  │
└──────┬──────┘
       │
       ▼
┌─────────────────────────┐  Controller has attribute:
│ [AuthorizePermission(   │  "alerts:acknowledge"
│   "alerts:acknowledge")]│
└──────┬──────────────────┘
       │
       ▼
┌─────────────────────────┐  Looks up: does the policy "perm:alerts:acknowledge"
│ PermissionPolicyProvider│  exist? Creates it on-the-fly.
└──────┬──────────────────┘
       │
       ▼
┌─────────────────────────────────┐  Checks the role-permission map:
│ PermissionAuthorizationHandler  │  Nurse → { "alerts:acknowledge" ✓ }
│                                 │
│ If denied: logs warning,        │
│   increments Prometheus counter │
└─────────────────────────────────┘

Step 1: Define Permissions

All permissions are defined as string constants in one class:

public static class ClinicalPermissions
{
    public const string PatientsRead       = "patients:read";
    public const string PatientsWrite      = "patients:write";
    public const string EncountersRead     = "encounters:read";
    public const string EncountersWrite    = "encounters:write";
    public const string ObservationsIngest = "observations:ingest";
    public const string AlertsRead         = "alerts:read";
    public const string AlertsAcknowledge  = "alerts:acknowledge";
    public const string AlertsResolve      = "alerts:resolve";
    public const string AlertsFeedback     = "alerts:feedback";
    public const string ThresholdsRead     = "thresholds:read";
    public const string ThresholdsWrite    = "thresholds:write";
    public const string AnalyticsRead      = "analytics:read";
    public const string OrdersWrite        = "orders:write";
    public const string MedicationsWrite   = "medications:write";
    public const string FhirIngest         = "fhir:ingest";
    public const string FhirRead           = "fhir:read";
    public const string AuditRead          = "audit:read";
    public const string UsersAdmin         = "users:admin";
}

The naming convention resource:action makes permissions self-documenting. "thresholds:write" clearly means "can modify alert thresholds."

Using const string rather than an enum means permissions can be used in attribute arguments (C# requires compile-time constants for attribute parameters).


Step 2: Map Roles to Permissions

The ClinicalRolePermissionMap defines which permissions each role has:

public static class ClinicalRolePermissionMap
{
    private static readonly Dictionary<ClinicalRole, HashSet<string>> _map = new()
    {
        [ClinicalRole.Nurse] = new()
        {
            ClinicalPermissions.PatientsRead,
            ClinicalPermissions.PatientsWrite,
            ClinicalPermissions.EncountersRead,
            ClinicalPermissions.EncountersWrite,
            ClinicalPermissions.ObservationsIngest,
            ClinicalPermissions.AlertsRead,
            ClinicalPermissions.AlertsAcknowledge,
            ClinicalPermissions.AlertsResolve,
            ClinicalPermissions.ThresholdsRead,
            ClinicalPermissions.AnalyticsRead,
            ClinicalPermissions.OrdersWrite,
            ClinicalPermissions.MedicationsWrite,
            ClinicalPermissions.AlertsFeedback,
        },

        [ClinicalRole.Physician] = new()
        {
            // Same as Nurse in this version
            // Physicians and nurses share clinical permissions
        },

        [ClinicalRole.Admin] = new()
        {
            // Everything nurses/physicians have, PLUS:
            ClinicalPermissions.ThresholdsWrite,   // modify alert thresholds
            ClinicalPermissions.FhirIngest,        // FHIR integration
            ClinicalPermissions.FhirRead,          // FHIR read/search
            ClinicalPermissions.AuditRead,         // view audit logs
            ClinicalPermissions.UsersAdmin,        // manage users
        },

        [ClinicalRole.Integration] = new()
        {
            // Narrow set — only what integration systems need
            ClinicalPermissions.PatientsWrite,
            ClinicalPermissions.EncountersWrite,
            ClinicalPermissions.ObservationsIngest,
            ClinicalPermissions.MedicationsWrite,
            ClinicalPermissions.FhirIngest,
            ClinicalPermissions.FhirRead,
        },
    };

    public static bool HasPermission(ClinicalRole role, string permission) =>
        _map.TryGetValue(role, out var perms) && perms.Contains(permission);
}

Key design choices:

  • Admin has superset permissions — everything clinical roles have plus administrative actions
  • Integration has minimum permissions — machine-to-machine integrations can only write data (patients, encounters, observations, medications) and use FHIR. They can't acknowledge alerts, modify thresholds, or view audit logs.
  • The map is in code, not the database — permission changes require a deployment, which provides a review and audit trail. For systems where permissions change frequently, you'd store them in a database instead.

Step 3: The AuthorizePermission Attribute

Controllers declare which permission is required using a custom attribute:

[AuthorizePermission(ClinicalPermissions.EncountersRead)]
[HttpGet]
public async Task<ActionResult> GetEncounters(...)

[AuthorizePermission(ClinicalPermissions.EncountersWrite)]
[HttpPost]
public async Task<ActionResult> OpenEncounter(...)

[AuthorizePermission(ClinicalPermissions.AlertsAcknowledge)]
[HttpPatch("{alertId}/acknowledge")]
public async Task<ActionResult> AcknowledgeAlert(...)

The attribute itself is a thin wrapper:

public class AuthorizePermissionAttribute : AuthorizeAttribute
{
    public AuthorizePermissionAttribute(string permission)
    {
        Policy = $"perm:{permission}";
    }
}

What does Policy = $"perm:{permission}" do? ASP.NET Core's authorization system works with named policies. When you write [Authorize(Policy = "perm:alerts:acknowledge")], ASP.NET asks "does a policy named perm:alerts:acknowledge exist?" — and if so, does the current user satisfy it?

The perm: prefix is a convention that the PermissionPolicyProvider uses to recognize permission-based policies and create them on the fly.


Step 4: Dynamic Policy Resolution

ASP.NET Core expects you to register all policies at startup. But with 18 permissions, you'd need 18 policy registrations — tedious and easy to forget. Instead, the PermissionPolicyProvider creates policies dynamically:

public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
    private readonly DefaultAuthorizationPolicyProvider _fallback;

    public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
    {
        _fallback = new DefaultAuthorizationPolicyProvider(options);
    }

    public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
    {
        if (policyName.StartsWith("perm:", StringComparison.Ordinal))
        {
            var permission = policyName["perm:".Length..];  // "alerts:acknowledge"
            var policy = new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .AddRequirements(new PermissionRequirement(permission))
                .Build();
            return Task.FromResult<AuthorizationPolicy?>(policy);
        }

        return _fallback.GetPolicyAsync(policyName);
    }
}

How does this work?

  1. ASP.NET Core sees [AuthorizePermission("alerts:acknowledge")] on a controller action
  2. The attribute sets Policy = "perm:alerts:acknowledge"
  3. ASP.NET asks PermissionPolicyProvider.GetPolicyAsync("perm:alerts:acknowledge")
  4. The provider sees the perm: prefix, extracts "alerts:acknowledge", and builds a policy that requires authentication + a PermissionRequirement
  5. If the policy name doesn't start with perm:, it falls through to the default provider (for standard ASP.NET policies)

The PermissionRequirement is a simple data object:

public class PermissionRequirement : IAuthorizationRequirement
{
    public string Permission { get; }
    public PermissionRequirement(string permission) => Permission = permission;
}

Step 5: The Authorization Handler

The PermissionAuthorizationHandler does the actual permission check:

public class PermissionAuthorizationHandler
    : AuthorizationHandler<PermissionRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        PermissionRequirement requirement)
    {
        // 1. Extract the role from the JWT claims
        var roleClaim = context.User.FindFirst("clinical_role")?.Value;
        if (roleClaim is null)
        {
            LogAuthorizationFailure(context.User, "none", requirement.Permission);
            return Task.CompletedTask;  // deny (no role claim)
        }

        // 2. Convert the string to the ClinicalRole enum
        var role = ClinicalRoleExtensions.FromDbString(roleClaim);

        // 3. Check the role-permission map
        if (ClinicalRolePermissionMap.HasPermission(role, requirement.Permission))
        {
            context.Succeed(requirement);  // allow
        }
        else
        {
            LogAuthorizationFailure(context.User, roleClaim, requirement.Permission);
            // don't call context.Fail() — just don't succeed
            // this lets other handlers potentially succeed for the same requirement
        }

        return Task.CompletedTask;
    }
}

Why not call context.Fail()? In ASP.NET Core's authorization pipeline, Fail() is a hard denial — no other handler can override it. By simply not calling Succeed(), the requirement remains unsatisfied, which still results in a denial, but allows the possibility of other handlers succeeding. This follows the ASP.NET Core best practice for custom handlers.

Logging and Metrics on Denial

private void LogAuthorizationFailure(ClaimsPrincipal user, string role, string permission)
{
    _logger.LogWarning(
        "Authorization denied: user={User}, role={Role}, " +
        "requiredPermission={Permission}, endpoint={Endpoint}",
        username, userId, role, permission, endpoint);

    _metrics.AuthorizationFailuresTotal.WithLabels(permission, role).Inc();
}

Every denied request is:

  1. Logged with full context (who, what role, what permission, which endpoint) — visible in Seq
  2. Counted in the authorization_failures_total Prometheus metric — visible on the Grafana dashboard

A spike in authorization failures could indicate a misconfigured role, a compromised account trying to access restricted resources, or a frontend bug sending requests to the wrong endpoint.


Step 6: Registration

All authorization components are registered in Program.cs:

builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();

Both are singletons because they're stateless — they don't hold user-specific data.


The Complete Authorization Flow

  1. Request arrives with Authorization: Bearer eyJhb... header
  2. JWT middleware validates the signature, checks expiry, extracts claims → HttpContext.User now has clinical_role = "Nurse"
  3. Routing matches the endpoint → [AuthorizePermission("alerts:acknowledge")]
  4. Policy provider creates a policy requiring PermissionRequirement("alerts:acknowledge")
  5. Authorization handler reads clinical_role from claims, checks ClinicalRolePermissionMap.HasPermission(Nurse, "alerts:acknowledge")truecontext.Succeed()
  6. Request proceeds to the controller action

If step 5 returns false → 403 Forbidden (authenticated but not authorized). If step 2 fails → 401 Unauthorized (not authenticated at all).


Permission Matrix

Permission Nurse Physician Admin Integration
patients:read
patients:write
encounters:read
encounters:write
observations:ingest
alerts:read
alerts:acknowledge
alerts:resolve
alerts:feedback
thresholds:read
thresholds:write
analytics:read
orders:write
medications:write
fhir:ingest
fhir:read
audit:read
users:admin

Key Takeaways

  • RBAC simplifies permission management — assign a role once, get all the right permissions. Change the role definition to update everyone with that role.
  • Dynamic policy providers avoid boilerplate — instead of registering 18 policies manually, the provider creates them on-the-fly from the perm: prefix convention.
  • The permission check is a simple map lookupHasPermission(role, permission) is O(1), adding zero measurable latency to request processing.
  • Authorization failures are observable — logged to Seq and counted in Prometheus, so security incidents are visible.
  • 401 vs 403 — 401 Unauthorized means "I don't know who you are" (missing or invalid token). 403 Forbidden means "I know who you are, but you're not allowed to do this" (valid token, insufficient permissions).