feat(portal): implement RBAC with permission gates and user management#526
feat(portal): implement RBAC with permission gates and user management#526aurelianware merged 3 commits intomainfrom
Conversation
Add role-based access control to the Blazor portal that restricts page access and UI elements based on the authenticated user's roles from tenant-service. - Add IUserContextService/UserContextService: fetches TenantUser from tenant-service, caches per circuit, expands roles to flat permissions, falls back to TenantAdmin if service unreachable - Add PermissionGate component: conditionally renders content based on required permissions, shows access-denied alert with role guidance - Wrap all 14 operational pages with PermissionGate checks: Claims/WorkQueues (claims:read), Members (members:read), Authorizations (authorizations:read), Appeals (appeals:read), Providers (providers:read), BenefitPlans (any role), PaymentRuns (payments:read), PremiumBilling (billing:read), EnrollmentOps (enrollment:read), Reports (reports:*), Settings (settings:manage), Eligibility (eligibility:check) - Update Dashboard welcome to show user name and primary role - Add User Management page at /settings/users (TenantAdmin only): list users with roles/status/last login, add/edit/disable users - Add AddEditUserDialog for creating and editing users - Add User Management nav link in MainLayout admin section - Register IUserContextService in Program.cs https://claude.ai/code/session_018xwRpYQjDDnEKD3HTzJSnz
There was a problem hiding this comment.
Pull request overview
Implements role-based access control (RBAC) in the Cloud Health Office Blazor portal by introducing a user context/permission model, a reusable permission gate component, and a TenantAdmin-only user management UI.
Changes:
- Added
IUserContextService/UserContextServiceto resolve tenant user roles and expand them into flattened permissions. - Added
PermissionGatecomponent and wrapped operational pages to enforce permission-based rendering. - Added
/settings/usersuser management page and add/edit dialog; added navigation entry and DI registration.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| src/portal/CloudHealthOffice.Portal/Shared/PermissionGate.razor | New component to gate UI/page rendering based on required permissions. |
| src/portal/CloudHealthOffice.Portal/Shared/MainLayout.razor | Adds “User Management” link under Admin navigation. |
| src/portal/CloudHealthOffice.Portal/Services/UserContextService.cs | New user context + permission expansion + permission matching logic. |
| src/portal/CloudHealthOffice.Portal/Program.cs | Registers IUserContextService for DI. |
| src/portal/CloudHealthOffice.Portal/Pages/WorkQueues.razor | Wraps page content in PermissionGate for claims/work access. |
| src/portal/CloudHealthOffice.Portal/Pages/UserManagement.razor | New TenantAdmin user list page with enable/disable and edit actions. |
| src/portal/CloudHealthOffice.Portal/Pages/Settings.razor | Wraps settings UI in PermissionGate for settings management. |
| src/portal/CloudHealthOffice.Portal/Pages/Reports.razor | Wraps reports UI in PermissionGate for reports permissions. |
| src/portal/CloudHealthOffice.Portal/Pages/Providers.razor | Wraps providers UI in PermissionGate for providers read access. |
| src/portal/CloudHealthOffice.Portal/Pages/PremiumBilling.razor | Wraps billing UI in PermissionGate for billing read access. |
| src/portal/CloudHealthOffice.Portal/Pages/PaymentRuns.razor | Wraps payment runs UI in PermissionGate for payments read access. |
| src/portal/CloudHealthOffice.Portal/Pages/Members.razor | Wraps members UI in PermissionGate for members read access. |
| src/portal/CloudHealthOffice.Portal/Pages/EnrollmentOperations.razor | Wraps enrollment ops UI in PermissionGate for enrollment read access. |
| src/portal/CloudHealthOffice.Portal/Pages/Eligibility.razor | Wraps eligibility UI in PermissionGate for eligibility checks. |
| src/portal/CloudHealthOffice.Portal/Pages/Dashboard.razor | Wraps dashboard in PermissionGate and displays user name/primary role. |
| src/portal/CloudHealthOffice.Portal/Pages/Claims.razor | Wraps claims UI in PermissionGate for claims access. |
| src/portal/CloudHealthOffice.Portal/Pages/BenefitPlans.razor | Wraps benefit plan UI in PermissionGate (any role). |
| src/portal/CloudHealthOffice.Portal/Pages/Authorizations.razor | Wraps authorizations UI in PermissionGate for auth read access. |
| src/portal/CloudHealthOffice.Portal/Pages/Appeals.razor | Wraps appeals UI in PermissionGate for appeals read access. |
| src/portal/CloudHealthOffice.Portal/Pages/AddEditUserDialog.razor | New dialog for creating/updating tenant users and roles. |
| [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!; | ||
| [Parameter] public string? TenantId { get; set; } | ||
| [Parameter] public UserManagement.UserListItem? ExistingUser { get; set; } |
There was a problem hiding this comment.
ExistingUser is typed as UserManagement.UserListItem, which couples this dialog to the UserManagement page component and makes reuse/testing harder. Consider moving UserListItem into a shared model/DTO class (e.g., under Services/ or Models/) and using that type in both components.
| [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!; | |
| [Parameter] public string? TenantId { get; set; } | |
| [Parameter] public UserManagement.UserListItem? ExistingUser { get; set; } | |
| public class UserListItemDto | |
| { | |
| public string Id { get; set; } = string.Empty; | |
| public string Email { get; set; } = string.Empty; | |
| public string DisplayName { get; set; } = string.Empty; | |
| public string Department { get; set; } = string.Empty; | |
| public IEnumerable<string> Roles { get; set; } = new List<string>(); | |
| } | |
| [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!; | |
| [Parameter] public string? TenantId { get; set; } | |
| [Parameter] public UserListItemDto? ExistingUser { get; set; } |
| var response = await Http.PutAsJsonAsync( | ||
| $"{baseUrl}/v1/tenants/{TenantId}/users/{ExistingUser!.Id}", |
There was a problem hiding this comment.
TenantId is nullable but is interpolated into the tenant-service URL. Add a submit-time guard that blocks saving and shows a clear error when TenantId is null/empty to avoid generating invalid endpoints.
| <MudNavLink Href="/settings/users" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ManageAccounts" | ||
| Style="border-left: 3px solid transparent; transition: all 0.3s ease;"> | ||
| User Management | ||
| </MudNavLink> |
There was a problem hiding this comment.
The “User Management” nav link is shown to all authenticated users. Since the page is TenantAdmin-only, this link should be conditionally rendered (e.g., via PermissionGate/AuthorizeView + UserContextService.HasPermission("users:manage")) to avoid advertising admin-only functionality to non-admins.
|
|
||
| try | ||
| { | ||
| var baseUrl = _configuration["Services:TenantService"] ?? "http://tenant-service.cho-svcs/api"; |
There was a problem hiding this comment.
The tenant-service base URL fallback (http://tenant-service.cho-svcs/api) doesn’t match the portal’s configured service URLs (appsettings.json uses http://tenant-service.cloudhealthoffice/api) and will silently mask missing configuration. Prefer requiring Services:TenantService to be set (or use the same default as appsettings) so calls don’t unexpectedly target a non-existent host.
| var baseUrl = _configuration["Services:TenantService"] ?? "http://tenant-service.cho-svcs/api"; | |
| var baseUrl = _configuration["Services:TenantService"] ?? "http://tenant-service.cloudhealthoffice/api"; |
| public async Task<UserContext?> GetCurrentUserAsync() | ||
| { | ||
| if (_loaded) return _cachedContext; | ||
| _loaded = true; | ||
|
|
There was a problem hiding this comment.
_loaded is set to true before validating authentication/tenant context/email, so an early return null (e.g., transient tenant-context failure) permanently caches a null context for the circuit and prevents later retries. Consider only marking the context as loaded after a successful fetch, or cache a Task and retry/refresh on failure.
| <PermissionGate Permission="settings:manage" RoleName="TenantAdmin"> | ||
| <MudContainer MaxWidth="MaxWidth.Large" Class="mt-4"> |
There was a problem hiding this comment.
This page is now permission-gated, but it still lacks an @attribute [Authorize] at the top. Without it, unauthenticated users can navigate directly to /settings and will see an access-denied panel rather than being challenged to sign in. Consider adding [Authorize] so routing/auth behavior is consistent with other operational pages.
|
|
||
| <PageTitle>Reports & Analytics — Cloud Health Office</PageTitle> | ||
|
|
||
| <PermissionGate Permission="reports:claims,reports:financial,reports:compliance" RoleName="Reports access"> |
There was a problem hiding this comment.
RoleName is used verbatim in PermissionGate’s message as “request the <…> role”. Passing a value like "Reports access" (not an actual role name) makes the guidance misleading. Either pass a real role name (or multiple) here, or adjust PermissionGate’s wording to not assume the value is a role.
| <PermissionGate Permission="reports:claims,reports:financial,reports:compliance" RoleName="Reports access"> | |
| <PermissionGate Permission="reports:claims,reports:financial,reports:compliance" RoleName="Reporting"> |
| <MudText Typo="Typo.body1"> | ||
| You don't have access to this page. Contact your administrator | ||
| to request the <strong>@(RoleName ?? Permission)</strong> role. | ||
| </MudText> |
There was a problem hiding this comment.
When Permission is left empty (as on Dashboard/BenefitPlans), the access-denied message can render an empty “role” placeholder because both RoleName and Permission may be null/empty. Consider providing a default message for the no-permission case (e.g., “request a role assignment”) or require RoleName when Permission is empty.
| if (ExistingUser != null) | ||
| { | ||
| _email = ExistingUser.Email; | ||
| _displayName = ExistingUser.DisplayName; | ||
| _department = ExistingUser.Department; |
There was a problem hiding this comment.
In edit mode, only Email/DisplayName/Department/Roles are copied from ExistingUser; first/last name fields remain empty. Since the update request later includes first/last name, this can unintentionally clear those values. Consider loading first/last name for edits (e.g., include them in the list item or fetch the user by ID).
| var baseUrl = _configuration["Services:TenantService"] ?? "http://tenant-service.cho-svcs/api"; | ||
| var encodedEmail = Uri.EscapeDataString(email); | ||
| var response = await _httpClient.GetAsync( | ||
| $"{baseUrl}/v1/tenants/{tenantContext.TenantId}/users?email={encodedEmail}"); | ||
|
|
There was a problem hiding this comment.
User lookup calls GET /v1/tenants/{tenantId}/users?email=..., but tenant-service’s GET /users only supports filtering by role or department, so this likely fetches the full tenant user list and filters client-side. Consider using the existing GET .../users/by-oid/{azureAdObjectId} endpoint (claim oid) or adding an email filter endpoint to reduce data transfer and latency.
- Redact email addresses in UserContextService log statements to prevent private data exposure (security bot finding) - Fix _loaded flag: only set to true after context is fully resolved so transient failures don't permanently cache null context - Remove hardcoded tenant-service fallback URL; log warning when config is missing instead of silently using wrong endpoint - Use by-oid endpoint for user lookup instead of unsupported email filter - Add TenantId null guard in AddEditUserDialog to prevent requests with null tenant - Copy FirstName/LastName from ExistingUser in edit mode to prevent clearing those fields; add fields to UserListItem DTO - Conditionally render User Management and Settings nav links for TenantAdmin users only in MainLayout - Add [Authorize] attribute to Settings page - Fix Reports page RoleName to show actual role names instead of 'Reports access' - Handle empty Permission/RoleName in PermissionGate denied message https://claude.ai/code/session_018xwRpYQjDDnEKD3HTzJSnz
| }; | ||
|
|
||
| _logger.LogInformation("User context loaded for {RedactedEmail} with roles: {Roles}", | ||
| RedactEmail(email), string.Join(", ", _cachedContext.Roles)); |
Check warning
Code scanning / CodeQL
Exposure of private information Medium
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 days ago
In general, to fix this type of issue, you should avoid sending raw or directly-derived private data (like email addresses) to log sinks. Instead, log either: (a) a non-identifying surrogate (e.g., an opaque user ID already in your system), or (b) a one-way hash / stable token that cannot be reversed to recover the original value, while still letting you correlate log entries belonging to the same user.
For this specific case, the best fix without changing overall functionality is to change RedactEmail so it no longer constructs its result by slicing the original email string. Instead, derive a deterministic, non-reversible representation based on a cryptographic hash (e.g., SHA-256) of the email, and log only that hash or a shortened version of it. This preserves the ability to correlate log entries for the same email while ensuring the logged value cannot be used to recover the actual address. Concretely:
- Update
RedactEmailinUserContextService.csto compute a SHA-256 hash of the supplied email (usingSystem.Security.Cryptography) and return it in a standardized format (e.g.,"email-hash:" + first 8 hex chars). - Keep the method signature the same so existing call sites (
LogInformationandLogWarning) remain unchanged. - Add the required
using System.Security.Cryptography;and potentiallyusing System.Text;if needed for encoding.
This single change will address all alert variants, because the data flow fromemailinto the log sink now passes through an irreversible transformation that no longer contains substrings of the original email.
| @@ -1,5 +1,7 @@ | ||
| using System.Security.Claims; | ||
| using Microsoft.AspNetCore.Components.Authorization; | ||
| using System.Security.Cryptography; | ||
| using System.Text; | ||
|
|
||
| namespace CloudHealthOffice.Portal.Services; | ||
|
|
||
| @@ -195,9 +197,25 @@ | ||
|
|
||
| private static string RedactEmail(string email) | ||
| { | ||
| var atIndex = email.IndexOf('@'); | ||
| if (atIndex <= 1) return "***@" + (atIndex >= 0 ? email[(atIndex + 1)..] : "***"); | ||
| return email[0] + "***" + email[(atIndex - 1)..]; | ||
| if (string.IsNullOrWhiteSpace(email)) | ||
| { | ||
| return "email-hash:unknown"; | ||
| } | ||
|
|
||
| // Use a one-way hash so that the original email address cannot be reconstructed | ||
| // from log files, while still allowing correlation of log entries for the same user. | ||
| using var sha = SHA256.Create(); | ||
| var bytes = Encoding.UTF8.GetBytes(email); | ||
| var hashBytes = sha.ComputeHash(bytes); | ||
|
|
||
| // Represent the hash in hex and truncate to a reasonable length for logging. | ||
| var sb = new StringBuilder(capacity: 32); | ||
| for (int i = 0; i < 16 && i < hashBytes.Length; i++) | ||
| { | ||
| sb.Append(hashBytes[i].ToString("x2")); | ||
| } | ||
|
|
||
| return "email-hash:" + sb.ToString(); | ||
| } | ||
|
|
||
| public bool HasPermission(string permission) |
| } | ||
|
|
||
| _logger.LogWarning("No active TenantUser found for {RedactedEmail} in tenant {TenantId}, using fallback", | ||
| RedactEmail(email), tenantContext.TenantId); |
Check warning
Code scanning / CodeQL
Exposure of private information Medium
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 4 days ago
In general, to fix exposure of private information in logs, avoid logging raw or lightly masked PII. Either remove it entirely from log messages or replace it with a non‑reversible surrogate such as a hash or opaque identifier. This keeps logs useful for correlation while preventing anyone with log access from reconstructing the original private data.
In this specific case, the issue is the implementation of RedactEmail. CodeQL is correct that the return value is derived from the private email string and still includes recognizable portions. The best fix without changing existing behavior contracts is to change RedactEmail so it never returns any portion of the actual email address. A common pattern is to return a deterministic hash of the email encoded as hex; this allows operators to correlate repeated log entries for the same user without learning the email. We will implement RedactEmail so that:
- If the input is null or empty, it returns
"***". - Otherwise, it computes a SHA‑256 hash of the UTF‑8 bytes of the email and returns a string like
"email_hash:<hex>".
This keeps the method name and its callers unchanged while eliminating PII exposure. To do this we need to importSystem.Security.CryptographyandSystem.Textat the top of the file and replace the body ofRedactEmail. No other code changes are necessary, and this will address all variants (1–1f) since they all trace throughRedactEmail(email)to the log sink.
| @@ -1,4 +1,6 @@ | ||
| using System.Security.Claims; | ||
| using System.Security.Cryptography; | ||
| using System.Text; | ||
| using Microsoft.AspNetCore.Components.Authorization; | ||
|
|
||
| namespace CloudHealthOffice.Portal.Services; | ||
| @@ -195,9 +197,22 @@ | ||
|
|
||
| private static string RedactEmail(string email) | ||
| { | ||
| var atIndex = email.IndexOf('@'); | ||
| if (atIndex <= 1) return "***@" + (atIndex >= 0 ? email[(atIndex + 1)..] : "***"); | ||
| return email[0] + "***" + email[(atIndex - 1)..]; | ||
| if (string.IsNullOrEmpty(email)) | ||
| { | ||
| return "***"; | ||
| } | ||
|
|
||
| // Use a one-way hash so logs can be correlated without exposing the actual email. | ||
| using var sha256 = SHA256.Create(); | ||
| var bytes = Encoding.UTF8.GetBytes(email); | ||
| var hashBytes = sha256.ComputeHash(bytes); | ||
| var sb = new StringBuilder(hashBytes.Length * 2); | ||
| foreach (var b in hashBytes) | ||
| { | ||
| sb.Append(b.ToString("x2")); | ||
| } | ||
|
|
||
| return "email_hash:" + sb.ToString(); | ||
| } | ||
|
|
||
| public bool HasPermission(string permission) |
…tance MudBlazor 6.14.0 uses the concrete class MudDialogInstance, not the IMudDialogInstance interface (introduced in v7). This matches the pattern used by all other dialog components in the project. https://claude.ai/code/session_018xwRpYQjDDnEKD3HTzJSnz
Add role-based access control to the Blazor portal that restricts page access and UI elements based on the authenticated user's roles from tenant-service.
https://claude.ai/code/session_018xwRpYQjDDnEKD3HTzJSnz