2035 lines
80 KiB
C#
2035 lines
80 KiB
C#
#pragma warning disable CS0436 // Type conflicts with imported type - local Program class is intentionally used
|
|
using Microsoft.AspNetCore.Diagnostics;
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Extensions.Options;
|
|
using Serilog;
|
|
using Serilog.Events;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.ServerIntegration;
|
|
using StellaOps.Configuration;
|
|
using StellaOps.DependencyInjection;
|
|
using StellaOps.Findings.Ledger.Domain;
|
|
using Domain = StellaOps.Findings.Ledger.Domain;
|
|
using StellaOps.Findings.Ledger.Infrastructure;
|
|
using StellaOps.Findings.Ledger.Infrastructure.AirGap;
|
|
using StellaOps.Findings.Ledger.Infrastructure.Merkle;
|
|
using StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
|
using StellaOps.Findings.Ledger.Infrastructure.Projection;
|
|
using StellaOps.Findings.Ledger.Infrastructure.Policy;
|
|
using StellaOps.Findings.Ledger.Infrastructure.Exports;
|
|
using StellaOps.Findings.Ledger.Options;
|
|
using StellaOps.Findings.Ledger.Services;
|
|
using StellaOps.Findings.Ledger.WebService.Contracts;
|
|
using StellaOps.Findings.Ledger.WebService.Mappings;
|
|
using StellaOps.Findings.Ledger.WebService.Services;
|
|
using StellaOps.Findings.Ledger.WebService.Endpoints;
|
|
using StellaOps.Telemetry.Core;
|
|
using StellaOps.Findings.Ledger.Services.Security;
|
|
using StellaOps.Findings.Ledger;
|
|
using StellaOps.Findings.Ledger.Observability;
|
|
using StellaOps.Findings.Ledger.OpenApi;
|
|
using System.Text.Json.Nodes;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Threading.RateLimiting;
|
|
using StellaOps.Findings.Ledger.Services.Incident;
|
|
using StellaOps.Router.AspNet;
|
|
|
|
const string LedgerWritePolicy = "ledger.events.write";
|
|
const string LedgerExportPolicy = "ledger.export.read";
|
|
|
|
// Scoring API policies (SPRINT_8200.0012.0004 - Wave 7)
|
|
const string ScoringReadPolicy = "scoring.read";
|
|
const string ScoringWritePolicy = "scoring.write";
|
|
const string ScoringAdminPolicy = "scoring.admin";
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Configuration.AddStellaOpsDefaults(options =>
|
|
{
|
|
options.BasePath = builder.Environment.ContentRootPath;
|
|
options.EnvironmentPrefix = "FINDINGS_LEDGER_";
|
|
options.ConfigureBuilder = configurationBuilder =>
|
|
{
|
|
configurationBuilder.AddYamlFile("../etc/findings-ledger.yaml", optional: true, reloadOnChange: true);
|
|
};
|
|
});
|
|
|
|
var bootstrapOptions = builder.Configuration.BindOptions<LedgerServiceOptions>(
|
|
LedgerServiceOptions.SectionName,
|
|
(opts, _) => opts.Validate());
|
|
|
|
LedgerMetrics.ConfigureQuotas(bootstrapOptions.Quotas.MaxIngestBacklog);
|
|
|
|
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
|
{
|
|
loggerConfiguration
|
|
.MinimumLevel.Information()
|
|
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
|
.Enrich.FromLogContext()
|
|
.WriteTo.Console();
|
|
});
|
|
|
|
builder.Services.AddOptions<LedgerServiceOptions>()
|
|
.Bind(builder.Configuration.GetSection(LedgerServiceOptions.SectionName))
|
|
.PostConfigure(options => options.Validate())
|
|
.ValidateOnStart();
|
|
|
|
builder.Services.AddOptions<LedgerIncidentOptions>()
|
|
.Bind(builder.Configuration.GetSection(LedgerIncidentOptions.SectionName))
|
|
.PostConfigure(options => options.Validate())
|
|
.ValidateOnStart();
|
|
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
builder.Services.AddProblemDetails();
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddHealthChecks();
|
|
|
|
builder.Services.AddStellaOpsTelemetry(
|
|
builder.Configuration,
|
|
serviceName: "StellaOps.Findings.Ledger",
|
|
configureMetrics: meterBuilder =>
|
|
{
|
|
meterBuilder.AddMeter("StellaOps.Findings.Ledger");
|
|
});
|
|
|
|
// Rate limiting is handled by API Gateway - see docs/modules/gateway/rate-limiting.md
|
|
// Endpoint-level rate limits: scoring-read (1000/min), scoring-calculate (100/min), scoring-batch (10/min), scoring-webhook (10/min)
|
|
|
|
builder.Services.AddIncidentMode(builder.Configuration);
|
|
|
|
builder.Services.AddStellaOpsResourceServerAuthentication(
|
|
builder.Configuration,
|
|
configurationSection: null,
|
|
configure: resourceOptions =>
|
|
{
|
|
resourceOptions.Authority = bootstrapOptions.Authority.Issuer;
|
|
resourceOptions.RequireHttpsMetadata = bootstrapOptions.Authority.RequireHttpsMetadata;
|
|
resourceOptions.MetadataAddress = bootstrapOptions.Authority.MetadataAddress;
|
|
resourceOptions.BackchannelTimeout = bootstrapOptions.Authority.BackchannelTimeout;
|
|
resourceOptions.TokenClockSkew = bootstrapOptions.Authority.TokenClockSkew;
|
|
|
|
resourceOptions.Audiences.Clear();
|
|
foreach (var audience in bootstrapOptions.Authority.Audiences)
|
|
{
|
|
resourceOptions.Audiences.Add(audience);
|
|
}
|
|
|
|
resourceOptions.RequiredScopes.Clear();
|
|
foreach (var scope in bootstrapOptions.Authority.RequiredScopes)
|
|
{
|
|
resourceOptions.RequiredScopes.Add(scope);
|
|
}
|
|
|
|
foreach (var network in bootstrapOptions.Authority.BypassNetworks)
|
|
{
|
|
resourceOptions.BypassNetworks.Add(network);
|
|
}
|
|
});
|
|
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
var scopes = bootstrapOptions.Authority.RequiredScopes.Count > 0
|
|
? bootstrapOptions.Authority.RequiredScopes.ToArray()
|
|
: new[] { StellaOpsScopes.VulnOperate };
|
|
|
|
options.AddPolicy(LedgerWritePolicy, policy =>
|
|
{
|
|
policy.RequireAuthenticatedUser();
|
|
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
|
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
|
});
|
|
|
|
options.AddPolicy(LedgerExportPolicy, policy =>
|
|
{
|
|
policy.RequireAuthenticatedUser();
|
|
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
|
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
|
});
|
|
|
|
// Scoring API policies (SPRINT_8200.0012.0004 - Wave 7)
|
|
options.AddPolicy(ScoringReadPolicy, policy =>
|
|
{
|
|
policy.RequireAuthenticatedUser();
|
|
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
|
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
|
});
|
|
|
|
options.AddPolicy(ScoringWritePolicy, policy =>
|
|
{
|
|
policy.RequireAuthenticatedUser();
|
|
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
|
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
|
});
|
|
|
|
options.AddPolicy(ScoringAdminPolicy, policy =>
|
|
{
|
|
policy.RequireAuthenticatedUser();
|
|
policy.Requirements.Add(new StellaOpsScopeRequirement(scopes));
|
|
policy.AddAuthenticationSchemes(StellaOpsAuthenticationDefaults.AuthenticationScheme);
|
|
});
|
|
});
|
|
|
|
builder.Services.AddSingleton<ILedgerIncidentNotifier, LoggingLedgerIncidentNotifier>();
|
|
builder.Services.AddSingleton<LedgerIncidentCoordinator>();
|
|
builder.Services.AddSingleton<ILedgerIncidentDiagnostics>(sp => sp.GetRequiredService<LedgerIncidentCoordinator>());
|
|
builder.Services.AddSingleton<ILedgerIncidentState>(sp => sp.GetRequiredService<LedgerIncidentCoordinator>());
|
|
builder.Services.AddSingleton<LedgerAnchorQueue>();
|
|
builder.Services.AddSingleton<LedgerDataSource>();
|
|
builder.Services.AddSingleton<IMerkleAnchorRepository, PostgresMerkleAnchorRepository>();
|
|
builder.Services.AddSingleton<ILedgerEventRepository, PostgresLedgerEventRepository>();
|
|
builder.Services.AddSingleton<IMerkleAnchorScheduler, PostgresMerkleAnchorScheduler>();
|
|
builder.Services.AddSingleton<ILedgerEventStream, PostgresLedgerEventStream>();
|
|
builder.Services.AddSingleton<IFindingProjectionRepository, PostgresFindingProjectionRepository>();
|
|
builder.Services.AddHttpClient("ledger-policy-engine");
|
|
builder.Services.AddSingleton<InlinePolicyEvaluationService>();
|
|
builder.Services.AddSingleton<PolicyEvaluationCache>();
|
|
builder.Services.AddSingleton<PolicyEngineEvaluationService>();
|
|
builder.Services.AddSingleton<IPolicyEvaluationService>(sp => sp.GetRequiredService<PolicyEngineEvaluationService>());
|
|
builder.Services.AddSingleton<ILedgerEventWriteService, LedgerEventWriteService>();
|
|
builder.Services.AddSingleton<IFindingWorkflowService, FindingWorkflowService>();
|
|
builder.Services.AddSingleton<IOrchestratorExportRepository, PostgresOrchestratorExportRepository>();
|
|
builder.Services.AddSingleton<OrchestratorExportService>();
|
|
builder.Services.AddSingleton<IAirgapImportRepository, PostgresAirgapImportRepository>();
|
|
builder.Services.AddSingleton<AirgapImportService>();
|
|
builder.Services.AddSingleton<IAttachmentEncryptionService, AttachmentEncryptionService>();
|
|
builder.Services.AddSingleton<IAttachmentUrlSigner, AttachmentUrlSigner>();
|
|
builder.Services.AddSingleton<IConsoleCsrfValidator, ConsoleCsrfValidator>();
|
|
builder.Services.AddHostedService<LedgerMerkleAnchorWorker>();
|
|
builder.Services.AddHostedService<LedgerProjectionWorker>();
|
|
builder.Services.AddSingleton<ExportQueryService>();
|
|
builder.Services.AddSingleton<AttestationQueryService>();
|
|
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Attestation.IAttestationPointerRepository,
|
|
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresAttestationPointerRepository>();
|
|
builder.Services.AddSingleton<AttestationPointerService>();
|
|
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ISnapshotRepository,
|
|
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresSnapshotRepository>();
|
|
builder.Services.AddSingleton<StellaOps.Findings.Ledger.Infrastructure.Snapshot.ITimeTravelRepository,
|
|
StellaOps.Findings.Ledger.Infrastructure.Postgres.PostgresTimeTravelRepository>();
|
|
builder.Services.AddSingleton<SnapshotService>();
|
|
builder.Services.AddSingleton<VexConsensusService>();
|
|
|
|
// Finding summary, evidence graph, reachability, and runtime timeline endpoints
|
|
builder.Services.AddSingleton<IFindingSummaryBuilder, FindingSummaryBuilder>();
|
|
builder.Services.AddSingleton<IFindingRepository, InMemoryFindingRepository>();
|
|
builder.Services.AddSingleton<IFindingSummaryService, FindingSummaryService>();
|
|
builder.Services.AddSingleton<IEvidenceRepository, NullEvidenceRepository>();
|
|
builder.Services.AddSingleton<IAttestationVerifier, NullAttestationVerifier>();
|
|
builder.Services.AddSingleton<IEvidenceGraphBuilder, EvidenceGraphBuilder>();
|
|
builder.Services.AddSingleton<IEvidenceContentService, NullEvidenceContentService>();
|
|
builder.Services.AddSingleton<IReachabilityMapService, NullReachabilityMapService>();
|
|
builder.Services.AddSingleton<IRuntimeTimelineService, NullRuntimeTimelineService>();
|
|
|
|
// Alert and Decision services (SPRINT_3602)
|
|
builder.Services.AddSingleton<IAlertService, AlertService>();
|
|
builder.Services.AddSingleton<IDecisionService, DecisionService>();
|
|
builder.Services.AddSingleton<IEvidenceBundleService, EvidenceBundleService>();
|
|
|
|
// Evidence-Weighted Score services (SPRINT_8200.0012.0004)
|
|
builder.Services.AddSingleton<IScoreHistoryStore, InMemoryScoreHistoryStore>();
|
|
builder.Services.AddSingleton<IFindingScoringService, FindingScoringService>();
|
|
|
|
// Webhook services (SPRINT_8200.0012.0004 - Wave 6)
|
|
builder.Services.AddSingleton<IWebhookStore, InMemoryWebhookStore>();
|
|
builder.Services.AddSingleton<IWebhookDeliveryService, WebhookDeliveryService>();
|
|
builder.Services.AddHttpClient("webhook-delivery", client =>
|
|
{
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
});
|
|
|
|
// Stella Router integration
|
|
var routerOptions = builder.Configuration.GetSection("FindingsLedger:Router").Get<StellaRouterOptionsBase>();
|
|
builder.Services.TryAddStellaRouter(
|
|
serviceName: "findings-ledger",
|
|
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
|
routerOptions: routerOptions);
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseSerilogRequestLogging();
|
|
app.UseExceptionHandler(exceptionApp =>
|
|
{
|
|
exceptionApp.Run(async context =>
|
|
{
|
|
var feature = context.Features.Get<IExceptionHandlerFeature>();
|
|
if (feature?.Error is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var problem = Results.Problem(
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "ledger_internal_error",
|
|
detail: feature.Error.Message);
|
|
await problem.ExecuteAsync(context);
|
|
});
|
|
});
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.TryUseStellaRouter(routerOptions);
|
|
|
|
app.MapHealthChecks("/healthz");
|
|
|
|
app.MapPost("/vuln/ledger/events", async Task<Results<Created<LedgerEventResponse>, Ok<LedgerEventResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
IConsoleCsrfValidator csrfValidator,
|
|
LedgerEventRequest request,
|
|
ILedgerEventWriteService writeService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
csrfValidator.Validate(httpContext);
|
|
|
|
var draft = request.ToDraft();
|
|
var result = await writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
|
|
return result.Status switch
|
|
{
|
|
LedgerWriteStatus.Success => CreateCreatedResponse(result.Record!),
|
|
LedgerWriteStatus.Idempotent => TypedResults.Ok(CreateResponse(result.Record!, "idempotent")),
|
|
LedgerWriteStatus.ValidationFailed => TypedResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "validation_failed",
|
|
detail: string.Join(";", result.Errors)),
|
|
LedgerWriteStatus.Conflict => TypedResults.Problem(
|
|
statusCode: StatusCodes.Status409Conflict,
|
|
title: result.ConflictCode ?? "conflict",
|
|
detail: string.Join(";", result.Errors)),
|
|
_ => TypedResults.Problem(
|
|
statusCode: StatusCodes.Status500InternalServerError,
|
|
title: "ledger_internal_error",
|
|
detail: "Unexpected ledger status.")
|
|
};
|
|
})
|
|
.WithName("LedgerEventAppend")
|
|
.RequireAuthorization(LedgerWritePolicy)
|
|
.Produces(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status409Conflict)
|
|
.ProducesProblem(StatusCodes.Status500InternalServerError);
|
|
|
|
app.MapGet("/ledger/export/findings", async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<FindingExportItem>>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
ExportQueryService exportQueryService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
DeprecationHeaders.Apply(httpContext.Response, "ledger.export.findings");
|
|
|
|
if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant", detail: "X-Stella-Tenant header is required.");
|
|
}
|
|
|
|
var tenantId = tenantValues.ToString();
|
|
var shape = httpContext.Request.Query["shape"].ToString();
|
|
if (string.IsNullOrWhiteSpace(shape))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_shape", detail: "shape is required (canonical|compact).");
|
|
}
|
|
|
|
var pageSize = exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"]));
|
|
|
|
long? sinceSequence = ParseLong(httpContext.Request.Query["since_sequence"]);
|
|
long? untilSequence = ParseLong(httpContext.Request.Query["until_sequence"]);
|
|
DateTimeOffset? sinceObservedAt = ParseDate(httpContext.Request.Query["since_observed_at"]);
|
|
DateTimeOffset? untilObservedAt = ParseDate(httpContext.Request.Query["until_observed_at"]);
|
|
var status = httpContext.Request.Query["finding_status"].ToString();
|
|
var severity = ParseDecimal(httpContext.Request.Query["severity"]);
|
|
|
|
var request = new ExportFindingsRequest(
|
|
TenantId: tenantId,
|
|
Shape: shape,
|
|
SinceSequence: sinceSequence,
|
|
UntilSequence: untilSequence,
|
|
SinceObservedAt: sinceObservedAt,
|
|
UntilObservedAt: untilObservedAt,
|
|
Status: string.IsNullOrWhiteSpace(status) ? null : status,
|
|
Severity: severity,
|
|
PageSize: pageSize,
|
|
FiltersHash: string.Empty,
|
|
PagingKey: null);
|
|
|
|
var filtersHash = exportQueryService.ComputeFiltersHash(request);
|
|
|
|
ExportPagingKey? pagingKey = null;
|
|
var pageToken = httpContext.Request.Query["page_token"].ToString();
|
|
if (!string.IsNullOrWhiteSpace(pageToken))
|
|
{
|
|
if (!ExportPaging.TryParsePageToken(pageToken, filtersHash, out var parsedKey, out var error))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token");
|
|
}
|
|
|
|
pagingKey = new ExportPagingKey(parsedKey!.SequenceNumber, parsedKey.PolicyVersion, parsedKey.CycleHash);
|
|
}
|
|
|
|
request = request with { FiltersHash = filtersHash, PagingKey = pagingKey };
|
|
|
|
ExportPage<FindingExportItem> page;
|
|
try
|
|
{
|
|
page = await exportQueryService.GetFindingsAsync(request, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch")
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch");
|
|
}
|
|
|
|
return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false);
|
|
})
|
|
.WithName("LedgerExportFindings")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status401Unauthorized)
|
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
|
.ProducesProblem(StatusCodes.Status500InternalServerError);
|
|
|
|
app.MapGet("/v1/ledger/attestations", async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<AttestationExportItem>>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
AttestationQueryService attestationQueryService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var request = new AttestationQueryRequest(
|
|
tenantId,
|
|
httpContext.Request.Query["artifactId"].ToString(),
|
|
httpContext.Request.Query["findingId"].ToString(),
|
|
httpContext.Request.Query["attestationId"].ToString(),
|
|
httpContext.Request.Query["status"].ToString(),
|
|
ParseDate(httpContext.Request.Query["sinceRecordedAt"]),
|
|
ParseDate(httpContext.Request.Query["untilRecordedAt"]),
|
|
attestationQueryService.ClampLimit(ParseInt(httpContext.Request.Query["limit"])),
|
|
FiltersHash: string.Empty,
|
|
PagingKey: null);
|
|
|
|
var filtersHash = attestationQueryService.ComputeFiltersHash(request);
|
|
|
|
AttestationPagingKey? pagingKey = null;
|
|
var pageToken = httpContext.Request.Query["page_token"].ToString();
|
|
if (!string.IsNullOrWhiteSpace(pageToken))
|
|
{
|
|
if (!attestationQueryService.TryParsePageToken(pageToken, filtersHash, out pagingKey, out var error))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token");
|
|
}
|
|
}
|
|
|
|
request = request with { FiltersHash = filtersHash, PagingKey = pagingKey };
|
|
|
|
ExportPage<AttestationExportItem> page;
|
|
try
|
|
{
|
|
page = await attestationQueryService.GetAttestationsAsync(request, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch")
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch");
|
|
}
|
|
|
|
return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false);
|
|
})
|
|
.WithName("LedgerAttestationsList")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/ledger/export/vex", async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<VexExportItem>>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
ExportQueryService exportQueryService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var shape = httpContext.Request.Query["shape"].ToString();
|
|
if (string.IsNullOrWhiteSpace(shape))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_shape", detail: "shape is required (canonical|compact).");
|
|
}
|
|
|
|
var request = new ExportVexRequest(
|
|
tenantId,
|
|
shape,
|
|
ParseLong(httpContext.Request.Query["since_sequence"]),
|
|
ParseLong(httpContext.Request.Query["until_sequence"]),
|
|
ParseDate(httpContext.Request.Query["since_observed_at"]),
|
|
ParseDate(httpContext.Request.Query["until_observed_at"]),
|
|
httpContext.Request.Query["product_id"].ToString(),
|
|
httpContext.Request.Query["advisory_id"].ToString(),
|
|
httpContext.Request.Query["status"].ToString(),
|
|
httpContext.Request.Query["statement_type"].ToString(),
|
|
exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])),
|
|
FiltersHash: string.Empty,
|
|
PagingKey: null);
|
|
|
|
var filtersHash = exportQueryService.ComputeFiltersHash(request);
|
|
ExportPagingKey? pagingKey = null;
|
|
var pageToken = httpContext.Request.Query["page_token"].ToString();
|
|
if (!string.IsNullOrWhiteSpace(pageToken))
|
|
{
|
|
if (!ExportPaging.TryParsePageToken(pageToken, filtersHash, out var parsedKey, out var error))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token");
|
|
}
|
|
|
|
pagingKey = new ExportPagingKey(parsedKey!.SequenceNumber, parsedKey.PolicyVersion, parsedKey.CycleHash);
|
|
}
|
|
|
|
request = request with { FiltersHash = filtersHash, PagingKey = pagingKey };
|
|
|
|
ExportPage<VexExportItem> page;
|
|
try
|
|
{
|
|
page = await exportQueryService.GetVexAsync(request, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch")
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch");
|
|
}
|
|
|
|
return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false);
|
|
})
|
|
.WithName("LedgerExportVex")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/ledger/export/advisories", async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<AdvisoryExportItem>>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
ExportQueryService exportQueryService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var shape = httpContext.Request.Query["shape"].ToString();
|
|
if (string.IsNullOrWhiteSpace(shape))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_shape", detail: "shape is required (canonical|compact).");
|
|
}
|
|
|
|
var kev = ParseBool(httpContext.Request.Query["kev"]);
|
|
var cvssScoreMin = ParseDecimal(httpContext.Request.Query["cvss_score_min"]);
|
|
var cvssScoreMax = ParseDecimal(httpContext.Request.Query["cvss_score_max"]);
|
|
|
|
var request = new ExportAdvisoryRequest(
|
|
tenantId,
|
|
shape,
|
|
ParseLong(httpContext.Request.Query["since_sequence"]),
|
|
ParseLong(httpContext.Request.Query["until_sequence"]),
|
|
ParseDate(httpContext.Request.Query["since_observed_at"]),
|
|
ParseDate(httpContext.Request.Query["until_observed_at"]),
|
|
httpContext.Request.Query["severity"].ToString(),
|
|
httpContext.Request.Query["source"].ToString(),
|
|
httpContext.Request.Query["cwe_id"].ToString(),
|
|
kev,
|
|
httpContext.Request.Query["cvss_version"].ToString(),
|
|
cvssScoreMin,
|
|
cvssScoreMax,
|
|
exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])),
|
|
FiltersHash: string.Empty,
|
|
PagingKey: null);
|
|
|
|
var filtersHash = exportQueryService.ComputeFiltersHash(request);
|
|
ExportPagingKey? pagingKey = null;
|
|
var pageToken = httpContext.Request.Query["page_token"].ToString();
|
|
if (!string.IsNullOrWhiteSpace(pageToken))
|
|
{
|
|
if (!ExportPaging.TryParsePageToken(pageToken, filtersHash, out var parsedKey, out var error))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token");
|
|
}
|
|
|
|
pagingKey = new ExportPagingKey(parsedKey!.SequenceNumber, parsedKey.PolicyVersion, parsedKey.CycleHash);
|
|
}
|
|
|
|
request = request with { FiltersHash = filtersHash, PagingKey = pagingKey };
|
|
|
|
ExportPage<AdvisoryExportItem> page;
|
|
try
|
|
{
|
|
page = await exportQueryService.GetAdvisoriesAsync(request, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch")
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch");
|
|
}
|
|
|
|
return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false);
|
|
})
|
|
.WithName("LedgerExportAdvisories")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/ledger/export/sboms", async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<SbomExportItem>>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
ExportQueryService exportQueryService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var shape = httpContext.Request.Query["shape"].ToString();
|
|
if (string.IsNullOrWhiteSpace(shape))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_shape", detail: "shape is required (canonical|compact).");
|
|
}
|
|
|
|
var request = new ExportSbomRequest(
|
|
tenantId,
|
|
shape,
|
|
ParseLong(httpContext.Request.Query["since_sequence"]),
|
|
ParseLong(httpContext.Request.Query["until_sequence"]),
|
|
ParseDate(httpContext.Request.Query["since_observed_at"]),
|
|
ParseDate(httpContext.Request.Query["until_observed_at"]),
|
|
httpContext.Request.Query["subject_digest"].ToString(),
|
|
httpContext.Request.Query["sbom_format"].ToString(),
|
|
httpContext.Request.Query["component_purl"].ToString(),
|
|
ParseBool(httpContext.Request.Query["contains_native"]),
|
|
httpContext.Request.Query["slsa_build_type"].ToString(),
|
|
exportQueryService.ClampPageSize(ParseInt(httpContext.Request.Query["page_size"])),
|
|
FiltersHash: string.Empty,
|
|
PagingKey: null);
|
|
|
|
var filtersHash = exportQueryService.ComputeFiltersHash(request);
|
|
ExportPagingKey? pagingKey = null;
|
|
var pageToken = httpContext.Request.Query["page_token"].ToString();
|
|
if (!string.IsNullOrWhiteSpace(pageToken))
|
|
{
|
|
if (!ExportPaging.TryParsePageToken(pageToken, filtersHash, out var parsedKey, out var error))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: error ?? "invalid_page_token");
|
|
}
|
|
|
|
pagingKey = new ExportPagingKey(parsedKey!.SequenceNumber, parsedKey.PolicyVersion, parsedKey.CycleHash);
|
|
}
|
|
|
|
request = request with { FiltersHash = filtersHash, PagingKey = pagingKey };
|
|
|
|
ExportPage<SbomExportItem> page;
|
|
try
|
|
{
|
|
page = await exportQueryService.GetSbomsAsync(request, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message == "filters_hash_mismatch")
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch");
|
|
}
|
|
|
|
return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false);
|
|
})
|
|
.WithName("LedgerExportSboms")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapPost("/internal/ledger/orchestrator-export", async Task<Results<Accepted<OrchestratorExportResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
OrchestratorExportRequest request,
|
|
OrchestratorExportService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant");
|
|
}
|
|
|
|
var tenantId = tenantValues.ToString();
|
|
var input = new OrchestratorExportInput(
|
|
tenantId,
|
|
request.RunId,
|
|
request.JobType,
|
|
request.ArtifactHash,
|
|
request.PolicyHash,
|
|
request.StartedAt,
|
|
request.CompletedAt,
|
|
request.Status,
|
|
request.ManifestPath,
|
|
request.LogsPath);
|
|
|
|
var record = await service.RecordAsync(input, cancellationToken).ConfigureAwait(false);
|
|
var response = new OrchestratorExportResponse(record.RunId, record.MerkleRoot);
|
|
return TypedResults.Accepted($"/internal/ledger/orchestrator-export/{record.RunId}", response);
|
|
})
|
|
.WithName("OrchestratorExportRecord")
|
|
.RequireAuthorization(LedgerWritePolicy)
|
|
.Produces(StatusCodes.Status202Accepted)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/internal/ledger/orchestrator-export/{artifactHash}", async Task<Results<JsonHttpResult<IReadOnlyList<OrchestratorExportRecord>>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string artifactHash,
|
|
OrchestratorExportService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant");
|
|
}
|
|
|
|
var records = await service.GetByArtifactAsync(tenantValues.ToString(), artifactHash, cancellationToken).ConfigureAwait(false);
|
|
return TypedResults.Json(records);
|
|
})
|
|
.WithName("OrchestratorExportQuery")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapPost("/internal/ledger/airgap-import", async Task<Results<Accepted<AirgapImportResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
AirgapImportRequest request,
|
|
AirgapImportService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant");
|
|
}
|
|
|
|
var input = new AirgapImportInput(
|
|
tenantValues.ToString(),
|
|
request.BundleId,
|
|
request.MirrorGeneration,
|
|
request.MerkleRoot,
|
|
request.TimeAnchor,
|
|
request.Publisher,
|
|
request.HashAlgorithm,
|
|
request.Contents ?? Array.Empty<string>(),
|
|
request.ImportOperator);
|
|
|
|
var result = await service.RecordAsync(input, cancellationToken).ConfigureAwait(false);
|
|
if (!result.Success)
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: "airgap_import_failed", detail: result.Error ?? "Failed to record air-gap import.");
|
|
}
|
|
|
|
var response = new AirgapImportResponse(result.ChainId, result.SequenceNumber, result.LedgerEventId, "accepted", null);
|
|
return TypedResults.Accepted($"/internal/ledger/airgap-import/{request.BundleId}", response);
|
|
})
|
|
.WithName("AirgapImportRecord")
|
|
.RequireAuthorization(LedgerWritePolicy)
|
|
.Produces(StatusCodes.Status202Accepted)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
|
|
|
// Attestation Pointer Endpoints (LEDGER-ATTEST-73-001)
|
|
app.MapPost("/v1/ledger/attestation-pointers", async Task<Results<Created<CreateAttestationPointerResponse>, Ok<CreateAttestationPointerResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
CreateAttestationPointerRequest request,
|
|
AttestationPointerService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
try
|
|
{
|
|
var input = request.ToInput(tenantId);
|
|
var result = await service.CreatePointerAsync(input, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new CreateAttestationPointerResponse(
|
|
result.Success,
|
|
result.PointerId?.ToString(),
|
|
result.LedgerEventId?.ToString(),
|
|
result.Error);
|
|
|
|
if (!result.Success)
|
|
{
|
|
return TypedResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "attestation_pointer_failed",
|
|
detail: result.Error);
|
|
}
|
|
|
|
return TypedResults.Created($"/v1/ledger/attestation-pointers/{result.PointerId}", response);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return TypedResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "invalid_request",
|
|
detail: ex.Message);
|
|
}
|
|
})
|
|
.WithName("CreateAttestationPointer")
|
|
.RequireAuthorization(LedgerWritePolicy)
|
|
.Produces(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/ledger/attestation-pointers/{pointerId}", async Task<Results<JsonHttpResult<AttestationPointerResponse>, NotFound, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string pointerId,
|
|
AttestationPointerService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
if (!Guid.TryParse(pointerId, out var pointerGuid))
|
|
{
|
|
return TypedResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "invalid_pointer_id",
|
|
detail: "Pointer ID must be a valid GUID.");
|
|
}
|
|
|
|
var pointer = await service.GetPointerAsync(tenantId, pointerGuid, cancellationToken).ConfigureAwait(false);
|
|
if (pointer is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
return TypedResults.Json(pointer.ToResponse());
|
|
})
|
|
.WithName("GetAttestationPointer")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/ledger/findings/{findingId}/attestation-pointers", async Task<Results<JsonHttpResult<IReadOnlyList<AttestationPointerResponse>>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string findingId,
|
|
AttestationPointerService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var pointers = await service.GetPointersAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
|
|
IReadOnlyList<AttestationPointerResponse> responseList = pointers.Select(p => p.ToResponse()).ToList();
|
|
return TypedResults.Json(responseList);
|
|
})
|
|
.WithName("GetFindingAttestationPointers")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/ledger/findings/{findingId}/attestation-summary", async Task<Results<JsonHttpResult<AttestationSummaryResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string findingId,
|
|
AttestationPointerService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var summary = await service.GetSummaryAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
|
|
return TypedResults.Json(summary.ToResponse());
|
|
})
|
|
.WithName("GetFindingAttestationSummary")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapPost("/v1/ledger/attestation-pointers/search", async Task<Results<JsonHttpResult<AttestationPointerSearchResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
AttestationPointerSearchRequest request,
|
|
AttestationPointerService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
try
|
|
{
|
|
var query = request.ToQuery(tenantId);
|
|
var pointers = await service.SearchAsync(query, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new AttestationPointerSearchResponse(
|
|
pointers.Select(p => p.ToResponse()).ToList(),
|
|
pointers.Count);
|
|
|
|
return TypedResults.Json(response);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return TypedResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "invalid_request",
|
|
detail: ex.Message);
|
|
}
|
|
})
|
|
.WithName("SearchAttestationPointers")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapPut("/v1/ledger/attestation-pointers/{pointerId}/verification", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string pointerId,
|
|
UpdateVerificationResultRequest request,
|
|
AttestationPointerService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
if (!Guid.TryParse(pointerId, out var pointerGuid))
|
|
{
|
|
return TypedResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "invalid_pointer_id",
|
|
detail: "Pointer ID must be a valid GUID.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var verificationResult = request.VerificationResult.ToModel();
|
|
var success = await service.UpdateVerificationResultAsync(tenantId, pointerGuid, verificationResult, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!success)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
return TypedResults.NoContent();
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return TypedResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "invalid_request",
|
|
detail: ex.Message);
|
|
}
|
|
})
|
|
.WithName("UpdateAttestationPointerVerification")
|
|
.RequireAuthorization(LedgerWritePolicy)
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/.well-known/openapi", async (HttpContext context) =>
|
|
{
|
|
var contentRoot = AppContext.BaseDirectory;
|
|
var specPath = OpenApiMetadataFactory.GetSpecPath(contentRoot);
|
|
|
|
if (!File.Exists(specPath))
|
|
{
|
|
return Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: "openapi_missing", detail: "OpenAPI document not found on server.");
|
|
}
|
|
|
|
var specBytes = await File.ReadAllBytesAsync(specPath, context.RequestAborted).ConfigureAwait(false);
|
|
var etag = OpenApiMetadataFactory.ComputeEtag(specBytes);
|
|
|
|
if (context.Request.Headers.IfNoneMatch.Any(match => string.Equals(match, etag, StringComparison.Ordinal)))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status304NotModified);
|
|
}
|
|
|
|
context.Response.Headers.ETag = etag;
|
|
context.Response.Headers.CacheControl = "public, max-age=300, must-revalidate";
|
|
context.Response.Headers.Append("X-Api-Version", OpenApiMetadataFactory.ApiVersion);
|
|
context.Response.Headers.Append("X-Build-Version", OpenApiMetadataFactory.GetBuildVersion());
|
|
|
|
var lastModified = OpenApiMetadataFactory.GetLastModified(specPath);
|
|
if (lastModified.HasValue)
|
|
{
|
|
context.Response.Headers.LastModified = lastModified.Value.ToString("R");
|
|
}
|
|
|
|
return Results.Text(Encoding.UTF8.GetString(specBytes), "application/yaml");
|
|
})
|
|
.WithName("LedgerOpenApiDocument")
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status304NotModified)
|
|
.ProducesProblem(StatusCodes.Status500InternalServerError);
|
|
|
|
// Snapshot Endpoints (LEDGER-PACKS-42-001-DEV)
|
|
app.MapPost("/v1/ledger/snapshots", async Task<Results<Created<CreateSnapshotResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
CreateSnapshotRequest request,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var input = request.ToInput(tenantId);
|
|
var result = await service.CreateSnapshotAsync(input, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new CreateSnapshotResponse(
|
|
result.Success,
|
|
result.Snapshot?.ToResponse(),
|
|
result.Error);
|
|
|
|
if (!result.Success)
|
|
{
|
|
return TypedResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "snapshot_creation_failed",
|
|
detail: result.Error);
|
|
}
|
|
|
|
return TypedResults.Created($"/v1/ledger/snapshots/{result.Snapshot!.SnapshotId}", response);
|
|
})
|
|
.WithName("CreateSnapshot")
|
|
.RequireAuthorization(LedgerWritePolicy)
|
|
.Produces(StatusCodes.Status201Created)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/ledger/snapshots", async Task<Results<JsonHttpResult<SnapshotListResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var statusStr = httpContext.Request.Query["status"].ToString();
|
|
Domain.SnapshotStatus? status = null;
|
|
if (!string.IsNullOrEmpty(statusStr) && Enum.TryParse<Domain.SnapshotStatus>(statusStr, true, out var parsedStatus))
|
|
{
|
|
status = parsedStatus;
|
|
}
|
|
|
|
var query = new Domain.SnapshotListQuery(
|
|
tenantId,
|
|
status,
|
|
ParseDate(httpContext.Request.Query["created_after"]),
|
|
ParseDate(httpContext.Request.Query["created_before"]),
|
|
ParseInt(httpContext.Request.Query["page_size"]) ?? 100,
|
|
httpContext.Request.Query["page_token"].ToString());
|
|
|
|
var (snapshots, nextPageToken) = await service.ListSnapshotsAsync(query, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new SnapshotListResponse(
|
|
snapshots.Select(s => s.ToResponse()).ToList(),
|
|
nextPageToken);
|
|
|
|
return TypedResults.Json(response);
|
|
})
|
|
.WithName("ListSnapshots")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/ledger/snapshots/{snapshotId}", async Task<Results<JsonHttpResult<SnapshotResponse>, NotFound, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string snapshotId,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
if (!Guid.TryParse(snapshotId, out var snapshotGuid))
|
|
{
|
|
return TypedResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "invalid_snapshot_id",
|
|
detail: "Snapshot ID must be a valid GUID.");
|
|
}
|
|
|
|
var snapshot = await service.GetSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
|
|
if (snapshot is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
return TypedResults.Json(snapshot.ToResponse());
|
|
})
|
|
.WithName("GetSnapshot")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapDelete("/v1/ledger/snapshots/{snapshotId}", async Task<Results<NoContent, NotFound, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string snapshotId,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
if (!Guid.TryParse(snapshotId, out var snapshotGuid))
|
|
{
|
|
return TypedResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "invalid_snapshot_id",
|
|
detail: "Snapshot ID must be a valid GUID.");
|
|
}
|
|
|
|
var deleted = await service.DeleteSnapshotAsync(tenantId, snapshotGuid, cancellationToken).ConfigureAwait(false);
|
|
if (!deleted)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
return TypedResults.NoContent();
|
|
})
|
|
.WithName("DeleteSnapshot")
|
|
.RequireAuthorization(LedgerWritePolicy)
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// Time-Travel Query Endpoints
|
|
app.MapGet("/v1/ledger/time-travel/findings", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<FindingHistoryResponse>>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var request = new HistoricalQueryApiRequest(
|
|
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
|
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
|
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
|
Status: httpContext.Request.Query["status"].ToString(),
|
|
SeverityMin: ParseDecimal(httpContext.Request.Query["severity_min"]),
|
|
SeverityMax: ParseDecimal(httpContext.Request.Query["severity_max"]),
|
|
PolicyVersion: httpContext.Request.Query["policy_version"].ToString(),
|
|
ArtifactId: httpContext.Request.Query["artifact_id"].ToString(),
|
|
VulnId: httpContext.Request.Query["vuln_id"].ToString(),
|
|
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
|
PageToken: httpContext.Request.Query["page_token"].ToString());
|
|
|
|
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Finding);
|
|
var result = await service.QueryHistoricalFindingsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new HistoricalQueryApiResponse<FindingHistoryResponse>(
|
|
result.QueryPoint.ToResponse(),
|
|
"Finding",
|
|
result.Items.Select(i => i.ToResponse()).ToList(),
|
|
result.NextPageToken,
|
|
result.TotalCount);
|
|
|
|
return TypedResults.Json(response);
|
|
})
|
|
.WithName("TimeTravelQueryFindings")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/ledger/time-travel/vex", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<VexHistoryResponse>>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var request = new HistoricalQueryApiRequest(
|
|
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
|
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
|
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
|
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
|
PageToken: httpContext.Request.Query["page_token"].ToString());
|
|
|
|
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Vex);
|
|
var result = await service.QueryHistoricalVexAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new HistoricalQueryApiResponse<VexHistoryResponse>(
|
|
result.QueryPoint.ToResponse(),
|
|
"Vex",
|
|
result.Items.Select(i => i.ToResponse()).ToList(),
|
|
result.NextPageToken,
|
|
result.TotalCount);
|
|
|
|
return TypedResults.Json(response);
|
|
})
|
|
.WithName("TimeTravelQueryVex")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/ledger/time-travel/advisories", async Task<Results<JsonHttpResult<HistoricalQueryApiResponse<AdvisoryHistoryResponse>>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var request = new HistoricalQueryApiRequest(
|
|
AtTimestamp: ParseDate(httpContext.Request.Query["at_timestamp"]),
|
|
AtSequence: ParseLong(httpContext.Request.Query["at_sequence"]),
|
|
SnapshotId: ParseGuid(httpContext.Request.Query["snapshot_id"]),
|
|
PageSize: ParseInt(httpContext.Request.Query["page_size"]) ?? 500,
|
|
PageToken: httpContext.Request.Query["page_token"].ToString());
|
|
|
|
var domainRequest = request.ToRequest(tenantId, Domain.EntityType.Advisory);
|
|
var result = await service.QueryHistoricalAdvisoriesAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new HistoricalQueryApiResponse<AdvisoryHistoryResponse>(
|
|
result.QueryPoint.ToResponse(),
|
|
"Advisory",
|
|
result.Items.Select(i => i.ToResponse()).ToList(),
|
|
result.NextPageToken,
|
|
result.TotalCount);
|
|
|
|
return TypedResults.Json(response);
|
|
})
|
|
.WithName("TimeTravelQueryAdvisories")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// Replay Endpoint
|
|
app.MapPost("/v1/ledger/replay", async Task<Results<JsonHttpResult<ReplayApiResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
ReplayApiRequest request,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var domainRequest = request.ToRequest(tenantId);
|
|
var (events, metadata) = await service.ReplayEventsAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new ReplayApiResponse(
|
|
events.Select(e => e.ToResponse()).ToList(),
|
|
metadata.ToResponse());
|
|
|
|
return TypedResults.Json(response);
|
|
})
|
|
.WithName("ReplayEvents")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// Diff Endpoint
|
|
app.MapPost("/v1/ledger/diff", async Task<Results<JsonHttpResult<DiffApiResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
DiffApiRequest request,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var domainRequest = request.ToRequest(tenantId);
|
|
var result = await service.ComputeDiffAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new DiffApiResponse(
|
|
result.FromPoint.ToResponse(),
|
|
result.ToPoint.ToResponse(),
|
|
result.Summary.ToResponse(),
|
|
result.Changes?.Select(c => c.ToResponse()).ToList(),
|
|
result.NextPageToken);
|
|
|
|
return TypedResults.Json(response);
|
|
})
|
|
.WithName("ComputeDiff")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// Changelog Endpoint
|
|
app.MapGet("/v1/ledger/changelog/{entityType}/{entityId}", async Task<Results<JsonHttpResult<IReadOnlyList<ChangeLogEntryResponse>>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string entityType,
|
|
string entityId,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
if (!Enum.TryParse<Domain.EntityType>(entityType, true, out var parsedEntityType))
|
|
{
|
|
return TypedResults.Problem(
|
|
statusCode: StatusCodes.Status400BadRequest,
|
|
title: "invalid_entity_type",
|
|
detail: "Entity type must be one of: Finding, Vex, Advisory, Sbom, Evidence.");
|
|
}
|
|
|
|
var limit = ParseInt(httpContext.Request.Query["limit"]) ?? 100;
|
|
var changelog = await service.GetChangelogAsync(tenantId, parsedEntityType, entityId, limit, cancellationToken).ConfigureAwait(false);
|
|
|
|
IReadOnlyList<ChangeLogEntryResponse> response = changelog.Select(e => e.ToResponse()).ToList();
|
|
return TypedResults.Json(response);
|
|
})
|
|
.WithName("GetChangelog")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// Staleness Check Endpoint
|
|
app.MapGet("/v1/ledger/staleness", async Task<Results<JsonHttpResult<StalenessResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var thresholdMinutes = ParseInt(httpContext.Request.Query["threshold_minutes"]) ?? 60;
|
|
var threshold = TimeSpan.FromMinutes(thresholdMinutes);
|
|
|
|
var result = await service.CheckStalenessAsync(tenantId, threshold, cancellationToken).ConfigureAwait(false);
|
|
|
|
return TypedResults.Json(result.ToResponse());
|
|
})
|
|
.WithName("CheckStaleness")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// Current Point Endpoint
|
|
app.MapGet("/v1/ledger/current-point", async Task<Results<JsonHttpResult<QueryPointResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
SnapshotService service,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var point = await service.GetCurrentPointAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
|
return TypedResults.Json(point.ToResponse());
|
|
})
|
|
.WithName("GetCurrentPoint")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// VexLens Consensus Endpoints (UI-PROOF-VEX-0215-010)
|
|
app.MapPost("/v1/vex-consensus/compute", async Task<Results<JsonHttpResult<VexConsensusResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
ComputeVexConsensusRequest request,
|
|
VexConsensusService consensusService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var result = await consensusService.ComputeConsensusAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
|
return TypedResults.Json(result);
|
|
})
|
|
.WithName("ComputeVexConsensus")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapPost("/v1/vex-consensus/compute-batch", async Task<Results<JsonHttpResult<VexConsensusBatchResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
ComputeVexConsensusBatchRequest request,
|
|
VexConsensusService consensusService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var result = await consensusService.ComputeConsensusBatchAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
|
return TypedResults.Json(result);
|
|
})
|
|
.WithName("ComputeVexConsensusBatch")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/vex-consensus/projections/{projectionId}", async Task<Results<JsonHttpResult<VexProjectionDetailResponse>, NotFound, ProblemHttpResult>> (
|
|
string projectionId,
|
|
VexConsensusService consensusService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var result = await consensusService.GetProjectionAsync(projectionId, cancellationToken).ConfigureAwait(false);
|
|
if (result is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
return TypedResults.Json(result);
|
|
})
|
|
.WithName("GetVexProjection")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/vex-consensus/projections", async Task<Results<JsonHttpResult<QueryVexProjectionsResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
VexConsensusService consensusService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var request = new QueryVexProjectionsRequest(
|
|
VulnerabilityId: httpContext.Request.Query["vulnerability_id"].ToString(),
|
|
ProductKey: httpContext.Request.Query["product_key"].ToString(),
|
|
Status: httpContext.Request.Query["status"].ToString(),
|
|
Outcome: httpContext.Request.Query["outcome"].ToString(),
|
|
MinimumConfidence: ParseDecimal(httpContext.Request.Query["min_confidence"].ToString()) is decimal d ? (double)d : null,
|
|
ComputedAfter: ParseDate(httpContext.Request.Query["computed_after"].ToString()),
|
|
ComputedBefore: ParseDate(httpContext.Request.Query["computed_before"].ToString()),
|
|
StatusChanged: ParseBool(httpContext.Request.Query["status_changed"].ToString()),
|
|
Limit: ParseInt(httpContext.Request.Query["limit"].ToString()),
|
|
Offset: ParseInt(httpContext.Request.Query["offset"].ToString()),
|
|
SortBy: httpContext.Request.Query["sort_by"].ToString(),
|
|
SortDescending: ParseBool(httpContext.Request.Query["sort_desc"].ToString()));
|
|
|
|
var result = await consensusService.QueryProjectionsAsync(tenantId, request, cancellationToken).ConfigureAwait(false);
|
|
return TypedResults.Json(result);
|
|
})
|
|
.WithName("QueryVexProjections")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/vex-consensus/projections/latest", async Task<Results<JsonHttpResult<VexProjectionDetailResponse>, NotFound, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
VexConsensusService consensusService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var vulnId = httpContext.Request.Query["vulnerability_id"].ToString();
|
|
var productKey = httpContext.Request.Query["product_key"].ToString();
|
|
|
|
if (string.IsNullOrEmpty(vulnId) || string.IsNullOrEmpty(productKey))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_params", detail: "vulnerability_id and product_key are required.");
|
|
}
|
|
|
|
var result = await consensusService.GetLatestProjectionAsync(tenantId, vulnId, productKey, cancellationToken).ConfigureAwait(false);
|
|
if (result is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
return TypedResults.Json(result);
|
|
})
|
|
.WithName("GetLatestVexProjection")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/vex-consensus/history", async Task<Results<JsonHttpResult<VexProjectionHistoryResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
VexConsensusService consensusService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var vulnId = httpContext.Request.Query["vulnerability_id"].ToString();
|
|
var productKey = httpContext.Request.Query["product_key"].ToString();
|
|
var limit = ParseInt(httpContext.Request.Query["limit"].ToString());
|
|
|
|
if (string.IsNullOrEmpty(vulnId) || string.IsNullOrEmpty(productKey))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_params", detail: "vulnerability_id and product_key are required.");
|
|
}
|
|
|
|
var result = await consensusService.GetProjectionHistoryAsync(tenantId, vulnId, productKey, limit, cancellationToken).ConfigureAwait(false);
|
|
return TypedResults.Json(result);
|
|
})
|
|
.WithName("GetVexProjectionHistory")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/vex-consensus/statistics", async Task<Results<JsonHttpResult<VexConsensusStatisticsResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
VexConsensusService consensusService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var result = await consensusService.GetStatisticsAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
|
return TypedResults.Json(result);
|
|
})
|
|
.WithName("GetVexConsensusStatistics")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/vex-consensus/issuers", async Task<Results<JsonHttpResult<VexIssuerListResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
VexConsensusService consensusService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var result = await consensusService.ListIssuersAsync(
|
|
httpContext.Request.Query["category"].ToString(),
|
|
httpContext.Request.Query["min_trust_tier"].ToString(),
|
|
httpContext.Request.Query["status"].ToString(),
|
|
httpContext.Request.Query["search"].ToString(),
|
|
ParseInt(httpContext.Request.Query["limit"].ToString()),
|
|
ParseInt(httpContext.Request.Query["offset"].ToString()),
|
|
cancellationToken).ConfigureAwait(false);
|
|
return TypedResults.Json(result);
|
|
})
|
|
.WithName("ListVexIssuers")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/vex-consensus/issuers/{issuerId}", async Task<Results<JsonHttpResult<VexIssuerDetailResponse>, NotFound, ProblemHttpResult>> (
|
|
string issuerId,
|
|
VexConsensusService consensusService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var result = await consensusService.GetIssuerAsync(issuerId, cancellationToken).ConfigureAwait(false);
|
|
if (result is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
return TypedResults.Json(result);
|
|
})
|
|
.WithName("GetVexIssuer")
|
|
.RequireAuthorization(LedgerExportPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// Alert Triage Endpoints (SPRINT_3602)
|
|
const string AlertReadPolicy = LedgerExportPolicy;
|
|
const string AlertDecidePolicy = LedgerWritePolicy;
|
|
|
|
app.MapGet("/v1/alerts", async Task<Results<JsonHttpResult<AlertListResponse>, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
IAlertService alertService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var filter = new AlertFilterOptions(
|
|
Band: httpContext.Request.Query["band"].ToString(),
|
|
Severity: httpContext.Request.Query["severity"].ToString(),
|
|
Status: httpContext.Request.Query["status"].ToString(),
|
|
ArtifactId: httpContext.Request.Query["artifact_id"].ToString(),
|
|
VulnId: httpContext.Request.Query["vuln_id"].ToString(),
|
|
ComponentPurl: httpContext.Request.Query["component_purl"].ToString(),
|
|
Limit: ParseInt(httpContext.Request.Query["limit"]) ?? 50,
|
|
Offset: ParseInt(httpContext.Request.Query["offset"]) ?? 0,
|
|
SortBy: httpContext.Request.Query["sort_by"].ToString(),
|
|
SortDescending: ParseBool(httpContext.Request.Query["sort_desc"]) ?? false);
|
|
|
|
var result = await alertService.ListAsync(tenantId, filter, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new AlertListResponse(
|
|
result.Items.Select(a => new AlertSummary
|
|
{
|
|
AlertId = a.AlertId,
|
|
ArtifactId = a.ArtifactId,
|
|
VulnId = a.VulnId,
|
|
ComponentPurl = a.ComponentPurl,
|
|
Severity = a.Severity,
|
|
Band = a.Band,
|
|
Status = a.Status,
|
|
Score = a.Score,
|
|
CreatedAt = a.CreatedAt,
|
|
UpdatedAt = a.UpdatedAt,
|
|
DecisionCount = a.DecisionCount
|
|
}).ToList(),
|
|
result.TotalCount,
|
|
result.NextPageToken);
|
|
|
|
return TypedResults.Json(response);
|
|
})
|
|
.WithName("ListAlerts")
|
|
.RequireAuthorization(AlertReadPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/alerts/{alertId}", async Task<Results<JsonHttpResult<AlertSummary>, NotFound, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string alertId,
|
|
IAlertService alertService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var alert = await alertService.GetAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false);
|
|
if (alert is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
var response = new AlertSummary
|
|
{
|
|
AlertId = alert.AlertId,
|
|
ArtifactId = alert.ArtifactId,
|
|
VulnId = alert.VulnId,
|
|
ComponentPurl = alert.ComponentPurl,
|
|
Severity = alert.Severity,
|
|
Band = alert.Band,
|
|
Status = alert.Status,
|
|
Score = alert.Score,
|
|
CreatedAt = alert.CreatedAt,
|
|
UpdatedAt = alert.UpdatedAt,
|
|
DecisionCount = alert.DecisionCount
|
|
};
|
|
|
|
return TypedResults.Json(response);
|
|
})
|
|
.WithName("GetAlert")
|
|
.RequireAuthorization(AlertReadPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapPost("/v1/alerts/{alertId}/decisions", async Task<Results<Created<DecisionResponse>, NotFound, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string alertId,
|
|
DecisionRequest request,
|
|
IAlertService alertService,
|
|
IDecisionService decisionService,
|
|
TimeProvider timeProvider,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
// Validate alert exists
|
|
var alert = await alertService.GetAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false);
|
|
if (alert is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
// Get actor from auth context
|
|
var actorId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
|
|
|
|
// Generate simple replay token
|
|
var tokenInput = $"{alertId}|{actorId}|{request.DecisionStatus}|{timeProvider.GetUtcNow():O}";
|
|
var replayToken = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(tokenInput))).ToLowerInvariant();
|
|
|
|
// Record decision (append-only)
|
|
var decision = await decisionService.RecordAsync(new DecisionEvent
|
|
{
|
|
AlertId = alertId,
|
|
ArtifactId = alert.ArtifactId,
|
|
ActorId = actorId,
|
|
Timestamp = timeProvider.GetUtcNow(),
|
|
DecisionStatus = request.DecisionStatus,
|
|
ReasonCode = request.ReasonCode,
|
|
ReasonText = request.ReasonText,
|
|
EvidenceHashes = request.EvidenceHashes?.ToList() ?? new(),
|
|
PolicyContext = request.PolicyContext,
|
|
ReplayToken = replayToken
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new DecisionResponse
|
|
{
|
|
DecisionId = decision.Id,
|
|
AlertId = decision.AlertId,
|
|
ActorId = decision.ActorId,
|
|
Timestamp = decision.Timestamp,
|
|
ReplayToken = decision.ReplayToken,
|
|
EvidenceHashes = decision.EvidenceHashes
|
|
};
|
|
|
|
return TypedResults.Created($"/v1/alerts/{alertId}/audit", response);
|
|
})
|
|
.WithName("RecordDecision")
|
|
.RequireAuthorization(AlertDecidePolicy)
|
|
.Produces(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapGet("/v1/alerts/{alertId}/audit", async Task<Results<JsonHttpResult<AuditTimelineResponse>, NotFound, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string alertId,
|
|
IDecisionService decisionService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
var decisions = await decisionService.GetHistoryAsync(tenantId, alertId, cancellationToken).ConfigureAwait(false);
|
|
|
|
var events = decisions.Select(d => new AuditEventResponse
|
|
{
|
|
EventId = d.Id,
|
|
EventType = "decision_recorded",
|
|
ActorId = d.ActorId,
|
|
Timestamp = d.Timestamp,
|
|
Details = new
|
|
{
|
|
decision_status = d.DecisionStatus,
|
|
reason_code = d.ReasonCode,
|
|
reason_text = d.ReasonText,
|
|
evidence_hashes = d.EvidenceHashes
|
|
},
|
|
ReplayToken = d.ReplayToken
|
|
}).ToList();
|
|
|
|
var response = new AuditTimelineResponse
|
|
{
|
|
AlertId = alertId,
|
|
Events = events,
|
|
TotalCount = events.Count
|
|
};
|
|
|
|
return TypedResults.Json(response);
|
|
})
|
|
.WithName("GetAlertAudit")
|
|
.RequireAuthorization(AlertReadPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// Sprint: SPRINT_3602_0001_0001 - Task 9: Bundle download endpoint
|
|
app.MapGet("/v1/alerts/{alertId}/bundle", async Task<Results<FileStreamHttpResult, NotFound, ProblemHttpResult>> (
|
|
string alertId,
|
|
[FromServices] IAlertService alertService,
|
|
[FromServices] IEvidenceBundleService bundleService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var alert = await alertService.GetAlertAsync(alertId, cancellationToken).ConfigureAwait(false);
|
|
if (alert is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
var bundle = await bundleService.CreateBundleAsync(alertId, cancellationToken).ConfigureAwait(false);
|
|
if (bundle is null)
|
|
{
|
|
return TypedResults.Problem(
|
|
detail: "Failed to create evidence bundle",
|
|
statusCode: StatusCodes.Status500InternalServerError);
|
|
}
|
|
|
|
return TypedResults.File(
|
|
bundle.Content,
|
|
contentType: "application/gzip",
|
|
fileDownloadName: $"evidence-{alertId}.tar.gz");
|
|
})
|
|
.WithName("DownloadAlertBundle")
|
|
.RequireAuthorization(AlertReadPolicy)
|
|
.Produces<FileStreamHttpResult>(StatusCodes.Status200OK, "application/gzip")
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// Sprint: SPRINT_3602_0001_0001 - Task 10: Bundle verify endpoint
|
|
app.MapPost("/v1/alerts/{alertId}/bundle/verify", async Task<Results<Ok<BundleVerificationResponse>, NotFound, ProblemHttpResult>> (
|
|
string alertId,
|
|
[FromBody] BundleVerificationRequest request,
|
|
[FromServices] IAlertService alertService,
|
|
[FromServices] IEvidenceBundleService bundleService,
|
|
[FromServices] TimeProvider timeProvider,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var alert = await alertService.GetAlertAsync(alertId, cancellationToken).ConfigureAwait(false);
|
|
if (alert is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
var result = await bundleService.VerifyBundleAsync(
|
|
alertId,
|
|
request.BundleHash,
|
|
request.Signature,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
var response = new BundleVerificationResponse
|
|
{
|
|
AlertId = alertId,
|
|
IsValid = result.IsValid,
|
|
VerifiedAt = timeProvider.GetUtcNow(),
|
|
SignatureValid = result.SignatureValid,
|
|
HashValid = result.HashValid,
|
|
ChainValid = result.ChainValid,
|
|
Errors = result.Errors
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
})
|
|
.WithName("VerifyAlertBundle")
|
|
.RequireAuthorization(AlertReadPolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
app.MapPost("/v1/vex-consensus/issuers", async Task<Results<Created<VexIssuerDetailResponse>, ProblemHttpResult>> (
|
|
RegisterVexIssuerRequest request,
|
|
VexConsensusService consensusService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var result = await consensusService.RegisterIssuerAsync(request, cancellationToken).ConfigureAwait(false);
|
|
return TypedResults.Created($"/v1/vex-consensus/issuers/{result.IssuerId}", result);
|
|
})
|
|
.WithName("RegisterVexIssuer")
|
|
.RequireAuthorization(LedgerWritePolicy)
|
|
.Produces(StatusCodes.Status201Created)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest);
|
|
|
|
// PATCH /api/v1/findings/{findingId}/state - SPRINT_4000_0100_0002
|
|
app.MapPatch("/api/v1/findings/{findingId}/state", async Task<Results<Ok<StateTransitionResponse>, NotFound, ProblemHttpResult>> (
|
|
HttpContext httpContext,
|
|
string findingId,
|
|
StateTransitionRequest request,
|
|
ILedgerEventWriteService writeService,
|
|
ILedgerEventRepository eventRepository,
|
|
TimeProvider timeProvider,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
|
|
{
|
|
return tenantProblem!;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(findingId))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "invalid_finding_id", detail: "Finding ID is required.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.TargetState))
|
|
{
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "invalid_target_state", detail: "Target state is required.");
|
|
}
|
|
|
|
var actorId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
|
|
var actorType = httpContext.User.FindFirst("actor_type")?.Value ?? "user";
|
|
var evidenceRefs = await eventRepository.GetEvidenceReferencesAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
|
|
|
|
var artifactId = "unknown";
|
|
var chainId = Guid.NewGuid();
|
|
var previousStatus = "affected";
|
|
long sequenceNumber = 1;
|
|
|
|
var latestEvidenceRef = evidenceRefs.FirstOrDefault();
|
|
if (latestEvidenceRef != null)
|
|
{
|
|
var latestEvent = await eventRepository.GetByEventIdAsync(tenantId, latestEvidenceRef.EventId, cancellationToken).ConfigureAwait(false);
|
|
if (latestEvent != null)
|
|
{
|
|
artifactId = latestEvent.ArtifactId;
|
|
chainId = latestEvent.ChainId;
|
|
sequenceNumber = latestEvent.SequenceNumber + 1;
|
|
}
|
|
}
|
|
|
|
var targetState = request.TargetState.ToLowerInvariant().Trim();
|
|
var now = timeProvider.GetUtcNow();
|
|
|
|
var payload = new JsonObject { ["status"] = targetState, ["previous_status"] = previousStatus };
|
|
if (!string.IsNullOrWhiteSpace(request.Justification)) payload["justification"] = request.Justification;
|
|
if (!string.IsNullOrWhiteSpace(request.Notes)) payload["notes"] = request.Notes;
|
|
if (request.DueDate.HasValue) payload["due_date"] = request.DueDate.Value.ToString("O", CultureInfo.InvariantCulture);
|
|
if (request.Tags is { Count: > 0 })
|
|
{
|
|
var tagsArray = new JsonArray();
|
|
foreach (var tag in request.Tags) tagsArray.Add(tag);
|
|
payload["tags"] = tagsArray;
|
|
}
|
|
|
|
var eventEnvelope = new JsonObject { ["event"] = new JsonObject { ["eventType"] = LedgerEventConstants.EventFindingStatusChanged, ["payload"] = payload } };
|
|
|
|
var draft = new LedgerEventDraft(
|
|
TenantId: tenantId, ChainId: chainId, SequenceNumber: sequenceNumber, EventId: Guid.NewGuid(),
|
|
EventType: LedgerEventConstants.EventFindingStatusChanged, PolicyVersion: "1", FindingId: findingId,
|
|
ArtifactId: artifactId, SourceRunId: null, ActorId: actorId, ActorType: actorType,
|
|
OccurredAt: now, RecordedAt: now, Payload: payload, CanonicalEnvelope: eventEnvelope, ProvidedPreviousHash: null);
|
|
|
|
var result = await writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (result.Status == LedgerWriteStatus.ValidationFailed)
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "validation_failed", detail: string.Join("; ", result.Errors));
|
|
if (result.Status == LedgerWriteStatus.Conflict)
|
|
return TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: result.ConflictCode ?? "conflict", detail: string.Join("; ", result.Errors));
|
|
|
|
var response = new StateTransitionResponse
|
|
{
|
|
FindingId = findingId, PreviousState = previousStatus, CurrentState = targetState, TransitionRecordedAt = now,
|
|
ActorId = actorId, Justification = request.Justification, Notes = request.Notes, DueDate = request.DueDate,
|
|
Tags = request.Tags, EventId = result.Record?.EventId
|
|
};
|
|
return TypedResults.Ok(response);
|
|
})
|
|
.WithName("TransitionFindingState")
|
|
.RequireAuthorization(LedgerWritePolicy)
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
|
|
|
// Refresh Router endpoint cache
|
|
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
|
|
|
// Findings summary, evidence graph, reachability, and runtime timeline endpoints
|
|
app.MapFindingSummaryEndpoints();
|
|
app.MapEvidenceGraphEndpoints();
|
|
app.MapReachabilityMapEndpoints();
|
|
app.MapRuntimeTimelineEndpoints();
|
|
|
|
// Map EWS scoring and webhook endpoints (SPRINT_8200.0012.0004)
|
|
app.MapScoringEndpoints();
|
|
app.MapWebhookEndpoints();
|
|
|
|
app.Run();
|
|
|
|
static Created<LedgerEventResponse> CreateCreatedResponse(LedgerEventRecord record)
|
|
{
|
|
var response = CreateResponse(record, "created");
|
|
return TypedResults.Created($"/vuln/ledger/events/{record.EventId}", response);
|
|
}
|
|
|
|
static LedgerEventResponse CreateResponse(LedgerEventRecord record, string status)
|
|
=> new()
|
|
{
|
|
EventId = record.EventId,
|
|
ChainId = record.ChainId,
|
|
Sequence = record.SequenceNumber,
|
|
Status = status,
|
|
EventHash = record.EventHash,
|
|
PreviousHash = record.PreviousHash,
|
|
MerkleLeafHash = record.MerkleLeafHash,
|
|
RecordedAt = record.RecordedAt
|
|
};
|
|
|
|
static async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<T>>, ProblemHttpResult>> WritePagedResponse<T>(
|
|
HttpContext httpContext,
|
|
ExportPage<T> page,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!string.IsNullOrEmpty(page.NextPageToken))
|
|
{
|
|
httpContext.Response.Headers["X-Stella-Next-Page-Token"] = page.NextPageToken;
|
|
}
|
|
httpContext.Response.Headers["X-Stella-Result-Count"] = page.Items.Count.ToString();
|
|
|
|
var acceptsNdjson = httpContext.Request.Headers.Accept.Any(h => h?.Contains("application/x-ndjson", StringComparison.OrdinalIgnoreCase) == true);
|
|
if (acceptsNdjson)
|
|
{
|
|
httpContext.Response.ContentType = "application/x-ndjson";
|
|
var stream = new MemoryStream();
|
|
await using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { SkipValidation = false, Indented = false });
|
|
foreach (var item in page.Items)
|
|
{
|
|
JsonSerializer.Serialize(writer, item);
|
|
writer.Flush();
|
|
await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
stream.Position = 0;
|
|
return TypedResults.Stream(stream, contentType: "application/x-ndjson");
|
|
}
|
|
|
|
return TypedResults.Json(page);
|
|
}
|
|
|
|
static bool TryGetTenant(HttpContext httpContext, out ProblemHttpResult? problem, out string tenantId)
|
|
{
|
|
tenantId = string.Empty;
|
|
if (!httpContext.Request.Headers.TryGetValue("X-Stella-Tenant", out var tenantValues) || string.IsNullOrWhiteSpace(tenantValues))
|
|
{
|
|
problem = TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "missing_tenant");
|
|
return false;
|
|
}
|
|
|
|
tenantId = tenantValues.ToString();
|
|
problem = null;
|
|
return true;
|
|
}
|
|
|
|
static int? ParseInt(string? value)
|
|
{
|
|
return int.TryParse(value, out var result) ? result : null;
|
|
}
|
|
|
|
static long? ParseLong(string? value)
|
|
{
|
|
return long.TryParse(value, out var result) ? result : null;
|
|
}
|
|
|
|
static DateTimeOffset? ParseDate(string? value)
|
|
{
|
|
return DateTimeOffset.TryParse(value, out var result) ? result : null;
|
|
}
|
|
|
|
static decimal? ParseDecimal(string? value)
|
|
{
|
|
return decimal.TryParse(value, out var result) ? result : null;
|
|
}
|
|
|
|
static bool? ParseBool(string? value)
|
|
{
|
|
return bool.TryParse(value, out var result) ? result : null;
|
|
}
|
|
|
|
static Guid? ParseGuid(string? value)
|
|
{
|
|
return Guid.TryParse(value, out var result) ? result : null;
|
|
}
|
|
|
|
namespace StellaOps.Findings.Ledger.WebService
|
|
{
|
|
/// <summary>
|
|
/// Marker class for WebApplicationFactory integration tests.
|
|
/// </summary>
|
|
public partial class Program { }
|
|
}
|