// SPDX-License-Identifier: BUSL-1.1 // Sprint: SPRINT_20251226_001_BE_cicd_gate_integration // Task: CICD-GATE-01 - Create POST /api/v1/policy/gate/evaluate endpoint using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using StellaOps.Auth.Abstractions; using StellaOps.Auth.ServerIntegration; using StellaOps.Auth.ServerIntegration.Tenancy; using StellaOps.Policy.Audit; using StellaOps.Policy.Deltas; using StellaOps.Policy.Engine.Gates; using StellaOps.Policy.Engine.Services; using StellaOps.Policy.Engine.Contracts.Gateway; namespace StellaOps.Policy.Engine.Endpoints.Gateway; /// /// Gate API endpoints for CI/CD release gating. /// public static class GateEndpoints { private const string DeltaCachePrefix = "delta:"; private static readonly TimeSpan DeltaCacheDuration = TimeSpan.FromMinutes(30); /// /// Maps gate endpoints to the application. /// public static void MapGateEndpoints(this WebApplication app) { var gates = app.MapGroup("/api/v1/policy/gate") .WithTags("Gates") .RequireTenant(); // POST /api/v1/policy/gate/evaluate - Evaluate gate for image gates.MapPost("/evaluate", async Task( HttpContext httpContext, GateEvaluateRequest request, IDriftGateEvaluator gateEvaluator, IDeltaComputer deltaComputer, IBaselineSelector baselineSelector, IGateBypassAuditor bypassAuditor, IMemoryCache cache, [FromServices] TimeProvider timeProvider, ILogger logger, CancellationToken cancellationToken) => { if (request is null) { return Results.BadRequest(new ProblemDetails { Title = "Request body required", Status = 400 }); } if (string.IsNullOrWhiteSpace(request.ImageDigest)) { return Results.BadRequest(new ProblemDetails { Title = "Image digest is required", Status = 400, Detail = "Provide a valid container image digest (e.g., sha256:abc123...)" }); } try { // Step 1: Resolve baseline snapshot var baselineResult = await ResolveBaselineAsync( request.ImageDigest, request.BaselineRef, baselineSelector, cancellationToken); if (!baselineResult.IsFound) { // If no baseline, allow with a note (first build scenario) logger.LogInformation( "No baseline found for {ImageDigest}, allowing first build", request.ImageDigest); return Results.Ok(new GateEvaluateResponse { DecisionId = $"gate:{timeProvider.GetUtcNow():yyyyMMddHHmmss}:{Guid.NewGuid():N}", Status = GateStatus.Pass, ExitCode = GateExitCodes.Pass, ImageDigest = request.ImageDigest, BaselineRef = request.BaselineRef, DecidedAt = timeProvider.GetUtcNow(), Summary = "First build - no baseline for comparison", Advisory = "This appears to be a first build. Future builds will be compared against this baseline." }); } // Step 2: Compute delta between baseline and current var delta = await deltaComputer.ComputeDeltaAsync( baselineResult.Snapshot!.SnapshotId, request.ImageDigest, // Use image digest as target snapshot ID new ArtifactRef(request.ImageDigest, null, null), cancellationToken); // Cache the delta for audit cache.Set( DeltaCachePrefix + delta.DeltaId, delta, DeltaCacheDuration); // Step 3: Build gate context from delta var gateContext = BuildGateContext(delta); // Step 4: Evaluate gates var gateRequest = new DriftGateRequest { Context = gateContext, PolicyId = request.PolicyId, AllowOverride = request.AllowOverride, OverrideJustification = request.OverrideJustification }; var gateDecision = await gateEvaluator.EvaluateAsync(gateRequest, cancellationToken); logger.LogInformation( "Gate evaluated for {ImageDigest}: decision={Decision}, decisionId={DecisionId}", request.ImageDigest, gateDecision.Decision, gateDecision.DecisionId); // Step 5: Record bypass audit if override was applied if (request.AllowOverride && !string.IsNullOrWhiteSpace(request.OverrideJustification) && gateDecision.Decision != DriftGateDecisionType.Allow) { var actor = httpContext.User.Identity?.Name ?? "unknown"; var actorSubject = httpContext.User.Claims .FirstOrDefault(c => c.Type == "sub")?.Value; var actorEmail = httpContext.User.Claims .FirstOrDefault(c => c.Type == "email")?.Value; var actorIp = httpContext.Connection.RemoteIpAddress?.ToString(); var bypassContext = new GateBypassContext { Decision = gateDecision, Request = gateRequest, ImageDigest = request.ImageDigest, Repository = request.Repository, Tag = request.Tag, BaselineRef = request.BaselineRef, Actor = actor, ActorSubject = actorSubject, ActorEmail = actorEmail, ActorIpAddress = actorIp, Justification = request.OverrideJustification, Source = request.Source ?? "api", CiContext = request.CiContext }; await bypassAuditor.RecordBypassAsync(bypassContext, cancellationToken); } // Step 6: Build response var response = BuildResponse(request, gateDecision, delta); // Return appropriate status code based on decision return gateDecision.Decision switch { DriftGateDecisionType.Block => Results.Json(response, statusCode: 403), DriftGateDecisionType.Warn => Results.Ok(response), _ => Results.Ok(response) }; } catch (InvalidOperationException ex) when (ex.Message.Contains("not found")) { return Results.NotFound(new ProblemDetails { Title = "Resource not found", Status = 404, Detail = ex.Message }); } catch (Exception ex) { logger.LogError(ex, "Gate evaluation failed for {ImageDigest}", request.ImageDigest); return Results.Problem(new ProblemDetails { Title = "Gate evaluation failed", Status = 500, Detail = "An error occurred during gate evaluation" }); } }) .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRun)) .WithName("EvaluateGate") .WithDescription("Evaluate the CI/CD release gate for a container image by comparing it against a baseline snapshot. Resolves the baseline using a configurable strategy (last-approved, previous-build, production-deployed, or branch-base), computes the security state delta, runs gate rules against the delta context, and returns a pass/warn/block decision with exit codes. If an override justification is supplied on a non-blocking verdict, a bypass audit record is created. Returns HTTP 403 when the gate blocks the release."); // GET /api/v1/policy/gate/decision/{decisionId} - Get a previous decision gates.MapGet("/decision/{decisionId}", async Task( string decisionId, IMemoryCache cache, CancellationToken cancellationToken) => { if (string.IsNullOrWhiteSpace(decisionId)) { return Results.BadRequest(new ProblemDetails { Title = "Decision ID required", Status = 400 }); } // Try to retrieve cached decision var cacheKey = $"gate:decision:{decisionId}"; if (!cache.TryGetValue(cacheKey, out GateEvaluateResponse? response) || response is null) { return Results.NotFound(new ProblemDetails { Title = "Decision not found", Status = 404, Detail = $"No gate decision found with ID: {decisionId}" }); } return Results.Ok(response); }) .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead)) .WithName("GetGateDecision") .WithDescription("Retrieve a previously cached gate evaluation decision by its decision ID. Gate decisions are retained in memory for 30 minutes after evaluation, after which this endpoint returns HTTP 404. Used by CI/CD pipelines to poll for results when the evaluation was triggered asynchronously via a registry webhook."); // GET /api/v1/policy/gate/health - Health check for gate service gates.MapGet("/health", ([FromServices] TimeProvider timeProvider) => Results.Ok(new { status = "healthy", timestamp = timeProvider.GetUtcNow() })) .WithName("GateHealth") .WithDescription("Health check for the gate evaluation service") .AllowAnonymous(); } private static async Task ResolveBaselineAsync( string imageDigest, string? baselineRef, IBaselineSelector baselineSelector, CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(baselineRef)) { // Check if it's an explicit snapshot ID if (baselineRef.StartsWith("snapshot:") || Guid.TryParse(baselineRef, out _)) { return await baselineSelector.SelectExplicitAsync( baselineRef.Replace("snapshot:", ""), cancellationToken); } // Parse as strategy name var strategy = baselineRef.ToLowerInvariant() switch { "last-approved" or "lastapproved" => BaselineSelectionStrategy.LastApproved, "previous-build" or "previousbuild" => BaselineSelectionStrategy.PreviousBuild, "production" or "production-deployed" => BaselineSelectionStrategy.ProductionDeployed, "branch-base" or "branchbase" => BaselineSelectionStrategy.BranchBase, _ => BaselineSelectionStrategy.LastApproved }; return await baselineSelector.SelectBaselineAsync(imageDigest, strategy, cancellationToken); } // Default to LastApproved strategy return await baselineSelector.SelectBaselineAsync( imageDigest, BaselineSelectionStrategy.LastApproved, cancellationToken); } private static DriftGateContext BuildGateContext(SecurityStateDelta delta) { var newlyReachableVexStatuses = new List(); var newlyReachableSinkIds = new List(); var newlyUnreachableSinkIds = new List(); double? maxCvss = null; double? maxEpss = null; var hasKev = false; var deltaReachable = 0; var deltaUnreachable = 0; // Extract metrics from delta drivers foreach (var driver in delta.Drivers) { if (driver.Type is "new-reachable-cve" or "new-reachable-path") { deltaReachable++; if (driver.CveId is not null) { newlyReachableSinkIds.Add(driver.CveId); } // Extract optional details from the Details dictionary if (driver.Details.TryGetValue("vex_status", out var vexStatus)) { newlyReachableVexStatuses.Add(vexStatus); } if (driver.Details.TryGetValue("cvss", out var cvssStr) && double.TryParse(cvssStr, out var cvss)) { if (!maxCvss.HasValue || cvss > maxCvss.Value) { maxCvss = cvss; } } if (driver.Details.TryGetValue("epss", out var epssStr) && double.TryParse(epssStr, out var epss)) { if (!maxEpss.HasValue || epss > maxEpss.Value) { maxEpss = epss; } } if (driver.Details.TryGetValue("is_kev", out var kevStr) && bool.TryParse(kevStr, out var isKev) && isKev) { hasKev = true; } } else if (driver.Type is "removed-reachable-cve" or "removed-reachable-path") { deltaUnreachable++; if (driver.CveId is not null) { newlyUnreachableSinkIds.Add(driver.CveId); } } } return new DriftGateContext { DeltaReachable = deltaReachable, DeltaUnreachable = deltaUnreachable, HasKevReachable = hasKev, NewlyReachableVexStatuses = newlyReachableVexStatuses, MaxCvss = maxCvss, MaxEpss = maxEpss, BaseScanId = delta.BaselineSnapshotId, HeadScanId = delta.TargetSnapshotId, NewlyReachableSinkIds = newlyReachableSinkIds, NewlyUnreachableSinkIds = newlyUnreachableSinkIds }; } private static GateEvaluateResponse BuildResponse( GateEvaluateRequest request, DriftGateDecision decision, SecurityStateDelta delta) { var status = decision.Decision switch { DriftGateDecisionType.Allow => GateStatus.Pass, DriftGateDecisionType.Warn => GateStatus.Warn, DriftGateDecisionType.Block => GateStatus.Fail, _ => GateStatus.Pass }; var exitCode = decision.Decision switch { DriftGateDecisionType.Allow => GateExitCodes.Pass, DriftGateDecisionType.Warn => GateExitCodes.Warn, DriftGateDecisionType.Block => GateExitCodes.Fail, _ => GateExitCodes.Pass }; return new GateEvaluateResponse { DecisionId = decision.DecisionId, Status = status, ExitCode = exitCode, ImageDigest = request.ImageDigest, BaselineRef = request.BaselineRef, DecidedAt = decision.DecidedAt, Summary = BuildSummary(decision), Advisory = decision.Advisory, Gates = decision.Gates.Select(g => new GateResultDto { Name = g.Name, Result = g.Result.ToString(), Reason = g.Reason, Note = g.Note, Condition = g.Condition }).ToList(), BlockedBy = decision.BlockedBy, BlockReason = decision.BlockReason, Suggestion = decision.Suggestion, OverrideApplied = request.AllowOverride && decision.Decision == DriftGateDecisionType.Warn && !string.IsNullOrWhiteSpace(request.OverrideJustification), DeltaSummary = DeltaSummaryDto.FromModel(delta.Summary) }; } private static string BuildSummary(DriftGateDecision decision) { return decision.Decision switch { DriftGateDecisionType.Allow => "Gate passed - release may proceed", DriftGateDecisionType.Warn => $"Gate passed with warnings - review recommended{(decision.Advisory is not null ? $": {decision.Advisory}" : "")}", DriftGateDecisionType.Block => $"Gate blocked - {decision.BlockReason ?? "release cannot proceed"}", _ => "Gate evaluation complete" }; } }