mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
3d920a8113
Every HTTP request body the web, the MCP or the agent sends is now a flat class in
AutopilotMonitor.Shared implementing IApiRequest — the request twin of IApiResponse.
WireTypeManifestBuilder exports the implementers as a third root, so the request DTOs
reach wire-types.generated.ts on both clients; for request types a nullable property
keeps `| null` (a PATCH may send an explicit null to clear an override).
Functions read a body only through RequestBody.ReadAsync<T>/ReadOptionalAsync<T>
(System.Text.Json, ApiJsonOptions.Read = wire options + case-insensitive; empty or
malformed body = 400 envelope, never a 500) whose generic constrains on IApiRequest.
ReadNewtonsoftAsync<T> stays for the Newtonsoft-bound cluster (tenant/admin config via
the patch service's PopulateObject, rule and pattern documents) as a frozen per-file
baseline. The tenant-plan PATCH keeps its presence-sensitive JsonDocument walk with the
keys derived from PatchTenantPlanRequest by nameof (PlanPatchKeyParityTests).
35 nested request classes, 7 JObject/JsonDocument hand-walks and the anonymous
DeserializeAnonymousType bodies moved to Shared; the two identical TicketRequests are
one DownloadTicketRequest; ServerAction is no longer the request (QueueSessionActionRequest,
the stored row is built server-side); create-from-template takes { "variables": … }
instead of a bare map. Fields the endpoints default server-side are nullable now
(CPE mapping, report attachments, feedback rating on dismissal, upn for applications).
TypedRequestGuardTests: no raw deserializer under Functions/ (named exemptions: agent
routes frozen by D-200, Azure Monitor webhook, upstream/stored-column parses), the
Newtonsoft branch only in its baseline, no request type in the Functions assembly, every
DTO flat and in Shared. RequestBodyTests cover both readers, the cap and the envelope.
QueryParamGuardTests baseline shrinks by 14 (the Content-Length TryParse lives in the reader).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
198 lines
12 KiB
C#
198 lines
12 KiB
C#
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<SignalRAddToGroupFunction> _logger;
|
|
private readonly DelegatedAdminService _delegatedAdminService;
|
|
private readonly ISessionRepository _sessionRepo;
|
|
|
|
public SignalRAddToGroupFunction(
|
|
ILogger<SignalRAddToGroupFunction> logger,
|
|
DelegatedAdminService delegatedAdminService,
|
|
ISessionRepository sessionRepo)
|
|
{
|
|
_logger = logger;
|
|
_delegatedAdminService = delegatedAdminService;
|
|
_sessionRepo = sessionRepo;
|
|
}
|
|
|
|
[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
|
|
var read = await req.ReadAsync<SignalRJoinGroupRequest>(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; }
|
|
}
|
|
}
|