using Microsoft.AspNetCore.Mvc; using StellaOps.Auth.Abstractions; using StellaOps.Auth.ServerIntegration; using StellaOps.Policy.Persistence.Postgres.Repositories; using System.Text.Json; namespace StellaOps.Policy.Gateway.Endpoints; /// /// Advisory-source policy endpoints (impact and conflict facts). /// public static class AdvisorySourceEndpoints { private static readonly HashSet AllowedConflictStatuses = new(StringComparer.OrdinalIgnoreCase) { "open", "resolved", "dismissed" }; public static void MapAdvisorySourcePolicyEndpoints(this WebApplication app) { var group = app.MapGroup("/api/v1/advisory-sources") .WithTags("Advisory Sources"); group.MapGet("/{sourceId}/impact", GetImpactAsync) .WithName("GetAdvisorySourceImpact") .WithDescription("Get policy impact facts for an advisory source.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead)); group.MapGet("/{sourceId}/conflicts", GetConflictsAsync) .WithName("GetAdvisorySourceConflicts") .WithDescription("Get active/resolved advisory conflicts for a source.") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead)); } private static async Task GetImpactAsync( HttpContext httpContext, [FromRoute] string sourceId, [FromQuery] string? region, [FromQuery] string? environment, [FromQuery] string? sourceFamily, [FromServices] IAdvisorySourcePolicyReadRepository repository, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) { if (!TryGetTenantId(httpContext, out var tenantId)) { return TenantMissingProblem(); } if (string.IsNullOrWhiteSpace(sourceId)) { return Results.BadRequest(new ProblemDetails { Title = "sourceId is required.", Status = StatusCodes.Status400BadRequest }); } var normalizedSourceId = sourceId.Trim(); var normalizedRegion = NormalizeOptional(region); var normalizedEnvironment = NormalizeOptional(environment); var normalizedSourceFamily = NormalizeOptional(sourceFamily); var impact = await repository.GetImpactAsync( tenantId, normalizedSourceId, normalizedRegion, normalizedEnvironment, normalizedSourceFamily, cancellationToken).ConfigureAwait(false); var response = new AdvisorySourceImpactResponse { SourceId = normalizedSourceId, SourceFamily = impact.SourceFamily ?? normalizedSourceFamily ?? string.Empty, Region = normalizedRegion, Environment = normalizedEnvironment, ImpactedDecisionsCount = impact.ImpactedDecisionsCount, ImpactSeverity = impact.ImpactSeverity, LastDecisionAt = impact.LastDecisionAt, DecisionRefs = ParseDecisionRefs(impact.DecisionRefsJson), DataAsOf = timeProvider.GetUtcNow() }; return Results.Ok(response); } private static async Task GetConflictsAsync( HttpContext httpContext, [FromRoute] string sourceId, [FromQuery] string? status, [FromQuery] int? limit, [FromQuery] int? offset, [FromServices] IAdvisorySourcePolicyReadRepository repository, [FromServices] TimeProvider timeProvider, CancellationToken cancellationToken) { if (!TryGetTenantId(httpContext, out var tenantId)) { return TenantMissingProblem(); } if (string.IsNullOrWhiteSpace(sourceId)) { return Results.BadRequest(new ProblemDetails { Title = "sourceId is required.", Status = StatusCodes.Status400BadRequest }); } var normalizedStatus = NormalizeOptional(status) ?? "open"; if (!AllowedConflictStatuses.Contains(normalizedStatus)) { return Results.BadRequest(new ProblemDetails { Title = "status must be one of: open, resolved, dismissed.", Status = StatusCodes.Status400BadRequest }); } var normalizedSourceId = sourceId.Trim(); var normalizedLimit = Math.Clamp(limit ?? 50, 1, 200); var normalizedOffset = Math.Max(offset ?? 0, 0); var page = await repository.ListConflictsAsync( tenantId, normalizedSourceId, normalizedStatus, normalizedLimit, normalizedOffset, cancellationToken).ConfigureAwait(false); var items = page.Items.Select(static item => new AdvisorySourceConflictResponse { ConflictId = item.ConflictId, AdvisoryId = item.AdvisoryId, PairedSourceKey = item.PairedSourceKey, ConflictType = item.ConflictType, Severity = item.Severity, Status = item.Status, Description = item.Description, FirstDetectedAt = item.FirstDetectedAt, LastDetectedAt = item.LastDetectedAt, ResolvedAt = item.ResolvedAt, Details = ParseDetails(item.DetailsJson) }).ToList(); return Results.Ok(new AdvisorySourceConflictListResponse { SourceId = normalizedSourceId, Status = normalizedStatus, Limit = normalizedLimit, Offset = normalizedOffset, TotalCount = page.TotalCount, Items = items, DataAsOf = timeProvider.GetUtcNow() }); } private static string? NormalizeOptional(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static bool TryGetTenantId(HttpContext httpContext, out string tenantId) { tenantId = string.Empty; var claimTenant = httpContext.User.Claims.FirstOrDefault(claim => claim.Type == "tenant_id")?.Value; if (!string.IsNullOrWhiteSpace(claimTenant)) { tenantId = claimTenant.Trim(); return true; } var stellaHeaderTenant = httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(stellaHeaderTenant)) { tenantId = stellaHeaderTenant.Trim(); return true; } var tenantHeader = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(tenantHeader)) { tenantId = tenantHeader.Trim(); return true; } return false; } private static IResult TenantMissingProblem() { return Results.Problem( title: "Tenant header required.", detail: "Provide tenant via X-StellaOps-Tenant, X-Tenant-Id, or tenant_id claim.", statusCode: StatusCodes.Status400BadRequest); } private static IReadOnlyList ParseDecisionRefs(string decisionRefsJson) { if (string.IsNullOrWhiteSpace(decisionRefsJson)) { return Array.Empty(); } try { using var document = JsonDocument.Parse(decisionRefsJson); if (document.RootElement.ValueKind != JsonValueKind.Array) { return Array.Empty(); } var refs = new List(); foreach (var item in document.RootElement.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object) { continue; } refs.Add(new AdvisorySourceDecisionRef { DecisionId = TryGetString(item, "decisionId") ?? TryGetString(item, "decision_id") ?? string.Empty, DecisionType = TryGetString(item, "decisionType") ?? TryGetString(item, "decision_type"), Label = TryGetString(item, "label"), Route = TryGetString(item, "route") }); } return refs; } catch (JsonException) { return Array.Empty(); } } private static JsonElement? ParseDetails(string detailsJson) { if (string.IsNullOrWhiteSpace(detailsJson)) { return null; } try { using var document = JsonDocument.Parse(detailsJson); return document.RootElement.Clone(); } catch (JsonException) { return null; } } private static string? TryGetString(JsonElement element, string propertyName) { return element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null; } } public sealed record AdvisorySourceImpactResponse { public string SourceId { get; init; } = string.Empty; public string SourceFamily { get; init; } = string.Empty; public string? Region { get; init; } public string? Environment { get; init; } public int ImpactedDecisionsCount { get; init; } public string ImpactSeverity { get; init; } = "none"; public DateTimeOffset? LastDecisionAt { get; init; } public IReadOnlyList DecisionRefs { get; init; } = Array.Empty(); public DateTimeOffset DataAsOf { get; init; } } public sealed record AdvisorySourceDecisionRef { public string DecisionId { get; init; } = string.Empty; public string? DecisionType { get; init; } public string? Label { get; init; } public string? Route { get; init; } } public sealed record AdvisorySourceConflictListResponse { public string SourceId { get; init; } = string.Empty; public string Status { get; init; } = "open"; public int Limit { get; init; } public int Offset { get; init; } public int TotalCount { get; init; } public IReadOnlyList Items { get; init; } = Array.Empty(); public DateTimeOffset DataAsOf { get; init; } } public sealed record AdvisorySourceConflictResponse { public Guid ConflictId { get; init; } public string AdvisoryId { get; init; } = string.Empty; public string? PairedSourceKey { get; init; } public string ConflictType { get; init; } = string.Empty; public string Severity { get; init; } = string.Empty; public string Status { get; init; } = string.Empty; public string Description { get; init; } = string.Empty; public DateTimeOffset FirstDetectedAt { get; init; } public DateTimeOffset LastDetectedAt { get; init; } public DateTimeOffset? ResolvedAt { get; init; } public JsonElement? Details { get; init; } }