using System.Net; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Infrastructure { public class SignalRAddToGroupFunction { private readonly ILogger _logger; private readonly DelegatedAdminService _delegatedAdminService; private readonly ISessionRepository _sessionRepo; public SignalRAddToGroupFunction( ILogger logger, DelegatedAdminService delegatedAdminService, ISessionRepository sessionRepo) { _logger = logger; _delegatedAdminService = delegatedAdminService; _sessionRepo = sessionRepo; } [Function("AddToGroup")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "realtime/groups/join")] HttpRequestData req) { try { // Authentication + AuthenticatedUser authorization enforced by PolicyEnforcementMiddleware // Parse request var read = await req.ReadAsync(1_048_576); if (read.Error != null) return new AddToGroupOutput { HttpResponse = read.Error }; var request = read.Value!; if (string.IsNullOrEmpty(request.ConnectionId) || string.IsNullOrEmpty(request.GroupName)) { return new AddToGroupOutput { HttpResponse = await req.BadRequestAsync("ConnectionId and GroupName are required") }; } // Get user's tenant ID from RequestContext var requestCtx = req.GetRequestContext(); var userTenantId = requestCtx.TenantId; var userEmail = requestCtx.UserPrincipalName; // Validate tenant access // Group names are in format: "tenant-{tenantId}", "session-{tenantId}-{sessionId}", or "global-admins" // Users can only join groups for their own tenant (unless they are Global Admin) // Explicit validation for the global-admins group. This is a READ broadcast group // (cross-tenant new-session/new-event + global-notification live pushes), so any platform // scope — Global Admin OR the read-only Global Reader — may join. Mutating actions remain // gated elsewhere (the reader only RECEIVES pushes). if (request.GroupName == "global-admins") { if (!requestCtx.HasGlobalScope) { _logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join global-admins group without platform scope"); return new AddToGroupOutput { HttpResponse = await req.ForbiddenAsync("Access denied: platform scope (Global Admin or Global Reader) required to join this group") }; } _logger.LogInformation($"Platform-scope user {userEmail} (role={requestCtx.UserRole}) joining global-admins group"); } else { var requestedTenantId = SignalRGroupHelper.ExtractTenantIdFromGroupName(request.GroupName); if (string.IsNullOrEmpty(requestedTenantId)) { _logger.LogWarning($"User {userEmail} attempted to join unrecognized group format: {request.GroupName}"); return new AddToGroupOutput { HttpResponse = await req.BadRequestAsync("Unrecognized group name format") }; } // Check if user is allowed to join this tenant's group. Cross-tenant joins require // platform scope (Global Admin OR read-only Global Reader) — both have cross-tenant // READ scope, and group membership only RECEIVES live updates — OR a delegated ("MSP") // admin whose managed scope includes the requested tenant (so the delegated dashboard // gets the same live session/event pushes a GA gets for that tenant). The admin/member // notification-group checks below still gate by the real tenant role, so a delegated // caller (no tenant role) receives session/event broadcasts but NOT notification pushes. // OrdinalIgnoreCase, matching the middleware's cross-tenant check and the delegated // scope dictionary — a casing difference must never flip which branch authorizes. if (!string.Equals(requestedTenantId, userTenantId, StringComparison.OrdinalIgnoreCase)) { var allowedCrossTenant = requestCtx.HasGlobalScope; if (!allowedCrossTenant && !string.IsNullOrEmpty(userEmail)) { // realtime/groups/join is not a tenant-scoped route, so the middleware did not // resolve the delegated scope onto RequestContext — resolve it here, bounded to // the requested tenant only. Home tid gates the MSP seat (Pro-only). var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromRequestContext(requestCtx)); allowedCrossTenant = scope.RoleFor(requestedTenantId) != null; if (allowedCrossTenant) _logger.LogInformation($"Delegated user {userEmail} joining managed cross-tenant group: {request.GroupName}"); } if (!allowedCrossTenant) { _logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join group for tenant {requestedTenantId}"); return new AddToGroupOutput { HttpResponse = await req.ForbiddenAsync("Access denied: You can only join groups for your own tenant") }; } else if (requestCtx.HasGlobalScope) { _logger.LogInformation($"Platform-scope user {userEmail} (role={requestCtx.UserRole}) joining cross-tenant group: {request.GroupName}"); } } // The plain "tenant-{tid}" broadcast group streams MemberRead-tier telemetry (new-session // pushes, per-session "newevents" deltas incl. rule results), but this route's policy // admits ANY authenticated user of the tenant — the Progress Portal's roleless end users, // who only need their own "session-{tid}-{sid}" group. A same-tenant join skipped the // cross-tenant gate above, so bind the broadcast group to a member role here; cross-tenant // callers reaching this point were already admitted as platform-scope or delegated. if (SignalRGroupHelper.IsTenantBroadcastJoinDenied(request.GroupName, requestedTenantId, requestCtx)) { _logger.LogWarning($"User {userEmail} (role={requestCtx.UserRole}) attempted to join tenant broadcast group without a member role: {request.GroupName}"); return new AddToGroupOutput { HttpResponse = await req.ForbiddenAsync("Access denied: Only tenant members can join the tenant broadcast group") }; } // Notification groups carry the full per-tenant payload (the REST list is Member/Admin-tier // gated), so they are role-gated AGAINST THE GROUP'S TENANT — not the caller's home tenant. // This is leak-critical for a cross-tenant caller admitted above (a delegated "MSP" reader, // or a Global Reader): their home-tenant Admin/member role must NOT let them join a DIFFERENT // (managed) tenant's notify group. See SignalRGroupHelper.CheckNotifyGroupAccess. var notifyDenial = SignalRGroupHelper.CheckNotifyGroupAccess(request.GroupName, requestedTenantId, requestCtx); if (notifyDenial != SignalRGroupHelper.NotifyGroupDenial.None) { var message = notifyDenial == SignalRGroupHelper.NotifyGroupDenial.AdminTier ? "Access denied: Only Tenant Admins can join the admin notification group" : "Access denied: Only tenant members can join the notification group"; _logger.LogWarning($"User {userEmail} (role={requestCtx.UserRole}) attempted to join {notifyDenial} notification group for tenant {requestedTenantId}: {request.GroupName}"); return new AddToGroupOutput { HttpResponse = await req.ForbiddenAsync(message) }; } // Session groups stream one device's live enrollment telemetry. Members (and the // platform/delegated-scope callers admitted above) may join any of their tenant's // session groups — the same data is MemberRead at REST. A roleless Progress-Portal // end user must instead prove serial knowledge, mirroring the REST-side // lookup/events model: without this, a leaked/known sessionId alone would grant // the live event stream. Session missing, unparseable group name, and serial // mismatch all take the same deny path (fail-closed, no existence oracle). if (SignalRGroupHelper.RequiresSessionGroupSerialProof(request.GroupName, requestedTenantId, requestCtx)) { var sessionId = SignalRGroupHelper.ExtractSessionIdFromGroupName(request.GroupName); var session = string.IsNullOrEmpty(sessionId) ? null : await _sessionRepo.GetSessionAsync(requestedTenantId!, sessionId); if (session == null || !SerialKnowledgeProof.Matches(session.SerialNumber, request.SerialNumber)) { _logger.LogWarning($"User {userEmail} (role={requestCtx.UserRole}) denied session-group join without valid serial proof: {request.GroupName}"); return new AddToGroupOutput { HttpResponse = await req.ForbiddenAsync("Access denied: This session group requires the device's serial number") }; } } } // Extract session ID from group name if it's a session-specific group var logPrefix = SignalRGroupHelper.ExtractLogPrefix(request.GroupName); _logger.LogInformation($"{logPrefix} AddToGroup: {request.GroupName} (User: {userEmail})"); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new SuccessMessageResponse { Success = true, Message = $"Added to group {request.GroupName}" }); return new AddToGroupOutput { HttpResponse = response, SignalRGroupAction = new SignalRGroupAction(SignalRGroupActionType.Add) { GroupName = request.GroupName, ConnectionId = request.ConnectionId } }; } catch (Exception ex) { return new AddToGroupOutput { HttpResponse = await req.InternalServerErrorAsync(_logger, ex, "SignalRAddToGroup") }; } } } public class AddToGroupOutput { [HttpResult] public HttpResponseData? HttpResponse { get; set; } [SignalROutput(HubName = SignalRGroupHelper.HubName)] public SignalRGroupAction? SignalRGroupAction { get; set; } } }