Refactor code structure for improved readability and maintainability

This commit is contained in:
StellaOps Bot
2025-12-06 10:23:40 +02:00
parent 6beb9d7c4e
commit 37304cf819
78 changed files with 5471 additions and 104 deletions

View File

@@ -10,7 +10,8 @@ public sealed record AdvisoryObservationQueryResponse(
ImmutableArray<AdvisoryObservation> Observations,
AdvisoryObservationLinksetAggregateResponse Linkset,
string? NextCursor,
bool HasMore);
bool HasMore,
DataFreshnessInfo? Freshness = null);
public sealed record AdvisoryObservationLinksetAggregateResponse(
ImmutableArray<string> Aliases,

View File

@@ -0,0 +1,217 @@
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.WebService.Contracts;
/// <summary>
/// Staleness metadata for air-gapped deployments.
/// Per CONCELIER-WEB-AIRGAP-56-002.
/// </summary>
public sealed record StalenessMetadata
{
/// <summary>
/// When the data was last refreshed from its source.
/// </summary>
[JsonPropertyName("lastRefreshedAt")]
public DateTimeOffset? LastRefreshedAt { get; init; }
/// <summary>
/// Age of the data in seconds since last refresh.
/// </summary>
[JsonPropertyName("ageSeconds")]
public long? AgeSeconds { get; init; }
/// <summary>
/// Whether the data is considered stale based on configured thresholds.
/// </summary>
[JsonPropertyName("isStale")]
public bool IsStale { get; init; }
/// <summary>
/// Staleness threshold in seconds (data older than this is stale).
/// </summary>
[JsonPropertyName("thresholdSeconds")]
public long? ThresholdSeconds { get; init; }
/// <summary>
/// Human-readable staleness status.
/// </summary>
[JsonPropertyName("status")]
public string Status { get; init; } = "unknown";
/// <summary>
/// Creates a fresh staleness metadata.
/// </summary>
public static StalenessMetadata Fresh(DateTimeOffset refreshedAt, long thresholdSeconds = 86400)
{
return new StalenessMetadata
{
LastRefreshedAt = refreshedAt,
AgeSeconds = 0,
IsStale = false,
ThresholdSeconds = thresholdSeconds,
Status = "fresh"
};
}
/// <summary>
/// Creates staleness metadata based on refresh time and threshold.
/// </summary>
public static StalenessMetadata Compute(
DateTimeOffset? lastRefreshedAt,
DateTimeOffset now,
long thresholdSeconds = 86400)
{
if (!lastRefreshedAt.HasValue)
{
return new StalenessMetadata
{
LastRefreshedAt = null,
AgeSeconds = null,
IsStale = true,
ThresholdSeconds = thresholdSeconds,
Status = "unknown"
};
}
var age = (long)(now - lastRefreshedAt.Value).TotalSeconds;
var isStale = age > thresholdSeconds;
return new StalenessMetadata
{
LastRefreshedAt = lastRefreshedAt,
AgeSeconds = age,
IsStale = isStale,
ThresholdSeconds = thresholdSeconds,
Status = isStale ? "stale" : "fresh"
};
}
}
/// <summary>
/// Bundle provenance metadata for air-gapped deployments.
/// Per CONCELIER-WEB-AIRGAP-56-002.
/// </summary>
public sealed record BundleProvenanceMetadata
{
/// <summary>
/// Bundle identifier the data originated from.
/// </summary>
[JsonPropertyName("bundleId")]
public string? BundleId { get; init; }
/// <summary>
/// Bundle version.
/// </summary>
[JsonPropertyName("bundleVersion")]
public string? BundleVersion { get; init; }
/// <summary>
/// Source that provided the bundle.
/// </summary>
[JsonPropertyName("sourceId")]
public string? SourceId { get; init; }
/// <summary>
/// When the bundle was imported.
/// </summary>
[JsonPropertyName("importedAt")]
public DateTimeOffset? ImportedAt { get; init; }
/// <summary>
/// Content hash for integrity verification.
/// </summary>
[JsonPropertyName("contentHash")]
public string? ContentHash { get; init; }
/// <summary>
/// Signature status (verified, unverified, unsigned).
/// </summary>
[JsonPropertyName("signatureStatus")]
public string? SignatureStatus { get; init; }
/// <summary>
/// Key ID used for signing.
/// </summary>
[JsonPropertyName("signatureKeyId")]
public string? SignatureKeyId { get; init; }
/// <summary>
/// Whether this data came from an air-gapped bundle (vs direct ingestion).
/// </summary>
[JsonPropertyName("isAirGapped")]
public bool IsAirGapped { get; init; }
}
/// <summary>
/// Combined data freshness information for API responses.
/// Per CONCELIER-WEB-AIRGAP-56-002.
/// </summary>
public sealed record DataFreshnessInfo
{
/// <summary>
/// Staleness metadata.
/// </summary>
[JsonPropertyName("staleness")]
public StalenessMetadata? Staleness { get; init; }
/// <summary>
/// Bundle provenance if data came from an air-gap bundle.
/// </summary>
[JsonPropertyName("bundleProvenance")]
public BundleProvenanceMetadata? BundleProvenance { get; init; }
/// <summary>
/// Whether data is from an air-gapped source.
/// </summary>
[JsonPropertyName("isAirGapped")]
public bool IsAirGapped => BundleProvenance?.IsAirGapped ?? false;
/// <summary>
/// Computed at timestamp.
/// </summary>
[JsonPropertyName("computedAt")]
public DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Creates freshness info for online (non-air-gapped) data.
/// </summary>
public static DataFreshnessInfo Online(DateTimeOffset now, DateTimeOffset? lastRefreshedAt = null)
{
return new DataFreshnessInfo
{
Staleness = StalenessMetadata.Compute(lastRefreshedAt ?? now, now),
BundleProvenance = null,
ComputedAt = now
};
}
/// <summary>
/// Creates freshness info for air-gapped data.
/// </summary>
public static DataFreshnessInfo AirGapped(
DateTimeOffset now,
string bundleId,
string? bundleVersion,
string sourceId,
DateTimeOffset importedAt,
string? contentHash = null,
string? signatureStatus = null,
long stalenessThresholdSeconds = 86400)
{
return new DataFreshnessInfo
{
Staleness = StalenessMetadata.Compute(importedAt, now, stalenessThresholdSeconds),
BundleProvenance = new BundleProvenanceMetadata
{
BundleId = bundleId,
BundleVersion = bundleVersion,
SourceId = sourceId,
ImportedAt = importedAt,
ContentHash = contentHash,
SignatureStatus = signatureStatus,
IsAirGapped = true
},
ComputedAt = now
};
}
}

View File

@@ -21,7 +21,8 @@ public sealed record LnmLinksetResponse(
[property: JsonPropertyName("normalized")] LnmLinksetNormalized? Normalized,
[property: JsonPropertyName("cached")] bool Cached,
[property: JsonPropertyName("remarks")] IReadOnlyList<string> Remarks,
[property: JsonPropertyName("observations")] IReadOnlyList<string> Observations);
[property: JsonPropertyName("observations")] IReadOnlyList<string> Observations,
[property: JsonPropertyName("freshness")] DataFreshnessInfo? Freshness = null);
public sealed record LnmLinksetPage(
[property: JsonPropertyName("items")] IReadOnlyList<LnmLinksetResponse> Items,

View File

@@ -62,6 +62,9 @@ public static class ErrorCodes
/// <summary>Bundle source not found.</summary>
public const string BundleSourceNotFound = "BUNDLE_SOURCE_NOT_FOUND";
/// <summary>Bundle not found in catalog.</summary>
public const string BundleNotFound = "BUNDLE_NOT_FOUND";
// ─────────────────────────────────────────────────────────────────────────
// AOC (Aggregation-Only Contract) Errors
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -161,5 +162,118 @@ internal static class AirGapEndpointExtensions
var status = sealedModeEnforcer.GetStatus();
return Results.Ok(status);
});
// POST /api/v1/concelier/airgap/bundles/{bundleId}/import - Import a bundle with timeline event
// Per CONCELIER-WEB-AIRGAP-58-001
group.MapPost("/bundles/{bundleId}/import", async (
HttpContext context,
IBundleCatalogService catalogService,
IBundleTimelineEmitter timelineEmitter,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
string bundleId,
[FromBody] BundleImportRequestDto requestDto,
CancellationToken cancellationToken) =>
{
var airGapOptions = optionsMonitor.CurrentValue.AirGap;
if (!airGapOptions.Enabled)
{
return ConcelierProblemResultFactory.AirGapDisabled(context);
}
if (string.IsNullOrWhiteSpace(requestDto.TenantId))
{
return ConcelierProblemResultFactory.RequiredFieldMissing(context, "tenantId");
}
// Find the bundle in the catalog
var catalog = await catalogService.GetCatalogAsync(null, 1000, cancellationToken)
.ConfigureAwait(false);
var bundle = catalog.Entries.FirstOrDefault(e => e.BundleId == bundleId);
if (bundle is null)
{
return ConcelierProblemResultFactory.BundleNotFound(context, bundleId);
}
// Create actor from request or default
var actor = new BundleImportActor
{
Id = requestDto.ActorId ?? context.User?.Identity?.Name ?? "anonymous",
Type = requestDto.ActorType ?? "user",
DisplayName = requestDto.ActorDisplayName
};
// Create import request
var importRequest = new BundleImportRequest
{
TenantId = requestDto.TenantId,
Bundle = bundle,
Scope = Enum.TryParse<BundleImportScope>(requestDto.Scope, true, out var scope)
? scope
: BundleImportScope.Delta,
Actor = actor,
TraceId = Activity.Current?.TraceId.ToString()
};
// Simulate import (actual import would happen via ingestion pipeline)
var sw = Stopwatch.StartNew();
// TODO: Wire actual bundle import logic here
var importStats = new BundleImportStats
{
TotalItems = bundle.ItemCount,
ItemsAdded = bundle.ItemCount,
ItemsUpdated = 0,
ItemsRemoved = 0,
ItemsSkipped = 0,
DurationMs = sw.ElapsedMilliseconds,
SizeBytes = bundle.SizeBytes
};
var importResult = new BundleImportResult
{
Success = true,
Stats = importStats,
EvidenceBundleRef = requestDto.EvidenceBundleRef
};
// Emit timeline event
var timelineEvent = await timelineEmitter.EmitImportAsync(importRequest, importResult, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new BundleImportResponseDto
{
EventId = timelineEvent.EventId,
BundleId = bundleId,
TenantId = requestDto.TenantId,
Stats = importStats,
OccurredAt = timelineEvent.OccurredAt
});
});
}
}
/// <summary>
/// Request DTO for bundle import.
/// </summary>
public sealed record BundleImportRequestDto
{
public required string TenantId { get; init; }
public string? Scope { get; init; }
public string? ActorId { get; init; }
public string? ActorType { get; init; }
public string? ActorDisplayName { get; init; }
public string? EvidenceBundleRef { get; init; }
}
/// <summary>
/// Response DTO for bundle import.
/// </summary>
public sealed record BundleImportResponseDto
{
public Guid EventId { get; init; }
public required string BundleId { get; init; }
public required string TenantId { get; init; }
public required BundleImportStats Stats { get; init; }
public DateTimeOffset OccurredAt { get; init; }
}

View File

@@ -2552,7 +2552,8 @@ LnmLinksetResponse ToLnmResponse(
bool includeConflicts,
bool includeTimeline,
bool includeObservations,
LinksetObservationSummary summary)
LinksetObservationSummary summary,
DataFreshnessInfo? freshness = null)
{
var normalized = linkset.Normalized;
var severity = summary.Severity ?? (normalized?.Severities?.FirstOrDefault() is { } severityDict
@@ -2607,7 +2608,8 @@ LnmLinksetResponse ToLnmResponse(
normalizedDto,
Cached: false,
Remarks: Array.Empty<string>(),
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>());
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>(),
Freshness: freshness);
}
string? ExtractSeverity(IReadOnlyDictionary<string, object?> severityDict)

View File

@@ -199,6 +199,14 @@ public static class ConcelierProblemResultFactory
return NotFound(context, ErrorCodes.BundleSourceNotFound, "Bundle source", sourceId);
}
/// <summary>
/// Creates a 404 Not Found response for bundle not found.
/// </summary>
public static IResult BundleNotFound(HttpContext context, string? bundleId = null)
{
return NotFound(context, ErrorCodes.BundleNotFound, "Bundle", bundleId);
}
/// <summary>
/// Creates a generic 404 Not Found response.
/// </summary>
@@ -316,6 +324,64 @@ public static class ConcelierProblemResultFactory
new Dictionary<string, object?> { ["destination"] = destination });
}
/// <summary>
/// Creates a 403 Forbidden response for egress blocked with full payload and remediation.
/// Per CONCELIER-WEB-AIRGAP-57-001.
/// </summary>
public static IResult EgressBlocked(
HttpContext context,
StellaOps.Concelier.Core.AirGap.SealedModeViolationException exception)
{
var payload = exception.Payload;
var envelope = new ErrorEnvelope
{
Type = "https://stellaops.org/problems/airgap-egress-blocked",
Title = "Egress blocked by sealed mode",
Status = StatusCodes.Status403Forbidden,
Detail = payload.Reason,
Instance = context.Request.Path,
TraceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
Error = new ErrorDetail
{
Code = StellaOps.Concelier.Core.AirGap.Models.AirGapEgressBlockedPayload.ErrorCode,
Message = payload.Reason,
Target = payload.SourceName,
Metadata = new Dictionary<string, object?>
{
["sourceName"] = payload.SourceName,
["destination"] = payload.Destination,
["destinationHost"] = payload.DestinationHost,
["occurredAt"] = payload.OccurredAt,
["wasBlocked"] = payload.WasBlocked,
["remediation"] = new
{
summary = payload.Remediation.Summary,
steps = payload.Remediation.Steps.Select(s => new
{
order = s.Order,
action = s.Action,
description = s.Description
}).ToArray(),
configurationHints = payload.Remediation.ConfigurationHints.Select(h => new
{
key = h.Key,
description = h.Description,
example = h.Example
}).ToArray(),
documentationLinks = payload.Remediation.DocumentationLinks.Select(l => new
{
title = l.Title,
url = l.Url
}).ToArray()
}
},
HelpUrl = "https://docs.stellaops.org/concelier/airgap/sealed-mode"
}
};
return Microsoft.AspNetCore.Http.Results.Json(envelope, statusCode: StatusCodes.Status403Forbidden);
}
// ─────────────────────────────────────────────────────────────────────────
// Rate Limiting (429)
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -1,8 +1,25 @@
openapi: 3.1.0
info:
title: StellaOps Concelier Link-Not-Merge Policy APIs
version: "0.1.0"
description: Fact-only advisory/linkset retrieval for Policy Engine consumers.
version: "1.0.0"
description: |
Fact-only advisory/linkset retrieval for Policy Engine consumers.
## Philosophy
Link-Not-Merge (LNM) provides raw advisory data with full provenance:
- **Link**: Observations from multiple sources are linked via shared identifiers.
- **Not Merge**: Conflicting data is preserved rather than collapsed.
- **Surface, Don't Resolve**: Conflicts are clearly marked for consumers.
## Authentication
All endpoints require the `X-Stella-Tenant` header for multi-tenant isolation.
## Pagination
List endpoints support cursor-based pagination with `page` and `pageSize` parameters.
Maximum page size is 200 items.
## Documentation
See `/docs/modules/concelier/api/` for detailed examples and conflict resolution strategies.
servers:
- url: /
description: Relative base path (API Gateway rewrites in production).
@@ -44,6 +61,65 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/PagedLinksets'
examples:
single-linkset:
summary: Single linkset result
value:
items:
- advisoryId: "CVE-2021-23337"
source: "nvd"
purl: ["pkg:npm/lodash@4.17.20"]
cpe: ["cpe:2.3:a:lodash:lodash:4.17.20:*:*:*:*:node.js:*:*"]
summary: "Lodash Command Injection vulnerability"
publishedAt: "2021-02-15T13:15:00Z"
modifiedAt: "2024-08-04T19:16:00Z"
severity: "high"
provenance:
ingestedAt: "2025-11-20T10:30:00Z"
connectorId: "nvd-osv-connector"
evidenceHash: "sha256:a1b2c3d4e5f6"
conflicts: []
cached: false
page: 1
pageSize: 50
total: 1
with-conflicts:
summary: Linkset with severity conflict
value:
items:
- advisoryId: "CVE-2024-1234"
source: "aggregated"
purl: ["pkg:npm/example@1.0.0"]
cpe: []
severity: "high"
provenance:
ingestedAt: "2025-11-20T10:30:00Z"
connectorId: "multi-source"
conflicts:
- field: "severity"
reason: "severity-mismatch"
observedValue: "critical"
observedAt: "2025-11-18T08:00:00Z"
evidenceHash: "sha256:conflict-hash"
cached: false
page: 1
pageSize: 50
total: 1
"400":
description: Invalid request parameters
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
example:
type: "https://stellaops.io/errors/validation-failed"
title: "Validation Failed"
status: 400
detail: "The 'pageSize' parameter exceeds the maximum allowed value."
error:
code: "ERR_PAGE_SIZE_EXCEEDED"
message: "Page size must be between 1 and 200."
target: "pageSize"
/v1/lnm/linksets/{advisoryId}:
get:
summary: Get linkset by advisory ID
@@ -275,3 +351,63 @@ components:
event: { type: string }
at: { type: string, format: date-time }
evidenceHash: { type: string }
ErrorEnvelope:
type: object
description: RFC 7807 Problem Details with StellaOps extensions
properties:
type:
type: string
format: uri
description: URI identifying the problem type
title:
type: string
description: Short, human-readable summary
status:
type: integer
description: HTTP status code
detail:
type: string
description: Specific explanation of the problem
instance:
type: string
format: uri
description: URI of the specific occurrence
traceId:
type: string
description: Distributed trace identifier
error:
$ref: '#/components/schemas/ErrorDetail'
ErrorDetail:
type: object
description: Machine-readable error information
properties:
code:
type: string
description: Machine-readable error code (e.g., ERR_VALIDATION_FAILED)
message:
type: string
description: Human-readable error message
target:
type: string
description: Field or resource that caused the error
metadata:
type: object
additionalProperties: true
description: Additional contextual data
innerErrors:
type: array
items:
$ref: '#/components/schemas/ValidationError'
description: Nested validation errors
ValidationError:
type: object
properties:
field:
type: string
description: Field path (e.g., "data.severity")
code:
type: string
description: Error code for this field
message:
type: string
description: Human-readable message