save progress
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.AirGap.Controller.Options;
|
||||
|
||||
public sealed class AirGapTelemetryOptions
|
||||
{
|
||||
public int MaxTenantEntries { get; set; } = 1000;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user