Files
git.stella-ops.org/src/AirGap/StellaOps.AirGap.Controller/Endpoints/AirGapEndpoints.cs
StellaOps Bot d63af51f84
Some checks failed
api-governance / spectral-lint (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
up
2025-11-26 20:23:28 +02:00

109 lines
3.9 KiB
C#

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<IResult> 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<IResult> 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<IResult> 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);
});
});
}
}