refactor(policy): merge policy gateway into policy-engine
- 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>
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
// 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user