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 && (