// 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"
};
}
}