mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
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:
@@ -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
|
||||
|
||||
+22
@@ -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>
|
||||
|
||||
+13
-1
@@ -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
|
||||
|
||||
+20
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user