318 lines
11 KiB
C#
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; }
|
|
}
|