using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using StellaOps.AirGap.Controller.Endpoints.Contracts; using StellaOps.AirGap.Controller.Services; using StellaOps.AirGap.Time.Models; using StellaOps.AirGap.Time.Services; namespace StellaOps.AirGap.Controller.Endpoints; internal static class AirGapEndpoints { private const string StatusScope = "airgap:status:read"; private const string SealScope = "airgap:seal"; public static RouteGroupBuilder MapAirGapEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/system/airgap") .RequireAuthorization(); group.MapGet("/status", HandleStatus) .RequireScope(StatusScope) .WithName("AirGapStatus"); group.MapPost("/seal", HandleSeal) .RequireScope(SealScope) .WithName("AirGapSeal"); group.MapPost("/unseal", HandleUnseal) .RequireScope(SealScope) .WithName("AirGapUnseal"); return group; } private static async Task HandleStatus( ClaimsPrincipal user, AirGapStateService service, TimeProvider timeProvider, HttpContext httpContext, CancellationToken cancellationToken) { var tenantId = ResolveTenant(httpContext); var status = await service.GetStatusAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken); return Results.Ok(AirGapStatusResponse.FromStatus(status)); } private static async Task HandleSeal( SealRequest request, ClaimsPrincipal user, AirGapStateService service, StalenessCalculator stalenessCalculator, TimeProvider timeProvider, HttpContext httpContext, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.PolicyHash)) { return Results.BadRequest(new { error = "policy_hash_required" }); } var tenantId = ResolveTenant(httpContext); var anchor = request.TimeAnchor ?? TimeAnchor.Unknown; var budget = request.StalenessBudget ?? StalenessBudget.Default; var now = timeProvider.GetUtcNow(); var state = await service.SealAsync(tenantId, request.PolicyHash!, anchor, budget, now, cancellationToken); var status = new AirGapStatus(state, stalenessCalculator.Evaluate(anchor, budget, now), now); return Results.Ok(AirGapStatusResponse.FromStatus(status)); } private static async Task HandleUnseal( ClaimsPrincipal user, AirGapStateService service, TimeProvider timeProvider, HttpContext httpContext, CancellationToken cancellationToken) { var tenantId = ResolveTenant(httpContext); var state = await service.UnsealAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken); var status = new AirGapStatus(state, StalenessEvaluation.Unknown, timeProvider.GetUtcNow()); return Results.Ok(AirGapStatusResponse.FromStatus(status)); } private static string ResolveTenant(HttpContext httpContext) { if (httpContext.Request.Headers.TryGetValue("x-tenant-id", out var tenantHeader) && !string.IsNullOrWhiteSpace(tenantHeader)) { return tenantHeader.ToString(); } return "default"; } } internal static class AuthorizationExtensions { public static RouteHandlerBuilder RequireScope(this RouteHandlerBuilder builder, string requiredScope) { return builder.RequireAuthorization(policy => { policy.RequireAssertion(ctx => { var scopes = ctx.User.FindFirstValue("scope") ?? ctx.User.FindFirstValue("scp") ?? string.Empty; return scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Contains(requiredScope, StringComparer.OrdinalIgnoreCase); }); }); } }