feat: Implement air-gap functionality with timeline impact and evidence snapshot services
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
- Added AirgapTimelineImpact, AirgapTimelineImpactInput, and AirgapTimelineImpactResult records for managing air-gap bundle import impacts. - Introduced EvidenceSnapshotRecord, EvidenceSnapshotLinkInput, and EvidenceSnapshotLinkResult records for linking findings to evidence snapshots. - Created IEvidenceSnapshotRepository interface for managing evidence snapshot records. - Developed StalenessValidationService to validate staleness and enforce freshness thresholds. - Implemented AirgapTimelineService for emitting timeline events related to bundle imports. - Added EvidenceSnapshotService for linking findings to evidence snapshots and verifying their validity. - Introduced AirGapOptions for configuring air-gap staleness enforcement and thresholds. - Added minimal jsPDF stub for offline/testing builds in the web application. - Created TypeScript definitions for jsPDF to enhance type safety in the web application.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
@@ -20,3 +21,13 @@ public sealed record AdvisoryObservationLinksetAggregateResponse(
|
||||
ImmutableArray<RawRelationship> Relationships,
|
||||
double Confidence,
|
||||
ImmutableArray<AdvisoryLinksetConflict> Conflicts);
|
||||
|
||||
/// <summary>
|
||||
/// Request to publish observation events to NATS/Redis.
|
||||
/// </summary>
|
||||
public sealed record ObservationEventPublishRequest(IReadOnlyList<string>? ObservationIds);
|
||||
|
||||
/// <summary>
|
||||
/// Request to publish linkset events to NATS/Redis.
|
||||
/// </summary>
|
||||
public sealed record LinksetEventPublishRequest(IReadOnlyList<string>? AdvisoryIds);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid RFC 7807 + Standard Error Envelope.
|
||||
/// Per CONCELIER-WEB-OAS-61-002.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Combines RFC 7807 Problem Details format with a structured error code
|
||||
/// for machine-readable error handling. This enables both human-readable
|
||||
/// problem descriptions and programmatic error code checking.
|
||||
/// </remarks>
|
||||
public sealed record ErrorEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// A URI reference that identifies the problem type (RFC 7807).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A short, human-readable summary of the problem type (RFC 7807).
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The HTTP status code (RFC 7807).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required int Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A human-readable explanation specific to this occurrence (RFC 7807).
|
||||
/// </summary>
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// A URI reference that identifies the specific occurrence (RFC 7807).
|
||||
/// </summary>
|
||||
[JsonPropertyName("instance")]
|
||||
public string? Instance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Distributed trace identifier for correlation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("traceId")]
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Structured error details with machine-readable code.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public ErrorDetail? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured error detail with machine-readable code.
|
||||
/// </summary>
|
||||
public sealed record ErrorDetail
|
||||
{
|
||||
/// <summary>
|
||||
/// Machine-readable error code (e.g., "VALIDATION_FAILED", "RESOURCE_NOT_FOUND").
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable error message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target of the error (field name, resource identifier, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("target")]
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("metadata")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyDictionary<string, object?>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Nested validation errors for complex validation failures.
|
||||
/// </summary>
|
||||
[JsonPropertyName("innerErrors")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<ValidationError>? InnerErrors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for more information about this error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("helpUrl")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? HelpUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Retry-after hint in seconds (for rate limiting).
|
||||
/// </summary>
|
||||
[JsonPropertyName("retryAfter")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? RetryAfter { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual validation error for field-level issues.
|
||||
/// </summary>
|
||||
public sealed record ValidationError
|
||||
{
|
||||
/// <summary>
|
||||
/// Field path (e.g., "advisoryId", "data.severity").
|
||||
/// </summary>
|
||||
[JsonPropertyName("field")]
|
||||
public required string Field { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error code for this specific validation error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("code")]
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message for this validation error.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
namespace StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Machine-readable error codes for API responses.
|
||||
/// Per CONCELIER-WEB-OAS-61-002.
|
||||
/// </summary>
|
||||
public static class ErrorCodes
|
||||
{
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Validation Errors (4xx)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Generic validation failure.</summary>
|
||||
public const string ValidationFailed = "VALIDATION_FAILED";
|
||||
|
||||
/// <summary>Required field is missing.</summary>
|
||||
public const string RequiredFieldMissing = "REQUIRED_FIELD_MISSING";
|
||||
|
||||
/// <summary>Field value is invalid.</summary>
|
||||
public const string InvalidFieldValue = "INVALID_FIELD_VALUE";
|
||||
|
||||
/// <summary>Tenant ID is required but not provided.</summary>
|
||||
public const string TenantRequired = "TENANT_REQUIRED";
|
||||
|
||||
/// <summary>Advisory ID is required but not provided.</summary>
|
||||
public const string AdvisoryIdRequired = "ADVISORY_ID_REQUIRED";
|
||||
|
||||
/// <summary>Vulnerability key is required but not provided.</summary>
|
||||
public const string VulnerabilityKeyRequired = "VULNERABILITY_KEY_REQUIRED";
|
||||
|
||||
/// <summary>Cursor parameter must be an integer.</summary>
|
||||
public const string InvalidCursor = "INVALID_CURSOR";
|
||||
|
||||
/// <summary>Invalid pagination parameters.</summary>
|
||||
public const string InvalidPagination = "INVALID_PAGINATION";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Resource Errors (404)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Requested resource was not found.</summary>
|
||||
public const string ResourceNotFound = "RESOURCE_NOT_FOUND";
|
||||
|
||||
/// <summary>Advisory not found.</summary>
|
||||
public const string AdvisoryNotFound = "ADVISORY_NOT_FOUND";
|
||||
|
||||
/// <summary>Vulnerability not found.</summary>
|
||||
public const string VulnerabilityNotFound = "VULNERABILITY_NOT_FOUND";
|
||||
|
||||
/// <summary>Evidence not found.</summary>
|
||||
public const string EvidenceNotFound = "EVIDENCE_NOT_FOUND";
|
||||
|
||||
/// <summary>Tenant not found.</summary>
|
||||
public const string TenantNotFound = "TENANT_NOT_FOUND";
|
||||
|
||||
/// <summary>Job not found.</summary>
|
||||
public const string JobNotFound = "JOB_NOT_FOUND";
|
||||
|
||||
/// <summary>Mirror not found.</summary>
|
||||
public const string MirrorNotFound = "MIRROR_NOT_FOUND";
|
||||
|
||||
/// <summary>Bundle source not found.</summary>
|
||||
public const string BundleSourceNotFound = "BUNDLE_SOURCE_NOT_FOUND";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// AOC (Aggregation-Only Contract) Errors
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>AOC violation occurred.</summary>
|
||||
public const string AocViolation = "AOC_VIOLATION";
|
||||
|
||||
/// <summary>Forbidden field in advisory (ERR_AOC_001).</summary>
|
||||
public const string AocForbiddenField = "AOC_FORBIDDEN_FIELD";
|
||||
|
||||
/// <summary>Merge attempt detected (ERR_AOC_002).</summary>
|
||||
public const string AocMergeAttempt = "AOC_MERGE_ATTEMPT";
|
||||
|
||||
/// <summary>Derived field modification (ERR_AOC_006).</summary>
|
||||
public const string AocDerivedField = "AOC_DERIVED_FIELD";
|
||||
|
||||
/// <summary>Unknown field detected (ERR_AOC_007).</summary>
|
||||
public const string AocUnknownField = "AOC_UNKNOWN_FIELD";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Conflict Errors (409)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Resource already exists.</summary>
|
||||
public const string ResourceConflict = "RESOURCE_CONFLICT";
|
||||
|
||||
/// <summary>Concurrent modification detected.</summary>
|
||||
public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT";
|
||||
|
||||
/// <summary>Lease already held by another client.</summary>
|
||||
public const string LeaseConflict = "LEASE_CONFLICT";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// State Errors (423 Locked)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Resource is locked.</summary>
|
||||
public const string ResourceLocked = "RESOURCE_LOCKED";
|
||||
|
||||
/// <summary>Lease rejected.</summary>
|
||||
public const string LeaseRejected = "LEASE_REJECTED";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// AirGap/Sealed Mode Errors
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>AirGap mode is disabled.</summary>
|
||||
public const string AirGapDisabled = "AIRGAP_DISABLED";
|
||||
|
||||
/// <summary>Sealed mode violation.</summary>
|
||||
public const string SealedModeViolation = "SEALED_MODE_VIOLATION";
|
||||
|
||||
/// <summary>Source blocked by sealed mode.</summary>
|
||||
public const string SourceBlocked = "SOURCE_BLOCKED";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rate Limiting (429)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Rate limit exceeded.</summary>
|
||||
public const string RateLimitExceeded = "RATE_LIMIT_EXCEEDED";
|
||||
|
||||
/// <summary>Quota exceeded.</summary>
|
||||
public const string QuotaExceeded = "QUOTA_EXCEEDED";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Server Errors (5xx)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Internal server error.</summary>
|
||||
public const string InternalError = "INTERNAL_ERROR";
|
||||
|
||||
/// <summary>Service unavailable.</summary>
|
||||
public const string ServiceUnavailable = "SERVICE_UNAVAILABLE";
|
||||
|
||||
/// <summary>Job execution failure.</summary>
|
||||
public const string JobFailure = "JOB_FAILURE";
|
||||
|
||||
/// <summary>External service failure.</summary>
|
||||
public const string ExternalServiceFailure = "EXTERNAL_SERVICE_FAILURE";
|
||||
|
||||
/// <summary>Database operation failed.</summary>
|
||||
public const string DatabaseError = "DATABASE_ERROR";
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Core.AirGap;
|
||||
using StellaOps.Concelier.Core.AirGap.Models;
|
||||
using StellaOps.Concelier.WebService.Diagnostics;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint extensions for AirGap functionality.
|
||||
/// Per CONCELIER-WEB-AIRGAP-56-001.
|
||||
/// </summary>
|
||||
internal static class AirGapEndpointExtensions
|
||||
{
|
||||
public static void MapConcelierAirGapEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/concelier/airgap")
|
||||
.WithTags("AirGap");
|
||||
|
||||
// GET /api/v1/concelier/airgap/catalog - Aggregated bundle catalog
|
||||
group.MapGet("/catalog", async (
|
||||
HttpContext context,
|
||||
IBundleCatalogService catalogService,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var airGapOptions = optionsMonitor.CurrentValue.AirGap;
|
||||
if (!airGapOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.AirGapDisabled(context);
|
||||
}
|
||||
|
||||
var catalog = await catalogService.GetCatalogAsync(cursor, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(catalog);
|
||||
});
|
||||
|
||||
// GET /api/v1/concelier/airgap/sources - List registered sources
|
||||
group.MapGet("/sources", (
|
||||
HttpContext context,
|
||||
IBundleSourceRegistry sourceRegistry,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor) =>
|
||||
{
|
||||
var airGapOptions = optionsMonitor.CurrentValue.AirGap;
|
||||
if (!airGapOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.AirGapDisabled(context);
|
||||
}
|
||||
|
||||
var sources = sourceRegistry.GetSources();
|
||||
return Results.Ok(new { sources, count = sources.Count });
|
||||
});
|
||||
|
||||
// POST /api/v1/concelier/airgap/sources - Register new source
|
||||
group.MapPost("/sources", async (
|
||||
HttpContext context,
|
||||
IBundleSourceRegistry sourceRegistry,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
[FromBody] BundleSourceRegistration registration,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var airGapOptions = optionsMonitor.CurrentValue.AirGap;
|
||||
if (!airGapOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.AirGapDisabled(context);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(registration.Id))
|
||||
{
|
||||
return ConcelierProblemResultFactory.RequiredFieldMissing(context, "id");
|
||||
}
|
||||
|
||||
var source = await sourceRegistry.RegisterAsync(registration, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/concelier/airgap/sources/{source.Id}", source);
|
||||
});
|
||||
|
||||
// GET /api/v1/concelier/airgap/sources/{sourceId} - Get specific source
|
||||
group.MapGet("/sources/{sourceId}", (
|
||||
HttpContext context,
|
||||
IBundleSourceRegistry sourceRegistry,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
string sourceId) =>
|
||||
{
|
||||
var airGapOptions = optionsMonitor.CurrentValue.AirGap;
|
||||
if (!airGapOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.AirGapDisabled(context);
|
||||
}
|
||||
|
||||
var source = sourceRegistry.GetSource(sourceId);
|
||||
if (source is null)
|
||||
{
|
||||
return ConcelierProblemResultFactory.BundleSourceNotFound(context, sourceId);
|
||||
}
|
||||
|
||||
return Results.Ok(source);
|
||||
});
|
||||
|
||||
// DELETE /api/v1/concelier/airgap/sources/{sourceId} - Unregister source
|
||||
group.MapDelete("/sources/{sourceId}", async (
|
||||
HttpContext context,
|
||||
IBundleSourceRegistry sourceRegistry,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var airGapOptions = optionsMonitor.CurrentValue.AirGap;
|
||||
if (!airGapOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.AirGapDisabled(context);
|
||||
}
|
||||
|
||||
var removed = await sourceRegistry.UnregisterAsync(sourceId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return removed
|
||||
? Results.NoContent()
|
||||
: ConcelierProblemResultFactory.BundleSourceNotFound(context, sourceId);
|
||||
});
|
||||
|
||||
// POST /api/v1/concelier/airgap/sources/{sourceId}/validate - Validate source
|
||||
group.MapPost("/sources/{sourceId}/validate", async (
|
||||
HttpContext context,
|
||||
IBundleSourceRegistry sourceRegistry,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var airGapOptions = optionsMonitor.CurrentValue.AirGap;
|
||||
if (!airGapOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.AirGapDisabled(context);
|
||||
}
|
||||
|
||||
var result = await sourceRegistry.ValidateAsync(sourceId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(result);
|
||||
});
|
||||
|
||||
// GET /api/v1/concelier/airgap/status - Sealed-mode status
|
||||
group.MapGet("/status", (
|
||||
HttpContext context,
|
||||
ISealedModeEnforcer sealedModeEnforcer,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor) =>
|
||||
{
|
||||
var airGapOptions = optionsMonitor.CurrentValue.AirGap;
|
||||
if (!airGapOptions.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.AirGapDisabled(context);
|
||||
}
|
||||
|
||||
var status = sealedModeEnforcer.GetStatus();
|
||||
return Results.Ok(status);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Services;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.WebService.Diagnostics;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
using StellaOps.Concelier.WebService.Services;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
@@ -24,7 +26,7 @@ internal static class MirrorEndpointExtensions
|
||||
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
|
||||
if (!mirrorOptions.Enabled)
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
if (!TryAuthorize(mirrorOptions.RequireAuthentication, enforceAuthority, context, authorityConfigured, out var unauthorizedResult))
|
||||
@@ -35,15 +37,15 @@ internal static class MirrorEndpointExtensions
|
||||
if (!limiter.TryAcquire("__index__", IndexScope, mirrorOptions.MaxIndexRequestsPerHour, out var retryAfter))
|
||||
{
|
||||
ApplyRetryAfter(context.Response, retryAfter);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
return ConcelierProblemResultFactory.RateLimitExceeded(context, (int?)retryAfter?.TotalSeconds);
|
||||
}
|
||||
|
||||
if (!locator.TryResolveIndex(out var path, out _))
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
return await WriteFileAsync(path, context.Response, "application/json").ConfigureAwait(false);
|
||||
return await WriteFileAsync(context, path, "application/json").ConfigureAwait(false);
|
||||
});
|
||||
|
||||
app.MapGet("/concelier/exports/{**relativePath}", async (
|
||||
@@ -57,17 +59,17 @@ internal static class MirrorEndpointExtensions
|
||||
var mirrorOptions = optionsMonitor.CurrentValue.Mirror ?? new ConcelierOptions.MirrorOptions();
|
||||
if (!mirrorOptions.Enabled)
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context);
|
||||
}
|
||||
|
||||
if (!locator.TryResolveRelativePath(relativePath, out var path, out _, out var domainId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.MirrorNotFound(context, relativePath);
|
||||
}
|
||||
|
||||
var domain = FindDomain(mirrorOptions, domainId);
|
||||
@@ -81,11 +83,11 @@ internal static class MirrorEndpointExtensions
|
||||
if (!limiter.TryAcquire(domain?.Id ?? "__mirror__", DownloadScope, limit, out var retryAfter))
|
||||
{
|
||||
ApplyRetryAfter(context.Response, retryAfter);
|
||||
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
return ConcelierProblemResultFactory.RateLimitExceeded(context, (int?)retryAfter?.TotalSeconds);
|
||||
}
|
||||
|
||||
var contentType = ResolveContentType(path);
|
||||
return await WriteFileAsync(path, context.Response, contentType).ConfigureAwait(false);
|
||||
return await WriteFileAsync(context, path, contentType).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,12 +114,12 @@ internal static class MirrorEndpointExtensions
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryAuthorize(bool requireAuthentication, bool enforceAuthority, HttpContext context, bool authorityConfigured, out IResult result)
|
||||
{
|
||||
result = Results.Empty;
|
||||
if (!requireAuthentication)
|
||||
{
|
||||
return true;
|
||||
private static bool TryAuthorize(bool requireAuthentication, bool enforceAuthority, HttpContext context, bool authorityConfigured, out IResult result)
|
||||
{
|
||||
result = Results.Empty;
|
||||
if (!requireAuthentication)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!enforceAuthority || !authorityConfigured)
|
||||
@@ -128,19 +130,19 @@ internal static class MirrorEndpointExtensions
|
||||
if (context.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
context.Response.Headers.WWWAuthenticate = "Bearer realm=\"StellaOps Concelier Mirror\"";
|
||||
result = Results.StatusCode(StatusCodes.Status401Unauthorized);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Task<IResult> WriteFileAsync(string path, HttpResponse response, string contentType)
|
||||
{
|
||||
}
|
||||
|
||||
context.Response.Headers.WWWAuthenticate = "Bearer realm=\"StellaOps Concelier Mirror\"";
|
||||
result = Results.StatusCode(StatusCodes.Status401Unauthorized);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Task<IResult> WriteFileAsync(HttpContext context, string path, string contentType)
|
||||
{
|
||||
var fileInfo = new FileInfo(path);
|
||||
if (!fileInfo.Exists)
|
||||
{
|
||||
return Task.FromResult(Results.NotFound());
|
||||
return Task.FromResult(ConcelierProblemResultFactory.MirrorNotFound(context, path));
|
||||
}
|
||||
|
||||
var stream = new FileStream(
|
||||
@@ -149,12 +151,12 @@ internal static class MirrorEndpointExtensions
|
||||
FileAccess.Read,
|
||||
FileShare.Read | FileShare.Delete);
|
||||
|
||||
response.Headers.CacheControl = BuildCacheControlHeader(path);
|
||||
response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R", CultureInfo.InvariantCulture);
|
||||
response.ContentLength = fileInfo.Length;
|
||||
return Task.FromResult(Results.Stream(stream, contentType));
|
||||
}
|
||||
|
||||
context.Response.Headers.CacheControl = BuildCacheControlHeader(path);
|
||||
context.Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R", CultureInfo.InvariantCulture);
|
||||
context.Response.ContentLength = fileInfo.Length;
|
||||
return Task.FromResult(Results.Stream(stream, contentType));
|
||||
}
|
||||
|
||||
private static string ResolveContentType(string path)
|
||||
{
|
||||
if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -178,28 +180,28 @@ internal static class MirrorEndpointExtensions
|
||||
}
|
||||
|
||||
var seconds = Math.Max((int)Math.Ceiling(retryAfter.Value.TotalSeconds), 1);
|
||||
response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string BuildCacheControlHeader(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (fileName is null)
|
||||
{
|
||||
return "public, max-age=60";
|
||||
}
|
||||
|
||||
if (string.Equals(fileName, "index.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public, max-age=60";
|
||||
}
|
||||
|
||||
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public, max-age=300, immutable";
|
||||
}
|
||||
|
||||
return "public, max-age=300";
|
||||
}
|
||||
}
|
||||
response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string BuildCacheControlHeader(string path)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (fileName is null)
|
||||
{
|
||||
return "public, max-age=60";
|
||||
}
|
||||
|
||||
if (string.Equals(fileName, "index.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public, max-age=60";
|
||||
}
|
||||
|
||||
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileName.EndsWith(".jws", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "public, max-age=300, immutable";
|
||||
}
|
||||
|
||||
return "public, max-age=300";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap configuration options for Concelier.
|
||||
/// Per CONCELIER-WEB-AIRGAP-56-001.
|
||||
/// </summary>
|
||||
public sealed class AirGapOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable air-gap mode with bundle-based feed consumption.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sealed mode configuration (blocks direct internet feeds when enabled).
|
||||
/// </summary>
|
||||
public SealedModeOptions SealedMode { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Bundle sources configuration.
|
||||
/// </summary>
|
||||
public BundleSourcesOptions Sources { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Catalog configuration.
|
||||
/// </summary>
|
||||
public CatalogOptions Catalog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Sealed mode configuration options.
|
||||
/// When sealed mode is enabled, direct internet feeds are blocked.
|
||||
/// </summary>
|
||||
public sealed class SealedModeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable sealed mode (block direct internet feeds).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of sources explicitly allowed even in sealed mode.
|
||||
/// </summary>
|
||||
public IList<string> AllowedSources { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// List of hosts that are allowed for egress even in sealed mode.
|
||||
/// Useful for internal mirrors or private registries.
|
||||
/// </summary>
|
||||
public IList<string> AllowedHosts { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Warn-only mode: log violations but don't block requests.
|
||||
/// Useful for testing sealed mode before full enforcement.
|
||||
/// </summary>
|
||||
public bool WarnOnly { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bundle sources configuration options.
|
||||
/// </summary>
|
||||
public sealed class BundleSourcesOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Root directory for bundle storage.
|
||||
/// </summary>
|
||||
public string Root { get; set; } = "bundles";
|
||||
|
||||
/// <summary>
|
||||
/// Automatically register sources from bundle directory on startup.
|
||||
/// </summary>
|
||||
public bool AutoDiscovery { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// File patterns to match for auto-discovery.
|
||||
/// </summary>
|
||||
public IList<string> DiscoveryPatterns { get; set; } = new List<string> { "*.bundle.json", "catalog.json" };
|
||||
|
||||
/// <summary>
|
||||
/// Pre-configured bundle sources.
|
||||
/// </summary>
|
||||
public IList<BundleSourceConfig> Configured { get; set; } = new List<BundleSourceConfig>();
|
||||
|
||||
/// <summary>
|
||||
/// Computed absolute path to root directory.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string RootAbsolute { get; internal set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a single bundle source.
|
||||
/// </summary>
|
||||
public sealed class BundleSourceConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for the source.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the source.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Source type (directory, archive, remote).
|
||||
/// </summary>
|
||||
public string Type { get; set; } = "directory";
|
||||
|
||||
/// <summary>
|
||||
/// Path or URL to the bundle source.
|
||||
/// </summary>
|
||||
public string Location { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Enable this source.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Priority for this source (lower = higher priority).
|
||||
/// </summary>
|
||||
public int Priority { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Verification mode for bundles from this source.
|
||||
/// </summary>
|
||||
public string VerificationMode { get; set; } = "signature";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Catalog configuration options.
|
||||
/// </summary>
|
||||
public sealed class CatalogOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable catalog aggregation from all sources.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Cache duration for aggregated catalog in seconds.
|
||||
/// </summary>
|
||||
public int CacheDurationSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of items per catalog page.
|
||||
/// </summary>
|
||||
public int MaxPageSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Include bundle provenance in catalog responses.
|
||||
/// </summary>
|
||||
public bool IncludeProvenance { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,12 @@ public sealed class ConcelierOptions
|
||||
|
||||
public StellaOpsCryptoOptions Crypto { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Air-gap mode configuration.
|
||||
/// Per CONCELIER-WEB-AIRGAP-56-001.
|
||||
/// </summary>
|
||||
public AirGapOptions AirGap { get; set; } = new();
|
||||
|
||||
public sealed class StorageOptions
|
||||
{
|
||||
public string Driver { get; set; } = "mongo";
|
||||
|
||||
@@ -51,6 +51,7 @@ using StellaOps.Aoc;
|
||||
using StellaOps.Aoc.AspNetCore.Routing;
|
||||
using StellaOps.Aoc.AspNetCore.Results;
|
||||
using StellaOps.Concelier.WebService.Contracts;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
using StellaOps.Concelier.Core.Aoc;
|
||||
using StellaOps.Concelier.Core.Raw;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
@@ -712,7 +713,7 @@ var observationsEndpoint = app.MapGet("/concelier/observations", async (
|
||||
{"reason", "format"},
|
||||
{"stage", "ingest"}
|
||||
});
|
||||
return Results.BadRequest(ex.Message);
|
||||
return ConcelierProblemResultFactory.ValidationFailed(context, ex.Message);
|
||||
}
|
||||
var elapsed = stopwatch.Elapsed;
|
||||
|
||||
@@ -867,7 +868,7 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryId))
|
||||
{
|
||||
return Results.BadRequest("advisoryId is required.");
|
||||
return ConcelierProblemResultFactory.AdvisoryIdRequired(context);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
@@ -880,7 +881,7 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
|
||||
|
||||
if (result.Linksets.IsDefaultOrEmpty)
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.AdvisoryNotFound(context, advisoryId);
|
||||
}
|
||||
|
||||
var linkset = result.Linksets[0];
|
||||
@@ -1178,7 +1179,7 @@ var advisoryRawGetEndpoint = app.MapGet("/advisories/raw/{id}", async (
|
||||
var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.AdvisoryNotFound(context, id);
|
||||
}
|
||||
|
||||
var response = new AdvisoryRawRecordResponse(
|
||||
@@ -1222,7 +1223,7 @@ var advisoryRawProvenanceEndpoint = app.MapGet("/advisories/raw/{id}/provenance"
|
||||
var record = await rawService.FindByIdAsync(tenant, id.Trim(), cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.AdvisoryNotFound(context, id);
|
||||
}
|
||||
|
||||
var response = new AdvisoryRawProvenanceResponse(
|
||||
@@ -1241,6 +1242,379 @@ if (authorityConfigured)
|
||||
advisoryRawProvenanceEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
||||
}
|
||||
|
||||
// Advisory observations endpoint - filtered by alias/purl/source with strict tenant scopes.
|
||||
// Echoes upstream values + provenance fields only (no merge-derived judgments).
|
||||
var advisoryObservationsEndpoint = app.MapGet("/advisories/observations", async (
|
||||
HttpContext context,
|
||||
[FromServices] IAdvisoryObservationQueryService observationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var authorizationError = EnsureTenantAuthorized(context, tenant);
|
||||
if (authorizationError is not null)
|
||||
{
|
||||
return authorizationError;
|
||||
}
|
||||
|
||||
var query = context.Request.Query;
|
||||
|
||||
// Parse query parameters
|
||||
var aliases = query.TryGetValue("alias", out var aliasValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(aliasValues)
|
||||
: null;
|
||||
|
||||
var purls = query.TryGetValue("purl", out var purlValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(purlValues)
|
||||
: null;
|
||||
|
||||
var cpes = query.TryGetValue("cpe", out var cpeValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(cpeValues)
|
||||
: null;
|
||||
|
||||
var observationIds = query.TryGetValue("id", out var idValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(idValues)
|
||||
: null;
|
||||
|
||||
int? limit = null;
|
||||
if (query.TryGetValue("limit", out var limitValues) &&
|
||||
int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit) &&
|
||||
parsedLimit > 0)
|
||||
{
|
||||
limit = Math.Min(parsedLimit, 200); // Cap at 200
|
||||
}
|
||||
|
||||
string? cursor = null;
|
||||
if (query.TryGetValue("cursor", out var cursorValues))
|
||||
{
|
||||
var cursorValue = cursorValues.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(cursorValue))
|
||||
{
|
||||
cursor = cursorValue.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Build query options with tenant scope
|
||||
var options = new AdvisoryObservationQueryOptions(
|
||||
tenant,
|
||||
observationIds: observationIds,
|
||||
aliases: aliases,
|
||||
purls: purls,
|
||||
cpes: cpes,
|
||||
limit: limit,
|
||||
cursor: cursor);
|
||||
|
||||
var result = await observationService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Map to response contracts
|
||||
var linksetResponse = new AdvisoryObservationLinksetAggregateResponse(
|
||||
result.Linkset.Aliases,
|
||||
result.Linkset.Purls,
|
||||
result.Linkset.Cpes,
|
||||
result.Linkset.References,
|
||||
result.Linkset.Scopes,
|
||||
result.Linkset.Relationships,
|
||||
result.Linkset.Confidence,
|
||||
result.Linkset.Conflicts);
|
||||
|
||||
var response = new AdvisoryObservationQueryResponse(
|
||||
result.Observations,
|
||||
linksetResponse,
|
||||
result.NextCursor,
|
||||
result.HasMore);
|
||||
|
||||
return JsonResult(response);
|
||||
}).WithName("GetAdvisoryObservations");
|
||||
|
||||
if (authorityConfigured)
|
||||
{
|
||||
advisoryObservationsEndpoint.RequireAuthorization(ObservationsPolicyName);
|
||||
}
|
||||
|
||||
// Advisory linksets endpoint - surfaces correlation + conflict payloads with ERR_AGG_* mapping.
|
||||
// No synthesis/merge - echoes upstream values only.
|
||||
var advisoryLinksetsEndpoint = app.MapGet("/advisories/linksets", async (
|
||||
HttpContext context,
|
||||
[FromServices] IAdvisoryLinksetQueryService linksetService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var authorizationError = EnsureTenantAuthorized(context, tenant);
|
||||
if (authorizationError is not null)
|
||||
{
|
||||
return authorizationError;
|
||||
}
|
||||
|
||||
var query = context.Request.Query;
|
||||
|
||||
// Parse advisory IDs (alias values like CVE-*, GHSA-*)
|
||||
var advisoryIds = query.TryGetValue("advisoryId", out var advisoryIdValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(advisoryIdValues)
|
||||
: (query.TryGetValue("alias", out var aliasValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(aliasValues)
|
||||
: null);
|
||||
|
||||
var sources = query.TryGetValue("source", out var sourceValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(sourceValues)
|
||||
: null;
|
||||
|
||||
int? limit = null;
|
||||
if (query.TryGetValue("limit", out var limitValues) &&
|
||||
int.TryParse(limitValues.FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLimit) &&
|
||||
parsedLimit > 0)
|
||||
{
|
||||
limit = Math.Min(parsedLimit, 500); // Cap at 500
|
||||
}
|
||||
|
||||
string? cursor = null;
|
||||
if (query.TryGetValue("cursor", out var cursorValues))
|
||||
{
|
||||
var cursorValue = cursorValues.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(cursorValue))
|
||||
{
|
||||
cursor = cursorValue.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
var options = new AdvisoryLinksetQueryOptions(
|
||||
tenant,
|
||||
advisoryIds,
|
||||
sources,
|
||||
limit,
|
||||
cursor);
|
||||
|
||||
var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Map to LNM linkset response format
|
||||
var items = result.Linksets.Select(linkset => new LnmLinksetResponse(
|
||||
linkset.AdvisoryId,
|
||||
linkset.Source,
|
||||
linkset.Normalized?.Purls ?? Array.Empty<string>(),
|
||||
linkset.Normalized?.Cpes ?? Array.Empty<string>(),
|
||||
null, // Summary not available in linkset
|
||||
null, // PublishedAt
|
||||
null, // ModifiedAt
|
||||
null, // Severity - no derived judgment
|
||||
null, // Status
|
||||
linkset.Provenance is not null
|
||||
? new LnmLinksetProvenance(
|
||||
linkset.CreatedAt,
|
||||
null, // ConnectorId
|
||||
linkset.Provenance.ObservationHashes?.FirstOrDefault(),
|
||||
null) // DsseEnvelopeHash
|
||||
: null,
|
||||
linkset.Conflicts?.Select(c => new LnmLinksetConflict(
|
||||
c.Field,
|
||||
c.Reason,
|
||||
c.Values?.FirstOrDefault(),
|
||||
null,
|
||||
null)).ToArray() ?? Array.Empty<LnmLinksetConflict>(),
|
||||
Array.Empty<LnmLinksetTimeline>(),
|
||||
linkset.Normalized is not null
|
||||
? new LnmLinksetNormalized(
|
||||
null, // Aliases not in normalized
|
||||
linkset.Normalized.Purls,
|
||||
linkset.Normalized.Cpes,
|
||||
linkset.Normalized.Versions,
|
||||
null) // Ranges serialized differently
|
||||
: null,
|
||||
false, // Not from cache
|
||||
Array.Empty<string>(),
|
||||
linkset.ObservationIds.ToArray())).ToArray();
|
||||
|
||||
var response = new LnmLinksetPage(items, 1, items.Length, null);
|
||||
return JsonResult(response);
|
||||
}).WithName("GetAdvisoryLinksets");
|
||||
|
||||
if (authorityConfigured)
|
||||
{
|
||||
advisoryLinksetsEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
||||
}
|
||||
|
||||
// Advisory linksets export endpoint for evidence bundles
|
||||
var advisoryLinksetsExportEndpoint = app.MapGet("/advisories/linksets/export", async (
|
||||
HttpContext context,
|
||||
[FromServices] IAdvisoryLinksetQueryService linksetService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var authorizationError = EnsureTenantAuthorized(context, tenant);
|
||||
if (authorizationError is not null)
|
||||
{
|
||||
return authorizationError;
|
||||
}
|
||||
|
||||
var query = context.Request.Query;
|
||||
|
||||
var advisoryIds = query.TryGetValue("advisoryId", out var advisoryIdValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(advisoryIdValues)
|
||||
: null;
|
||||
|
||||
var sources = query.TryGetValue("source", out var sourceValues)
|
||||
? AdvisoryRawRequestMapper.NormalizeStrings(sourceValues)
|
||||
: null;
|
||||
|
||||
var options = new AdvisoryLinksetQueryOptions(tenant, advisoryIds, sources, 1000, null);
|
||||
var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Export format with provenance metadata
|
||||
var exportItems = result.Linksets.Select(linkset => new
|
||||
{
|
||||
advisoryId = linkset.AdvisoryId,
|
||||
source = linkset.Source,
|
||||
tenantId = linkset.TenantId,
|
||||
observationIds = linkset.ObservationIds.ToArray(),
|
||||
confidence = linkset.Confidence,
|
||||
conflicts = linkset.Conflicts?.Select(c => new
|
||||
{
|
||||
field = c.Field,
|
||||
reason = c.Reason,
|
||||
values = c.Values,
|
||||
sourceIds = c.SourceIds
|
||||
}).ToArray(),
|
||||
normalized = linkset.Normalized is not null ? new
|
||||
{
|
||||
purls = linkset.Normalized.Purls,
|
||||
cpes = linkset.Normalized.Cpes,
|
||||
versions = linkset.Normalized.Versions
|
||||
} : null,
|
||||
provenance = linkset.Provenance is not null ? new
|
||||
{
|
||||
observationHashes = linkset.Provenance.ObservationHashes,
|
||||
toolVersion = linkset.Provenance.ToolVersion,
|
||||
policyHash = linkset.Provenance.PolicyHash
|
||||
} : null,
|
||||
createdAt = linkset.CreatedAt,
|
||||
builtByJobId = linkset.BuiltByJobId
|
||||
}).ToArray();
|
||||
|
||||
var export = new
|
||||
{
|
||||
tenant = tenant,
|
||||
exportedAt = timeProvider.GetUtcNow(),
|
||||
count = exportItems.Length,
|
||||
hasMore = result.HasMore,
|
||||
linksets = exportItems
|
||||
};
|
||||
|
||||
return JsonResult(export);
|
||||
}).WithName("ExportAdvisoryLinksets");
|
||||
|
||||
if (authorityConfigured)
|
||||
{
|
||||
advisoryLinksetsExportEndpoint.RequireAuthorization(AdvisoryReadPolicyName);
|
||||
}
|
||||
|
||||
// Internal endpoint for publishing observation events to NATS/Redis.
|
||||
// Publishes advisory.observation.updated@1 events with tenant + provenance references only.
|
||||
app.MapPost("/internal/events/observations/publish", async (
|
||||
HttpContext context,
|
||||
[FromBody] ObservationEventPublishRequest request,
|
||||
[FromServices] IAdvisoryObservationQueryService observationService,
|
||||
[FromServices] IAdvisoryObservationEventPublisher? eventPublisher,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (eventPublisher is null)
|
||||
{
|
||||
return Problem(context, "Event publishing not configured", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, "Event publisher service is not available.");
|
||||
}
|
||||
|
||||
if (request?.ObservationIds is null || request.ObservationIds.Count == 0)
|
||||
{
|
||||
return Problem(context, "observationIds required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide at least one observation ID.");
|
||||
}
|
||||
|
||||
var options = new AdvisoryObservationQueryOptions(tenant, observationIds: request.ObservationIds);
|
||||
var result = await observationService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var published = 0;
|
||||
foreach (var observation in result.Observations)
|
||||
{
|
||||
var @event = AdvisoryObservationUpdatedEvent.FromObservation(
|
||||
observation,
|
||||
supersedesId: null,
|
||||
traceId: context.TraceIdentifier);
|
||||
|
||||
await eventPublisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false);
|
||||
published++;
|
||||
}
|
||||
|
||||
return Results.Ok(new { tenant, published, requestedCount = request.ObservationIds.Count, timestamp = timeProvider.GetUtcNow() });
|
||||
}).WithName("PublishObservationEvents");
|
||||
|
||||
// Internal endpoint for publishing linkset events to NATS/Redis.
|
||||
// Publishes advisory.linkset.updated@1 events with idempotent keys and tenant + provenance references.
|
||||
app.MapPost("/internal/events/linksets/publish", async (
|
||||
HttpContext context,
|
||||
[FromBody] LinksetEventPublishRequest request,
|
||||
[FromServices] IAdvisoryLinksetQueryService linksetService,
|
||||
[FromServices] IAdvisoryLinksetEventPublisher? eventPublisher,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (eventPublisher is null)
|
||||
{
|
||||
return Problem(context, "Event publishing not configured", StatusCodes.Status503ServiceUnavailable, ProblemTypes.ServiceUnavailable, "Event publisher service is not available.");
|
||||
}
|
||||
|
||||
if (request?.AdvisoryIds is null || request.AdvisoryIds.Count == 0)
|
||||
{
|
||||
return Problem(context, "advisoryIds required", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "Provide at least one advisory ID.");
|
||||
}
|
||||
|
||||
var options = new AdvisoryLinksetQueryOptions(tenant, request.AdvisoryIds, null, 500);
|
||||
var result = await linksetService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var published = 0;
|
||||
foreach (var linkset in result.Linksets)
|
||||
{
|
||||
var linksetId = $"{linkset.TenantId}:{linkset.Source}:{linkset.AdvisoryId}";
|
||||
var @event = AdvisoryLinksetUpdatedEvent.FromLinkset(
|
||||
linkset,
|
||||
previousLinkset: null,
|
||||
linksetId: linksetId,
|
||||
traceId: context.TraceIdentifier);
|
||||
|
||||
await eventPublisher.PublishAsync(@event, cancellationToken).ConfigureAwait(false);
|
||||
published++;
|
||||
}
|
||||
|
||||
return Results.Ok(new { tenant, published, requestedCount = request.AdvisoryIds.Count, hasMore = result.HasMore, timestamp = timeProvider.GetUtcNow() });
|
||||
}).WithName("PublishLinksetEvents");
|
||||
|
||||
var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKey}", async (
|
||||
string advisoryKey,
|
||||
HttpContext context,
|
||||
@@ -1743,7 +2117,7 @@ var advisorySummaryEndpoint = app.MapGet("/advisories/summary", async (
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return Results.BadRequest(ex.Message);
|
||||
return ConcelierProblemResultFactory.ValidationFailed(context, ex.Message);
|
||||
}
|
||||
|
||||
var items = queryResult.Linksets
|
||||
@@ -1947,13 +2321,13 @@ app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async (
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityKey))
|
||||
{
|
||||
return Results.BadRequest("vulnerabilityKey must be provided.");
|
||||
return ConcelierProblemResultFactory.VulnerabilityKeyRequired(context);
|
||||
}
|
||||
|
||||
var replay = await eventLog.ReplayAsync(vulnerabilityKey.Trim(), asOf, cancellationToken).ConfigureAwait(false);
|
||||
if (replay.Statements.Length == 0 && replay.Conflicts.Length == 0)
|
||||
{
|
||||
return Results.NotFound();
|
||||
return ConcelierProblemResultFactory.VulnerabilityNotFound(context, vulnerabilityKey);
|
||||
}
|
||||
|
||||
var response = new
|
||||
@@ -2309,7 +2683,7 @@ IResult JsonResult<T>(T value, int? statusCode = null)
|
||||
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
|
||||
}
|
||||
|
||||
IResult Problem(HttpContext context, string title, int statusCode, string type, string? detail = null, IDictionary<string, object?>? extensions = null)
|
||||
IResult Problem(HttpContext context, string title, int statusCode, string type, string? detail = null, IDictionary<string, object?>? extensions = null, string? errorCode = null)
|
||||
{
|
||||
var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
|
||||
extensions ??= new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
@@ -2322,6 +2696,12 @@ IResult Problem(HttpContext context, string title, int statusCode, string type,
|
||||
extensions["traceId"] = traceId;
|
||||
}
|
||||
|
||||
// Per CONCELIER-WEB-OAS-61-002: Add error code extension for machine-readable errors
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
{
|
||||
extensions["error"] = new { code = errorCode, message = detail ?? title };
|
||||
}
|
||||
|
||||
var problemDetails = new ProblemDetails
|
||||
{
|
||||
Type = type,
|
||||
@@ -3208,7 +3588,7 @@ var concelierTimelineEndpoint = app.MapGet("/obs/concelier/timeline", async (
|
||||
var candidateCursor = cursor ?? context.Request.Headers["Last-Event-ID"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(candidateCursor) && !int.TryParse(candidateCursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out startId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "cursor must be integer" });
|
||||
return ConcelierProblemResultFactory.InvalidCursor(context);
|
||||
}
|
||||
|
||||
var logger = loggerFactory.CreateLogger("ConcelierTimeline");
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Concelier.WebService.Contracts;
|
||||
using StellaOps.Concelier.WebService.Diagnostics;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Results;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating standardized error responses.
|
||||
/// Per CONCELIER-WEB-OAS-61-002.
|
||||
/// </summary>
|
||||
public static class ConcelierProblemResultFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a standardized Problem response with error code.
|
||||
/// </summary>
|
||||
public static IResult Problem(
|
||||
HttpContext context,
|
||||
string type,
|
||||
string title,
|
||||
int statusCode,
|
||||
string errorCode,
|
||||
string? detail = null,
|
||||
string? target = null,
|
||||
IReadOnlyDictionary<string, object?>? metadata = null,
|
||||
IReadOnlyList<ValidationError>? innerErrors = null)
|
||||
{
|
||||
var envelope = new ErrorEnvelope
|
||||
{
|
||||
Type = type,
|
||||
Title = title,
|
||||
Status = statusCode,
|
||||
Detail = detail,
|
||||
Instance = context.Request.Path,
|
||||
TraceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
|
||||
Error = new ErrorDetail
|
||||
{
|
||||
Code = errorCode,
|
||||
Message = detail ?? title,
|
||||
Target = target,
|
||||
Metadata = metadata,
|
||||
InnerErrors = innerErrors
|
||||
}
|
||||
};
|
||||
|
||||
return Microsoft.AspNetCore.Http.Results.Json(envelope, statusCode: statusCode);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Validation Errors (400)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 400 Bad Request response for validation failure.
|
||||
/// </summary>
|
||||
public static IResult ValidationFailed(
|
||||
HttpContext context,
|
||||
string detail,
|
||||
string? target = null,
|
||||
IReadOnlyList<ValidationError>? innerErrors = null)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Validation failed",
|
||||
StatusCodes.Status400BadRequest,
|
||||
ErrorCodes.ValidationFailed,
|
||||
detail,
|
||||
target,
|
||||
innerErrors: innerErrors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 400 Bad Request response for required field missing.
|
||||
/// </summary>
|
||||
public static IResult RequiredFieldMissing(
|
||||
HttpContext context,
|
||||
string fieldName)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Required field missing",
|
||||
StatusCodes.Status400BadRequest,
|
||||
ErrorCodes.RequiredFieldMissing,
|
||||
$"{fieldName} is required.",
|
||||
fieldName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 400 Bad Request response for advisory ID required.
|
||||
/// </summary>
|
||||
public static IResult AdvisoryIdRequired(HttpContext context)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Advisory ID required",
|
||||
StatusCodes.Status400BadRequest,
|
||||
ErrorCodes.AdvisoryIdRequired,
|
||||
"advisoryId is required.",
|
||||
"advisoryId");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 400 Bad Request response for vulnerability key required.
|
||||
/// </summary>
|
||||
public static IResult VulnerabilityKeyRequired(HttpContext context)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Vulnerability key required",
|
||||
StatusCodes.Status400BadRequest,
|
||||
ErrorCodes.VulnerabilityKeyRequired,
|
||||
"vulnerabilityKey must be provided.",
|
||||
"vulnerabilityKey");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 400 Bad Request response for invalid cursor.
|
||||
/// </summary>
|
||||
public static IResult InvalidCursor(HttpContext context)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid cursor",
|
||||
StatusCodes.Status400BadRequest,
|
||||
ErrorCodes.InvalidCursor,
|
||||
"cursor must be an integer.",
|
||||
"cursor");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Not Found Errors (404)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 404 Not Found response for resource not found.
|
||||
/// </summary>
|
||||
public static IResult NotFound(
|
||||
HttpContext context,
|
||||
string errorCode,
|
||||
string resourceType,
|
||||
string? resourceId = null)
|
||||
{
|
||||
var detail = resourceId is not null
|
||||
? $"{resourceType} '{resourceId}' not found."
|
||||
: $"{resourceType} not found.";
|
||||
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
$"{resourceType} not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
errorCode,
|
||||
detail,
|
||||
resourceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 404 Not Found response for advisory not found.
|
||||
/// </summary>
|
||||
public static IResult AdvisoryNotFound(HttpContext context, string? advisoryId = null)
|
||||
{
|
||||
return NotFound(context, ErrorCodes.AdvisoryNotFound, "Advisory", advisoryId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 404 Not Found response for vulnerability not found.
|
||||
/// </summary>
|
||||
public static IResult VulnerabilityNotFound(HttpContext context, string? vulnerabilityKey = null)
|
||||
{
|
||||
return NotFound(context, ErrorCodes.VulnerabilityNotFound, "Vulnerability", vulnerabilityKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 404 Not Found response for evidence not found.
|
||||
/// </summary>
|
||||
public static IResult EvidenceNotFound(HttpContext context, string? evidenceId = null)
|
||||
{
|
||||
return NotFound(context, ErrorCodes.EvidenceNotFound, "Evidence", evidenceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 404 Not Found response for mirror not found.
|
||||
/// </summary>
|
||||
public static IResult MirrorNotFound(HttpContext context, string? mirrorId = null)
|
||||
{
|
||||
return NotFound(context, ErrorCodes.MirrorNotFound, "Mirror", mirrorId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 404 Not Found response for bundle source not found.
|
||||
/// </summary>
|
||||
public static IResult BundleSourceNotFound(HttpContext context, string? sourceId = null)
|
||||
{
|
||||
return NotFound(context, ErrorCodes.BundleSourceNotFound, "Bundle source", sourceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a generic 404 Not Found response.
|
||||
/// </summary>
|
||||
public static IResult ResourceNotFound(HttpContext context, string? detail = null)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Resource not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
ErrorCodes.ResourceNotFound,
|
||||
detail ?? "The requested resource was not found.");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Conflict Errors (409)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 409 Conflict response.
|
||||
/// </summary>
|
||||
public static IResult Conflict(
|
||||
HttpContext context,
|
||||
string errorCode,
|
||||
string detail,
|
||||
string? target = null)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.Conflict,
|
||||
"Conflict",
|
||||
StatusCodes.Status409Conflict,
|
||||
errorCode,
|
||||
detail,
|
||||
target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 409 Conflict response for lease conflict.
|
||||
/// </summary>
|
||||
public static IResult LeaseConflict(HttpContext context, string detail)
|
||||
{
|
||||
return Conflict(context, ErrorCodes.LeaseConflict, detail);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Locked Errors (423)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 423 Locked response.
|
||||
/// </summary>
|
||||
public static IResult Locked(
|
||||
HttpContext context,
|
||||
string errorCode,
|
||||
string detail)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.Locked,
|
||||
"Resource locked",
|
||||
StatusCodes.Status423Locked,
|
||||
errorCode,
|
||||
detail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 423 Locked response for lease rejection.
|
||||
/// </summary>
|
||||
public static IResult LeaseRejected(HttpContext context, string detail)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.LeaseRejected,
|
||||
"Lease rejected",
|
||||
StatusCodes.Status423Locked,
|
||||
ErrorCodes.LeaseRejected,
|
||||
detail);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// AirGap/Sealed Mode Errors
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 404 Not Found response for AirGap disabled.
|
||||
/// </summary>
|
||||
public static IResult AirGapDisabled(HttpContext context)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
"https://stellaops.org/problems/airgap-disabled",
|
||||
"AirGap mode disabled",
|
||||
StatusCodes.Status404NotFound,
|
||||
ErrorCodes.AirGapDisabled,
|
||||
"AirGap mode is not enabled on this instance.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 403 Forbidden response for sealed mode violation.
|
||||
/// </summary>
|
||||
public static IResult SealedModeViolation(
|
||||
HttpContext context,
|
||||
string sourceName,
|
||||
string destination)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
"https://stellaops.org/problems/sealed-violation",
|
||||
"Sealed mode violation",
|
||||
StatusCodes.Status403Forbidden,
|
||||
ErrorCodes.SealedModeViolation,
|
||||
$"Source '{sourceName}' is not allowed to access '{destination}' in sealed mode.",
|
||||
sourceName,
|
||||
new Dictionary<string, object?> { ["destination"] = destination });
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rate Limiting (429)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 429 Too Many Requests response.
|
||||
/// </summary>
|
||||
public static IResult RateLimitExceeded(HttpContext context, int? retryAfterSeconds = null)
|
||||
{
|
||||
var envelope = new ErrorEnvelope
|
||||
{
|
||||
Type = "https://stellaops.org/problems/rate-limit",
|
||||
Title = "Rate limit exceeded",
|
||||
Status = StatusCodes.Status429TooManyRequests,
|
||||
Detail = "Too many requests. Please try again later.",
|
||||
Instance = context.Request.Path,
|
||||
TraceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
|
||||
Error = new ErrorDetail
|
||||
{
|
||||
Code = ErrorCodes.RateLimitExceeded,
|
||||
Message = "Too many requests. Please try again later.",
|
||||
RetryAfter = retryAfterSeconds
|
||||
}
|
||||
};
|
||||
|
||||
return Microsoft.AspNetCore.Http.Results.Json(envelope, statusCode: StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Server Errors (5xx)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 500 Internal Server Error response.
|
||||
/// </summary>
|
||||
public static IResult InternalError(
|
||||
HttpContext context,
|
||||
string? detail = null)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
"https://stellaops.org/problems/internal-error",
|
||||
"Internal server error",
|
||||
StatusCodes.Status500InternalServerError,
|
||||
ErrorCodes.InternalError,
|
||||
detail ?? "An unexpected error occurred.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 503 Service Unavailable response.
|
||||
/// </summary>
|
||||
public static IResult ServiceUnavailable(
|
||||
HttpContext context,
|
||||
string? detail = null)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.ServiceUnavailable,
|
||||
"Service unavailable",
|
||||
StatusCodes.Status503ServiceUnavailable,
|
||||
ErrorCodes.ServiceUnavailable,
|
||||
detail ?? "The service is temporarily unavailable.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 500 response for job failure.
|
||||
/// </summary>
|
||||
public static IResult JobFailure(
|
||||
HttpContext context,
|
||||
string detail)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
ProblemTypes.JobFailure,
|
||||
"Job failure",
|
||||
StatusCodes.Status500InternalServerError,
|
||||
ErrorCodes.JobFailure,
|
||||
detail);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user