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
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
18
src/AirGap/StellaOps.AirGap.Controller/Domain/AirGapState.cs
Normal file
18
src/AirGap/StellaOps.AirGap.Controller/Domain/AirGapState.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
25
src/AirGap/StellaOps.AirGap.Controller/Program.cs
Normal file
25
src/AirGap/StellaOps.AirGap.Controller/Program.cs
Normal 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 { }
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user