892 lines
30 KiB
C#
892 lines
30 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// REST endpoints for gate checks.
|
|
/// </summary>
|
|
public static class GatesEndpoints
|
|
{
|
|
private const string CachePrefix = "gates:";
|
|
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
|
|
|
|
/// <summary>
|
|
/// Maps gate endpoints.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /gates/{bom_ref}
|
|
/// Returns the current unknowns state for a component.
|
|
/// </summary>
|
|
private static async Task<IResult> 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<GateStatusResponse>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// POST /gates/{bom_ref}/check
|
|
/// Performs a gate check with optional verdict.
|
|
/// </summary>
|
|
private static async Task<IResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// POST /gates/{bom_ref}/exception
|
|
/// Requests an exception to bypass blocking unknowns.
|
|
/// </summary>
|
|
private static async Task<IResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /gates/{gateId}/decisions
|
|
/// Returns historical gate decisions for a gate.
|
|
/// </summary>
|
|
private static async Task<IResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /gates/decisions/{decisionId}
|
|
/// Returns a specific gate decision by ID.
|
|
/// </summary>
|
|
private static async Task<IResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /gates/decisions/{decisionId}/export
|
|
/// Exports a gate decision in CI/CD format.
|
|
/// Sprint: SPRINT_20260118_019 (GR-008)
|
|
/// </summary>
|
|
private static async Task<IResult> 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)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports decision as JUnit XML.
|
|
/// </summary>
|
|
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 = $"""
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<testsuites name="{suiteName}" tests="1" failures="{(passed ? 0 : 1)}" errors="0" time="0">
|
|
<testsuite name="{suiteName}" tests="1" failures="{(passed ? 0 : 1)}" errors="0">
|
|
<testcase name="{System.Security.SecurityElement.Escape(testCaseName)}" classname="gates">
|
|
{(passed ? "" : $"""
|
|
<failure message="Gate blocked: {decision.GateStatus}" type="GateFailure">
|
|
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)}
|
|
</failure>
|
|
""")}
|
|
</testcase>
|
|
</testsuite>
|
|
</testsuites>
|
|
""";
|
|
|
|
return Results.Content(xml, "application/xml");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports decision as SARIF 2.1.0.
|
|
/// </summary>
|
|
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<object>();
|
|
|
|
// 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports decision as JSON.
|
|
/// </summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines the worst-case state across all unknowns.
|
|
/// </summary>
|
|
private static string DetermineAggregateState(IReadOnlyList<UnknownState> 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";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes a deterministic verdict hash for caching/verification.
|
|
/// </summary>
|
|
private static string ComputeVerdictHash(string bomRef, IReadOnlyList<UnknownState> 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
|
|
|
|
/// <summary>
|
|
/// Gate status response.
|
|
/// </summary>
|
|
public sealed record GateStatusResponse
|
|
{
|
|
/// <summary>BOM reference.</summary>
|
|
[JsonPropertyName("bom_ref")]
|
|
public required string BomRef { get; init; }
|
|
|
|
/// <summary>Aggregate state: resolved, pending, under_review, escalated, rejected.</summary>
|
|
[JsonPropertyName("state")]
|
|
public required string State { get; init; }
|
|
|
|
/// <summary>Verdict hash if resolved.</summary>
|
|
[JsonPropertyName("verdict_hash")]
|
|
public string? VerdictHash { get; init; }
|
|
|
|
/// <summary>Individual unknowns.</summary>
|
|
[JsonPropertyName("unknowns")]
|
|
public List<UnknownDto> Unknowns { get; init; } = [];
|
|
|
|
/// <summary>Gate decision: pass, warn, block.</summary>
|
|
[JsonPropertyName("gate_decision")]
|
|
public required string GateDecision { get; init; }
|
|
|
|
/// <summary>When checked.</summary>
|
|
[JsonPropertyName("checked_at")]
|
|
public DateTimeOffset CheckedAt { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unknown DTO for API response.
|
|
/// </summary>
|
|
public sealed record UnknownDto
|
|
{
|
|
/// <summary>Unknown ID.</summary>
|
|
[JsonPropertyName("unknown_id")]
|
|
public Guid UnknownId { get; init; }
|
|
|
|
/// <summary>CVE ID if applicable.</summary>
|
|
[JsonPropertyName("cve_id")]
|
|
public string? CveId { get; init; }
|
|
|
|
/// <summary>Priority band: hot, warm, cold.</summary>
|
|
[JsonPropertyName("band")]
|
|
public required string Band { get; init; }
|
|
|
|
/// <summary>SLA remaining hours.</summary>
|
|
[JsonPropertyName("sla_remaining_hours")]
|
|
public double? SlaRemainingHours { get; init; }
|
|
|
|
/// <summary>State: pending, under_review, escalated, resolved, rejected.</summary>
|
|
[JsonPropertyName("state")]
|
|
public required string State { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gate check request.
|
|
/// </summary>
|
|
public sealed record GateCheckRequest
|
|
{
|
|
/// <summary>Proposed VEX verdict (e.g., "not_affected").</summary>
|
|
[JsonPropertyName("proposed_verdict")]
|
|
public string? ProposedVerdict { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gate check response.
|
|
/// </summary>
|
|
public sealed record GateCheckResponse
|
|
{
|
|
/// <summary>BOM reference.</summary>
|
|
[JsonPropertyName("bom_ref")]
|
|
public required string BomRef { get; init; }
|
|
|
|
/// <summary>Decision: pass, warn, block.</summary>
|
|
[JsonPropertyName("decision")]
|
|
public required string Decision { get; init; }
|
|
|
|
/// <summary>Current state.</summary>
|
|
[JsonPropertyName("state")]
|
|
public required string State { get; init; }
|
|
|
|
/// <summary>Blocking unknown IDs.</summary>
|
|
[JsonPropertyName("blocking_unknown_ids")]
|
|
public List<Guid> BlockingUnknownIds { get; init; } = [];
|
|
|
|
/// <summary>Reason for decision.</summary>
|
|
[JsonPropertyName("reason")]
|
|
public string? Reason { get; init; }
|
|
|
|
/// <summary>Whether exception was granted.</summary>
|
|
[JsonPropertyName("exception_granted")]
|
|
public bool ExceptionGranted { get; init; }
|
|
|
|
/// <summary>Exception reference if granted.</summary>
|
|
[JsonPropertyName("exception_ref")]
|
|
public string? ExceptionRef { get; init; }
|
|
|
|
/// <summary>When checked.</summary>
|
|
[JsonPropertyName("checked_at")]
|
|
public DateTimeOffset CheckedAt { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exception request.
|
|
/// </summary>
|
|
public sealed record ExceptionRequest
|
|
{
|
|
/// <summary>Unknown IDs to bypass.</summary>
|
|
[JsonPropertyName("unknown_ids")]
|
|
public List<Guid> UnknownIds { get; init; } = [];
|
|
|
|
/// <summary>Justification for bypass.</summary>
|
|
[JsonPropertyName("justification")]
|
|
public required string Justification { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exception response.
|
|
/// </summary>
|
|
public sealed record ExceptionResponse
|
|
{
|
|
/// <summary>Whether exception was granted.</summary>
|
|
[JsonPropertyName("granted")]
|
|
public bool Granted { get; init; }
|
|
|
|
/// <summary>Exception reference.</summary>
|
|
[JsonPropertyName("exception_ref")]
|
|
public string? ExceptionRef { get; init; }
|
|
|
|
/// <summary>Denial reason if not granted.</summary>
|
|
[JsonPropertyName("denial_reason")]
|
|
public string? DenialReason { get; init; }
|
|
|
|
/// <summary>When exception expires.</summary>
|
|
[JsonPropertyName("expires_at")]
|
|
public DateTimeOffset? ExpiresAt { get; init; }
|
|
|
|
/// <summary>When requested.</summary>
|
|
[JsonPropertyName("requested_at")]
|
|
public DateTimeOffset RequestedAt { get; init; }
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Gate Decision History DTOs
|
|
|
|
/// <summary>
|
|
/// Response for gate decision history query.
|
|
/// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
|
|
/// Task: GR-005 - Add gate decision history endpoint
|
|
/// </summary>
|
|
public sealed record GateDecisionHistoryResponse
|
|
{
|
|
/// <summary>List of gate decisions.</summary>
|
|
[JsonPropertyName("decisions")]
|
|
public List<GateDecisionDto> Decisions { get; init; } = [];
|
|
|
|
/// <summary>Total count of matching decisions.</summary>
|
|
[JsonPropertyName("total")]
|
|
public long Total { get; init; }
|
|
|
|
/// <summary>Token for fetching next page.</summary>
|
|
[JsonPropertyName("continuation_token")]
|
|
public string? ContinuationToken { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gate decision DTO for API response.
|
|
/// </summary>
|
|
public sealed record GateDecisionDto
|
|
{
|
|
/// <summary>Unique decision ID.</summary>
|
|
[JsonPropertyName("decision_id")]
|
|
public Guid DecisionId { get; init; }
|
|
|
|
/// <summary>BOM reference.</summary>
|
|
[JsonPropertyName("bom_ref")]
|
|
public required string BomRef { get; init; }
|
|
|
|
/// <summary>Image digest if applicable.</summary>
|
|
[JsonPropertyName("image_digest")]
|
|
public string? ImageDigest { get; init; }
|
|
|
|
/// <summary>Gate decision: pass, warn, block.</summary>
|
|
[JsonPropertyName("gate_status")]
|
|
public required string GateStatus { get; init; }
|
|
|
|
/// <summary>Verdict hash for replay verification.</summary>
|
|
[JsonPropertyName("verdict_hash")]
|
|
public string? VerdictHash { get; init; }
|
|
|
|
/// <summary>Policy bundle ID used for evaluation.</summary>
|
|
[JsonPropertyName("policy_bundle_id")]
|
|
public string? PolicyBundleId { get; init; }
|
|
|
|
/// <summary>Policy bundle content hash.</summary>
|
|
[JsonPropertyName("policy_bundle_hash")]
|
|
public string? PolicyBundleHash { get; init; }
|
|
|
|
/// <summary>When the evaluation occurred.</summary>
|
|
[JsonPropertyName("evaluated_at")]
|
|
public DateTimeOffset EvaluatedAt { get; init; }
|
|
|
|
/// <summary>CI/CD context (branch, commit, pipeline).</summary>
|
|
[JsonPropertyName("ci_context")]
|
|
public string? CiContext { get; init; }
|
|
|
|
/// <summary>Actor who triggered evaluation.</summary>
|
|
[JsonPropertyName("actor")]
|
|
public string? Actor { get; init; }
|
|
|
|
/// <summary>IDs of unknowns that blocked the release.</summary>
|
|
[JsonPropertyName("blocking_unknown_ids")]
|
|
public List<Guid> BlockingUnknownIds { get; init; } = [];
|
|
|
|
/// <summary>Warning messages.</summary>
|
|
[JsonPropertyName("warnings")]
|
|
public List<string> Warnings { get; init; } = [];
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region CI/CD Export DTOs (GR-008)
|
|
|
|
/// <summary>
|
|
/// Export format for gate decisions.
|
|
/// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
|
|
/// Task: GR-008 - Implement CI/CD status export formats
|
|
/// </summary>
|
|
public enum ExportFormat
|
|
{
|
|
/// <summary>JSON format for custom integrations.</summary>
|
|
Json,
|
|
|
|
/// <summary>JUnit XML format for Jenkins, GitHub Actions, GitLab CI.</summary>
|
|
JUnit,
|
|
|
|
/// <summary>SARIF 2.1.0 format for GitHub Code Scanning, VS Code.</summary>
|
|
Sarif
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gate decision export JSON format for CI/CD integration.
|
|
/// </summary>
|
|
public sealed record GateDecisionExportJson
|
|
{
|
|
/// <summary>Unique decision ID.</summary>
|
|
[JsonPropertyName("decision_id")]
|
|
public Guid DecisionId { get; init; }
|
|
|
|
/// <summary>BOM reference.</summary>
|
|
[JsonPropertyName("bom_ref")]
|
|
public required string BomRef { get; init; }
|
|
|
|
/// <summary>Image digest if applicable.</summary>
|
|
[JsonPropertyName("image_digest")]
|
|
public string? ImageDigest { get; init; }
|
|
|
|
/// <summary>Gate decision: pass, warn, block.</summary>
|
|
[JsonPropertyName("gate_status")]
|
|
public required string GateStatus { get; init; }
|
|
|
|
/// <summary>Verdict hash for replay verification.</summary>
|
|
[JsonPropertyName("verdict_hash")]
|
|
public string? VerdictHash { get; init; }
|
|
|
|
/// <summary>Policy bundle ID used for evaluation.</summary>
|
|
[JsonPropertyName("policy_bundle_id")]
|
|
public string? PolicyBundleId { get; init; }
|
|
|
|
/// <summary>Policy bundle content hash.</summary>
|
|
[JsonPropertyName("policy_bundle_hash")]
|
|
public string? PolicyBundleHash { get; init; }
|
|
|
|
/// <summary>When the evaluation occurred.</summary>
|
|
[JsonPropertyName("evaluated_at")]
|
|
public DateTimeOffset EvaluatedAt { get; init; }
|
|
|
|
/// <summary>CI/CD context (branch, commit, pipeline).</summary>
|
|
[JsonPropertyName("ci_context")]
|
|
public string? CiContext { get; init; }
|
|
|
|
/// <summary>Actor who triggered evaluation.</summary>
|
|
[JsonPropertyName("actor")]
|
|
public string? Actor { get; init; }
|
|
|
|
/// <summary>IDs of unknowns that blocked the release.</summary>
|
|
[JsonPropertyName("blocking_unknown_ids")]
|
|
public List<Guid> BlockingUnknownIds { get; init; } = [];
|
|
|
|
/// <summary>Warning messages.</summary>
|
|
[JsonPropertyName("warnings")]
|
|
public List<string> Warnings { get; init; } = [];
|
|
|
|
/// <summary>
|
|
/// Exit code for CI/CD script integration.
|
|
/// 0 = pass, 1 = warn, 2 = block
|
|
/// </summary>
|
|
[JsonPropertyName("exit_code")]
|
|
public int ExitCode { get; init; }
|
|
}
|
|
|
|
#endregion
|