up
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Storage.Mongo;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using StellaOps.Excititor.WebService.Services;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Observation API endpoints (EXCITITOR-LNM-21-201).
|
||||
/// Exposes /vex/observations/* endpoints with filters for advisory/product/provider,
|
||||
/// strict RBAC, and deterministic pagination (no derived verdict fields).
|
||||
/// </summary>
|
||||
public static class ObservationEndpoints
|
||||
{
|
||||
public static void MapObservationEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/vex/observations");
|
||||
|
||||
// GET /vex/observations - List observations with filters
|
||||
group.MapGet("", async (
|
||||
HttpContext context,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IVexObservationStore observationStore,
|
||||
TimeProvider timeProvider,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] string? vulnerabilityId,
|
||||
[FromQuery] string? productKey,
|
||||
[FromQuery] string? providerId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 100);
|
||||
|
||||
IReadOnlyList<VexObservation> observations;
|
||||
|
||||
// Route to appropriate query method based on filters
|
||||
if (!string.IsNullOrWhiteSpace(vulnerabilityId) && !string.IsNullOrWhiteSpace(productKey))
|
||||
{
|
||||
observations = await observationStore
|
||||
.FindByVulnerabilityAndProductAsync(tenant, vulnerabilityId.Trim(), productKey.Trim(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(providerId))
|
||||
{
|
||||
observations = await observationStore
|
||||
.FindByProviderAsync(tenant, providerId.Trim(), take, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No filter - return empty for now (full list requires pagination infrastructure)
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "ERR_PARAMS",
|
||||
message = "At least one filter is required: vulnerabilityId+productKey or providerId"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var items = observations
|
||||
.Take(take)
|
||||
.Select(obs => ToListItem(obs))
|
||||
.ToList();
|
||||
|
||||
var hasMore = observations.Count > take;
|
||||
string? nextCursor = null;
|
||||
if (hasMore && items.Count > 0)
|
||||
{
|
||||
var last = observations[items.Count - 1];
|
||||
nextCursor = EncodeCursor(last.CreatedAt.UtcDateTime, last.ObservationId);
|
||||
}
|
||||
|
||||
var response = new VexObservationListResponse(items, nextCursor);
|
||||
return Results.Ok(response);
|
||||
}).WithName("ListVexObservations");
|
||||
|
||||
// GET /vex/observations/{observationId} - Get observation by ID
|
||||
group.MapGet("/{observationId}", async (
|
||||
HttpContext context,
|
||||
string observationId,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IVexObservationStore observationStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(observationId))
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_PARAMS", message = "observationId is required" }
|
||||
});
|
||||
}
|
||||
|
||||
var observation = await observationStore
|
||||
.GetByIdAsync(tenant, observationId.Trim(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (observation is null)
|
||||
{
|
||||
return Results.NotFound(new
|
||||
{
|
||||
error = new { code = "ERR_NOT_FOUND", message = $"Observation '{observationId}' not found" }
|
||||
});
|
||||
}
|
||||
|
||||
var response = ToDetailResponse(observation);
|
||||
return Results.Ok(response);
|
||||
}).WithName("GetVexObservation");
|
||||
|
||||
// GET /vex/observations/count - Get observation count for tenant
|
||||
group.MapGet("/count", async (
|
||||
HttpContext context,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
[FromServices] IVexObservationStore observationStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var count = await observationStore
|
||||
.CountAsync(tenant, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new { count });
|
||||
}).WithName("CountVexObservations");
|
||||
}
|
||||
|
||||
private static VexObservationListItem ToListItem(VexObservation obs)
|
||||
{
|
||||
var firstStatement = obs.Statements.FirstOrDefault();
|
||||
return new VexObservationListItem(
|
||||
ObservationId: obs.ObservationId,
|
||||
Tenant: obs.Tenant,
|
||||
ProviderId: obs.ProviderId,
|
||||
VulnerabilityId: firstStatement?.VulnerabilityId ?? string.Empty,
|
||||
ProductKey: firstStatement?.ProductKey ?? string.Empty,
|
||||
Status: firstStatement?.Status.ToString().ToLowerInvariant() ?? "unknown",
|
||||
CreatedAt: obs.CreatedAt,
|
||||
LastObserved: firstStatement?.LastObserved,
|
||||
Purls: obs.Linkset.Purls.ToList());
|
||||
}
|
||||
|
||||
private static VexObservationDetailResponse ToDetailResponse(VexObservation obs)
|
||||
{
|
||||
var upstream = new VexObservationUpstreamResponse(
|
||||
obs.Upstream.UpstreamId,
|
||||
obs.Upstream.DocumentVersion,
|
||||
obs.Upstream.FetchedAt,
|
||||
obs.Upstream.ReceivedAt,
|
||||
obs.Upstream.ContentHash,
|
||||
obs.Upstream.Signature.Present
|
||||
? new VexObservationSignatureResponse(
|
||||
obs.Upstream.Signature.Format ?? "dsse",
|
||||
obs.Upstream.Signature.KeyId,
|
||||
Issuer: null,
|
||||
VerifiedAtUtc: null)
|
||||
: null);
|
||||
|
||||
var content = new VexObservationContentResponse(
|
||||
obs.Content.Format,
|
||||
obs.Content.SpecVersion);
|
||||
|
||||
var statements = obs.Statements
|
||||
.Select(stmt => new VexObservationStatementItem(
|
||||
stmt.VulnerabilityId,
|
||||
stmt.ProductKey,
|
||||
stmt.Status.ToString().ToLowerInvariant(),
|
||||
stmt.LastObserved,
|
||||
stmt.Locator,
|
||||
stmt.Justification?.ToString().ToLowerInvariant(),
|
||||
stmt.IntroducedVersion,
|
||||
stmt.FixedVersion))
|
||||
.ToList();
|
||||
|
||||
var linkset = new VexObservationLinksetResponse(
|
||||
obs.Linkset.Aliases.ToList(),
|
||||
obs.Linkset.Purls.ToList(),
|
||||
obs.Linkset.Cpes.ToList(),
|
||||
obs.Linkset.References.Select(r => new VexObservationReferenceItem(r.Type, r.Url)).ToList());
|
||||
|
||||
return new VexObservationDetailResponse(
|
||||
obs.ObservationId,
|
||||
obs.Tenant,
|
||||
obs.ProviderId,
|
||||
obs.StreamId,
|
||||
upstream,
|
||||
content,
|
||||
statements,
|
||||
linkset,
|
||||
obs.CreatedAt);
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(
|
||||
HttpContext context,
|
||||
VexMongoStorageOptions options,
|
||||
out string tenant,
|
||||
out IResult? problem)
|
||||
{
|
||||
problem = null;
|
||||
tenant = string.Empty;
|
||||
|
||||
var headerTenant = context.Request.Headers["X-Stella-Tenant"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant))
|
||||
{
|
||||
tenant = headerTenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(options.DefaultTenant))
|
||||
{
|
||||
tenant = options.DefaultTenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
else
|
||||
{
|
||||
problem = Results.BadRequest(new
|
||||
{
|
||||
error = new { code = "ERR_TENANT", message = "X-Stella-Tenant header is required" }
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string EncodeCursor(DateTime timestamp, string id)
|
||||
{
|
||||
var raw = $"{timestamp:O}|{id}";
|
||||
return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(raw));
|
||||
}
|
||||
}
|
||||
|
||||
// Additional response DTOs for observation detail
|
||||
public sealed record VexObservationUpstreamResponse(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("upstreamId")] string UpstreamId,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("documentVersion")] string? DocumentVersion,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("fetchedAt")] DateTimeOffset FetchedAt,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("receivedAt")] DateTimeOffset ReceivedAt,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("contentHash")] string ContentHash,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("signature")] VexObservationSignatureResponse? Signature);
|
||||
|
||||
public sealed record VexObservationContentResponse(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("format")] string Format,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("specVersion")] string? SpecVersion);
|
||||
|
||||
public sealed record VexObservationStatementItem(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("vulnerabilityId")] string VulnerabilityId,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("productKey")] string ProductKey,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("status")] string Status,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("locator")] string? Locator,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("justification")] string? Justification,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("introducedVersion")] string? IntroducedVersion,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("fixedVersion")] string? FixedVersion);
|
||||
|
||||
public sealed record VexObservationLinksetResponse(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("aliases")] IReadOnlyList<string> Aliases,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("purls")] IReadOnlyList<string> Purls,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("cpes")] IReadOnlyList<string> Cpes,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("references")] IReadOnlyList<VexObservationReferenceItem> References);
|
||||
|
||||
public sealed record VexObservationReferenceItem(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("type")] string Type,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("url")] string Url);
|
||||
|
||||
public sealed record VexObservationDetailResponse(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("observationId")] string ObservationId,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("tenant")] string Tenant,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("providerId")] string ProviderId,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("streamId")] string StreamId,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("upstream")] VexObservationUpstreamResponse Upstream,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("content")] VexObservationContentResponse Content,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("statements")] IReadOnlyList<VexObservationStatementItem> Statements,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("linkset")] VexObservationLinksetResponse Linkset,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
Reference in New Issue
Block a user