fix(exportcenter): ship audit bundle http binding

This commit is contained in:
master
2026-03-08 14:29:33 +02:00
parent 3e531f0b9e
commit 8852928115
6 changed files with 537 additions and 20 deletions

View File

@@ -181,17 +181,54 @@ public sealed record BundleActorRefDto(
/// <summary>
/// Subject reference for audit bundle.
/// </summary>
public sealed record BundleSubjectRefDto(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("digest")] IReadOnlyDictionary<string, string> Digest);
public sealed record BundleSubjectRefDto
{
[JsonConstructor]
public BundleSubjectRefDto()
{
}
public BundleSubjectRefDto(string type, string name, IReadOnlyDictionary<string, string>? digest)
{
Type = type;
Name = name;
Digest = digest is null
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string>(digest, StringComparer.OrdinalIgnoreCase);
}
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("digest")]
public Dictionary<string, string> Digest { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Time window filter for included content.
/// </summary>
public sealed record BundleTimeWindowDto(
[property: JsonPropertyName("from")] DateTimeOffset? From,
[property: JsonPropertyName("to")] DateTimeOffset? To);
public sealed record BundleTimeWindowDto
{
[JsonConstructor]
public BundleTimeWindowDto()
{
}
public BundleTimeWindowDto(DateTimeOffset? from, DateTimeOffset? to)
{
From = from;
To = to;
}
[JsonPropertyName("from")]
public DateTimeOffset? From { get; init; }
[JsonPropertyName("to")]
public DateTimeOffset? To { get; init; }
}
/// <summary>
/// Artifact entry within an audit bundle.
@@ -232,21 +269,77 @@ public sealed record BundleIntegrityDto(
/// <summary>
/// Request to create an audit bundle.
/// </summary>
public sealed record CreateAuditBundleRequest(
[property: JsonPropertyName("subject")] BundleSubjectRefDto Subject,
[property: JsonPropertyName("timeWindow")] BundleTimeWindowDto? TimeWindow,
[property: JsonPropertyName("includeContent")] AuditBundleContentSelection IncludeContent,
[property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null);
public sealed record CreateAuditBundleRequest
{
[JsonConstructor]
public CreateAuditBundleRequest()
{
}
public CreateAuditBundleRequest(
BundleSubjectRefDto? Subject,
BundleTimeWindowDto? TimeWindow,
AuditBundleContentSelection? IncludeContent,
string? CallbackUrl = null)
{
this.Subject = Subject;
this.TimeWindow = TimeWindow;
this.IncludeContent = IncludeContent ?? new AuditBundleContentSelection();
this.CallbackUrl = CallbackUrl;
}
[JsonPropertyName("subject")]
public BundleSubjectRefDto? Subject { get; init; }
[JsonPropertyName("timeWindow")]
public BundleTimeWindowDto? TimeWindow { get; init; }
[JsonPropertyName("includeContent")]
public AuditBundleContentSelection IncludeContent { get; init; } = new();
[JsonPropertyName("callbackUrl")]
public string? CallbackUrl { get; init; }
}
/// <summary>
/// Content selection for audit bundle creation.
/// </summary>
public sealed record AuditBundleContentSelection(
[property: JsonPropertyName("vulnReports")] bool VulnReports = true,
[property: JsonPropertyName("sbom")] bool Sbom = true,
[property: JsonPropertyName("vexDecisions")] bool VexDecisions = true,
[property: JsonPropertyName("policyEvaluations")] bool PolicyEvaluations = true,
[property: JsonPropertyName("attestations")] bool Attestations = true);
public sealed record AuditBundleContentSelection
{
[JsonConstructor]
public AuditBundleContentSelection()
{
}
public AuditBundleContentSelection(
bool VulnReports = true,
bool Sbom = true,
bool VexDecisions = true,
bool PolicyEvaluations = true,
bool Attestations = true)
{
this.VulnReports = VulnReports;
this.Sbom = Sbom;
this.VexDecisions = VexDecisions;
this.PolicyEvaluations = PolicyEvaluations;
this.Attestations = Attestations;
}
[JsonPropertyName("vulnReports")]
public bool VulnReports { get; init; } = true;
[JsonPropertyName("sbom")]
public bool Sbom { get; init; } = true;
[JsonPropertyName("vexDecisions")]
public bool VexDecisions { get; init; } = true;
[JsonPropertyName("policyEvaluations")]
public bool PolicyEvaluations { get; init; } = true;
[JsonPropertyName("attestations")]
public bool Attestations { get; init; } = true;
}
/// <summary>
/// Response from creating an audit bundle.

View File

@@ -0,0 +1,79 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.ServerIntegration;
using StellaOps.ExportCenter.WebService.AuditBundle;
namespace StellaOps.ExportCenter.Tests.AuditBundle;
public sealed class AuditBundleHttpEndpointTests
{
[Fact]
[Trait("Intent", "Operational")]
[Trait("Category", "Integration")]
public async Task HttpPost_CreateAuditBundle_ReturnsAccepted()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddLogging();
builder.Services.AddAuthorizationBuilder()
.AddPolicy(
StellaOpsResourceServerPolicies.ExportOperator,
static policy => policy.RequireAssertion(static _ => true));
builder.Services.AddAuditBundleJobHandler();
await using var app = builder.Build();
app.MapAuditBundleEndpoints();
await app.StartAsync();
try
{
var baseAddress = new Uri(app.Urls.Single(), UriKind.Absolute);
using var client = new HttpClient
{
BaseAddress = baseAddress
};
var response = await client.PostAsJsonAsync(
"/v1/audit-bundles",
new
{
subject = new
{
type = "IMAGE",
name = "asset-review-prod",
digest = new Dictionary<string, string>
{
["sha256"] = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
}
},
includeContent = new
{
vulnReports = true,
sbom = true,
vexDecisions = true,
policyEvaluations = true,
attestations = true
}
});
var payload = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
using var document = JsonDocument.Parse(payload);
Assert.True(document.RootElement.TryGetProperty("bundleId", out var bundleId));
Assert.StartsWith("bndl-", bundleId.GetString(), StringComparison.Ordinal);
Assert.Equal("Pending", document.RootElement.GetProperty("status").GetString());
}
finally
{
await app.StopAsync();
}
}
}

View File

@@ -0,0 +1,143 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.ExportCenter.WebService.ExceptionReport;
using StellaOps.Policy.Exceptions.Repositories;
namespace StellaOps.ExportCenter.Tests.AuditBundle;
public sealed class AuditBundleProgramHttpIntegrationTests
{
[Fact]
[Trait("Intent", "Operational")]
[Trait("Category", "Integration")]
public async Task HttpPost_CreateAuditBundle_ThroughProgramPipeline_ReturnsAccepted()
{
await using var factory = new WebApplicationFactory<StellaOps.ExportCenter.WebService.Program>()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configurationBuilder) =>
{
configurationBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Router:Enabled"] = "false",
["Export:AllowInMemoryRepositories"] = "true"
});
});
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IPostConfigureOptions<AuthenticationOptions>>();
services.RemoveAll<IConfigureOptions<JwtBearerOptions>>();
services.RemoveAll<IPostConfigureOptions<JwtBearerOptions>>();
services.RemoveAll<IExceptionRepository>();
services.RemoveAll<IExceptionApplicationRepository>();
services.RemoveAll<IExceptionReportGenerator>();
services.AddSingleton(Mock.Of<IExceptionRepository>());
services.AddSingleton(Mock.Of<IExceptionApplicationRepository>());
services.AddSingleton(Mock.Of<IExceptionReportGenerator>());
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestExportAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestExportAuthHandler.SchemeName;
})
.AddScheme<AuthenticationSchemeOptions, TestExportAuthHandler>(TestExportAuthHandler.SchemeName, _ => { })
.AddScheme<AuthenticationSchemeOptions, TestExportAuthHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, _ => { });
services.PostConfigure<AuthorizationOptions>(options =>
{
var allowAllPolicy = new AuthorizationPolicyBuilder()
.AddAuthenticationSchemes(TestExportAuthHandler.SchemeName)
.RequireAssertion(_ => true)
.Build();
options.DefaultPolicy = allowAllPolicy;
options.FallbackPolicy = allowAllPolicy;
options.AddPolicy(StellaOpsResourceServerPolicies.ExportViewer, allowAllPolicy);
options.AddPolicy(StellaOpsResourceServerPolicies.ExportOperator, allowAllPolicy);
options.AddPolicy(StellaOpsResourceServerPolicies.ExportAdmin, allowAllPolicy);
});
});
});
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
"/v1/audit-bundles",
new
{
subject = new
{
type = "IMAGE",
name = "asset-review-prod",
digest = new Dictionary<string, string>
{
["sha256"] = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
}
},
includeContent = new
{
vulnReports = true,
sbom = true,
vexDecisions = true,
policyEvaluations = true,
attestations = true
}
});
var payload = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
using var document = JsonDocument.Parse(payload);
Assert.True(document.RootElement.TryGetProperty("bundleId", out var bundleId));
Assert.StartsWith("bndl-", bundleId.GetString(), StringComparison.Ordinal);
Assert.Equal("Pending", document.RootElement.GetProperty("status").GetString());
}
private sealed class TestExportAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "TestExport";
public TestExportAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(
[
new Claim("sub", "test-user"),
new Claim("name", "Test User"),
new Claim("scope", "export.operator export.viewer export.admin")
],
SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
}

View File

@@ -0,0 +1,111 @@
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.ExportCenter.WebService.AuditBundle;
using StellaOps.Microservice.AspNetCore;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Identity;
using StellaOps.Router.Common.Enums;
using Xunit;
namespace StellaOps.ExportCenter.Tests.AuditBundle;
public sealed class AuditBundleRouterDispatchTests
{
[Fact]
public async Task RouterDispatch_CreateAuditBundle_ReturnsAccepted()
{
const string signingKey = "exportcenter-router-test-signing-key";
var builder = WebApplication.CreateBuilder();
builder.Services.AddLogging();
builder.Services.AddAuthorization();
builder.Services.AddAuditBundleJobHandler();
var app = builder.Build();
app.MapAuditBundleEndpoints();
var endpointRouteBuilder = (IEndpointRouteBuilder)app;
var endpointDataSource = new StaticEndpointDataSource(
endpointRouteBuilder.DataSources.SelectMany(static dataSource => dataSource.Endpoints).ToArray());
var dispatcher = new AspNetRouterRequestDispatcher(
app.Services,
endpointDataSource,
new StellaRouterBridgeOptions
{
ServiceName = "exportcenter",
Version = "1.0.0-alpha1",
Region = "local",
AuthorizationTrustMode = GatewayAuthorizationTrustMode.Hybrid,
IdentityEnvelopeSigningKey = signingKey
},
NullLogger<AspNetRouterRequestDispatcher>.Instance);
var envelope = new GatewayIdentityEnvelope
{
Issuer = "gateway",
Subject = "qa-user",
Tenant = "demo-prod",
Scopes = ["export.operator", "export.viewer"],
Roles = ["admin"],
CorrelationId = "corr-audit-bundle-1",
IssuedAtUtc = DateTimeOffset.UtcNow.AddSeconds(-5),
ExpiresAtUtc = DateTimeOffset.UtcNow.AddMinutes(1)
};
var signature = GatewayIdentityEnvelopeCodec.Sign(envelope, signingKey);
var transportFrame = FrameConverter.ToFrame(new RequestFrame
{
RequestId = "req-export-audit-1",
Method = "POST",
Path = "/v1/audit-bundles",
Headers = new Dictionary<string, string>
{
["content-type"] = "application/json",
["X-StellaOps-Identity-Envelope"] = signature.Payload,
["X-StellaOps-Identity-Envelope-Signature"] = signature.Signature
},
Payload = Encoding.UTF8.GetBytes("""
{
"subject": {
"type": "IMAGE",
"name": "asset-review-prod",
"digest": {
"sha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111"
}
},
"includeContent": {
"vulnReports": true,
"sbom": true,
"vexDecisions": true,
"policyEvaluations": true,
"attestations": true
}
}
""")
});
var request = FrameConverter.ToRequestFrame(transportFrame);
Assert.NotNull(request);
var response = await dispatcher.DispatchAsync(request!);
var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray());
Assert.Equal(StatusCodes.Status202Accepted, response.StatusCode);
Assert.Contains("\"bundleId\":\"bndl-", responseBody, StringComparison.Ordinal);
Assert.Contains("\"status\":\"Pending\"", responseBody, StringComparison.Ordinal);
}
private sealed class StaticEndpointDataSource(params Endpoint[] endpoints) : EndpointDataSource
{
private readonly IReadOnlyList<Endpoint> _endpoints = endpoints;
public override IReadOnlyList<Endpoint> Endpoints => _endpoints;
public override Microsoft.Extensions.Primitives.IChangeToken GetChangeToken()
=> new Microsoft.Extensions.Primitives.CancellationChangeToken(CancellationToken.None);
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration;
using System.Text.Json;
using StellaOps.ExportCenter.Client.Models;
using static StellaOps.Localization.T;
@@ -64,11 +65,28 @@ public static class AuditBundleEndpoints
}
private static async Task<Results<Accepted<CreateAuditBundleResponse>, BadRequest<ErrorEnvelope>>> CreateAuditBundleAsync(
[FromBody] CreateAuditBundleRequest request,
HttpRequest request,
[FromServices] IAuditBundleJobHandler handler,
HttpContext httpContext,
CancellationToken cancellationToken)
{
CreateAuditBundleRequest? createRequest;
try
{
createRequest = await request.ReadFromJsonAsync<CreateAuditBundleRequest>(cancellationToken);
}
catch (JsonException)
{
return TypedResults.BadRequest(new ErrorEnvelope(
new ErrorDetail("INVALID_REQUEST", "Malformed request body.")));
}
if (createRequest is null)
{
return TypedResults.BadRequest(new ErrorEnvelope(
new ErrorDetail("INVALID_REQUEST", "Request body is required.")));
}
// Get actor from claims
var actorId = httpContext.User.FindFirst("sub")?.Value
?? httpContext.User.FindFirst("preferred_username")?.Value
@@ -77,7 +95,7 @@ public static class AuditBundleEndpoints
?? httpContext.User.FindFirst("preferred_username")?.Value
?? "Anonymous User";
var result = await handler.CreateBundleAsync(request, actorId, actorName, cancellationToken);
var result = await handler.CreateBundleAsync(createRequest, actorId, actorName, cancellationToken);
if (result.Error is not null)
{