Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Gateway/Endpoints/AdvisorySourceEndpoints.cs
2026-02-19 22:10:54 +02:00

318 lines
11 KiB
C#

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;
/// <summary>
/// Advisory-source policy endpoints (impact and conflict facts).
/// </summary>
public static class AdvisorySourceEndpoints
{
private static readonly HashSet<string> 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<AdvisorySourceImpactResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(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<AdvisorySourceConflictListResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
}
private static async Task<IResult> 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<IResult> 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<AdvisorySourceDecisionRef> ParseDecisionRefs(string decisionRefsJson)
{
if (string.IsNullOrWhiteSpace(decisionRefsJson))
{
return Array.Empty<AdvisorySourceDecisionRef>();
}
try
{
using var document = JsonDocument.Parse(decisionRefsJson);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return Array.Empty<AdvisorySourceDecisionRef>();
}
var refs = new List<AdvisorySourceDecisionRef>();
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<AdvisorySourceDecisionRef>();
}
}
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<AdvisorySourceDecisionRef> DecisionRefs { get; init; } = Array.Empty<AdvisorySourceDecisionRef>();
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<AdvisorySourceConflictResponse> Items { get; init; } = Array.Empty<AdvisorySourceConflictResponse>();
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; }
}