Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs
T
Oliver Kieselbach 5775c7c176 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>
2026-06-18 11:21:59 +02:00

165 lines
8.5 KiB
C#

using System.Net;
using AutopilotMonitor.Functions.Helpers;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Azure.Functions.Worker.Extensions.SignalRService;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions.Infrastructure
{
public class SignalRRemoveFromGroupFunction
{
private readonly ILogger<SignalRRemoveFromGroupFunction> _logger;
public SignalRRemoveFromGroupFunction(
ILogger<SignalRRemoveFromGroupFunction> logger)
{
_logger = logger;
}
[Function("RemoveFromGroup")]
public async Task<RemoveFromGroupOutput> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "realtime/groups/leave")] HttpRequestData req)
{
try
{
// Authentication + AuthenticatedUser authorization enforced by PolicyEnforcementMiddleware
// Parse request
if (req.Headers.TryGetValues("Content-Length", out var clValues)
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
&& contentLength > 1_048_576) // 1 MB limit
{
var errorResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Request body too large" });
return new RemoveFromGroupOutput { HttpResponse = errorResponse };
}
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
var request = JsonConvert.DeserializeObject<RemoveFromGroupRequest>(requestBody);
if (string.IsNullOrEmpty(request?.ConnectionId) || string.IsNullOrEmpty(request?.GroupName))
{
var errorResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "ConnectionId and GroupName are required" });
return new RemoveFromGroupOutput { HttpResponse = errorResponse };
}
// Get user's tenant ID from RequestContext
var requestCtx = req.GetRequestContext();
var userTenantId = requestCtx.TenantId;
var userEmail = requestCtx.UserPrincipalName;
// Validate tenant access (same logic as AddToGroup)
// Group names are in format: "tenant-{tenantId}", "session-{tenantId}-{sessionId}", or "global-admins"
// Users can only leave groups for their own tenant (unless they are Global Admin)
// Explicit validation for the global-admins group
if (request.GroupName == "global-admins")
{
if (!requestCtx.IsGlobalAdmin)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to leave global-admins group without being a Global Admin");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: Only Global Admins can leave this group" });
return new RemoveFromGroupOutput { HttpResponse = forbiddenResponse };
}
_logger.LogInformation($"Global Admin {userEmail} leaving global-admins group");
}
else
{
var requestedTenantId = SignalRGroupHelper.ExtractTenantIdFromGroupName(request.GroupName);
if (string.IsNullOrEmpty(requestedTenantId))
{
_logger.LogWarning($"User {userEmail} attempted to leave unrecognized group format: {request.GroupName}");
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { success = false, message = "Unrecognized group name format" });
return new RemoveFromGroupOutput { HttpResponse = badRequestResponse };
}
// Check if user is allowed to leave this tenant's group
if (requestedTenantId != userTenantId)
{
// Check if user is Global Admin (they can leave any tenant's group)
if (!requestCtx.IsGlobalAdmin)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to leave group for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: You can only leave groups for your own tenant" });
return new RemoveFromGroupOutput { HttpResponse = forbiddenResponse };
}
else
{
_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
var logPrefix = SignalRGroupHelper.ExtractLogPrefix(request.GroupName);
_logger.LogInformation($"{logPrefix} RemoveFromGroup: {request.GroupName} (User: {userEmail})");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
message = $"Removed from group {request.GroupName}"
});
return new RemoveFromGroupOutput
{
HttpResponse = response,
SignalRGroupAction = new SignalRGroupAction(SignalRGroupActionType.Remove)
{
GroupName = request.GroupName,
ConnectionId = request.ConnectionId
}
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing from group");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return new RemoveFromGroupOutput { HttpResponse = errorResponse };
}
}
}
public class RemoveFromGroupRequest
{
public string? ConnectionId { get; set; }
public string? GroupName { get; set; }
}
public class RemoveFromGroupOutput
{
[HttpResult]
public HttpResponseData? HttpResponse { get; set; }
[SignalROutput(HubName = "autopilotmonitor")]
public SignalRGroupAction? SignalRGroupAction { get; set; }
}
}