// ----------------------------------------------------------------------------- // 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(); // Add blocking unknowns as results foreach (var unknownId in decision.BlockingUnknownIds) { results.Add(new { ruleId = "GATE001", level = level, message = new { text = $"Blocking unknown: {unknownId}" }, locations = new[] { new { physicalLocation = new { artifactLocation = new { uri = decision.BomRef } } } } }); } // Add warnings as results foreach (var warning in decision.Warnings) { results.Add(new { ruleId = "GATE002", level = "warning", message = new { text = warning }, locations = new[] { new { physicalLocation = new { artifactLocation = new { uri = decision.BomRef } } } } }); } // If no results, add a summary result if (results.Count == 0) { results.Add(new { ruleId = "GATE000", level = level, message = new { text = $"Gate check {decision.GateStatus}: {decision.BomRef}" }, locations = new[] { new { physicalLocation = new { artifactLocation = new { uri = decision.BomRef } } } } }); } var sarif = new { version = "2.1.0", schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", runs = new[] { new { tool = new { driver = new { name = "StellaOps Gate", version = "1.0.0", informationUri = "https://stella-ops.org", rules = new[] { new { id = "GATE000", shortDescription = new { text = "Gate Check Result" }, fullDescription = new { text = "Summary of gate check result" } }, new { id = "GATE001", shortDescription = new { text = "Blocking Unknown" }, fullDescription = new { text = "A security unknown is blocking the release" } }, new { id = "GATE002", shortDescription = new { text = "Gate Warning" }, fullDescription = new { text = "A warning was generated during gate evaluation" } } } } }, results = results, invocations = new[] { new { executionSuccessful = passed, endTimeUtc = decision.EvaluatedAt.ToString("O"), properties = new { decisionId = decision.DecisionId.ToString(), bomRef = decision.BomRef, gateStatus = decision.GateStatus, verdictHash = decision.VerdictHash, policyBundleId = decision.PolicyBundleId, policyBundleHash = decision.PolicyBundleHash, actor = decision.Actor } } } } } }; var json = System.Text.Json.JsonSerializer.Serialize(sarif, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); return Results.Content(json, "application/sarif+json"); } /// /// Exports decision as JSON. /// private static IResult ExportAsJson(GateDecisionRecord decision) { var response = new GateDecisionExportJson { 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, ExitCode = decision.GateStatus.ToLowerInvariant() switch { "pass" => 0, "warn" => 1, "block" => 2, _ => 1 } }; return Results.Json(response, contentType: "application/json"); } /// /// Determines the worst-case state across all unknowns. /// private static string DetermineAggregateState(IReadOnlyList unknowns) { if (unknowns.Count == 0) { return "resolved"; } // Priority: escalated > under_review > pending > resolved if (unknowns.Any(u => u.State == "escalated")) { return "escalated"; } if (unknowns.Any(u => u.State == "under_review")) { return "under_review"; } if (unknowns.Any(u => u.State == "pending")) { return "pending"; } return "resolved"; } /// /// Computes a deterministic verdict hash for caching/verification. /// private static string ComputeVerdictHash(string bomRef, IReadOnlyList unknowns) { var input = $"{bomRef}:{unknowns.Count}:{DateTimeOffset.UtcNow:yyyyMMddHH}"; var bytes = System.Security.Cryptography.SHA256.HashData( System.Text.Encoding.UTF8.GetBytes(input)); return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}"; } } #region Request/Response DTOs /// /// Gate status response. /// public sealed record GateStatusResponse { /// BOM reference. [JsonPropertyName("bom_ref")] public required string BomRef { get; init; } /// Aggregate state: resolved, pending, under_review, escalated, rejected. [JsonPropertyName("state")] public required string State { get; init; } /// Verdict hash if resolved. [JsonPropertyName("verdict_hash")] public string? VerdictHash { get; init; } /// Individual unknowns. [JsonPropertyName("unknowns")] public List Unknowns { get; init; } = []; /// Gate decision: pass, warn, block. [JsonPropertyName("gate_decision")] public required string GateDecision { get; init; } /// When checked. [JsonPropertyName("checked_at")] public DateTimeOffset CheckedAt { get; init; } } /// /// Unknown DTO for API response. /// public sealed record UnknownDto { /// Unknown ID. [JsonPropertyName("unknown_id")] public Guid UnknownId { get; init; } /// CVE ID if applicable. [JsonPropertyName("cve_id")] public string? CveId { get; init; } /// Priority band: hot, warm, cold. [JsonPropertyName("band")] public required string Band { get; init; } /// SLA remaining hours. [JsonPropertyName("sla_remaining_hours")] public double? SlaRemainingHours { get; init; } /// State: pending, under_review, escalated, resolved, rejected. [JsonPropertyName("state")] public required string State { get; init; } } /// /// Gate check request. /// public sealed record GateCheckRequest { /// Proposed VEX verdict (e.g., "not_affected"). [JsonPropertyName("proposed_verdict")] public string? ProposedVerdict { get; init; } } /// /// Gate check response. /// public sealed record GateCheckResponse { /// BOM reference. [JsonPropertyName("bom_ref")] public required string BomRef { get; init; } /// Decision: pass, warn, block. [JsonPropertyName("decision")] public required string Decision { get; init; } /// Current state. [JsonPropertyName("state")] public required string State { get; init; } /// Blocking unknown IDs. [JsonPropertyName("blocking_unknown_ids")] public List BlockingUnknownIds { get; init; } = []; /// Reason for decision. [JsonPropertyName("reason")] public string? Reason { get; init; } /// Whether exception was granted. [JsonPropertyName("exception_granted")] public bool ExceptionGranted { get; init; } /// Exception reference if granted. [JsonPropertyName("exception_ref")] public string? ExceptionRef { get; init; } /// When checked. [JsonPropertyName("checked_at")] public DateTimeOffset CheckedAt { get; init; } } /// /// Exception request. /// public sealed record ExceptionRequest { /// Unknown IDs to bypass. [JsonPropertyName("unknown_ids")] public List UnknownIds { get; init; } = []; /// Justification for bypass. [JsonPropertyName("justification")] public required string Justification { get; init; } } /// /// Exception response. /// public sealed record ExceptionResponse { /// Whether exception was granted. [JsonPropertyName("granted")] public bool Granted { get; init; } /// Exception reference. [JsonPropertyName("exception_ref")] public string? ExceptionRef { get; init; } /// Denial reason if not granted. [JsonPropertyName("denial_reason")] public string? DenialReason { get; init; } /// When exception expires. [JsonPropertyName("expires_at")] public DateTimeOffset? ExpiresAt { get; init; } /// When requested. [JsonPropertyName("requested_at")] public DateTimeOffset RequestedAt { get; init; } } #endregion #region Gate Decision History DTOs /// /// Response for gate decision history query. /// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure /// Task: GR-005 - Add gate decision history endpoint /// public sealed record GateDecisionHistoryResponse { /// List of gate decisions. [JsonPropertyName("decisions")] public List Decisions { get; init; } = []; /// Total count of matching decisions. [JsonPropertyName("total")] public long Total { get; init; } /// Token for fetching next page. [JsonPropertyName("continuation_token")] public string? ContinuationToken { get; init; } } /// /// Gate decision DTO for API response. /// public sealed record GateDecisionDto { /// Unique decision ID. [JsonPropertyName("decision_id")] public Guid DecisionId { get; init; } /// BOM reference. [JsonPropertyName("bom_ref")] public required string BomRef { get; init; } /// Image digest if applicable. [JsonPropertyName("image_digest")] public string? ImageDigest { get; init; } /// Gate decision: pass, warn, block. [JsonPropertyName("gate_status")] public required string GateStatus { get; init; } /// Verdict hash for replay verification. [JsonPropertyName("verdict_hash")] public string? VerdictHash { get; init; } /// Policy bundle ID used for evaluation. [JsonPropertyName("policy_bundle_id")] public string? PolicyBundleId { get; init; } /// Policy bundle content hash. [JsonPropertyName("policy_bundle_hash")] public string? PolicyBundleHash { get; init; } /// When the evaluation occurred. [JsonPropertyName("evaluated_at")] public DateTimeOffset EvaluatedAt { get; init; } /// CI/CD context (branch, commit, pipeline). [JsonPropertyName("ci_context")] public string? CiContext { get; init; } /// Actor who triggered evaluation. [JsonPropertyName("actor")] public string? Actor { get; init; } /// IDs of unknowns that blocked the release. [JsonPropertyName("blocking_unknown_ids")] public List BlockingUnknownIds { get; init; } = []; /// Warning messages. [JsonPropertyName("warnings")] public List Warnings { get; init; } = []; } #endregion #region CI/CD Export DTOs (GR-008) /// /// Export format for gate decisions. /// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure /// Task: GR-008 - Implement CI/CD status export formats /// public enum ExportFormat { /// JSON format for custom integrations. Json, /// JUnit XML format for Jenkins, GitHub Actions, GitLab CI. JUnit, /// SARIF 2.1.0 format for GitHub Code Scanning, VS Code. Sarif } /// /// Gate decision export JSON format for CI/CD integration. /// public sealed record GateDecisionExportJson { /// Unique decision ID. [JsonPropertyName("decision_id")] public Guid DecisionId { get; init; } /// BOM reference. [JsonPropertyName("bom_ref")] public required string BomRef { get; init; } /// Image digest if applicable. [JsonPropertyName("image_digest")] public string? ImageDigest { get; init; } /// Gate decision: pass, warn, block. [JsonPropertyName("gate_status")] public required string GateStatus { get; init; } /// Verdict hash for replay verification. [JsonPropertyName("verdict_hash")] public string? VerdictHash { get; init; } /// Policy bundle ID used for evaluation. [JsonPropertyName("policy_bundle_id")] public string? PolicyBundleId { get; init; } /// Policy bundle content hash. [JsonPropertyName("policy_bundle_hash")] public string? PolicyBundleHash { get; init; } /// When the evaluation occurred. [JsonPropertyName("evaluated_at")] public DateTimeOffset EvaluatedAt { get; init; } /// CI/CD context (branch, commit, pipeline). [JsonPropertyName("ci_context")] public string? CiContext { get; init; } /// Actor who triggered evaluation. [JsonPropertyName("actor")] public string? Actor { get; init; } /// IDs of unknowns that blocked the release. [JsonPropertyName("blocking_unknown_ids")] public List BlockingUnknownIds { get; init; } = []; /// Warning messages. [JsonPropertyName("warnings")] public List Warnings { get; init; } = []; /// /// Exit code for CI/CD script integration. /// 0 = pass, 1 = warn, 2 = block /// [JsonPropertyName("exit_code")] public int ExitCode { get; init; } } #endregion