feature: Explainable Alerts
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
# 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:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```csharp
|
||||
[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:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```csharp
|
||||
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:
|
||||
|
||||
```csharp
|
||||
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
|
||||
|
||||
```csharp
|
||||
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`:
|
||||
|
||||
```csharp
|
||||
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")` → **true** → `context.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 lookup** — `HasPermission(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).
|
||||
Reference in New Issue
Block a user