mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
2d3853c38f
GlobalReader = cross-tenant READ visibility like a Global Admin, but no mutating operations and no platform settings. Provisioned exactly like a GA: a GlobalAdmins table row with Role=GlobalReader (empty/legacy Role = GlobalAdmin, back-compat). Semantics are ADDITIVE — a user who is also their own tenant's Admin keeps edit rights on that tenant; a pure GlobalReader is read-only everywhere. Backend: - GlobalAdmins.Role column + GlobalAdminService.GetGlobalRoleAsync; IsGlobalAdminAsync (incl. TableAdminRepository) delegates so a GlobalReader row never reads back as admin - RequestContext.IsGlobalReader + HasGlobalScope - Split GlobalAdminOnly into GlobalReadOrAdmin (cross-tenant reads) vs GlobalAdminOnly (writes/settings) + TenantAdminOrGlobalReader for config-read - PolicyEnforcementMiddleware: extracted testable DecideAsync seam; cross-tenant gate keys off HasGlobalScope; additive own-tenant role carried so IsTenantAdmin stays correct - Config secret redaction for readers (RedactedCopyForReader) + save-time placeholder restore (RestoreRedactedSecretsFrom) so a redacted view can't blank real secrets - raw table/log tools stay GlobalAdminOnly; SignalR global-admins read group admits HasGlobalScope - /auth/me emits isGlobalReader (+ preview-gate bypass, no auto-admin); /auth/mcp emits globalRole MCP: hasGlobalScope (GA or Reader) drives routing + tool catalog; raw secret-bearing tools (query_table/list_tables/query_backend_logs) gated strict-GA only. Web: AuthContext isGlobalReader + hasGlobalScope; reader reaches /dashboard + cross-tenant views; platform Settings/Ops/Backups/Software GA-only (nav-hidden + requireGlobalAdmin route layouts); mutating controls on mixed data pages disabled via useCanMutatePlatform; rules pages read-only on foreign tenants; additive own-tenant edit preserved. Tests: backend 2369, MCP 252, web 469 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
9.6 KiB
C#
174 lines
9.6 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 SignalRAddToGroupFunction
|
|
{
|
|
private readonly ILogger<SignalRAddToGroupFunction> _logger;
|
|
|
|
public SignalRAddToGroupFunction(
|
|
ILogger<SignalRAddToGroupFunction> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
[Function("AddToGroup")]
|
|
public async Task<AddToGroupOutput> Run(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "realtime/groups/join")] 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 AddToGroupOutput { HttpResponse = errorResponse };
|
|
}
|
|
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
|
|
var request = JsonConvert.DeserializeObject<AddToGroupRequest>(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 AddToGroupOutput { HttpResponse = errorResponse };
|
|
}
|
|
|
|
// 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");
|
|
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
|
|
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: platform scope (Global Admin or Global Reader) required to join this group" });
|
|
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
|
|
}
|
|
_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}");
|
|
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
|
|
await badRequestResponse.WriteAsJsonAsync(new { success = false, message = "Unrecognized group name format" });
|
|
return new AddToGroupOutput { HttpResponse = badRequestResponse };
|
|
}
|
|
|
|
// 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. The admin/member
|
|
// notification-group checks below still gate by the real tenant role.
|
|
if (requestedTenantId != userTenantId)
|
|
{
|
|
if (!requestCtx.HasGlobalScope)
|
|
{
|
|
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join group for tenant {requestedTenantId}");
|
|
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
|
|
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: You can only join groups for your own tenant" });
|
|
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation($"Platform-scope user {userEmail} (role={requestCtx.UserRole}) joining cross-tenant group: {request.GroupName}");
|
|
}
|
|
}
|
|
|
|
// Admin-tier notification group: only Tenant Admins or Global Admins may join.
|
|
if (SignalRGroupHelper.IsTenantNotifyAdminGroup(request.GroupName)
|
|
&& !requestCtx.IsTenantAdmin
|
|
&& !requestCtx.IsGlobalAdmin)
|
|
{
|
|
_logger.LogWarning($"User {userEmail} (role={requestCtx.UserRole}) attempted to join Admin-tier notification group: {request.GroupName}");
|
|
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
|
|
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
|
|
var logPrefix = SignalRGroupHelper.ExtractLogPrefix(request.GroupName);
|
|
_logger.LogInformation($"{logPrefix} AddToGroup: {request.GroupName} (User: {userEmail})");
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(new
|
|
{
|
|
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)
|
|
{
|
|
_logger.LogError(ex, "Error adding to group");
|
|
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
|
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
|
|
return new AddToGroupOutput { HttpResponse = errorResponse };
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
public class AddToGroupRequest
|
|
{
|
|
public string? ConnectionId { get; set; }
|
|
public string? GroupName { get; set; }
|
|
}
|
|
|
|
public class AddToGroupOutput
|
|
{
|
|
[HttpResult]
|
|
public HttpResponseData? HttpResponse { get; set; }
|
|
|
|
[SignalROutput(HubName = "autopilotmonitor")]
|
|
public SignalRGroupAction? SignalRGroupAction { get; set; }
|
|
}
|
|
}
|