mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-26 13:39:41 +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>
195 lines
8.6 KiB
C#
195 lines
8.6 KiB
C#
using System;
|
|
using System.Net;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
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.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Functions.Admin
|
|
{
|
|
public class DeviceBlockFunction
|
|
{
|
|
private readonly ILogger<DeviceBlockFunction> _logger;
|
|
private readonly BlockedDeviceService _blockedDeviceService;
|
|
private readonly IMaintenanceRepository _maintenanceRepo;
|
|
private readonly OpsEventService _opsEventService;
|
|
|
|
public DeviceBlockFunction(
|
|
ILogger<DeviceBlockFunction> logger,
|
|
BlockedDeviceService blockedDeviceService,
|
|
IMaintenanceRepository maintenanceRepo,
|
|
OpsEventService opsEventService)
|
|
{
|
|
_logger = logger;
|
|
_blockedDeviceService = blockedDeviceService;
|
|
_maintenanceRepo = maintenanceRepo;
|
|
_opsEventService = opsEventService;
|
|
}
|
|
|
|
/// <summary>GET /api/devices/blocked?tenantId={tenantId} — list all active blocks for a tenant</summary>
|
|
[Function("GetBlockedDevices")]
|
|
public async Task<HttpResponseData> GetBlockedDevices(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "devices/blocked")] HttpRequestData req)
|
|
{
|
|
try
|
|
{
|
|
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
|
|
|
var tenantId = req.Query["tenantId"];
|
|
if (string.IsNullOrEmpty(tenantId))
|
|
return await req.BadRequestAsync("tenantId query parameter is required");
|
|
|
|
var blocked = await _blockedDeviceService.GetBlockedDevicesAsync(tenantId);
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(new BlockedDeviceListResponse { Success = true, Blocked = blocked });
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return await req.InternalServerErrorAsync(_logger, ex, "DeviceBlock");
|
|
}
|
|
}
|
|
|
|
/// <summary>POST /api/devices/block — block a device temporarily</summary>
|
|
[Function("BlockDevice")]
|
|
public async Task<HttpResponseData> BlockDevice(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "devices/block")] HttpRequestData req)
|
|
{
|
|
try
|
|
{
|
|
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
|
var userIdentifier = TenantHelper.GetUserIdentifier(req);
|
|
|
|
var read = await req.ReadAsync<BlockDeviceRequest>();
|
|
if (read.Error != null) return read.Error;
|
|
var body = read.Value!;
|
|
|
|
var tenantId = body.TenantId;
|
|
var serialNumber = body.SerialNumber;
|
|
var durationHours = body.DurationHours ?? 12;
|
|
var reason = body.Reason;
|
|
var action = body.Action ?? "Block";
|
|
var blockedSessionId = NormalizeOptionalSessionId(body.BlockedSessionId);
|
|
|
|
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(serialNumber))
|
|
return await req.BadRequestAsync("tenantId and serialNumber are required");
|
|
|
|
if (durationHours < 1 || durationHours > 720) // max 30 days
|
|
return await req.BadRequestAsync("durationHours must be between 1 and 720");
|
|
|
|
if (!string.Equals(action, "Block", StringComparison.OrdinalIgnoreCase) &&
|
|
!string.Equals(action, "Kill", StringComparison.OrdinalIgnoreCase))
|
|
return await req.BadRequestAsync("action must be 'Block' or 'Kill'");
|
|
|
|
// Normalize casing
|
|
action = string.Equals(action, "Kill", StringComparison.OrdinalIgnoreCase) ? "Kill" : "Block";
|
|
|
|
await _blockedDeviceService.BlockDeviceAsync(
|
|
tenantId, serialNumber, durationHours, userIdentifier, reason, action, blockedSessionId);
|
|
|
|
var auditFields = new Dictionary<string, string>
|
|
{
|
|
{ "Action", action },
|
|
{ "DurationHours", durationHours.ToString() },
|
|
{ "Reason", reason ?? string.Empty }
|
|
};
|
|
if (blockedSessionId != null)
|
|
auditFields["BlockedSessionId"] = blockedSessionId;
|
|
|
|
await _maintenanceRepo.LogAuditEntryAsync(
|
|
tenantId,
|
|
"CREATE",
|
|
"DeviceBlock",
|
|
serialNumber,
|
|
userIdentifier,
|
|
auditFields
|
|
);
|
|
|
|
await _opsEventService.RecordDeviceBlockedAsync(tenantId, serialNumber, reason ?? "No reason", userIdentifier);
|
|
|
|
var isKill = action == "Kill";
|
|
_logger.LogWarning(
|
|
"Global Admin {User} {Action} device {SerialNumber} in tenant {TenantId} for {Hours}h. Reason: {Reason} SessionId: {SessionId}",
|
|
userIdentifier, isKill ? "issued KILL signal to" : "blocked", serialNumber, tenantId, durationHours, reason, blockedSessionId);
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(new BlockDeviceResponse
|
|
{
|
|
Success = true,
|
|
Message = isKill
|
|
? $"Device {serialNumber} issued remote kill signal for {durationHours} hours."
|
|
: $"Device {serialNumber} blocked for {durationHours} hours.",
|
|
UnblockAt = DateTime.UtcNow.AddHours(durationHours),
|
|
Action = action
|
|
});
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return await req.InternalServerErrorAsync(_logger, ex, "DeviceBlock");
|
|
}
|
|
}
|
|
|
|
/// <summary>DELETE /api/devices/block/{encodedSerialNumber}?tenantId={tenantId} — unblock a device</summary>
|
|
[Function("UnblockDevice")]
|
|
public async Task<HttpResponseData> UnblockDevice(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "devices/block/{encodedSerialNumber}")] HttpRequestData req,
|
|
string encodedSerialNumber)
|
|
{
|
|
try
|
|
{
|
|
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
|
var userIdentifier = TenantHelper.GetUserIdentifier(req);
|
|
|
|
var tenantId = req.Query["tenantId"];
|
|
if (string.IsNullOrEmpty(tenantId))
|
|
return await req.BadRequestAsync("tenantId query parameter is required");
|
|
|
|
var serialNumber = Uri.UnescapeDataString(encodedSerialNumber ?? string.Empty);
|
|
if (string.IsNullOrEmpty(serialNumber))
|
|
return await req.BadRequestAsync("serialNumber is required");
|
|
|
|
await _blockedDeviceService.UnblockDeviceAsync(tenantId, serialNumber);
|
|
|
|
await _maintenanceRepo.LogAuditEntryAsync(
|
|
tenantId,
|
|
"DELETE",
|
|
"DeviceBlock",
|
|
serialNumber,
|
|
userIdentifier
|
|
);
|
|
|
|
_logger.LogInformation(
|
|
"Global Admin {User} unblocked device {SerialNumber} in tenant {TenantId}",
|
|
userIdentifier, serialNumber, tenantId);
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(new SuccessMessageResponse { Success = true, Message = $"Device {serialNumber} unblocked." });
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return await req.InternalServerErrorAsync(_logger, ex, "DeviceBlock");
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Trims and rejects whitespace/empty sessionIds so the service layer never receives " " as a valid session token.
|
|
/// </summary>
|
|
internal static string? NormalizeOptionalSessionId(string? raw)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
|
return raw.Trim();
|
|
}
|
|
}
|
|
}
|