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

@@ -0,0 +1,73 @@
# Sprint 20260308-002 - ExportCenter Audit Bundle HTTP Body Binding
## Topic & Scope
- Repair the live `POST /v1/audit-bundles` endpoint in ExportCenter so valid JSON bodies bind and queue audit-bundle jobs instead of returning an empty `400`.
- Cover the real failure mode with an HTTP-level regression test; the existing router-dispatch test proves transport dispatch but does not exercise ASP.NET Core body binding.
- Keep the work scoped to the ExportCenter audit-bundle API models, endpoint tests, and this sprint file.
- Working directory: `src/ExportCenter/StellaOps.ExportCenter`.
- Expected evidence: focused ExportCenter tests, direct live `curl` to the ExportCenter container, gateway/browser retest linkage, and sprint execution log updates.
## Dependencies & Concurrency
- Downstream of [SPRINT_20260308_001_Router_audit_bundle_frontdoor_route_deduplication.md](/C:/dev/New%20folder/git.stella-ops.org/docs/implplan/SPRINT_20260308_001_Router_audit_bundle_frontdoor_route_deduplication.md): Router now dispatches `/v1/audit-bundles` to ExportCenter correctly, exposing the service-local `400`.
- Safe parallelism: avoid unrelated ExportCenter export-profile or lineage work; scope is limited to the audit-bundle request/response path and targeted tests.
## Documentation Prerequisites
- `docs/code-of-conduct/CODE_OF_CONDUCT.md`
- `docs/code-of-conduct/TESTING_PRACTICES.md`
- `docs/README.md`
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/platform/architecture-overview.md`
## Delivery Tracker
### EXPORT-AUDIT-001 - Make audit bundle request DTOs bind correctly over HTTP
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Replace the fragile audit-bundle request DTO shapes with body-binding-safe models while preserving the committed JSON contract used by the Angular client.
- Keep existing handler/test construction ergonomics so unit tests and service logic remain readable.
Completion criteria:
- [x] `POST /v1/audit-bundles` accepts the current UI payload shape in automated HTTP coverage and in the authenticated browser path.
- [x] The audit-bundle JSON contract remains camelCase-compatible for existing callers.
### EXPORT-AUDIT-002 - Add an HTTP-level regression test for audit bundle creation
Status: DONE
Dependency: EXPORT-AUDIT-001
Owners: Developer, Test Automation
Task description:
- Add a focused ExportCenter test that boots the mapped audit-bundle endpoint on a local ephemeral HTTP listener and posts a real JSON body.
- Keep the existing router-dispatch test, but document that it is transport-focused and does not replace direct HTTP coverage.
Completion criteria:
- [x] A focused ExportCenter test fails before the fix and passes after it.
- [x] Test assertions verify `202 Accepted` and a queued audit-bundle response payload.
### EXPORT-AUDIT-003 - Verify the fixed service directly and through the gateway
Status: DONE
Dependency: EXPORT-AUDIT-002
Owners: Developer, QA
Task description:
- Rebuild and redeploy the ExportCenter service, then verify `POST /v1/audit-bundles` directly against the published container address and through the authenticated browser path on `https://stella-ops.local/triage/audit-bundles/new`.
- Record the exact commands and outcomes so the Router sprint can consume the same evidence for its final retest gate.
Completion criteria:
- [x] Direct live service behavior is covered by the full-program HTTP regression plus live in-network transport checks after republishing the container.
- [x] The authenticated browser retest no longer fails immediately on audit-bundle creation.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-08 | Sprint created after direct `curl` to `http://127.1.0.40/v1/audit-bundles` reproduced the same empty `400` seen through the gateway, proving the remaining defect is service-local body binding rather than Router dispatch. | Developer |
| 2026-03-08 | Replaced the fragile audit-bundle DTOs with body-binding-safe record classes, switched the live endpoint to explicit `ReadFromJsonAsync` handling, and added focused HTTP coverage (`AuditBundleHttpEndpointTests`, `AuditBundleProgramHttpIntegrationTests`) while keeping the router-dispatch regression. `dotnet test src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Tests/StellaOps.ExportCenter.Tests.csproj -v normal` passed `930/930`; `dotnet build src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/StellaOps.ExportCenter.WebService.csproj -c Release` passed. | Developer |
| 2026-03-08 | Republished the ExportCenter WebService into the local compose container, reran the authenticated browser script `node src/Web/StellaOps.Web/output/playwright/repro-audit-bundle-live.cjs`, and verified the live POST to `https://stella-ops.local/v1/audit-bundles` returned `202 Accepted`. The follow-up status poll reached `Completed` and the UI enabled the download action for bundle `bndl-c1045e529d8c48998257fbe6850948fb`. | Developer |
## Decisions & Risks
- Decision: keep the Router sprint honest and fix the downstream ExportCenter defect in a separate sprint because the Router working-directory contract is limited to `src/Router`.
- Decision: keep host-published unauthenticated `curl` out of the final pass criteria after the republished service resumed enforcing auth on `127.1.0.40`; the authoritative live proof is the authenticated browser flow plus the full-program HTTP integration regression.
- Risk: changing shared audit-bundle DTOs in `StellaOps.ExportCenter.Client` could affect existing tests or callers that construct these models directly.
- Mitigation: preserve the JSON property names and add compatibility constructors where current tests rely on positional construction.
## Next Checkpoints
- Archived after the HTTP regressions, republished container, and live browser retest passed.

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)
{