fix(web+backend): Progress Portal live updates for end users + sidebar Dashboard gate

Two related fixes for the end-user Progress Portal experience:

Live updates / permissions: realtime/groups/join+leave were elevated to
MemberRead (regression c4dabeee), which 403'd roleless end users tracking
their own device - the page loaded initial status but never received live
eventStream pushes (looked frozen). Introduce a new EndpointPolicy tier
AuthenticatedUserWithRole that admits any authenticated caller but still
resolves IsGlobalAdmin/IsTenantAdmin/UserRole onto RequestContext, so the
SignalR functions can do per-group authorization. Real gating now lives in
SignalRAddToGroup/RemoveFromGroupFunction: tenant match, global-admins group
on GA, admin-tier notify group on TenantAdmin, member-tier notify group on
tenant membership. Plain AuthenticatedUser was rejected - it would leave the
role flags always-false and break GA/admin group joins.

Sidebar: hide the global Dashboard nav item for regular users (no
admin/operator access); they only see the Progress Portal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-18 11:21:59 +02:00
parent 879bd0c59b
commit 5775c7c176
9 changed files with 145 additions and 5 deletions
+2
View File
@@ -138,3 +138,5 @@ plans/REFACTOR_AGENT_V2.md
# (storage account name, resource group are operator-specific) so it stays
# out of the repo. Each operator keeps their own copy.
scripts/Backup/setup-backup-container.ps1
docs/Introduction-AutopilotMonitor.pptx
docs/~$Introduction-AutopilotMonitor.pptx
@@ -264,6 +264,28 @@ public class EndpointPolicyCatalogCompletenessTests
Assert.Equal(TenantScoping.None, entry.TenantScoping);
}
/// <summary>
/// SignalR group join/leave MUST be AuthenticatedUserWithRole — NOT MemberRead and NOT plain
/// AuthenticatedUser. The Progress Portal admits non-member end users, so MemberRead 403s them
/// (no live updates). Plain AuthenticatedUser would admit them but leaves IsGlobalAdmin/
/// IsTenantAdmin/UserRole unresolved, breaking the function's per-group gates (GA global-admins,
/// cross-tenant, admin/member notification groups). Only AuthenticatedUserWithRole satisfies both.
/// Guards against either accidental downgrade. TenantScoping stays None — the function enforces
/// the group's tenant against the caller's JWT tenant itself.
/// </summary>
[Theory]
[InlineData("POST", "/api/realtime/groups/join", "realtime/groups/join")]
[InlineData("POST", "/api/realtime/groups/leave", "realtime/groups/leave")]
public void SignalRGroupJoinLeave_AreAuthenticatedUserWithRole(string method, string path, string expectedTemplate)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
Assert.NotNull(entry);
Assert.Equal(expectedTemplate, entry!.RouteTemplate);
Assert.Equal(EndpointPolicy.AuthenticatedUserWithRole, entry.Policy);
Assert.Equal(TenantScoping.None, entry.TenantScoping);
}
/// <summary>
/// Named capture group for {tenantId} correctly extracts the value from request paths.
/// </summary>
@@ -95,7 +95,6 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
}
// Admin-tier notification group: only Tenant Admins or Global Admins may join.
// The Member-tier group has no extra check beyond the tenant-membership above.
if (SignalRGroupHelper.IsTenantNotifyAdminGroup(request.GroupName)
&& !requestCtx.IsTenantAdmin
&& !requestCtx.IsGlobalAdmin)
@@ -105,6 +104,19 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: Only Tenant Admins can join the admin notification group" });
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
}
// Member-tier notification group: requires tenant membership (any role) or GA.
// The endpoint policy (AuthenticatedUserWithRole) admits roleless end users so the
// Progress Portal works, but the notification push carries the full payload (the
// REST list is MemberRead-gated), so a roleless caller must not join this group.
if (SignalRGroupHelper.IsTenantNotifyMemberGroup(request.GroupName)
&& !requestCtx.IsTenantMemberOrGlobalAdmin())
{
_logger.LogWarning($"User {userEmail} (role={requestCtx.UserRole}) attempted to join Member-tier notification group without a tenant role: {request.GroupName}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: Only tenant members can join the notification group" });
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
}
}
// Extract session ID from group name if it's a session-specific group
@@ -93,6 +93,26 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
_logger.LogInformation($"Global Admin {userEmail} leaving cross-tenant group: {request.GroupName}");
}
}
// Symmetric with AddToGroup: the privileged notification groups are membership-gated.
// A roleless authenticated end user is never in these groups, so reject the leave too
// (defense-in-depth — keeps join/leave authorization identical).
if (SignalRGroupHelper.IsTenantNotifyAdminGroup(request.GroupName)
&& !requestCtx.IsTenantAdmin
&& !requestCtx.IsGlobalAdmin)
{
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: Only Tenant Admins can leave the admin notification group" });
return new RemoveFromGroupOutput { HttpResponse = forbiddenResponse };
}
if (SignalRGroupHelper.IsTenantNotifyMemberGroup(request.GroupName)
&& !requestCtx.IsTenantMemberOrGlobalAdmin())
{
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: Only tenant members can leave the notification group" });
return new RemoveFromGroupOutput { HttpResponse = forbiddenResponse };
}
}
// Extract session ID from group name if it's a session-specific group
@@ -1,3 +1,4 @@
using AutopilotMonitor.Shared;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -59,6 +60,18 @@ public static class RequestContextExtensions
public static RequestContext GetRequestContext(this HttpRequestData req)
=> req.FunctionContext.GetRequestContext();
/// <summary>
/// True if the caller is a tenant member (any Admin/Operator/Viewer role) or a Global Admin.
/// Only meaningful when the request was evaluated under a role-resolving policy tier
/// (MemberRead / TenantAdminOrGA / AuthenticatedUserWithRole / …); under plain AuthenticatedUser
/// the role is not resolved and this returns false.
/// </summary>
public static bool IsTenantMemberOrGlobalAdmin(this RequestContext ctx)
=> ctx.IsGlobalAdmin
|| ctx.UserRole == Constants.TenantRoles.Admin
|| ctx.UserRole == Constants.TenantRoles.Operator
|| ctx.UserRole == Constants.TenantRoles.Viewer;
/// <summary>Gets the correlation ID for this request (set by CorrelationIdMiddleware).</summary>
public static string GetCorrelationId(this FunctionContext context)
{
@@ -52,6 +52,15 @@ public static class SignalRGroupHelper
public static bool IsTenantNotifyAdminGroup(string groupName)
=> groupName.StartsWith("tenant-") && groupName.EndsWith(TenantNotifyAdminSuffix);
/// <summary>
/// True for the Member-tier tenant notification group. Joining requires tenant membership
/// (any Admin/Operator/Viewer role) or Global-Admin — the live push carries the full
/// notification payload, which is otherwise MemberRead-gated at the REST layer, so a roleless
/// authenticated end user must not be allowed to join it.
/// </summary>
public static bool IsTenantNotifyMemberGroup(string groupName)
=> groupName.StartsWith("tenant-") && groupName.EndsWith(TenantNotifyMemberSuffix);
public static string TenantNotifyMemberGroup(string tenantId) => $"tenant-{tenantId}{TenantNotifyMemberSuffix}";
public static string TenantNotifyAdminGroup(string tenantId) => $"tenant-{tenantId}{TenantNotifyAdminSuffix}";
public const string GlobalAdminsGroup = "global-admins";
@@ -228,6 +228,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Allow(userIdentifier, "Authenticated", "ValidJWT");
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "NoJWT");
case EndpointPolicy.AuthenticatedUserWithRole:
return await EvaluateAuthenticatedUserWithRoleAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.MemberRead:
return await EvaluateMemberReadAsync(tenantId, upn, principal, userIdentifier);
@@ -245,6 +248,38 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
}
}
/// <summary>
/// Allows ANY authenticated caller (valid JWT) but still resolves their Global-Admin status and
/// effective tenant role so the downstream function can make fine-grained, per-resource decisions
/// (the resolved values flow onto RequestContext.IsGlobalAdmin/IsTenantAdmin/UserRole). A caller
/// with no special role is admitted as "Authenticated" (role flags stay false) — non-member end
/// users are intentionally allowed here; the function gates the privileged resources.
/// </summary>
private async Task<CatalogDecisionResult> EvaluateAuthenticatedUserWithRoleAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
if (principal == null)
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "NoJWT");
// Global Admin outranks any tenant role.
if (!string.IsNullOrEmpty(upn) && await _globalAdminService.IsGlobalAdminAsync(upn))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "ValidJWT+GA");
// Resolve the effective tenant role (may be null for non-members). A resolved role is
// surfaced so the function can distinguish members from roleless end users; either way
// the request is allowed at the middleware tier.
if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(upn))
{
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role?.Role != null)
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "ValidJWT+Member");
}
// Authenticated but not a member and not a GA — still allowed (the function decides which
// groups, if any, this caller may join).
return CatalogDecisionResult.Allow(userIdentifier, "Authenticated", "ValidJWT");
}
private async Task<CatalogDecisionResult> EvaluateMemberReadAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
@@ -36,6 +36,19 @@ public enum EndpointPolicy
/// <summary>Valid JWT token required. Any authenticated user.</summary>
AuthenticatedUser,
/// <summary>
/// Valid JWT required (admits ANY authenticated user, including non-members), but the
/// middleware still resolves the caller's Global-Admin status and effective tenant role and
/// publishes them on <see cref="Helpers.RequestContext"/>. Use for endpoints that must admit
/// non-member end users yet whose function performs fine-grained, per-resource authorization
/// needing the role/GA flags — e.g. SignalR group join/leave: an end user may join their own
/// tenant's progress/session groups, but global-admins / cross-tenant / notify-admin /
/// notify-member groups are gated inside the function on the resolved role. Differs from
/// <see cref="AuthenticatedUser"/>, which short-circuits and leaves
/// IsGlobalAdmin/IsTenantAdmin/UserRole unpopulated (always-false / empty).
/// </summary>
AuthenticatedUserWithRole,
/// <summary>Tenant member with Admin, Operator, or Viewer role. Tenant-scoped read access.</summary>
MemberRead,
@@ -118,8 +131,22 @@ public static class EndpointAccessPolicyCatalog
new("GET", "auth/me", EndpointPolicy.AuthenticatedUser),
new("GET", "auth/is-global-admin", EndpointPolicy.AuthenticatedUser),
new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser),
new("POST", "realtime/groups/join", EndpointPolicy.MemberRead),
new("POST", "realtime/groups/leave", EndpointPolicy.MemberRead),
// Group join/leave is AuthenticatedUserWithRole — NOT MemberRead — on purpose. The real,
// per-group authorization lives inside SignalRAddToGroup/RemoveFromGroupFunction: it
// requires the requested group's tenant to match the caller's JWT tenant (Global Admins
// excepted), gates the global-admins group on IsGlobalAdmin, the admin-tier notification
// group on IsTenantAdmin, and the member-tier notification group on tenant membership.
// The Progress Portal is an END-USER feature: employees tracking their own device are
// authenticated (JWT tid = their tenant) but are typically NOT tenant members (no
// TenantAdmins row, no Entra app-role). Gating join at MemberRead let them load the
// initial status (progress/* is AuthenticatedUser) but silently 403'd their session-group
// join, so they never received live eventStream pushes — the page looked frozen.
// AuthenticatedUserWithRole (not plain AuthenticatedUser) is required so the middleware
// still resolves IsGlobalAdmin/IsTenantAdmin/UserRole — the function's per-group gates
// depend on those flags; plain AuthenticatedUser leaves them always-false and would break
// GA/admin group joins. (Regression history: c4dabeee elevated these to MemberRead.)
new("POST", "realtime/groups/join", EndpointPolicy.AuthenticatedUserWithRole),
new("POST", "realtime/groups/leave", EndpointPolicy.AuthenticatedUserWithRole),
new("GET", "progress/sessions", EndpointPolicy.AuthenticatedUser),
new("GET", "progress/sessions/{sessionId}/events", EndpointPolicy.AuthenticatedUser, TenantScoping.QueryParam),
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUser),
@@ -395,8 +395,8 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
{/* Global nav — only when authenticated */}
{isAuthenticated && (
<>
{/* Dashboard */}
{renderGlobalItem(DASHBOARD_ITEM)}
{/* Dashboard — hidden for regular users (no admin/operator access) */}
{!isRegularUser && renderGlobalItem(DASHBOARD_ITEM)}
{/* Regular user: only show Progress Portal */}
{isRegularUser && (