up
This commit is contained in:
@@ -76,6 +76,14 @@ services.AddRedHatCsafConnector();
|
||||
services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName));
|
||||
services.AddSingleton<MirrorRateLimiter>();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
// CRYPTO-90-001: Crypto provider abstraction for pluggable hashing algorithms (GOST/SM support)
|
||||
services.AddSingleton<IVexHashingService>(sp =>
|
||||
{
|
||||
// When ICryptoProviderRegistry is available, use it for pluggable algorithms
|
||||
var registry = sp.GetService<StellaOps.Cryptography.ICryptoProviderRegistry>();
|
||||
return new VexHashingService(registry);
|
||||
});
|
||||
services.AddSingleton<IVexObservationProjectionService, VexObservationProjectionService>();
|
||||
services.AddScoped<IVexObservationQueryService, VexObservationQueryService>();
|
||||
|
||||
@@ -387,6 +395,471 @@ app.MapGet("/openapi/excititor.json", () =>
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// WEB-OBS-53-001: Evidence API endpoints
|
||||
["/evidence/vex/list"] = new
|
||||
{
|
||||
get = new
|
||||
{
|
||||
summary = "List VEX evidence exports",
|
||||
parameters = new object[]
|
||||
{
|
||||
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false },
|
||||
new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false },
|
||||
new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false }
|
||||
},
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
description = "Evidence list response",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
examples = new Dictionary<string, object>
|
||||
{
|
||||
["evidence-list"] = new
|
||||
{
|
||||
value = new
|
||||
{
|
||||
items = new[] {
|
||||
new {
|
||||
bundleId = "vex-bundle-2025-11-24-001",
|
||||
tenant = "acme",
|
||||
format = "openvex",
|
||||
createdAt = "2025-11-24T00:00:00Z",
|
||||
itemCount = 42,
|
||||
merkleRoot = "sha256:abc123...",
|
||||
sealed_ = false
|
||||
}
|
||||
},
|
||||
nextCursor = (string?)null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["/evidence/vex/bundle/{bundleId}"] = new
|
||||
{
|
||||
get = new
|
||||
{
|
||||
summary = "Get VEX evidence bundle details",
|
||||
parameters = new object[]
|
||||
{
|
||||
new { name = "bundleId", @in = "path", schema = new { type = "string" }, required = true },
|
||||
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
|
||||
},
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
description = "Bundle detail response",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
examples = new Dictionary<string, object>
|
||||
{
|
||||
["bundle-detail"] = new
|
||||
{
|
||||
value = new
|
||||
{
|
||||
bundleId = "vex-bundle-2025-11-24-001",
|
||||
tenant = "acme",
|
||||
format = "openvex",
|
||||
specVersion = "0.2.0",
|
||||
createdAt = "2025-11-24T00:00:00Z",
|
||||
itemCount = 42,
|
||||
merkleRoot = "sha256:abc123...",
|
||||
sealed_ = false,
|
||||
metadata = new { source = "excititor" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["404"] = new
|
||||
{
|
||||
description = "Bundle not found",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
schema = new { @ref = "#/components/schemas/Error" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["/evidence/vex/lookup"] = new
|
||||
{
|
||||
get = new
|
||||
{
|
||||
summary = "Lookup evidence for vulnerability/product pair",
|
||||
parameters = new object[]
|
||||
{
|
||||
new { name = "vulnerabilityId", @in = "query", schema = new { type = "string" }, required = true, example = "CVE-2024-12345" },
|
||||
new { name = "productKey", @in = "query", schema = new { type = "string" }, required = true, example = "pkg:npm/lodash@4.17.21" },
|
||||
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
|
||||
},
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
description = "Evidence lookup response",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
examples = new Dictionary<string, object>
|
||||
{
|
||||
["lookup-result"] = new
|
||||
{
|
||||
value = new
|
||||
{
|
||||
vulnerabilityId = "CVE-2024-12345",
|
||||
productKey = "pkg:npm/lodash@4.17.21",
|
||||
evidence = new[] {
|
||||
new { bundleId = "vex-bundle-001", observationId = "obs-001" }
|
||||
},
|
||||
queriedAt = "2025-11-24T12:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// WEB-OBS-54-001: Attestation API endpoints
|
||||
["/attestations/vex/list"] = new
|
||||
{
|
||||
get = new
|
||||
{
|
||||
summary = "List VEX attestations",
|
||||
parameters = new object[]
|
||||
{
|
||||
new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 200 }, required = false },
|
||||
new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false },
|
||||
new { name = "vulnerabilityId", @in = "query", schema = new { type = "string" }, required = false },
|
||||
new { name = "productKey", @in = "query", schema = new { type = "string" }, required = false },
|
||||
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
|
||||
},
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
description = "Attestation list response",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
examples = new Dictionary<string, object>
|
||||
{
|
||||
["attestation-list"] = new
|
||||
{
|
||||
value = new
|
||||
{
|
||||
items = new[] {
|
||||
new {
|
||||
attestationId = "att-2025-001",
|
||||
tenant = "acme",
|
||||
createdAt = "2025-11-24T00:00:00Z",
|
||||
predicateType = "https://in-toto.io/attestation/v1",
|
||||
subjectDigest = "sha256:abc123...",
|
||||
valid = true,
|
||||
builderId = "excititor:redhat"
|
||||
}
|
||||
},
|
||||
nextCursor = (string?)null,
|
||||
hasMore = false,
|
||||
count = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["/attestations/vex/{attestationId}"] = new
|
||||
{
|
||||
get = new
|
||||
{
|
||||
summary = "Get VEX attestation details with DSSE verification state",
|
||||
parameters = new object[]
|
||||
{
|
||||
new { name = "attestationId", @in = "path", schema = new { type = "string" }, required = true },
|
||||
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
|
||||
},
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
description = "Attestation detail response with chain-of-custody",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
examples = new Dictionary<string, object>
|
||||
{
|
||||
["attestation-detail"] = new
|
||||
{
|
||||
value = new
|
||||
{
|
||||
attestationId = "att-2025-001",
|
||||
tenant = "acme",
|
||||
createdAt = "2025-11-24T00:00:00Z",
|
||||
predicateType = "https://in-toto.io/attestation/v1",
|
||||
subject = new { digest = "sha256:abc123...", name = "CVE-2024-12345/pkg:npm/lodash@4.17.21" },
|
||||
builder = new { id = "excititor:redhat", builderId = "excititor:redhat" },
|
||||
verification = new { valid = true, verifiedAt = "2025-11-24T00:00:00Z", signatureType = "dsse" },
|
||||
chainOfCustody = new[] {
|
||||
new { step = 1, actor = "excititor:redhat", action = "created", timestamp = "2025-11-24T00:00:00Z" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["404"] = new
|
||||
{
|
||||
description = "Attestation not found",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
schema = new { @ref = "#/components/schemas/Error" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["/attestations/vex/lookup"] = new
|
||||
{
|
||||
get = new
|
||||
{
|
||||
summary = "Lookup attestations by linkset or observation",
|
||||
parameters = new object[]
|
||||
{
|
||||
new { name = "linksetId", @in = "query", schema = new { type = "string" }, required = false },
|
||||
new { name = "observationId", @in = "query", schema = new { type = "string" }, required = false },
|
||||
new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false },
|
||||
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
|
||||
},
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
description = "Attestation lookup response",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
examples = new Dictionary<string, object>
|
||||
{
|
||||
["lookup-result"] = new
|
||||
{
|
||||
value = new
|
||||
{
|
||||
subjectDigest = "linkset-001",
|
||||
attestations = new[] {
|
||||
new { attestationId = "att-001", valid = true }
|
||||
},
|
||||
queriedAt = "2025-11-24T12:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["400"] = new
|
||||
{
|
||||
description = "Missing required parameter",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
schema = new { @ref = "#/components/schemas/Error" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// EXCITITOR-LNM-21-201: Observation API endpoints
|
||||
["/vex/observations"] = new
|
||||
{
|
||||
get = new
|
||||
{
|
||||
summary = "List VEX observations with filters",
|
||||
parameters = new object[]
|
||||
{
|
||||
new { name = "limit", @in = "query", schema = new { type = "integer", minimum = 1, maximum = 100 }, required = false },
|
||||
new { name = "cursor", @in = "query", schema = new { type = "string" }, required = false },
|
||||
new { name = "vulnerabilityId", @in = "query", schema = new { type = "string" }, required = false, example = "CVE-2024-12345" },
|
||||
new { name = "productKey", @in = "query", schema = new { type = "string" }, required = false, example = "pkg:npm/lodash@4.17.21" },
|
||||
new { name = "providerId", @in = "query", schema = new { type = "string" }, required = false, example = "excititor:redhat" },
|
||||
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
|
||||
},
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
description = "Observation list response",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
examples = new Dictionary<string, object>
|
||||
{
|
||||
["observation-list"] = new
|
||||
{
|
||||
value = new
|
||||
{
|
||||
items = new[] {
|
||||
new {
|
||||
observationId = "obs-2025-001",
|
||||
tenant = "acme",
|
||||
providerId = "excititor:redhat",
|
||||
vulnerabilityId = "CVE-2024-12345",
|
||||
productKey = "pkg:npm/lodash@4.17.21",
|
||||
status = "not_affected",
|
||||
createdAt = "2025-11-24T00:00:00Z"
|
||||
}
|
||||
},
|
||||
nextCursor = (string?)null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["400"] = new
|
||||
{
|
||||
description = "Missing required filter",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
schema = new { @ref = "#/components/schemas/Error" },
|
||||
examples = new Dictionary<string, object>
|
||||
{
|
||||
["missing-filter"] = new
|
||||
{
|
||||
value = new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "ERR_PARAMS",
|
||||
message = "At least one filter is required: vulnerabilityId+productKey or providerId"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["/vex/observations/{observationId}"] = new
|
||||
{
|
||||
get = new
|
||||
{
|
||||
summary = "Get VEX observation by ID",
|
||||
parameters = new object[]
|
||||
{
|
||||
new { name = "observationId", @in = "path", schema = new { type = "string" }, required = true },
|
||||
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
|
||||
},
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
description = "Observation detail response",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
examples = new Dictionary<string, object>
|
||||
{
|
||||
["observation-detail"] = new
|
||||
{
|
||||
value = new
|
||||
{
|
||||
observationId = "obs-2025-001",
|
||||
tenant = "acme",
|
||||
providerId = "excititor:redhat",
|
||||
streamId = "stream-001",
|
||||
upstream = new { upstreamId = "RHSA-2024:001", fetchedAt = "2025-11-24T00:00:00Z" },
|
||||
content = new { format = "csaf", specVersion = "2.0" },
|
||||
statements = new[] {
|
||||
new { vulnerabilityId = "CVE-2024-12345", productKey = "pkg:npm/lodash@4.17.21", status = "not_affected" }
|
||||
},
|
||||
linkset = new { aliases = new[] { "CVE-2024-12345" }, purls = new[] { "pkg:npm/lodash@4.17.21" } },
|
||||
createdAt = "2025-11-24T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["404"] = new
|
||||
{
|
||||
description = "Observation not found",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
schema = new { @ref = "#/components/schemas/Error" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
["/vex/observations/count"] = new
|
||||
{
|
||||
get = new
|
||||
{
|
||||
summary = "Get observation count for tenant",
|
||||
parameters = new object[]
|
||||
{
|
||||
new { name = "X-Stella-Tenant", @in = "header", schema = new { type = "string" }, required = false }
|
||||
},
|
||||
responses = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new
|
||||
{
|
||||
description = "Count response",
|
||||
content = new Dictionary<string, object>
|
||||
{
|
||||
["application/json"] = new
|
||||
{
|
||||
examples = new Dictionary<string, object>
|
||||
{
|
||||
["count"] = new
|
||||
{
|
||||
value = new { count = 1234 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
components = new
|
||||
@@ -451,6 +924,8 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
[FromServices] AirgapSignerTrustService trustService,
|
||||
[FromServices] AirgapModeEnforcer modeEnforcer,
|
||||
[FromServices] IAirgapImportStore store,
|
||||
[FromServices] IVexTimelineEventEmitter timelineEmitter,
|
||||
[FromServices] IVexHashingService hashingService,
|
||||
[FromServices] ILoggerFactory loggerFactory,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
[FromBody] AirgapImportRequest request,
|
||||
@@ -465,6 +940,7 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
? (int?)null
|
||||
: (int)Math.Round((nowUtc - request.SignedAt.Value).TotalSeconds);
|
||||
|
||||
var traceId = Activity.Current?.TraceId.ToString();
|
||||
var timeline = new List<AirgapTimelineEntry>();
|
||||
void RecordEvent(string eventType, string? code = null, string? message = null)
|
||||
{
|
||||
@@ -481,6 +957,54 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
};
|
||||
timeline.Add(entry);
|
||||
logger.LogInformation("Airgap timeline event {EventType} bundle={BundleId} gen={Gen} tenant={Tenant} code={Code}", eventType, entry.BundleId, entry.MirrorGeneration, tenantId, code);
|
||||
|
||||
// WEB-AIRGAP-58-001: Emit timeline event to persistent store for SSE streaming
|
||||
_ = EmitTimelineEventAsync(eventType, code, message);
|
||||
}
|
||||
|
||||
async Task EmitTimelineEventAsync(string eventType, string? code, string? message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["bundle_id"] = request.BundleId ?? string.Empty,
|
||||
["mirror_generation"] = request.MirrorGeneration ?? string.Empty
|
||||
};
|
||||
if (stalenessSeconds.HasValue)
|
||||
{
|
||||
attributes["staleness_seconds"] = stalenessSeconds.Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
{
|
||||
attributes["error_code"] = code;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
attributes["message"] = message;
|
||||
}
|
||||
|
||||
var eventId = $"airgap-{request.BundleId}-{request.MirrorGeneration}-{nowUtc:yyyyMMddHHmmssfff}";
|
||||
var streamId = $"airgap:{request.BundleId}:{request.MirrorGeneration}";
|
||||
var evt = new TimelineEvent(
|
||||
eventId,
|
||||
tenantId,
|
||||
"airgap-import",
|
||||
streamId,
|
||||
eventType,
|
||||
traceId ?? Guid.NewGuid().ToString("N"),
|
||||
justificationSummary: message ?? string.Empty,
|
||||
nowUtc,
|
||||
evidenceHash: null,
|
||||
payloadHash: request.PayloadHash,
|
||||
attributes.ToImmutableDictionary());
|
||||
|
||||
await timelineEmitter.EmitAsync(evt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to emit timeline event {EventType} for bundle {BundleId}", eventType, request.BundleId);
|
||||
}
|
||||
}
|
||||
|
||||
RecordEvent("airgap.import.started");
|
||||
@@ -528,7 +1052,8 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
|
||||
var manifestPath = $"mirror/{request.BundleId}/{request.MirrorGeneration}/manifest.json";
|
||||
var evidenceLockerPath = $"evidence/{request.BundleId}/{request.MirrorGeneration}/bundle.ndjson";
|
||||
var manifestHash = ComputeSha256($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}");
|
||||
// CRYPTO-90-001: Use IVexHashingService for pluggable crypto algorithms
|
||||
var manifestHash = hashingService.ComputeHash($"{request.BundleId}:{request.MirrorGeneration}:{request.PayloadHash}");
|
||||
|
||||
RecordEvent("airgap.import.completed");
|
||||
|
||||
@@ -578,12 +1103,7 @@ app.MapPost("/airgap/v1/vex/import", async (
|
||||
});
|
||||
});
|
||||
|
||||
static string ComputeSha256(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
// CRYPTO-90-001: ComputeSha256 removed - now using IVexHashingService for pluggable crypto
|
||||
|
||||
app.MapPost("/v1/attestations/verify", async (
|
||||
[FromServices] IVexAttestationClient attestationClient,
|
||||
@@ -1666,10 +2186,13 @@ app.MapGet("/obs/excititor/health", async (
|
||||
app.MapGet("/obs/excititor/timeline", async (
|
||||
HttpContext context,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IVexTimelineEventStore timelineStore,
|
||||
TimeProvider timeProvider,
|
||||
ILoggerFactory loggerFactory,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? eventType,
|
||||
[FromQuery] string? providerId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError))
|
||||
@@ -1680,44 +2203,71 @@ app.MapGet("/obs/excititor/timeline", async (
|
||||
var logger = loggerFactory.CreateLogger("ExcititorTimeline");
|
||||
var take = Math.Clamp(limit.GetValueOrDefault(10), 1, 100);
|
||||
|
||||
var startId = 0;
|
||||
// Parse cursor as ISO-8601 timestamp or Last-Event-ID header
|
||||
DateTimeOffset? cursorTimestamp = null;
|
||||
var candidateCursor = cursor ?? context.Request.Headers["Last-Event-ID"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(candidateCursor) && !int.TryParse(candidateCursor, NumberStyles.Integer, CultureInfo.InvariantCulture, out startId))
|
||||
if (!string.IsNullOrWhiteSpace(candidateCursor))
|
||||
{
|
||||
return Results.BadRequest(new { error = "cursor must be integer" });
|
||||
if (DateTimeOffset.TryParse(candidateCursor, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
|
||||
{
|
||||
cursorTimestamp = parsed;
|
||||
}
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(new { error = new { code = "ERR_CURSOR", message = "cursor must be ISO-8601 timestamp" } });
|
||||
}
|
||||
}
|
||||
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
context.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
context.Response.Headers["Link"] = "</openapi/excititor.json>; rel=\"describedby\"; type=\"application/json\"";
|
||||
context.Response.ContentType = "text/event-stream";
|
||||
await context.Response.WriteAsync("retry: 5000\n\n", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Fetch real timeline events from the store
|
||||
IReadOnlyList<TimelineEvent> events;
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var events = Enumerable.Range(startId, take)
|
||||
.Select(id => new ExcititorTimelineEvent(
|
||||
Type: "evidence.update",
|
||||
Tenant: tenant,
|
||||
Source: "vex-runtime",
|
||||
Count: 0,
|
||||
Errors: 0,
|
||||
TraceId: null,
|
||||
OccurredAt: now.ToString("O", CultureInfo.InvariantCulture)))
|
||||
.ToList();
|
||||
|
||||
foreach (var (evt, idx) in events.Select((e, i) => (e, i)))
|
||||
if (!string.IsNullOrWhiteSpace(eventType))
|
||||
{
|
||||
events = await timelineStore.FindByEventTypeAsync(tenant, eventType, take, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(providerId))
|
||||
{
|
||||
events = await timelineStore.FindByProviderAsync(tenant, providerId, take, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (cursorTimestamp.HasValue)
|
||||
{
|
||||
// Get events after the cursor timestamp
|
||||
events = await timelineStore.FindByTimeRangeAsync(tenant, cursorTimestamp.Value, now, take, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
events = await timelineStore.GetRecentAsync(tenant, take, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var evt in events)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var id = startId + idx;
|
||||
await context.Response.WriteAsync($"id: {id}\n", cancellationToken).ConfigureAwait(false);
|
||||
await context.Response.WriteAsync($"event: {evt.Type}\n", cancellationToken).ConfigureAwait(false);
|
||||
await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(evt)}\n\n", cancellationToken).ConfigureAwait(false);
|
||||
var sseEvent = new ExcititorTimelineEvent(
|
||||
Type: evt.EventType,
|
||||
Tenant: evt.Tenant,
|
||||
Source: evt.ProviderId,
|
||||
Count: evt.Attributes.TryGetValue("observation_count", out var countStr) && int.TryParse(countStr, out var count) ? count : 1,
|
||||
Errors: evt.Attributes.TryGetValue("error_count", out var errStr) && int.TryParse(errStr, out var errCount) ? errCount : 0,
|
||||
TraceId: evt.TraceId,
|
||||
OccurredAt: evt.CreatedAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
await context.Response.WriteAsync($"id: {evt.CreatedAt:O}\n", cancellationToken).ConfigureAwait(false);
|
||||
await context.Response.WriteAsync($"event: {evt.EventType}\n", cancellationToken).ConfigureAwait(false);
|
||||
await context.Response.WriteAsync($"data: {JsonSerializer.Serialize(sseEvent)}\n\n", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await context.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var nextCursor = startId + events.Count;
|
||||
context.Response.Headers["X-Next-Cursor"] = nextCursor.ToString(CultureInfo.InvariantCulture);
|
||||
logger.LogInformation("obs excititor timeline emitted {Count} events for tenant {Tenant} start {Start} next {Next}", events.Count, tenant, startId, nextCursor);
|
||||
var nextCursor = events.Count > 0 ? events[^1].CreatedAt.ToString("O", CultureInfo.InvariantCulture) : now.ToString("O", CultureInfo.InvariantCulture);
|
||||
context.Response.Headers["X-Next-Cursor"] = nextCursor;
|
||||
logger.LogInformation("obs excititor timeline emitted {Count} events for tenant {Tenant} cursor {Cursor} next {Next}", events.Count, tenant, candidateCursor, nextCursor);
|
||||
|
||||
return Results.Empty;
|
||||
}).WithName("GetExcititorTimeline");
|
||||
@@ -1726,11 +2276,13 @@ IngestEndpoints.MapIngestEndpoints(app);
|
||||
ResolveEndpoint.MapResolveEndpoint(app);
|
||||
MirrorEndpoints.MapMirrorEndpoints(app);
|
||||
|
||||
app.MapGet("/v1/vex/observations", async (HttpContext _, CancellationToken __) =>
|
||||
Results.StatusCode(StatusCodes.Status501NotImplemented));
|
||||
// Evidence and Attestation APIs (WEB-OBS-53-001, WEB-OBS-54-001)
|
||||
EvidenceEndpoints.MapEvidenceEndpoints(app);
|
||||
AttestationEndpoints.MapAttestationEndpoints(app);
|
||||
|
||||
app.MapGet("/v1/vex/linksets", async (HttpContext _, CancellationToken __) =>
|
||||
Results.StatusCode(StatusCodes.Status501NotImplemented));
|
||||
// Observation and Linkset APIs (EXCITITOR-LNM-21-201, EXCITITOR-LNM-21-202)
|
||||
ObservationEndpoints.MapObservationEndpoints(app);
|
||||
LinksetEndpoints.MapLinksetEndpoints(app);
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user