Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs
T
Oliver Kieselbach e0ad51b965 refactor(urls): one guard-enforced URL registry per component, agent downloads move to the alias
Every well-known host -- own subdomains, both blob accounts, the API origin,
Graph, Entra login -- now lives in exactly one registry per component:
Constants.cs (*BaseUrl) for C#, utils/config.ts for the web app, src/config.ts
for the MCP server. lib/hostRouting.ts derives its hostnames from the registry
and middleware.ts imports from it, retiring what turned out to be a fourth
independent copy of the www/portal host pair.

Three new guard tests (backend xUnit, web vitest, MCP vitest) scan non-test
source and fail on any enforced host outside its registry. This is the lesson
of the EU cutover: the registry existed then too, but LatestVersionsService
(feeds the portal's version badge) and ValidateBootstrapCodeFunction carried
their own literal copies of the legacy blob host and were missed -- kept
working only by the fail-soft legacy mirror in build-agent.yml, whose silent
failure would have pinned badge and self-update to stale bits with a green
release. Both guards proved themselves on their first run, flagging the
middleware host copy and a literal in an MCP tool description. Test files stay
exempt on purpose: literals there are independent oracles, and
ValidateBootstrapCodeResponseShapeTests now pins the full alias URL against
the Constants-built one so registry changes must be acknowledged consciously.

The two legacy readers plus the agent SelfUpdater now use
Constants.AgentDownloadBaseUrl (download.autopilotmonitor.com/agent); the
alias was verified live for version.json, version-v2.json and both ZIPs
before the switch. The portal's bootstrap allow-list accepts the alias AND
the legacy host (AGENT_DOWNLOAD_HOSTNAMES) for already-deployed customer
scripts, which imposes a deploy order: web before or with backend. The
legacy keepalive probes and the release mirror stay untouched -- they exist
for exactly this transition. Also: the bootstrap short-link now targets www
directly instead of riding the registrar's apex 301, and the welcome-mail
docs button points at docs. instead of bouncing through www./docs.

Suites: backend 3194, agent core 1925, DecisionCore 474, web 582, MCP 449 --
all green; next build verified (it, not tsc, proves the next.config.ts
registry import). Docs: docs/url-registry.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 08:37:42 +02:00

117 lines
5.0 KiB
C#

using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions.Bootstrap
{
/// <summary>
/// POST /api/bootstrap/sessions — Create a new bootstrap session for OOBE agent deployment.
/// Requires JWT authentication and TenantAdmin role.
/// </summary>
public class CreateBootstrapSessionFunction
{
private readonly ILogger<CreateBootstrapSessionFunction> _logger;
private readonly BootstrapSessionService _bootstrapService;
private readonly TenantConfigurationService _configService;
private readonly IMaintenanceRepository _maintenanceRepo;
public CreateBootstrapSessionFunction(
ILogger<CreateBootstrapSessionFunction> logger,
BootstrapSessionService bootstrapService,
TenantConfigurationService configService,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_bootstrapService = bootstrapService;
_configService = configService;
_maintenanceRepo = maintenanceRepo;
}
[Function("CreateBootstrapSession")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "bootstrap/sessions")] HttpRequestData req)
{
try
{
// Authentication + BootstrapManagerOrGA authorization enforced by PolicyEnforcementMiddleware
// Cross-tenant check enforced by middleware via TenantScoping.QueryParam
var requestCtx = req.GetRequestContext();
var tenantId = requestCtx.TargetTenantId;
var userIdentifier = requestCtx.UserPrincipalName;
// Read request body
string body;
using (var reader = new StreamReader(req.Body))
body = await reader.ReadToEndAsync();
var request = JsonConvert.DeserializeObject<CreateBootstrapSessionRequest>(body);
if (request == null)
{
var badReq = req.CreateResponse(HttpStatusCode.BadRequest);
await badReq.WriteAsJsonAsync(new { error = "Invalid request body" });
return badReq;
}
// Check if bootstrap token feature is enabled for this tenant
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
if (!tenantConfig.BootstrapTokenEnabled)
{
var disabled = req.CreateResponse(HttpStatusCode.Forbidden);
await disabled.WriteAsJsonAsync(new { error = "Bootstrap token feature is not enabled for this tenant" });
return disabled;
}
// Validate validity hours
var validityHours = request.ValidityHours > 0 ? request.ValidityHours : 8;
var session = await _bootstrapService.CreateAsync(tenantId, validityHours, userIdentifier, request.Label);
await _maintenanceRepo.LogAuditEntryAsync(
tenantId,
"CREATE",
"BootstrapSession",
session.ShortCode,
userIdentifier,
new Dictionary<string, string>
{
{ "ValidityHours", validityHours.ToString() },
{ "Label", request.Label ?? string.Empty }
}
);
var responseData = new CreateBootstrapSessionResponse
{
Success = true,
ShortCode = session.ShortCode,
// www directly — the bare apex is only a registrar-level 301 to www,
// so pointing there just adds a redirect hop for every bootstrap.
BootstrapUrl = $"{Constants.WebsiteBaseUrl}/go/{session.ShortCode}",
ExpiresAt = session.ExpiresAt,
Message = $"Bootstrap session created. Valid for {validityHours} hours."
};
var response = req.CreateResponse(HttpStatusCode.Created);
await response.WriteAsJsonAsync(responseData);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating bootstrap session");
var error = req.CreateResponse(HttpStatusCode.InternalServerError);
await error.WriteAsJsonAsync(new { error = "Failed to create bootstrap session" });
return error;
}
}
}
}