diff --git a/.gitignore b/.gitignore
index 17991cfd..5c673091 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs
index aaa0a46d..66d18345 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs
@@ -264,6 +264,28 @@ public class EndpointPolicyCatalogCompletenessTests
Assert.Equal(TenantScoping.None, entry.TenantScoping);
}
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
///
/// Named capture group for {tenantId} correctly extracts the value from request paths.
///
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs
index 56e5648b..27be6969 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs
@@ -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
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs
index 7378423a..9979c5e4 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs
@@ -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
diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs
index f6c4f7e6..b29767f6 100644
--- a/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs
@@ -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();
+ ///
+ /// 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.
+ ///
+ public static bool IsTenantMemberOrGlobalAdmin(this RequestContext ctx)
+ => ctx.IsGlobalAdmin
+ || ctx.UserRole == Constants.TenantRoles.Admin
+ || ctx.UserRole == Constants.TenantRoles.Operator
+ || ctx.UserRole == Constants.TenantRoles.Viewer;
+
/// Gets the correlation ID for this request (set by CorrelationIdMiddleware).
public static string GetCorrelationId(this FunctionContext context)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs
index 65878683..6573f798 100644
--- a/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Helpers/SignalRGroupHelper.cs
@@ -52,6 +52,15 @@ public static class SignalRGroupHelper
public static bool IsTenantNotifyAdminGroup(string groupName)
=> groupName.StartsWith("tenant-") && groupName.EndsWith(TenantNotifyAdminSuffix);
+ ///
+ /// 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.
+ ///
+ 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";
diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs
index 6e52c378..af0a49ac 100644
--- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs
@@ -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
}
}
+ ///
+ /// 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.
+ ///
+ private async Task 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 EvaluateMemberReadAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
index ded6294b..529ad815 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
@@ -36,6 +36,19 @@ public enum EndpointPolicy
/// Valid JWT token required. Any authenticated user.
AuthenticatedUser,
+ ///
+ /// 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 . 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
+ /// , which short-circuits and leaves
+ /// IsGlobalAdmin/IsTenantAdmin/UserRole unpopulated (always-false / empty).
+ ///
+ AuthenticatedUserWithRole,
+
/// Tenant member with Admin, Operator, or Viewer role. Tenant-scoped read access.
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),
diff --git a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
index a2e76002..5d15814f 100644
--- a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
+++ b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
@@ -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 && (