feature: Explainable Alerts

This commit is contained in:
voltsrage
2026-06-25 00:25:31 +08:00
parent 279add1e45
commit 666d683d67
61 changed files with 9553 additions and 125 deletions
+359
View File
@@ -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).