Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs
T
Oliver Kieselbach d055eed074 Typed API contract: every success response is a wire DTO, generated end-to-end into TypeScript (#136)
* feat(backend): typed API response infrastructure — IApiResponse marker, wire-settings single source, widened anonymous-body ratchet (batch 0 of typed-api-contract)

IApiResponse (Shared, with SuccessMessageResponse/SuccessOnlyResponse and the
ProjectedItems/WireContract attributes for the upcoming manifest export) plus generic
OkAsync/CreatedAsync/JsonAsync<T> overloads constrained on it, so anonymous success
bodies stop compiling once the legacy object overloads go; ApiJsonOptions is now the
single source of the production wire settings (camelCase, WhenWritingNull, string
enums) used by Program.cs AND the three endpoint-test harnesses (two serialized
PascalCase-with-nulls, one JsonSerializerDefaults.Web — all could pass while the
deployed wire differed); TypedResponseGuardTests replaces OkAsyncBaselineGuardTests
with the same ratchet mechanics over two frozen baselines (44 anonymous helper sites
unchanged, plus 134 anonymous success WriteAsJsonAsync sites across 76 files —
error-shaped bodies stay anonymous by design) and a flatness/location reflection
guard (IApiResponse implementers derive from object and live in Shared, protecting
JSON key order); ApiResponseWireParityTests carries the ordinal old-vs-new
serialization proof each converted site must add

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(backend): Sessions/Annotations/Reports/Raw/Progress success responses are typed wire DTOs (batch 1 of typed-api-contract)

34 anonymous success bodies across 24 functions replaced with flat IApiResponse DTOs
in Shared (SessionApiModels/ReportApiModels + new Annotation/Raw/ProgressApiModels) —
minimal in-place transformation, call structure/status codes untouched, declaration
order == wire order, nullable exactly where a site can emit null (WhenWritingNull);
fields= projections stay IReadOnlyList<object> marked [ProjectedItems(SessionSummary/
EnrollmentEvent)], raw table rows stay PascalCase-verbatim dictionaries (no
DictionaryKeyPolicy — pinned by test), AnnotationWire and the distress-report
aggregations now build named item types instead of anonymous ones, and shared shapes
collapse to one DTO (SessionListResponse x4, SessionAnnotationListResponse x2,
SessionReportListResponse/GetSessionEventsResponse paged+unpaged via absent-null
nextLink); 54 new wire-parity facts serialize each OLD anonymous literal (independent
fixture copied from the pre-migration site) against the new DTO ordinally, with a
null case per vanishing key; ratchet baselines shrink 44->14 helper sites and
134->122 write sites; the three distress-report test files read the now-typed
aggregation items directly instead of via reflection-ToDynamic

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(backend): every anonymous API success body is a typed wire DTO — ratchet baselines empty, legacy object overloads deleted (batches 2-5 of typed-api-contract)

the remaining 141 anonymous success sites (Admin 36, Rules 24, Config 15, Graph 5,
Metrics 16, Vulnerability 17, Infrastructure/Global/Notifications/Bootstrap/
Diagnostics 20 incl. the 429 quota body in McpQuotaEnforcementMiddleware, plus the
14 helper-call sites) now return flat IApiResponse DTOs from Shared (new Admin/
Rules/Config/Graph/Metrics/Vulnerability/Infrastructure/Notifications/Diagnostics
ApiModels; GeographicApiModels extended) — same minimal in-place transformation as
batch 1: declaration order == wire order, nullable exactly where a site can emit
null, dual success/failure sites keep their status-code logic and reuse
SuccessMessageResponse, MCP-critical key names (records/summaries/inventory)
pinned; wire item shapes that were function-local (CustomsArchive Run/EntrySummary,
CpeMappingDto, UnmatchedSoftwareAgg, AutoResolve items, LogPatternTestResult,
CveExposureSummary/SeverityBreakdown/TopCve — records rewritten as flat classes,
netstandard2.0 has no IsExternalInit) moved to Shared so the manifest export sees
them; payload types that must stay in the Functions assembly (RuleDryRun,
PreviewWhitelistEntity, GlobalAdminEntity, HealthCheck, GlobalNotificationDto,
Backfill/Reclassification/MaintenanceResult, TenantAdminEntity,
TenantConfigFieldSchema) ride as object slots — wire-identical via runtime-type
serialization, proven by the parity facts; 126 new wire-parity facts total
(Admin 26, Rules 12, Config 17, Graph 7, Metrics 16, Vulnerability 19,
Infrastructure 21 + Dictionary-key-verbatim and null-value pins); both
TypedResponseGuard baselines are now EMPTY regression guards and the untyped
OkAsync/CreatedAsync(object) overloads are gone, so a new anonymous success body
no longer compiles through the helper and fails the guard through raw
WriteAsJsonAsync; deliberately untouched: error bodies (one anonymous shape by
design), auth/me (AuthDecisionResult.Body slot shared with error bodies, tests
reflect over it), CheckMcpAccess (conditional dictionary keys), raw-byte streamers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(contract): C#→TS wire codegen — shared-manifests v2 exports the full response-type graph as generated TypeScript (batch 6 of typed-api-contract)

SharedManifestParityTests now writes schemaVersion 2: the v1 catalog sections stay
byte-compatible (sectionFieldMap/signalrMessages/sharedManifestChecks untouched),
the consumer-less sessionSummary section is gone, and a new types section carries
every wire type — roots are all IApiResponse implementers plus [WireContract]-marked
types (SlaViolatorSession, ImeVersionHistoryEntry), transitively closed by the new
WireTypeManifestBuilder over refs, collections, dictionaries, enums and
[ProjectedItems] item types (208 types). Fields honour [JsonPropertyName]/
[JsonIgnore], get-only members export readonly, a nullable slot exports optional
(WhenWritingNull omits the KEY — explicit null survives only inside collections and
dictionary values, so the nullable flag lives on nested descriptors only), field
order follows STJ's derived-before-base serialization for the one legacy inherited
wire type (LocationSessionRow : SessionSummary), and unmappable shapes (non-string
dictionary keys, foreign BCL classes) fail the build instead of degrading to
unknown. C# <summary> texts travel as JSDoc: Shared builds XML docs now (doc-quality
warnings CS1591/1573/1574/1584/0419 suppressed — crefs to foreign assemblies cannot
resolve from Shared; CS1570 malformed-XML stays an error, which surfaced a raw
0x01 control char and an unescaped ampersand in existing comments).
generate-shared-manifest-types.js emits utils/wire-types.generated.ts (interfaces +
string-union enums, 2.7k lines) next to the lean literal module, npm run
generate:manifests wraps it, the vitest freshness test pins BOTH outputs, and a new
shared-manifests-in-sync CI job (guardrails-in-sync pattern) fails on drift

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): hand-written response types re-export the generated wire interfaces (batch 7+8 of typed-api-contract)

types/session.ts, types/enrollment.ts and types/adminConfig.ts are re-exports over
utils/wire-types.generated.ts (Session = SessionSummary alias for the 25+ barrel
importers; adminConfig keeps only the excessiveEventAutoActionMode literal-union
narrowing and the client-side OpsAlertRule view) — the generated types are never
weakened, consumers adapt: durationSeconds is now optional as on the wire (int? —
consumers guard with ?? 0, which also retires the latent NaN-minutes display on
in-progress sessions), status is the SessionStatus union (fixtures on canonical
values; the generic string-sort test keeps its own fixture type), required bools
break hand-built fixtures once → new test/factories.ts makeSession/makeEvent
(Object.assign, not spread — Partial spread re-widens and tests must force
data: undefined); the string|null lifecycle branches were already truthiness-based.
Duplicate local types deleted: geographic-performance now uses the real
LocationSessionRow wire type (incl. DO fields), GlobalSearch the generated
QuickSearchResult, sla/page.tsx the generated SlaViolatorSession (status stays a
number — C# int, rendered via statusLabels); sessionExportUtils aliases
SessionExportEvent = EnrollmentEvent and derives RuleResultCsvData via Pick, CSV
output unchanged; the three structural ReplayInputEvent clones (InstallEvent,
ScriptInputEvent, DownloadEvent) are aliases now. docs: drift-guards.md replaces the
shrink-only-OkAsync section with the typed-wire-DTO contract, index.md/log.md
updated. tsc 0, vitest 957, eslint 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tests): SlaMetricsServiceTests anchor sessions inside the current ISO week — the 'now - 1h' fixture crossed into LAST week during the first hour of every Monday (UTC), emptying CurrentWeek and failing the three snapshot asserts (CI on Mon 2026-08-31 00:20Z; pre-existing Monday-morning time bomb, unrelated to the DTO migration); the anchor is now clamped to the week's Monday 00:00, proven green inside the same failure window

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 02:30:39 +02:00

237 lines
15 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;
using Newtonsoft.Json;
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
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 — 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}");
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 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}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: Only tenant members can join the tenant broadcast group" });
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
}
// 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}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message });
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
}
// 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}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: This session group requires the device's serial number" });
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 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)
{
_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; }
/// <summary>
/// Serial-number knowledge proof for session-group joins. Required only for roleless
/// same-tenant callers (Progress Portal end users) — see
/// <see cref="SignalRGroupHelper.RequiresSessionGroupSerialProof"/>. Member/GA/delegated
/// callers never need to send it, so all other join call sites are unaffected.
/// </summary>
public string? SerialNumber { get; set; }
}
public class AddToGroupOutput
{
[HttpResult]
public HttpResponseData? HttpResponse { get; set; }
[SignalROutput(HubName = SignalRGroupHelper.HubName)]
public SignalRGroupAction? SignalRGroupAction { get; set; }
}
}