feat: Implement MongoDB orchestrator storage with registry, commands, and heartbeats
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added NullAdvisoryObservationEventTransport for handling advisory observation events. - Created IOrchestratorRegistryStore interface for orchestrator registry operations. - Implemented MongoOrchestratorRegistryStore for MongoDB interactions with orchestrator data. - Defined OrchestratorCommandDocument and OrchestratorCommandRecord for command handling. - Added OrchestratorHeartbeatDocument and OrchestratorHeartbeatRecord for heartbeat tracking. - Created OrchestratorRegistryDocument and OrchestratorRegistryRecord for registry management. - Developed tests for orchestrator collections migration and MongoOrchestratorRegistryStore functionality. - Introduced AirgapImportRequest and AirgapImportValidator for air-gapped VEX bundle imports. - Added incident mode rules sample JSON for notifier configuration.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Envelope for air-gapped VEX bundle imports.
|
||||
/// Mirrors the thin mirror bundle schema and carries signing metadata.
|
||||
/// </summary>
|
||||
public sealed class AirgapImportRequest
|
||||
{
|
||||
[JsonPropertyName("bundleId")]
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
[JsonPropertyName("mirrorGeneration")]
|
||||
public string? MirrorGeneration { get; init; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("publisher")]
|
||||
public string? Publisher { get; init; }
|
||||
|
||||
[JsonPropertyName("payloadHash")]
|
||||
public string? PayloadHash { get; init; }
|
||||
|
||||
[JsonPropertyName("payloadUrl")]
|
||||
public string? PayloadUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; init; }
|
||||
|
||||
[JsonPropertyName("transparencyLog")]
|
||||
public string? TransparencyLog { get; init; }
|
||||
}
|
||||
@@ -48,6 +48,7 @@ services.AddCsafNormalizer();
|
||||
services.AddCycloneDxNormalizer();
|
||||
services.AddOpenVexNormalizer();
|
||||
services.AddSingleton<IVexSignatureVerifier, NoopVexSignatureVerifier>();
|
||||
services.AddSingleton<AirgapImportValidator>();
|
||||
services.AddScoped<IVexIngestOrchestrator, VexIngestOrchestrator>();
|
||||
services.AddScoped<IVexObservationLookup, MongoVexObservationLookup>();
|
||||
services.AddOptions<ExcititorObservabilityOptions>()
|
||||
@@ -140,6 +141,33 @@ app.MapGet("/excititor/status", async (HttpContext context,
|
||||
|
||||
app.MapHealthChecks("/excititor/health");
|
||||
|
||||
app.MapPost("/airgap/v1/vex/import", async (
|
||||
[FromServices] AirgapImportValidator validator,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
[FromBody] AirgapImportRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var errors = validator.Validate(request, timeProvider.GetUtcNow());
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
var first = errors[0];
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = first.Code,
|
||||
message = first.Message
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Accepted($"/airgap/v1/vex/import/{request.BundleId}", new
|
||||
{
|
||||
bundleId = request.BundleId,
|
||||
generation = request.MirrorGeneration
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/v1/attestations/verify", async (
|
||||
[FromServices] IVexAttestationClient attestationClient,
|
||||
[FromBody] AttestationVerifyRequest request,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Services;
|
||||
|
||||
internal sealed class AirgapImportValidator
|
||||
{
|
||||
private static readonly TimeSpan AllowedSkew = TimeSpan.FromSeconds(5);
|
||||
|
||||
public IReadOnlyList<ValidationError> Validate(AirgapImportRequest request, DateTimeOffset nowUtc)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
errors.Add(new ValidationError("invalid_request", "Request body is required."));
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.BundleId))
|
||||
{
|
||||
errors.Add(new ValidationError("bundle_id_missing", "bundleId is required."));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.MirrorGeneration))
|
||||
{
|
||||
errors.Add(new ValidationError("mirror_generation_missing", "mirrorGeneration is required."));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Publisher))
|
||||
{
|
||||
errors.Add(new ValidationError("publisher_missing", "publisher is required."));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PayloadHash))
|
||||
{
|
||||
errors.Add(new ValidationError("payload_hash_missing", "payloadHash is required."));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Signature))
|
||||
{
|
||||
errors.Add(new ValidationError("AIRGAP_SIGNATURE_MISSING", "signature is required for air-gapped imports."));
|
||||
}
|
||||
|
||||
if (request.SignedAt is null)
|
||||
{
|
||||
errors.Add(new ValidationError("signed_at_missing", "signedAt is required."));
|
||||
}
|
||||
else
|
||||
{
|
||||
var delta = (nowUtc - request.SignedAt.Value).Duration();
|
||||
if (delta > AllowedSkew)
|
||||
{
|
||||
errors.Add(new ValidationError(
|
||||
"AIRGAP_PAYLOAD_STALE",
|
||||
$"signedAt exceeds allowed skew of {AllowedSkew.TotalSeconds.ToString(CultureInfo.InvariantCulture)} seconds."));
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
public readonly record struct ValidationError(string Code, string Message);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public class AirgapImportEndpointTests : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public AirgapImportEndpointTests(TestWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_returns_bad_request_when_signature_missing()
|
||||
{
|
||||
var request = new AirgapImportRequest
|
||||
{
|
||||
BundleId = "bundle-123",
|
||||
MirrorGeneration = "gen-1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
Publisher = "mirror-test",
|
||||
PayloadHash = "sha256:abc"
|
||||
};
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/airgap/v1/vex/import", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("AIRGAP_SIGNATURE_MISSING", json.GetProperty("error").GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_accepts_valid_payload()
|
||||
{
|
||||
var request = new AirgapImportRequest
|
||||
{
|
||||
BundleId = "bundle-123",
|
||||
MirrorGeneration = "gen-1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
Publisher = "mirror-test",
|
||||
PayloadHash = "sha256:abc",
|
||||
Signature = "sig"
|
||||
};
|
||||
|
||||
using var response = await _client.PostAsJsonAsync("/airgap/v1/vex/import", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user