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.Core.Storage; using StellaOps.Excititor.WebService.Contracts; using StellaOps.Excititor.WebService.Services; namespace StellaOps.Excititor.WebService.Endpoints; /// /// 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). /// 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 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 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 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 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, VexStorageOptions 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 Aliases, [property: System.Text.Json.Serialization.JsonPropertyName("purls")] IReadOnlyList Purls, [property: System.Text.Json.Serialization.JsonPropertyName("cpes")] IReadOnlyList Cpes, [property: System.Text.Json.Serialization.JsonPropertyName("references")] IReadOnlyList 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 Statements, [property: System.Text.Json.Serialization.JsonPropertyName("linkset")] VexObservationLinksetResponse Linkset, [property: System.Text.Json.Serialization.JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);