save progress

This commit is contained in:
StellaOps Bot
2026-01-03 00:47:24 +02:00
parent 3f197814c5
commit ca578801fd
319 changed files with 32478 additions and 2202 deletions

View File

@@ -1,7 +1,9 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
namespace StellaOps.AirGap.Controller.Auth;
@@ -21,12 +23,28 @@ public sealed class HeaderScopeAuthenticationHandler : AuthenticationHandler<Aut
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Accept any request; scopes are read from `scope` header (space-separated)
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "anonymous") };
if (Request.Headers.TryGetValue("scope", out var scopeHeader))
var scopes = ExtractScopes(Request.Headers);
if (scopes.Count == 0)
{
claims.Add(new("scope", scopeHeader.ToString()));
return Task.FromResult(AuthenticateResult.Fail("scope_header_missing"));
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "header-scope"),
new(StellaOpsClaimTypes.Subject, "header-scope"),
new(StellaOpsClaimTypes.Scope, string.Join(' ', scopes))
};
foreach (var scope in scopes)
{
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
}
if (TryGetTenantHeader(Request.Headers, out var tenantId))
{
claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenantId));
claims.Add(new Claim("tid", tenantId));
}
var identity = new ClaimsIdentity(claims, SchemeName);
@@ -34,4 +52,49 @@ public sealed class HeaderScopeAuthenticationHandler : AuthenticationHandler<Aut
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
private static HashSet<string> ExtractScopes(IHeaderDictionary headers)
{
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AddScopes(headers, "scope", scopes);
AddScopes(headers, "scp", scopes);
return scopes;
}
private static void AddScopes(IHeaderDictionary headers, string headerName, ISet<string> scopes)
{
if (!headers.TryGetValue(headerName, out var values))
{
return;
}
foreach (var value in values)
{
foreach (var scope in value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
scopes.Add(scope);
}
}
}
private static bool TryGetTenantHeader(IHeaderDictionary headers, out string tenantId)
{
tenantId = string.Empty;
if (headers.TryGetValue("x-tenant-id", out var headerValue) && !string.IsNullOrWhiteSpace(headerValue))
{
tenantId = headerValue.ToString().Trim();
return true;
}
if (headers.TryGetValue("tid", out var legacyValue) && !string.IsNullOrWhiteSpace(legacyValue))
{
tenantId = legacyValue.ToString().Trim();
return true;
}
return false;
}
}

View File

@@ -14,6 +14,7 @@ public static class AirGapControllerServiceCollectionExtensions
public static IServiceCollection AddAirGapController(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<AirGapStartupOptions>(configuration.GetSection("AirGap:Startup"));
services.Configure<AirGapTelemetryOptions>(configuration.GetSection("AirGap:Telemetry"));
services.AddSingleton<AirGapTelemetry>();
services.AddSingleton<StalenessCalculator>();

View File

@@ -1,5 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using StellaOps.Auth.Abstractions;
using StellaOps.AirGap.Controller.Endpoints.Contracts;
using StellaOps.AirGap.Controller.Services;
using StellaOps.AirGap.Time.Models;
@@ -45,7 +46,11 @@ internal static class AirGapEndpoints
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenant(httpContext);
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
{
return failure!;
}
var status = await service.GetStatusAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
telemetry.RecordStatus(tenantId, status);
return Results.Ok(AirGapStatusResponse.FromStatus(status));
@@ -61,17 +66,29 @@ internal static class AirGapEndpoints
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.PolicyHash))
var validation = RequestValidation.ValidateSeal(request);
if (validation is not null)
{
return Results.BadRequest(new { error = "policy_hash_required" });
return validation;
}
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
{
return failure!;
}
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, request.ContentBudgets, cancellationToken);
var state = await service.SealAsync(
tenantId,
request.PolicyHash!.Trim(),
anchor,
budget,
now,
request.ContentBudgets,
cancellationToken);
var staleness = stalenessCalculator.Evaluate(anchor, budget, now);
var contentStaleness = stalenessCalculator.EvaluateContent(anchor, state.ContentBudgets, now);
var status = new AirGapStatus(state, staleness, contentStaleness, now);
@@ -87,7 +104,11 @@ internal static class AirGapEndpoints
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenant(httpContext);
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
{
return failure!;
}
var now = timeProvider.GetUtcNow();
var state = await service.UnsealAsync(tenantId, now, cancellationToken);
var emptyContentStaleness = new Dictionary<string, StalenessEvaluation>(StringComparer.OrdinalIgnoreCase);
@@ -98,12 +119,23 @@ internal static class AirGapEndpoints
private static async Task<IResult> HandleVerify(
VerifyRequest request,
ClaimsPrincipal user,
ReplayVerificationService verifier,
TimeProvider timeProvider,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = ResolveTenant(httpContext);
var validation = RequestValidation.ValidateVerify(request);
if (validation is not null)
{
return validation;
}
if (!TryResolveTenant(httpContext, user, out var tenantId, out var failure))
{
return failure!;
}
var now = timeProvider.GetUtcNow();
var result = await verifier.VerifyAsync(tenantId, request, now, cancellationToken);
if (!result.IsValid)
@@ -114,13 +146,91 @@ internal static class AirGapEndpoints
return Results.Ok(new VerifyResponse(true, result.Reason));
}
private static string ResolveTenant(HttpContext httpContext)
private static bool TryResolveTenant(
HttpContext httpContext,
ClaimsPrincipal user,
out string tenantId,
out IResult? failure)
{
if (httpContext.Request.Headers.TryGetValue("x-tenant-id", out var tenantHeader) && !string.IsNullOrWhiteSpace(tenantHeader))
tenantId = string.Empty;
failure = null;
var claimTenant = NormalizeTenant(user.FindFirstValue(StellaOpsClaimTypes.Tenant))
?? NormalizeTenant(user.FindFirstValue("tid"));
var headerTenant = NormalizeTenant(ReadTenantHeader(httpContext.Request));
if (string.IsNullOrEmpty(claimTenant) && string.IsNullOrEmpty(headerTenant))
{
failure = Results.BadRequest(new { error = "tenant_required" });
return false;
}
if (!string.IsNullOrEmpty(headerTenant) && !IsValidTenantId(headerTenant))
{
failure = Results.BadRequest(new { error = "tenant_invalid" });
return false;
}
if (!string.IsNullOrEmpty(claimTenant) && !IsValidTenantId(claimTenant))
{
failure = Results.Forbid();
return false;
}
if (!string.IsNullOrEmpty(headerTenant) && !string.IsNullOrEmpty(claimTenant)
&& !string.Equals(headerTenant, claimTenant, StringComparison.OrdinalIgnoreCase))
{
failure = Results.Forbid();
return false;
}
tenantId = claimTenant ?? headerTenant ?? string.Empty;
return true;
}
private static string? ReadTenantHeader(HttpRequest request)
{
if (request.Headers.TryGetValue("x-tenant-id", out var tenantHeader)
&& !string.IsNullOrWhiteSpace(tenantHeader))
{
return tenantHeader.ToString();
}
return "default";
if (request.Headers.TryGetValue("tid", out var legacyHeader)
&& !string.IsNullOrWhiteSpace(legacyHeader))
{
return legacyHeader.ToString();
}
return null;
}
private static string? NormalizeTenant(string? tenant)
=> string.IsNullOrWhiteSpace(tenant) ? null : tenant.Trim();
private static bool IsValidTenantId(string tenantId)
{
if (tenantId.Length is 0 or > 128)
{
return false;
}
foreach (var ch in tenantId)
{
if (ch > 0x7F)
{
return false;
}
if (char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.')
{
continue;
}
return false;
}
return true;
}
}
@@ -132,9 +242,25 @@ internal static class AuthorizationExtensions
{
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);
if (ctx.User.HasClaim(c => c.Type == StellaOpsClaimTypes.ScopeItem))
{
return ctx.User.FindAll(StellaOpsClaimTypes.ScopeItem)
.Select(c => c.Value)
.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
}
var scopes = ctx.User.FindAll(StellaOpsClaimTypes.Scope)
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToArray();
if (scopes.Length == 0)
{
scopes = ctx.User.FindAll("scp")
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToArray();
}
return scopes.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
});
});
}

View File

@@ -0,0 +1,107 @@
using StellaOps.AirGap.Controller.Endpoints.Contracts;
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Controller.Endpoints;
internal static class RequestValidation
{
public static IResult? ValidateSeal(SealRequest request)
{
var errors = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(request.PolicyHash))
{
errors["policyHash"] = new[] { "required" };
}
if (request.StalenessBudget is not null
&& !IsValidBudget(request.StalenessBudget, out var budgetError))
{
errors["stalenessBudget"] = new[] { budgetError };
}
if (request.ContentBudgets is not null)
{
foreach (var kvp in request.ContentBudgets)
{
if (string.IsNullOrWhiteSpace(kvp.Key))
{
errors["contentBudgets"] = new[] { "key_required" };
continue;
}
if (!IsValidBudget(kvp.Value, out var contentError))
{
errors[$"contentBudgets.{kvp.Key}"] = new[] { contentError };
}
}
}
return errors.Count > 0 ? Results.ValidationProblem(errors) : null;
}
public static IResult? ValidateVerify(VerifyRequest request)
{
var errors = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(request.ManifestSha256))
{
errors["manifestSha256"] = new[] { "required" };
}
if (string.IsNullOrWhiteSpace(request.BundleSha256))
{
errors["bundleSha256"] = new[] { "required" };
}
if (request.ManifestCreatedAt == DateTimeOffset.MinValue)
{
errors["manifestCreatedAt"] = new[] { "required" };
}
if (request.StalenessWindowHours < 0)
{
errors["stalenessWindowHours"] = new[] { "must_be_non_negative" };
}
if (request.ComputedManifestSha256 is not null && string.IsNullOrWhiteSpace(request.ComputedManifestSha256))
{
errors["computedManifestSha256"] = new[] { "invalid" };
}
if (request.ComputedBundleSha256 is not null && string.IsNullOrWhiteSpace(request.ComputedBundleSha256))
{
errors["computedBundleSha256"] = new[] { "invalid" };
}
if (request.BundlePolicyHash is not null && string.IsNullOrWhiteSpace(request.BundlePolicyHash))
{
errors["bundlePolicyHash"] = new[] { "invalid" };
}
if (request.SealedPolicyHash is not null && string.IsNullOrWhiteSpace(request.SealedPolicyHash))
{
errors["sealedPolicyHash"] = new[] { "invalid" };
}
return errors.Count > 0 ? Results.ValidationProblem(errors) : null;
}
private static bool IsValidBudget(StalenessBudget budget, out string error)
{
if (budget.WarningSeconds < 0 || budget.BreachSeconds < 0)
{
error = "must_be_non_negative";
return false;
}
if (budget.WarningSeconds > budget.BreachSeconds)
{
error = "warning_exceeds_breach";
return false;
}
error = string.Empty;
return true;
}
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.AirGap.Controller.Options;
public sealed class AirGapTelemetryOptions
{
public int MaxTenantEntries { get; set; } = 1000;
}

View File

@@ -22,5 +22,5 @@ app.MapAirGapEndpoints();
app.Run();
// Make Program class file-scoped to prevent it from being exposed to referencing assemblies
file sealed partial class Program;
// Expose Program class for WebApplicationFactory tests.
public partial class Program;

View File

@@ -59,6 +59,10 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
{
failures.Add("egress-allowlist-missing");
}
else if (_options.EgressAllowlist.Length == 0)
{
failures.Add("egress-allowlist-empty");
}
if (state.TimeAnchor == TimeAnchor.Unknown)
{
@@ -69,7 +73,7 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
failures.Add("time-anchor-stale");
}
var trustResult = ValidateTrustMaterials(_options.Trust);
var trustResult = await ValidateTrustMaterialsAsync(_options.Trust, cancellationToken);
if (!trustResult.IsValid)
{
failures.Add($"trust:{trustResult.Reason}");
@@ -99,7 +103,9 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private StartupCheckResult ValidateTrustMaterials(TrustMaterialOptions trust)
private async Task<StartupCheckResult> ValidateTrustMaterialsAsync(
TrustMaterialOptions trust,
CancellationToken cancellationToken)
{
if (!trust.IsConfigured)
{
@@ -108,16 +114,21 @@ internal sealed class AirGapStartupDiagnosticsHostedService : IHostedService
try
{
var rootJson = File.ReadAllText(trust.RootJsonPath);
var snapshotJson = File.ReadAllText(trust.SnapshotJsonPath);
var timestampJson = File.ReadAllText(trust.TimestampJsonPath);
var rootJson = await File.ReadAllTextAsync(trust.RootJsonPath, cancellationToken);
var snapshotJson = await File.ReadAllTextAsync(trust.SnapshotJsonPath, cancellationToken);
var timestampJson = await File.ReadAllTextAsync(trust.TimestampJsonPath, cancellationToken);
var result = _tufValidator.Validate(rootJson, snapshotJson, timestampJson);
return result.IsValid
? StartupCheckResult.Success()
: StartupCheckResult.Failure(result.Reason);
if (result.IsValid)
{
return StartupCheckResult.Success();
}
_logger.LogWarning("AirGap trust validation failed: {Reason}", result.Reason);
return StartupCheckResult.Failure(result.Reason);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AirGap trust validation failed while reading trust material.");
return StartupCheckResult.Failure($"trust-read-failed:{ex.GetType().Name.ToLowerInvariant()}");
}
}

View File

@@ -88,7 +88,13 @@ public sealed class AirGapStateService
{
foreach (var kvp in provided)
{
result[kvp.Key] = kvp.Value;
if (string.IsNullOrWhiteSpace(kvp.Key))
{
throw new ArgumentException("content-budget-key-invalid", nameof(provided));
}
kvp.Value.Validate();
result[kvp.Key.Trim()] = kvp.Value;
}
}

View File

@@ -1,7 +1,10 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Threading;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Controller.Options;
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Time.Models;
@@ -19,19 +22,27 @@ public sealed class AirGapTelemetry
private static readonly Counter<long> UnsealCounter = Meter.CreateCounter<long>("airgap_unseal_total");
private static readonly Counter<long> StartupBlockedCounter = Meter.CreateCounter<long>("airgap_startup_blocked_total");
private readonly ConcurrentDictionary<string, (long Age, long Budget)> _latestByTenant = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, TelemetryEntry> _latestByTenant = new(StringComparer.Ordinal);
private readonly Queue<(string Tenant, long Sequence)> _evictionQueue = new();
private readonly object _cacheLock = new();
private readonly int _maxTenantEntries;
private long _sequence;
private readonly ObservableGauge<long> _anchorAgeGauge;
private readonly ObservableGauge<long> _budgetGauge;
private readonly ILogger<AirGapTelemetry> _logger;
public AirGapTelemetry(ILogger<AirGapTelemetry> logger)
public AirGapTelemetry(IOptions<AirGapTelemetryOptions> options, ILogger<AirGapTelemetry> logger)
{
var maxEntries = options.Value.MaxTenantEntries;
_maxTenantEntries = maxEntries > 0 ? maxEntries : 1000;
_logger = logger;
_anchorAgeGauge = Meter.CreateObservableGauge("airgap_time_anchor_age_seconds", ObserveAges);
_budgetGauge = Meter.CreateObservableGauge("airgap_staleness_budget_seconds", ObserveBudgets);
}
internal int TenantCacheCount => _latestByTenant.Count;
private IEnumerable<Measurement<long>> ObserveAges()
{
foreach (var kvp in _latestByTenant)
@@ -50,7 +61,7 @@ public sealed class AirGapTelemetry
public void RecordStatus(string tenantId, AirGapStatus status)
{
_latestByTenant[tenantId] = (status.Staleness.AgeSeconds, status.Staleness.BreachSeconds);
UpdateTenant(tenantId, status.Staleness.AgeSeconds, status.Staleness.BreachSeconds);
using var activity = ActivitySource.StartActivity("airgap.status.read");
activity?.SetTag("tenant", tenantId);
@@ -95,14 +106,14 @@ public sealed class AirGapTelemetry
public void RecordStartupBlocked(string tenantId, string reason, StalenessEvaluation staleness)
{
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
UpdateTenant(tenantId, staleness.AgeSeconds, staleness.BreachSeconds);
StartupBlockedCounter.Add(1, new TagList { { "tenant", tenantId }, { "reason", reason } });
_logger.LogCritical("airgap.startup.validation failed tenant={Tenant} reason={Reason}", tenantId, reason);
}
public void RecordStartupPassed(string tenantId, StalenessEvaluation staleness, int allowlistCount)
{
_latestByTenant[tenantId] = (staleness.AgeSeconds, staleness.BreachSeconds);
UpdateTenant(tenantId, staleness.AgeSeconds, staleness.BreachSeconds);
using var activity = ActivitySource.StartActivity("airgap.startup.validation");
activity?.SetTag("tenant", tenantId);
activity?.SetTag("result", "success");
@@ -115,4 +126,35 @@ public sealed class AirGapTelemetry
allowlistCount,
staleness.AgeSeconds);
}
private void UpdateTenant(string tenantId, long ageSeconds, long budgetSeconds)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return;
}
var sequence = Interlocked.Increment(ref _sequence);
_latestByTenant[tenantId] = new TelemetryEntry(ageSeconds, budgetSeconds, sequence);
lock (_cacheLock)
{
_evictionQueue.Enqueue((tenantId, sequence));
TrimCache();
}
}
private void TrimCache()
{
while (_latestByTenant.Count > _maxTenantEntries && _evictionQueue.Count > 0)
{
var (tenant, sequence) = _evictionQueue.Dequeue();
if (_latestByTenant.TryGetValue(tenant, out var entry) && entry.Sequence == sequence)
{
_latestByTenant.TryRemove(tenant, out _);
}
}
}
private readonly record struct TelemetryEntry(long Age, long Budget, long Sequence);
}

View File

@@ -8,5 +8,6 @@
<ItemGroup>
<ProjectReference Include="../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0024-M | DONE | Maintainability audit for StellaOps.AirGap.Controller. |
| AUDIT-0024-T | DONE | Test coverage audit for StellaOps.AirGap.Controller. |
| AUDIT-0024-A | TODO | Pending approval for changes. |
| AUDIT-0024-A | DONE | Applied auth/tenant validation, request validation, telemetry cap, and tests. |