up
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

This commit is contained in:
StellaOps Bot
2025-11-26 20:23:28 +02:00
parent 4831c7fcb0
commit d63af51f84
139 changed files with 8010 additions and 2795 deletions

View File

@@ -0,0 +1,37 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace StellaOps.AirGap.Controller.Auth;
public sealed class HeaderScopeAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "HeaderScope";
#pragma warning disable CS0618 // ISystemClock obsolete; base ctor signature still requires it on this TF.
public HeaderScopeAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
}
#pragma warning restore CS0618
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))
{
claims.Add(new("scope", scopeHeader.ToString()));
}
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.AirGap.Controller.Options;
using StellaOps.AirGap.Controller.Services;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Controller.DependencyInjection;
public static class AirGapControllerServiceCollectionExtensions
{
public static IServiceCollection AddAirGapController(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<AirGapControllerMongoOptions>(configuration.GetSection("AirGap:Mongo"));
services.AddSingleton<StalenessCalculator>();
services.AddSingleton<AirGapStateService>();
services.AddSingleton<IAirGapStateStore>(sp =>
{
var opts = sp.GetRequiredService<IOptions<AirGapControllerMongoOptions>>().Value;
if (string.IsNullOrWhiteSpace(opts.ConnectionString))
{
return new InMemoryAirGapStateStore();
}
var mongoClient = new MongoClient(opts.ConnectionString);
var database = mongoClient.GetDatabase(string.IsNullOrWhiteSpace(opts.Database) ? "stellaops_airgap" : opts.Database);
var collection = MongoAirGapStateStore.EnsureCollection(database);
return new MongoAirGapStateStore(collection);
});
return services;
}
}

View File

@@ -0,0 +1,18 @@
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Controller.Domain;
public sealed record AirGapState
{
public const string SingletonId = "singleton";
public string Id { get; init; } = SingletonId;
public string TenantId { get; init; } = "default";
public bool Sealed { get; init; }
= false;
public string? PolicyHash { get; init; }
= null;
public TimeAnchor TimeAnchor { get; init; } = TimeAnchor.Unknown;
public DateTimeOffset LastTransitionAt { get; init; } = DateTimeOffset.MinValue;
public StalenessBudget StalenessBudget { get; init; } = StalenessBudget.Default;
}

View File

@@ -0,0 +1,108 @@
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);
});
});
}
}

View File

@@ -0,0 +1,25 @@
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Controller.Services;
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Controller.Endpoints.Contracts;
public sealed record AirGapStatusResponse(
string TenantId,
bool Sealed,
string? PolicyHash,
TimeAnchor TimeAnchor,
StalenessEvaluation Staleness,
DateTimeOffset LastTransitionAt,
DateTimeOffset EvaluatedAt)
{
public static AirGapStatusResponse FromStatus(AirGapStatus status) =>
new(
status.State.TenantId,
status.State.Sealed,
status.State.PolicyHash,
status.State.TimeAnchor,
status.Staleness,
status.State.LastTransitionAt,
status.EvaluatedAt);
}

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Controller.Endpoints.Contracts;
public sealed class SealRequest
{
[Required]
public string? PolicyHash { get; set; }
public TimeAnchor? TimeAnchor { get; set; }
public StalenessBudget? StalenessBudget { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.AirGap.Controller.Options;
/// <summary>
/// Mongo configuration for the air-gap controller state store.
/// </summary>
public sealed class AirGapControllerMongoOptions
{
/// <summary>
/// Mongo connection string; when missing, the controller falls back to the in-memory store.
/// </summary>
public string? ConnectionString { get; set; }
/// <summary>
/// Database name. Default: "stellaops_airgap".
/// </summary>
public string Database { get; set; } = "stellaops_airgap";
/// <summary>
/// Collection name for state documents. Default: "airgap_state".
/// </summary>
public string Collection { get; set; } = "airgap_state";
}

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Authentication;
using StellaOps.AirGap.Controller.Auth;
using StellaOps.AirGap.Controller.DependencyInjection;
using StellaOps.AirGap.Controller.Endpoints;
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(HeaderScopeAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, HeaderScopeAuthenticationHandler>(HeaderScopeAuthenticationHandler.SchemeName, _ => { });
builder.Services.AddAuthorization();
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
builder.Services.AddAirGapController(builder.Configuration);
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapAirGapEndpoints();
app.Run();
public partial class Program { }

View File

@@ -0,0 +1,70 @@
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Controller.Services;
public sealed class AirGapStateService
{
private readonly IAirGapStateStore _store;
private readonly StalenessCalculator _stalenessCalculator;
public AirGapStateService(IAirGapStateStore store, StalenessCalculator stalenessCalculator)
{
_store = store;
_stalenessCalculator = stalenessCalculator;
}
public async Task<AirGapState> SealAsync(
string tenantId,
string policyHash,
TimeAnchor timeAnchor,
StalenessBudget budget,
DateTimeOffset nowUtc,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyHash);
budget.Validate();
var newState = new AirGapState
{
TenantId = tenantId,
Sealed = true,
PolicyHash = policyHash,
TimeAnchor = timeAnchor,
StalenessBudget = budget,
LastTransitionAt = nowUtc
};
await _store.SetAsync(newState, cancellationToken);
return newState;
}
public async Task<AirGapState> UnsealAsync(
string tenantId,
DateTimeOffset nowUtc,
CancellationToken cancellationToken = default)
{
var current = await _store.GetAsync(tenantId, cancellationToken);
var newState = current with
{
Sealed = false,
LastTransitionAt = nowUtc
};
await _store.SetAsync(newState, cancellationToken);
return newState;
}
public async Task<AirGapStatus> GetStatusAsync(
string tenantId,
DateTimeOffset nowUtc,
CancellationToken cancellationToken = default)
{
var state = await _store.GetAsync(tenantId, cancellationToken);
var staleness = _stalenessCalculator.Evaluate(state.TimeAnchor, state.StalenessBudget, nowUtc);
return new AirGapStatus(state, staleness, nowUtc);
}
}
public sealed record AirGapStatus(AirGapState State, StalenessEvaluation Staleness, DateTimeOffset EvaluatedAt);

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>StellaOps.AirGap.Controller</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
using StellaOps.AirGap.Controller.Domain;
namespace StellaOps.AirGap.Controller.Stores;
public interface IAirGapStateStore
{
Task<AirGapState> GetAsync(string tenantId, CancellationToken cancellationToken = default);
Task SetAsync(AirGapState state, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,26 @@
using StellaOps.AirGap.Controller.Domain;
namespace StellaOps.AirGap.Controller.Stores;
public sealed class InMemoryAirGapStateStore : IAirGapStateStore
{
private readonly Dictionary<string, AirGapState> _states = new(StringComparer.Ordinal);
public Task<AirGapState> GetAsync(string tenantId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (_states.TryGetValue(tenantId, out var state))
{
return Task.FromResult(state);
}
return Task.FromResult(new AirGapState { TenantId = tenantId });
}
public Task SetAsync(AirGapState state, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_states[state.TenantId] = state;
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,156 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Controller.Stores;
/// <summary>
/// Mongo-backed air-gap state store; single document per tenant.
/// </summary>
internal sealed class MongoAirGapStateStore : IAirGapStateStore
{
private readonly IMongoCollection<AirGapStateDocument> _collection;
public MongoAirGapStateStore(IMongoCollection<AirGapStateDocument> collection)
{
_collection = collection;
}
public async Task<AirGapState> GetAsync(string tenantId, CancellationToken cancellationToken = default)
{
var filter = Builders<AirGapStateDocument>.Filter.And(
Builders<AirGapStateDocument>.Filter.Eq(x => x.TenantId, tenantId),
Builders<AirGapStateDocument>.Filter.Eq(x => x.Id, AirGapState.SingletonId));
var doc = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return doc?.ToDomain() ?? new AirGapState { TenantId = tenantId };
}
public async Task SetAsync(AirGapState state, CancellationToken cancellationToken = default)
{
var doc = AirGapStateDocument.FromDomain(state);
var filter = Builders<AirGapStateDocument>.Filter.And(
Builders<AirGapStateDocument>.Filter.Eq(x => x.TenantId, state.TenantId),
Builders<AirGapStateDocument>.Filter.Eq(x => x.Id, AirGapState.SingletonId));
var options = new ReplaceOptions { IsUpsert = true };
await _collection.ReplaceOneAsync(filter, doc, options, cancellationToken).ConfigureAwait(false);
}
internal static IMongoCollection<AirGapStateDocument> EnsureCollection(IMongoDatabase database)
{
var collectionName = "airgap_state";
var exists = database.ListCollectionNames().ToList().Contains(collectionName);
if (!exists)
{
database.CreateCollection(collectionName);
}
var collection = database.GetCollection<AirGapStateDocument>(collectionName);
var keys = Builders<AirGapStateDocument>.IndexKeys
.Ascending(x => x.TenantId)
.Ascending(x => x.Id);
var model = new CreateIndexModel<AirGapStateDocument>(keys, new CreateIndexOptions { Unique = true });
collection.Indexes.CreateOne(model);
return collection;
}
}
internal sealed class AirGapStateDocument
{
[BsonId]
public string Id { get; init; } = AirGapState.SingletonId;
[BsonElement("tenant_id")]
public string TenantId { get; init; } = "default";
[BsonElement("sealed")]
public bool Sealed { get; init; }
= false;
[BsonElement("policy_hash")]
public string? PolicyHash { get; init; }
= null;
[BsonElement("time_anchor")]
public AirGapTimeAnchorDocument TimeAnchor { get; init; } = new();
[BsonElement("staleness_budget")]
public StalenessBudgetDocument StalenessBudget { get; init; } = new();
[BsonElement("last_transition_at")]
public DateTimeOffset LastTransitionAt { get; init; }
= DateTimeOffset.MinValue;
public AirGapState ToDomain() => new()
{
TenantId = TenantId,
Sealed = Sealed,
PolicyHash = PolicyHash,
TimeAnchor = TimeAnchor.ToDomain(),
StalenessBudget = StalenessBudget.ToDomain(),
LastTransitionAt = LastTransitionAt
};
public static AirGapStateDocument FromDomain(AirGapState state) => new()
{
TenantId = state.TenantId,
Sealed = state.Sealed,
PolicyHash = state.PolicyHash,
TimeAnchor = AirGapTimeAnchorDocument.FromDomain(state.TimeAnchor),
StalenessBudget = StalenessBudgetDocument.FromDomain(state.StalenessBudget),
LastTransitionAt = state.LastTransitionAt
};
}
internal sealed class AirGapTimeAnchorDocument
{
[BsonElement("anchor_time")]
public DateTimeOffset AnchorTime { get; init; }
= DateTimeOffset.MinValue;
[BsonElement("source")]
public string Source { get; init; } = "unknown";
[BsonElement("format")]
public string Format { get; init; } = "unknown";
[BsonElement("signature_fp")]
public string SignatureFingerprint { get; init; } = string.Empty;
[BsonElement("token_digest")]
public string TokenDigest { get; init; } = string.Empty;
public StellaOps.AirGap.Time.Models.TimeAnchor ToDomain() =>
new(AnchorTime, Source, Format, SignatureFingerprint, TokenDigest);
public static AirGapTimeAnchorDocument FromDomain(StellaOps.AirGap.Time.Models.TimeAnchor anchor) => new()
{
AnchorTime = anchor.AnchorTime,
Source = anchor.Source,
Format = anchor.Format,
SignatureFingerprint = anchor.SignatureFingerprint,
TokenDigest = anchor.TokenDigest
};
}
internal sealed class StalenessBudgetDocument
{
[BsonElement("warning_seconds")]
public long WarningSeconds { get; init; } = StalenessBudget.Default.WarningSeconds;
[BsonElement("breach_seconds")]
public long BreachSeconds { get; init; } = StalenessBudget.Default.BreachSeconds;
public StalenessBudget ToDomain() => new(WarningSeconds, BreachSeconds);
public static StalenessBudgetDocument FromDomain(StalenessBudget budget) => new()
{
WarningSeconds = budget.WarningSeconds,
BreachSeconds = budget.BreachSeconds
};
}

View File

@@ -6,11 +6,11 @@
| PREP-AIRGAP-IMP-56-002-BLOCKED-ON-56-001 | DONE | Unblocked by importer scaffold/trust-root contract. | 2025-11-20 |
| PREP-AIRGAP-IMP-58-002-BLOCKED-ON-58-001 | DONE | Shares importer scaffold + validation envelopes. | 2025-11-20 |
| PREP-AIRGAP-TIME-57-001-TIME-COMPONENT-SCAFFO | DONE | Time anchor parser scaffold; doc at `docs/airgap/time-anchor-scaffold.md`. | 2025-11-20 |
| PREP-AIRGAP-CTL-56-001-CONTROLLER-PROJECT-SCA | DOING | Controller scaffold draft at `docs/airgap/controller-scaffold.md`; awaiting Authority scopes decision. | 2025-11-20 |
| PREP-AIRGAP-CTL-56-002-BLOCKED-ON-56-001-SCAF | DOING | Uses same scaffold doc; pending DevOps alignment on deployment skeleton. | 2025-11-20 |
| PREP-AIRGAP-CTL-56-001-CONTROLLER-PROJECT-SCA | DONE | Controller scaffold drafted; controller project created with seal/unseal/state endpoints per doc. | 2025-11-26 |
| PREP-AIRGAP-CTL-56-002-BLOCKED-ON-56-001-SCAF | DONE | Scaffold applied to status/seal endpoints; deployment skeleton present. | 2025-11-26 |
| PREP-AIRGAP-CTL-57-001-BLOCKED-ON-56-002 | DONE | Diagnostics doc at `docs/airgap/sealed-startup-diagnostics.md`. | 2025-11-20 |
| PREP-AIRGAP-CTL-57-002-BLOCKED-ON-57-001 | DONE | Telemetry/timeline hooks defined in `docs/airgap/sealed-startup-diagnostics.md`. | 2025-11-20 |
| PREP-AIRGAP-CTL-58-001-BLOCKED-ON-57-002 | DOING | Staleness/time-anchor fields specified; awaiting Time Guild token decision. | 2025-11-20 |
| PREP-AIRGAP-CTL-58-001-BLOCKED-ON-57-002 | DONE | Staleness/time-anchor fields wired in controller response; pending Time Guild token refinements. | 2025-11-26 |
| AIRGAP-IMP-56-001 | DONE | DSSE verifier, TUF validator, Merkle root calculator + import coordinator; tests passing. | 2025-11-20 |
| AIRGAP-IMP-56-002 | DONE | Root rotation policy (dual approval) + trust store; integrated into import validator; tests passing. | 2025-11-20 |
| AIRGAP-IMP-57-001 | DONE | In-memory RLS bundle catalog/items repos + schema doc; deterministic ordering and tests passing. | 2025-11-20 |