- Move 24 gateway source files (endpoints, services, contracts) into engine under Endpoints/Gateway/, Services/Gateway/, Contracts/Gateway/ namespaces - Add gateway DI registrations and endpoint mappings to engine Program.cs - Add missing project references (StellaOps.Policy.Scoring, DeltaVerdict, Localization) - Remove HTTP proxy layer (PolicyEngineClient, DPoP, forwarding context not copied) - Update gateway routes in router appsettings to point to policy-engine - Comment out policy service in docker-compose, add backwards-compat network alias - Update services-matrix (gateway build line commented out) - Update all codebase references: AdvisoryAI, JobEngine, CLI, router tests, helm - Update docs: OFFLINE_KIT, configuration-migration, gateway guide, port-registry - Deprecate etc/policy-gateway.yaml.sample with notice - Eliminates 1 container, 9 HTTP round-trips, DPoP token flow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
404 lines
17 KiB
C#
404 lines
17 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// Gate API endpoints for CI/CD release gating.
|
|
/// </summary>
|
|
public static class GateEndpoints
|
|
{
|
|
private const string DeltaCachePrefix = "delta:";
|
|
private static readonly TimeSpan DeltaCacheDuration = TimeSpan.FromMinutes(30);
|
|
|
|
/// <summary>
|
|
/// Maps gate endpoints to the application.
|
|
/// </summary>
|
|
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<IResult>(
|
|
HttpContext httpContext,
|
|
GateEvaluateRequest request,
|
|
IDriftGateEvaluator gateEvaluator,
|
|
IDeltaComputer deltaComputer,
|
|
IBaselineSelector baselineSelector,
|
|
IGateBypassAuditor bypassAuditor,
|
|
IMemoryCache cache,
|
|
[FromServices] TimeProvider timeProvider,
|
|
ILogger<DriftGateEvaluator> 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<IResult>(
|
|
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<BaselineSelectionResult> 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<string>();
|
|
var newlyReachableSinkIds = new List<string>();
|
|
var newlyUnreachableSinkIds = new List<string>();
|
|
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"
|
|
};
|
|
}
|
|
}
|