// -----------------------------------------------------------------------------
// GatesEndpoints.cs
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
// Task: UQ-006 - Implement GET /gates/{bom_ref} endpoint
// Description: REST endpoint for gate check with unknowns state
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Policy.Gates;
using StellaOps.Policy.Persistence.Postgres.Repositories;
namespace StellaOps.Policy.Gateway.Endpoints;
///
/// REST endpoints for gate checks.
///
public static class GatesEndpoints
{
private const string CachePrefix = "gates:";
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
///
/// Maps gate endpoints.
///
public static IEndpointRouteBuilder MapGatesEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/gates")
.WithTags("Gates")
.WithOpenApi();
group.MapGet("/{bomRef}", GetGateStatus)
.WithName("GetGateStatus")
.WithSummary("Get gate check result for a component")
.WithDescription("Returns the current unknowns state and gate decision for a BOM reference.");
group.MapPost("/{bomRef}/check", CheckGate)
.WithName("CheckGate")
.WithSummary("Perform gate check for a component")
.WithDescription("Performs a fresh gate check with optional verdict.");
group.MapPost("/{bomRef}/exception", RequestException)
.WithName("RequestGateException")
.WithSummary("Request an exception to bypass the gate")
.WithDescription("Requests approval to bypass blocking unknowns.");
group.MapGet("/{gateId}/decisions", GetGateDecisionHistory)
.WithName("GetGateDecisionHistory")
.WithSummary("Get historical gate decisions")
.WithDescription("Returns paginated list of historical gate decisions for audit and debugging.");
group.MapGet("/decisions/{decisionId}", GetGateDecisionById)
.WithName("GetGateDecisionById")
.WithSummary("Get a specific gate decision by ID")
.WithDescription("Returns full details of a specific gate decision.");
group.MapGet("/decisions/{decisionId}/export", ExportGateDecision)
.WithName("ExportGateDecision")
.WithSummary("Export gate decision in CI/CD format")
.WithDescription("Exports gate decision in JUnit, SARIF, or JSON format for CI/CD integration.");
return endpoints;
}
///
/// GET /gates/{bom_ref}
/// Returns the current unknowns state for a component.
///
private static async Task GetGateStatus(
[FromRoute] string bomRef,
[FromServices] IUnknownsGateChecker gateChecker,
[FromServices] IMemoryCache cache,
CancellationToken ct)
{
// Decode the bom_ref (URL encoded)
var decodedBomRef = Uri.UnescapeDataString(bomRef);
// Check cache
var cacheKey = $"{CachePrefix}{decodedBomRef}";
if (cache.TryGetValue(cacheKey, out var cached) && cached != null)
{
return Results.Ok(cached);
}
// Get unknowns and gate decision
var unknowns = await gateChecker.GetUnknownsAsync(decodedBomRef, ct);
var checkResult = await gateChecker.CheckAsync(decodedBomRef, null, ct);
// Build response
var response = new GateStatusResponse
{
BomRef = decodedBomRef,
State = DetermineAggregateState(unknowns),
VerdictHash = checkResult.Decision == GateDecision.Pass
? ComputeVerdictHash(decodedBomRef, unknowns)
: null,
Unknowns = unknowns.Select(u => new UnknownDto
{
UnknownId = u.UnknownId,
CveId = u.CveId,
Band = u.Band,
SlaRemainingHours = u.SlaRemainingHours,
State = u.State
}).ToList(),
GateDecision = checkResult.Decision.ToString().ToLowerInvariant(),
CheckedAt = DateTimeOffset.UtcNow
};
// Cache the response
cache.Set(cacheKey, response, CacheTtl);
return Results.Ok(response);
}
///
/// POST /gates/{bom_ref}/check
/// Performs a gate check with optional verdict.
///
private static async Task CheckGate(
[FromRoute] string bomRef,
[FromBody] GateCheckRequest request,
[FromServices] IUnknownsGateChecker gateChecker,
CancellationToken ct)
{
var decodedBomRef = Uri.UnescapeDataString(bomRef);
var result = await gateChecker.CheckAsync(
decodedBomRef,
request.ProposedVerdict,
ct);
var response = new GateCheckResponse
{
BomRef = decodedBomRef,
Decision = result.Decision.ToString().ToLowerInvariant(),
State = result.State,
BlockingUnknownIds = result.BlockingUnknownIds.ToList(),
Reason = result.Reason,
ExceptionGranted = result.ExceptionGranted,
ExceptionRef = result.ExceptionRef,
CheckedAt = DateTimeOffset.UtcNow
};
var statusCode = result.Decision switch
{
GateDecision.Pass => StatusCodes.Status200OK,
GateDecision.Warn => StatusCodes.Status200OK,
GateDecision.Block => StatusCodes.Status403Forbidden,
_ => StatusCodes.Status200OK
};
return Results.Json(response, statusCode: statusCode);
}
///
/// POST /gates/{bom_ref}/exception
/// Requests an exception to bypass blocking unknowns.
///
private static async Task RequestException(
[FromRoute] string bomRef,
[FromBody] ExceptionRequest request,
[FromServices] IUnknownsGateChecker gateChecker,
HttpContext httpContext,
CancellationToken ct)
{
var decodedBomRef = Uri.UnescapeDataString(bomRef);
var requestedBy = httpContext.User.Identity?.Name ?? "anonymous";
var result = await gateChecker.RequestExceptionAsync(
decodedBomRef,
request.UnknownIds,
request.Justification,
requestedBy,
ct);
var response = new ExceptionResponse
{
Granted = result.Granted,
ExceptionRef = result.ExceptionRef,
DenialReason = result.DenialReason,
ExpiresAt = result.ExpiresAt,
RequestedAt = DateTimeOffset.UtcNow
};
return result.Granted
? Results.Ok(response)
: Results.Json(response, statusCode: StatusCodes.Status403Forbidden);
}
///
/// GET /gates/{gateId}/decisions
/// Returns historical gate decisions for a gate.
///
private static async Task GetGateDecisionHistory(
[FromRoute] string gateId,
[FromQuery] int? limit,
[FromQuery] string? from_date,
[FromQuery] string? to_date,
[FromQuery] string? status,
[FromQuery] string? actor,
[FromQuery] string? bom_ref,
[FromQuery] string? continuation_token,
[FromServices] IGateDecisionHistoryRepository historyRepository,
HttpContext httpContext,
CancellationToken ct)
{
// Parse tenant from context (simplified - would come from auth)
var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g
? g
: Guid.Empty;
var query = new GateDecisionHistoryQuery
{
TenantId = tenantId,
GateId = Uri.UnescapeDataString(gateId),
BomRef = bom_ref,
Limit = limit ?? 50,
ContinuationToken = continuation_token,
Status = status,
Actor = actor,
FromDate = string.IsNullOrEmpty(from_date) ? null : DateTimeOffset.Parse(from_date),
ToDate = string.IsNullOrEmpty(to_date) ? null : DateTimeOffset.Parse(to_date)
};
var result = await historyRepository.GetDecisionsAsync(query, ct);
var response = new GateDecisionHistoryResponse
{
Decisions = result.Decisions.Select(d => new GateDecisionDto
{
DecisionId = d.DecisionId,
BomRef = d.BomRef,
ImageDigest = d.ImageDigest,
GateStatus = d.GateStatus,
VerdictHash = d.VerdictHash,
PolicyBundleId = d.PolicyBundleId,
PolicyBundleHash = d.PolicyBundleHash,
EvaluatedAt = new DateTimeOffset(d.EvaluatedAt, TimeSpan.Zero),
CiContext = d.CiContext,
Actor = d.Actor,
BlockingUnknownIds = d.BlockingUnknownIds,
Warnings = d.Warnings
}).ToList(),
Total = result.Total,
ContinuationToken = result.ContinuationToken
};
return Results.Ok(response);
}
///
/// GET /gates/decisions/{decisionId}
/// Returns a specific gate decision by ID.
///
private static async Task GetGateDecisionById(
[FromRoute] Guid decisionId,
[FromServices] IGateDecisionHistoryRepository historyRepository,
HttpContext httpContext,
CancellationToken ct)
{
var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g
? g
: Guid.Empty;
var decision = await historyRepository.GetDecisionByIdAsync(decisionId, tenantId, ct);
if (decision is null)
{
return Results.NotFound(new { error = "Decision not found", decision_id = decisionId });
}
var response = new GateDecisionDto
{
DecisionId = decision.DecisionId,
BomRef = decision.BomRef,
ImageDigest = decision.ImageDigest,
GateStatus = decision.GateStatus,
VerdictHash = decision.VerdictHash,
PolicyBundleId = decision.PolicyBundleId,
PolicyBundleHash = decision.PolicyBundleHash,
EvaluatedAt = new DateTimeOffset(decision.EvaluatedAt, TimeSpan.Zero),
CiContext = decision.CiContext,
Actor = decision.Actor,
BlockingUnknownIds = decision.BlockingUnknownIds,
Warnings = decision.Warnings
};
return Results.Ok(response);
}
///
/// GET /gates/decisions/{decisionId}/export
/// Exports a gate decision in CI/CD format.
/// Sprint: SPRINT_20260118_019 (GR-008)
///
private static async Task ExportGateDecision(
[FromRoute] Guid decisionId,
[FromQuery] string? format,
[FromServices] IGateDecisionHistoryRepository historyRepository,
HttpContext httpContext,
CancellationToken ct)
{
var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g
? g
: Guid.Empty;
var decision = await historyRepository.GetDecisionByIdAsync(decisionId, tenantId, ct);
if (decision is null)
{
return Results.NotFound(new { error = "Decision not found", decision_id = decisionId });
}
var exportFormat = (format?.ToLowerInvariant()) switch
{
"junit" => ExportFormat.JUnit,
"sarif" => ExportFormat.Sarif,
"json" => ExportFormat.Json,
_ => ExportFormat.Json
};
return exportFormat switch
{
ExportFormat.JUnit => ExportAsJUnit(decision),
ExportFormat.Sarif => ExportAsSarif(decision),
_ => ExportAsJson(decision)
};
}
///
/// Exports decision as JUnit XML.
///
private static IResult ExportAsJUnit(GateDecisionRecord decision)
{
var passed = decision.GateStatus.Equals("pass", StringComparison.OrdinalIgnoreCase);
var testCaseName = $"gate-check-{decision.BomRef}";
var suiteName = "StellaOps Gate Check";
var xml = $"""
{(passed ? "" : $"""
Decision ID: {decision.DecisionId}
BOM Reference: {decision.BomRef}
Status: {decision.GateStatus}
Evaluated At: {decision.EvaluatedAt:O}
Policy Bundle: {decision.PolicyBundleId ?? "N/A"}
Blocking Unknowns: {decision.BlockingUnknownIds.Count}
Warnings: {string.Join(", ", decision.Warnings)}
""")}
""";
return Results.Content(xml, "application/xml");
}
///
/// Exports decision as SARIF 2.1.0.
///
private static IResult ExportAsSarif(GateDecisionRecord decision)
{
var passed = decision.GateStatus.Equals("pass", StringComparison.OrdinalIgnoreCase);
var level = decision.GateStatus.ToLowerInvariant() switch
{
"pass" => "none",
"warn" => "warning",
"block" => "error",
_ => "note"
};
var results = new List