Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RiskBudgetEndpoints.cs
|
||||
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
|
||||
// Task: BUDGET-04 - Budget consumption API
|
||||
// Description: API endpoints for risk budget management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Gates;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for risk budget management.
|
||||
/// </summary>
|
||||
internal static class RiskBudgetEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRiskBudgets(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/policy/budget")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Risk Budgets");
|
||||
|
||||
group.MapGet("/status/{serviceId}", GetBudgetStatus)
|
||||
.WithName("GetRiskBudgetStatus")
|
||||
.WithSummary("Get current risk budget status for a service.")
|
||||
.Produces<RiskBudgetStatusResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/consume", ConsumeBudget)
|
||||
.WithName("ConsumeRiskBudget")
|
||||
.WithSummary("Record budget consumption after a release.")
|
||||
.Produces<BudgetConsumeResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/check", CheckRelease)
|
||||
.WithName("CheckRelease")
|
||||
.WithSummary("Check if a release can proceed given current budget.")
|
||||
.Produces<ReleaseCheckResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/history/{serviceId}", GetBudgetHistory)
|
||||
.WithName("GetBudgetHistory")
|
||||
.WithSummary("Get budget consumption history for a service.")
|
||||
.Produces<BudgetHistoryResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPost("/adjust", AdjustBudget)
|
||||
.WithName("AdjustBudget")
|
||||
.WithSummary("Adjust budget allocation (earned capacity or manual override).")
|
||||
.Produces<RiskBudgetStatusResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapGet("/list", ListBudgets)
|
||||
.WithName("ListRiskBudgets")
|
||||
.WithSummary("List all risk budgets with optional filtering.")
|
||||
.Produces<BudgetListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<Ok<RiskBudgetStatusResponse>> GetBudgetStatus(
|
||||
string serviceId,
|
||||
[FromQuery] string? window,
|
||||
IBudgetLedger ledger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var budget = await ledger.GetBudgetAsync(serviceId, window, ct);
|
||||
|
||||
return TypedResults.Ok(new RiskBudgetStatusResponse(
|
||||
budget.BudgetId,
|
||||
budget.ServiceId,
|
||||
budget.Tier.ToString(),
|
||||
budget.Window,
|
||||
budget.Allocated,
|
||||
budget.Consumed,
|
||||
budget.Remaining,
|
||||
budget.PercentageUsed,
|
||||
budget.Status.ToString().ToLowerInvariant(),
|
||||
budget.UpdatedAt));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<BudgetConsumeResponse>, ProblemHttpResult>> ConsumeBudget(
|
||||
[FromBody] BudgetConsumeRequest request,
|
||||
IBudgetLedger ledger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.RiskPoints <= 0)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
"Risk points must be greater than 0.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var result = await ledger.ConsumeAsync(
|
||||
request.ServiceId,
|
||||
request.RiskPoints,
|
||||
request.ReleaseId,
|
||||
ct);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
result.Error ?? "Budget consumption failed.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new BudgetConsumeResponse(
|
||||
result.IsSuccess,
|
||||
result.Entry?.EntryId,
|
||||
result.Budget.Remaining,
|
||||
result.Budget.PercentageUsed,
|
||||
result.Budget.Status.ToString().ToLowerInvariant(),
|
||||
result.Error));
|
||||
}
|
||||
|
||||
private static async Task<Ok<ReleaseCheckResponse>> CheckRelease(
|
||||
[FromBody] ReleaseCheckRequest request,
|
||||
IBudgetConstraintEnforcer enforcer,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var input = new ReleaseCheckInput
|
||||
{
|
||||
ServiceId = request.ServiceId,
|
||||
Tier = Enum.Parse<ServiceTier>(request.Tier, ignoreCase: true),
|
||||
DiffCategory = Enum.Parse<DiffCategory>(request.DiffCategory, ignoreCase: true),
|
||||
Context = new OperationalContext
|
||||
{
|
||||
// Map request properties to actual OperationalContext properties
|
||||
InRestrictedWindow = request.ChangeFreeze || !request.DeploymentWindow,
|
||||
HasRecentIncident = request.IncidentActive,
|
||||
ErrorBudgetBelow50Percent = false, // Would come from budget ledger
|
||||
HighOnCallLoad = false // Would come from external system
|
||||
},
|
||||
Mitigations = new MitigationFactors
|
||||
{
|
||||
HasFeatureFlag = request.HasFeatureFlag,
|
||||
HasCanaryDeployment = request.CanaryPercentage > 0,
|
||||
HasBackwardCompatibleMigration = request.HasRollbackPlan,
|
||||
HasHighTestCoverage = false, // Would come from CI metadata
|
||||
HasPermissionBoundary = request.IsNonProduction
|
||||
}
|
||||
};
|
||||
|
||||
var result = await enforcer.CheckReleaseAsync(input, ct);
|
||||
|
||||
return TypedResults.Ok(new ReleaseCheckResponse(
|
||||
result.CanProceed,
|
||||
result.RequiredGate.ToString().ToLowerInvariant(),
|
||||
result.RiskPoints,
|
||||
result.BudgetBefore.Remaining,
|
||||
result.BudgetAfter.Remaining,
|
||||
result.BudgetBefore.Status.ToString().ToLowerInvariant(),
|
||||
result.BudgetAfter.Status.ToString().ToLowerInvariant(),
|
||||
result.BlockReason,
|
||||
result.Requirements,
|
||||
result.Recommendations));
|
||||
}
|
||||
|
||||
private static async Task<Ok<BudgetHistoryResponse>> GetBudgetHistory(
|
||||
string serviceId,
|
||||
[FromQuery] string? window,
|
||||
IBudgetLedger ledger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entries = await ledger.GetHistoryAsync(serviceId, window, ct);
|
||||
|
||||
var items = entries.Select(e => new BudgetEntryDto(
|
||||
e.EntryId,
|
||||
e.ReleaseId,
|
||||
e.RiskPoints,
|
||||
e.ConsumedAt)).ToList();
|
||||
|
||||
return TypedResults.Ok(new BudgetHistoryResponse(
|
||||
serviceId,
|
||||
window ?? GetCurrentWindow(),
|
||||
items));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<RiskBudgetStatusResponse>, ProblemHttpResult>> AdjustBudget(
|
||||
[FromBody] BudgetAdjustRequest request,
|
||||
IBudgetLedger ledger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.Adjustment == 0)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
"Adjustment must be non-zero.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var budget = await ledger.AdjustAllocationAsync(
|
||||
request.ServiceId,
|
||||
request.Adjustment,
|
||||
request.Reason,
|
||||
ct);
|
||||
|
||||
return TypedResults.Ok(new RiskBudgetStatusResponse(
|
||||
budget.BudgetId,
|
||||
budget.ServiceId,
|
||||
budget.Tier.ToString(),
|
||||
budget.Window,
|
||||
budget.Allocated,
|
||||
budget.Consumed,
|
||||
budget.Remaining,
|
||||
budget.PercentageUsed,
|
||||
budget.Status.ToString().ToLowerInvariant(),
|
||||
budget.UpdatedAt));
|
||||
}
|
||||
|
||||
private static Ok<BudgetListResponse> ListBudgets(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? window,
|
||||
[FromQuery] int limit = 50)
|
||||
{
|
||||
// This would query from PostgresBudgetStore.GetBudgetsByStatusAsync or GetBudgetsByWindowAsync
|
||||
// For now, return empty list - implementation would need to inject the store
|
||||
return TypedResults.Ok(new BudgetListResponse([], 0));
|
||||
}
|
||||
|
||||
private static string GetCurrentWindow() =>
|
||||
DateTimeOffset.UtcNow.ToString("yyyy-MM");
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
/// <summary>Response containing risk budget status.</summary>
|
||||
public sealed record RiskBudgetStatusResponse(
|
||||
string BudgetId,
|
||||
string ServiceId,
|
||||
string Tier,
|
||||
string Window,
|
||||
int Allocated,
|
||||
int Consumed,
|
||||
int Remaining,
|
||||
decimal PercentageUsed,
|
||||
string Status,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>Request to consume budget.</summary>
|
||||
public sealed record BudgetConsumeRequest(
|
||||
string ServiceId,
|
||||
int RiskPoints,
|
||||
string ReleaseId,
|
||||
string? Reason = null);
|
||||
|
||||
/// <summary>Response from budget consumption.</summary>
|
||||
public sealed record BudgetConsumeResponse(
|
||||
bool IsSuccess,
|
||||
string? EntryId,
|
||||
int Remaining,
|
||||
decimal PercentageUsed,
|
||||
string Status,
|
||||
string? Error);
|
||||
|
||||
/// <summary>Request to check if release can proceed.</summary>
|
||||
public sealed record ReleaseCheckRequest(
|
||||
string ServiceId,
|
||||
string Tier,
|
||||
string DiffCategory,
|
||||
bool ChangeFreeze = false,
|
||||
bool IncidentActive = false,
|
||||
bool DeploymentWindow = true,
|
||||
bool HasFeatureFlag = false,
|
||||
int CanaryPercentage = 0,
|
||||
bool HasRollbackPlan = false,
|
||||
bool IsNonProduction = false);
|
||||
|
||||
/// <summary>Response from release check.</summary>
|
||||
public sealed record ReleaseCheckResponse(
|
||||
bool CanProceed,
|
||||
string RequiredGate,
|
||||
int RiskPoints,
|
||||
int BudgetRemainingBefore,
|
||||
int BudgetRemainingAfter,
|
||||
string StatusBefore,
|
||||
string StatusAfter,
|
||||
string? BlockReason,
|
||||
IReadOnlyList<string> Requirements,
|
||||
IReadOnlyList<string> Recommendations);
|
||||
|
||||
/// <summary>Budget entry DTO.</summary>
|
||||
public sealed record BudgetEntryDto(
|
||||
string EntryId,
|
||||
string ReleaseId,
|
||||
int RiskPoints,
|
||||
DateTimeOffset ConsumedAt);
|
||||
|
||||
/// <summary>Response containing budget history.</summary>
|
||||
public sealed record BudgetHistoryResponse(
|
||||
string ServiceId,
|
||||
string Window,
|
||||
IReadOnlyList<BudgetEntryDto> Entries);
|
||||
|
||||
/// <summary>Request to adjust budget.</summary>
|
||||
public sealed record BudgetAdjustRequest(
|
||||
string ServiceId,
|
||||
int Adjustment,
|
||||
string Reason);
|
||||
|
||||
/// <summary>Response containing budget list.</summary>
|
||||
public sealed record BudgetListResponse(
|
||||
IReadOnlyList<RiskBudgetStatusResponse> Budgets,
|
||||
int TotalCount);
|
||||
|
||||
#endregion
|
||||
253
src/Policy/StellaOps.Policy.Engine/Services/GateBypassAuditor.cs
Normal file
253
src/Policy/StellaOps.Policy.Engine/Services/GateBypassAuditor.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GateBypassAuditor.cs
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-06 - Gate bypass audit logging
|
||||
// Description: Service for recording gate bypass/override audit events
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Audit;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for auditing gate bypass events.
|
||||
/// </summary>
|
||||
public interface IGateBypassAuditor
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a gate bypass audit entry.
|
||||
/// </summary>
|
||||
/// <param name="context">The bypass context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created audit entry.</returns>
|
||||
Task<GateBypassAuditEntry> RecordBypassAsync(
|
||||
GateBypassContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an actor has exceeded bypass rate limits.
|
||||
/// </summary>
|
||||
/// <param name="actor">The actor identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if rate limit exceeded, false otherwise.</returns>
|
||||
Task<bool> IsRateLimitExceededAsync(
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for a gate bypass operation.
|
||||
/// </summary>
|
||||
public sealed record GateBypassContext
|
||||
{
|
||||
/// <summary>
|
||||
/// The gate decision that was bypassed.
|
||||
/// </summary>
|
||||
public required DriftGateDecision Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The original gate request.
|
||||
/// </summary>
|
||||
public required DriftGateRequest Request { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The image digest being evaluated.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The repository name.
|
||||
/// </summary>
|
||||
public string? Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The tag, if any.
|
||||
/// </summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The baseline reference.
|
||||
/// </summary>
|
||||
public string? BaselineRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The identity of the actor requesting the bypass.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject from the auth token.
|
||||
/// </summary>
|
||||
public string? ActorSubject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The email from the auth token.
|
||||
/// </summary>
|
||||
public string? ActorEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The IP address of the requester.
|
||||
/// </summary>
|
||||
public string? ActorIpAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The justification for the bypass.
|
||||
/// </summary>
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The source of the request (e.g., "cli", "api", "webhook").
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CI/CD context (e.g., "github-actions", "gitlab-ci").
|
||||
/// </summary>
|
||||
public string? CiContext { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IGateBypassAuditor"/>.
|
||||
/// </summary>
|
||||
public sealed class GateBypassAuditor : IGateBypassAuditor
|
||||
{
|
||||
private readonly IGateBypassAuditRepository _repository;
|
||||
private readonly ILogger<GateBypassAuditor> _logger;
|
||||
private readonly GateBypassAuditOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public GateBypassAuditor(
|
||||
IGateBypassAuditRepository repository,
|
||||
ILogger<GateBypassAuditor> logger,
|
||||
GateBypassAuditOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
_options = options ?? new GateBypassAuditOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateBypassAuditEntry> RecordBypassAsync(
|
||||
GateBypassContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var bypassedGates = context.Decision.Gates
|
||||
.Where(g => g.Result != DriftGateResultType.Pass && g.Result != DriftGateResultType.PassWithNote)
|
||||
.Select(g => g.Name)
|
||||
.ToList();
|
||||
|
||||
var entry = new GateBypassAuditEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
DecisionId = context.Decision.DecisionId,
|
||||
ImageDigest = context.ImageDigest,
|
||||
Repository = context.Repository,
|
||||
Tag = context.Tag,
|
||||
BaselineRef = context.BaselineRef,
|
||||
OriginalDecision = context.Decision.Decision.ToString(),
|
||||
FinalDecision = "Allow",
|
||||
BypassedGates = bypassedGates,
|
||||
Actor = context.Actor,
|
||||
ActorSubject = context.ActorSubject,
|
||||
ActorEmail = context.ActorEmail,
|
||||
ActorIpAddress = context.ActorIpAddress,
|
||||
Justification = context.Justification,
|
||||
PolicyId = context.Request.PolicyId,
|
||||
Source = context.Source,
|
||||
CiContext = context.CiContext,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["gates_count"] = context.Decision.Gates.Length.ToString(),
|
||||
["blocked_by"] = context.Decision.BlockedBy ?? "",
|
||||
["block_reason"] = context.Decision.BlockReason ?? ""
|
||||
}
|
||||
};
|
||||
|
||||
await _repository.AddAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Gate bypass recorded: DecisionId={DecisionId}, Actor={Actor}, " +
|
||||
"Image={ImageDigest}, BypassedGates={BypassedGates}, Justification={Justification}",
|
||||
entry.DecisionId,
|
||||
entry.Actor,
|
||||
entry.ImageDigest,
|
||||
string.Join(", ", bypassedGates),
|
||||
entry.Justification);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsRateLimitExceededAsync(
|
||||
string actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.EnableRateLimiting)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var since = _timeProvider.GetUtcNow().Add(-_options.RateLimitWindow);
|
||||
var count = await _repository.CountByActorSinceAsync(actor, since, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (count >= _options.MaxBypassesPerWindow)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Gate bypass rate limit exceeded for actor {Actor}: {Count} bypasses in {Window}",
|
||||
actor,
|
||||
count,
|
||||
_options.RateLimitWindow);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for gate bypass auditing.
|
||||
/// </summary>
|
||||
public sealed class GateBypassAuditOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Policy:GateBypassAudit";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable rate limiting on bypasses.
|
||||
/// </summary>
|
||||
public bool EnableRateLimiting { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// The time window for rate limiting.
|
||||
/// </summary>
|
||||
public TimeSpan RateLimitWindow { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bypasses allowed per actor within the rate limit window.
|
||||
/// </summary>
|
||||
public int MaxBypassesPerWindow { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require justification for all bypasses.
|
||||
/// </summary>
|
||||
public bool RequireJustification { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum justification length.
|
||||
/// </summary>
|
||||
public int MinJustificationLength { get; set; } = 10;
|
||||
}
|
||||
243
src/Policy/StellaOps.Policy.Gateway/Contracts/GateContracts.cs
Normal file
243
src/Policy/StellaOps.Policy.Gateway/Contracts/GateContracts.cs
Normal file
@@ -0,0 +1,243 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-01 - Create gate/evaluate endpoint contracts
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate a CI/CD gate for an image.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The image digest to evaluate (e.g., sha256:abc123...).
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The container repository name.
|
||||
/// </summary>
|
||||
public string? Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The image tag, if any.
|
||||
/// </summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The baseline reference for comparison.
|
||||
/// Can be a snapshot ID, image digest, or strategy name (e.g., "last-approved", "production").
|
||||
/// </summary>
|
||||
public string? BaselineRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional policy ID to use for evaluation.
|
||||
/// </summary>
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow override of blocking gates.
|
||||
/// </summary>
|
||||
public bool AllowOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for override (required if AllowOverride is true and gate would block).
|
||||
/// </summary>
|
||||
public string? OverrideJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source of the request (e.g., "cli", "api", "webhook").
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CI/CD context identifier (e.g., "github-actions", "gitlab-ci").
|
||||
/// </summary>
|
||||
public string? CiContext { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional context for the gate evaluation.
|
||||
/// </summary>
|
||||
public GateEvaluationContext? Context { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additional context for gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Git branch name.
|
||||
/// </summary>
|
||||
public string? Branch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Git commit SHA.
|
||||
/// </summary>
|
||||
public string? CommitSha { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CI/CD pipeline ID or job ID.
|
||||
/// </summary>
|
||||
public string? PipelineId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Environment being deployed to (e.g., "production", "staging").
|
||||
/// </summary>
|
||||
public string? Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor triggering the gate (e.g., user or service identity).
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique decision ID for audit and tracking.
|
||||
/// </summary>
|
||||
public required string DecisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The gate decision status.
|
||||
/// </summary>
|
||||
public required GateStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested CI exit code.
|
||||
/// 0 = Pass, 1 = Warn (configurable pass-through), 2 = Fail/Block
|
||||
/// </summary>
|
||||
public required int ExitCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The image digest that was evaluated.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The baseline reference used for comparison.
|
||||
/// </summary>
|
||||
public string? BaselineRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was made (UTC).
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary message for the decision.
|
||||
/// </summary>
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Advisory or suggestion for the developer.
|
||||
/// </summary>
|
||||
public string? Advisory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of gate results.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GateResultDto>? Gates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate that caused the block (if blocked).
|
||||
/// </summary>
|
||||
public string? BlockedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed reason for the block.
|
||||
/// </summary>
|
||||
public string? BlockReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggestion for resolving the block.
|
||||
/// </summary>
|
||||
public string? Suggestion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether an override was applied.
|
||||
/// </summary>
|
||||
public bool OverrideApplied { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Delta summary if available.
|
||||
/// </summary>
|
||||
public DeltaSummaryDto? DeltaSummary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record GateResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate name/ID.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate result type.
|
||||
/// </summary>
|
||||
public required string Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the result.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional note.
|
||||
/// </summary>
|
||||
public string? Note { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Condition expression that was evaluated.
|
||||
/// </summary>
|
||||
public string? Condition { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate evaluation status.
|
||||
/// </summary>
|
||||
public enum GateStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate passed - proceed with deployment.
|
||||
/// </summary>
|
||||
Pass = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Gate produced warnings - proceed with caution.
|
||||
/// </summary>
|
||||
Warn = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Gate blocked - do not proceed.
|
||||
/// </summary>
|
||||
Fail = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CI exit codes for gate evaluation.
|
||||
/// </summary>
|
||||
public static class GateExitCodes
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate passed - proceed with deployment.
|
||||
/// </summary>
|
||||
public const int Pass = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gate produced warnings - configurable pass-through.
|
||||
/// </summary>
|
||||
public const int Warn = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gate blocked - do not proceed.
|
||||
/// </summary>
|
||||
public const int Fail = 2;
|
||||
}
|
||||
398
src/Policy/StellaOps.Policy.Gateway/Endpoints/GateEndpoints.cs
Normal file
398
src/Policy/StellaOps.Policy.Gateway/Endpoints/GateEndpoints.cs
Normal file
@@ -0,0 +1,398 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 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.Policy.Audit;
|
||||
using StellaOps.Policy.Deltas;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Gateway.Contracts;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
/// <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");
|
||||
|
||||
// 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,
|
||||
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:{DateTimeOffset.UtcNow:yyyyMMddHHmmss}:{Guid.NewGuid():N}",
|
||||
Status = GateStatus.Pass,
|
||||
ExitCode = GateExitCodes.Pass,
|
||||
ImageDigest = request.ImageDigest,
|
||||
BaselineRef = request.BaselineRef,
|
||||
DecidedAt = DateTimeOffset.UtcNow,
|
||||
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 CI/CD gate for an image digest and baseline reference");
|
||||
|
||||
// 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 previous gate evaluation decision by ID");
|
||||
|
||||
// GET /api/v1/policy/gate/health - Health check for gate service
|
||||
gates.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow }))
|
||||
.WithName("GateHealth")
|
||||
.WithDescription("Health check for the gate evaluation service");
|
||||
}
|
||||
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RegistryWebhookEndpoints.cs
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-02 - Webhook handler for registry image-push events
|
||||
// Description: Receives webhooks from container registries and triggers gate evaluation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoints for receiving registry webhook events and triggering gate evaluations.
|
||||
/// </summary>
|
||||
internal static class RegistryWebhookEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRegistryWebhooks(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/webhooks/registry")
|
||||
.WithTags("Registry Webhooks");
|
||||
|
||||
group.MapPost("/docker", HandleDockerRegistryWebhook)
|
||||
.WithName("DockerRegistryWebhook")
|
||||
.WithSummary("Handle Docker Registry v2 webhook events")
|
||||
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/harbor", HandleHarborWebhook)
|
||||
.WithName("HarborWebhook")
|
||||
.WithSummary("Handle Harbor registry webhook events")
|
||||
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/generic", HandleGenericWebhook)
|
||||
.WithName("GenericRegistryWebhook")
|
||||
.WithSummary("Handle generic registry webhook events with image digest")
|
||||
.Produces<WebhookAcceptedResponse>(StatusCodes.Status202Accepted)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles Docker Registry v2 notification webhooks.
|
||||
/// </summary>
|
||||
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleDockerRegistryWebhook(
|
||||
[FromBody] DockerRegistryNotification notification,
|
||||
IGateEvaluationQueue evaluationQueue,
|
||||
ILogger<RegistryWebhookEndpointMarker> logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (notification.Events is null || notification.Events.Count == 0)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
"No events in notification",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var jobs = new List<string>();
|
||||
|
||||
foreach (var evt in notification.Events.Where(e => e.Action == "push"))
|
||||
{
|
||||
if (string.IsNullOrEmpty(evt.Target?.Digest))
|
||||
{
|
||||
logger.LogWarning("Skipping push event without digest: {Repository}", evt.Target?.Repository);
|
||||
continue;
|
||||
}
|
||||
|
||||
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
|
||||
{
|
||||
ImageDigest = evt.Target.Digest,
|
||||
Repository = evt.Target.Repository ?? "unknown",
|
||||
Tag = evt.Target.Tag,
|
||||
RegistryUrl = evt.Request?.Host,
|
||||
Source = "docker-registry",
|
||||
Timestamp = evt.Timestamp ?? DateTimeOffset.UtcNow
|
||||
}, ct);
|
||||
|
||||
jobs.Add(jobId);
|
||||
|
||||
logger.LogInformation(
|
||||
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
|
||||
evt.Target.Repository,
|
||||
evt.Target.Digest,
|
||||
jobId);
|
||||
}
|
||||
|
||||
return TypedResults.Accepted(
|
||||
$"/api/v1/policy/gate/jobs/{jobs.FirstOrDefault()}",
|
||||
new WebhookAcceptedResponse(jobs.Count, jobs));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles Harbor registry webhook events.
|
||||
/// </summary>
|
||||
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleHarborWebhook(
|
||||
[FromBody] HarborWebhookEvent notification,
|
||||
IGateEvaluationQueue evaluationQueue,
|
||||
ILogger<RegistryWebhookEndpointMarker> logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Only process push events
|
||||
if (notification.Type != "PUSH_ARTIFACT" && notification.Type != "pushImage")
|
||||
{
|
||||
logger.LogDebug("Ignoring Harbor event type: {Type}", notification.Type);
|
||||
return TypedResults.Accepted(
|
||||
"/api/v1/policy/gate/jobs",
|
||||
new WebhookAcceptedResponse(0, []));
|
||||
}
|
||||
|
||||
if (notification.EventData?.Resources is null || notification.EventData.Resources.Count == 0)
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
"No resources in Harbor notification",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var jobs = new List<string>();
|
||||
|
||||
foreach (var resource in notification.EventData.Resources)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resource.Digest))
|
||||
{
|
||||
logger.LogWarning("Skipping resource without digest: {ResourceUrl}", resource.ResourceUrl);
|
||||
continue;
|
||||
}
|
||||
|
||||
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
|
||||
{
|
||||
ImageDigest = resource.Digest,
|
||||
Repository = notification.EventData.Repository?.Name ?? "unknown",
|
||||
Tag = resource.Tag,
|
||||
RegistryUrl = notification.EventData.Repository?.RepoFullName,
|
||||
Source = "harbor",
|
||||
Timestamp = notification.OccurAt ?? DateTimeOffset.UtcNow
|
||||
}, ct);
|
||||
|
||||
jobs.Add(jobId);
|
||||
|
||||
logger.LogInformation(
|
||||
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
|
||||
notification.EventData.Repository?.Name,
|
||||
resource.Digest,
|
||||
jobId);
|
||||
}
|
||||
|
||||
return TypedResults.Accepted(
|
||||
$"/api/v1/policy/gate/jobs/{jobs.FirstOrDefault()}",
|
||||
new WebhookAcceptedResponse(jobs.Count, jobs));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles generic webhook events with image digest.
|
||||
/// </summary>
|
||||
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleGenericWebhook(
|
||||
[FromBody] GenericRegistryWebhook notification,
|
||||
IGateEvaluationQueue evaluationQueue,
|
||||
ILogger<RegistryWebhookEndpointMarker> logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(notification.ImageDigest))
|
||||
{
|
||||
return TypedResults.Problem(
|
||||
"imageDigest is required",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var jobId = await evaluationQueue.EnqueueAsync(new GateEvaluationRequest
|
||||
{
|
||||
ImageDigest = notification.ImageDigest,
|
||||
Repository = notification.Repository ?? "unknown",
|
||||
Tag = notification.Tag,
|
||||
RegistryUrl = notification.RegistryUrl,
|
||||
BaselineRef = notification.BaselineRef,
|
||||
Source = notification.Source ?? "generic",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
}, ct);
|
||||
|
||||
logger.LogInformation(
|
||||
"Queued gate evaluation for {Repository}@{Digest}, job: {JobId}",
|
||||
notification.Repository,
|
||||
notification.ImageDigest,
|
||||
jobId);
|
||||
|
||||
return TypedResults.Accepted(
|
||||
$"/api/v1/policy/gate/jobs/{jobId}",
|
||||
new WebhookAcceptedResponse(1, [jobId]));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marker type for endpoint logging.
|
||||
/// </summary>
|
||||
internal sealed class RegistryWebhookEndpointMarker;
|
||||
|
||||
// ============================================================================
|
||||
// Docker Registry Notification Models
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Docker Registry v2 notification envelope.
|
||||
/// </summary>
|
||||
public sealed record DockerRegistryNotification
|
||||
{
|
||||
[JsonPropertyName("events")]
|
||||
public List<DockerRegistryEvent>? Events { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Docker Registry v2 event.
|
||||
/// </summary>
|
||||
public sealed record DockerRegistryEvent
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTimeOffset? Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public string? Action { get; init; }
|
||||
|
||||
[JsonPropertyName("target")]
|
||||
public DockerRegistryTarget? Target { get; init; }
|
||||
|
||||
[JsonPropertyName("request")]
|
||||
public DockerRegistryRequest? Request { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Docker Registry event target (the image).
|
||||
/// </summary>
|
||||
public sealed record DockerRegistryTarget
|
||||
{
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long? Size { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public string? Repository { get; init; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Docker Registry request metadata.
|
||||
/// </summary>
|
||||
public sealed record DockerRegistryRequest
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string? Host { get; init; }
|
||||
|
||||
[JsonPropertyName("method")]
|
||||
public string? Method { get; init; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Harbor Webhook Models
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Harbor webhook event.
|
||||
/// </summary>
|
||||
public sealed record HarborWebhookEvent
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("occur_at")]
|
||||
public DateTimeOffset? OccurAt { get; init; }
|
||||
|
||||
[JsonPropertyName("operator")]
|
||||
public string? Operator { get; init; }
|
||||
|
||||
[JsonPropertyName("event_data")]
|
||||
public HarborEventData? EventData { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harbor event data.
|
||||
/// </summary>
|
||||
public sealed record HarborEventData
|
||||
{
|
||||
[JsonPropertyName("resources")]
|
||||
public List<HarborResource>? Resources { get; init; }
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public HarborRepository? Repository { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harbor resource (artifact).
|
||||
/// </summary>
|
||||
public sealed record HarborResource
|
||||
{
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; init; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; init; }
|
||||
|
||||
[JsonPropertyName("resource_url")]
|
||||
public string? ResourceUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harbor repository info.
|
||||
/// </summary>
|
||||
public sealed record HarborRepository
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
[JsonPropertyName("repo_full_name")]
|
||||
public string? RepoFullName { get; init; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Generic Webhook Models
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Generic registry webhook payload.
|
||||
/// </summary>
|
||||
public sealed record GenericRegistryWebhook
|
||||
{
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("repository")]
|
||||
public string? Repository { get; init; }
|
||||
|
||||
[JsonPropertyName("tag")]
|
||||
public string? Tag { get; init; }
|
||||
|
||||
[JsonPropertyName("registryUrl")]
|
||||
public string? RegistryUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("baselineRef")]
|
||||
public string? BaselineRef { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Response Models
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Response indicating webhook was accepted.
|
||||
/// </summary>
|
||||
public sealed record WebhookAcceptedResponse(
|
||||
int JobsQueued,
|
||||
IReadOnlyList<string> JobIds);
|
||||
|
||||
// ============================================================================
|
||||
// Gate Evaluation Queue Interface
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Interface for queuing gate evaluation jobs.
|
||||
/// </summary>
|
||||
public interface IGateEvaluationQueue
|
||||
{
|
||||
/// <summary>
|
||||
/// Enqueues a gate evaluation request.
|
||||
/// </summary>
|
||||
/// <param name="request">The evaluation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The job ID for tracking.</returns>
|
||||
Task<string> EnqueueAsync(GateEvaluationRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate a gate for an image.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluationRequest
|
||||
{
|
||||
public required string ImageDigest { get; init; }
|
||||
public required string Repository { get; init; }
|
||||
public string? Tag { get; init; }
|
||||
public string? RegistryUrl { get; init; }
|
||||
public string? BaselineRef { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
}
|
||||
@@ -20,6 +20,7 @@ using StellaOps.Policy.Gateway.Infrastructure;
|
||||
using StellaOps.Policy.Gateway.Options;
|
||||
using StellaOps.Policy.Gateway.Services;
|
||||
using StellaOps.Policy.Deltas;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Snapshots;
|
||||
using StellaOps.Policy.Storage.Postgres;
|
||||
using Polly;
|
||||
@@ -127,6 +128,21 @@ builder.Services.AddScoped<IBaselineSelector, BaselineSelector>();
|
||||
builder.Services.AddScoped<ISnapshotStore, InMemorySnapshotStore>();
|
||||
builder.Services.AddScoped<StellaOps.Policy.Deltas.ISnapshotService, DeltaSnapshotServiceAdapter>();
|
||||
|
||||
// Gate services (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
||||
builder.Services.Configure<DriftGateOptions>(
|
||||
builder.Configuration.GetSection(DriftGateOptions.SectionName));
|
||||
builder.Services.AddScoped<IDriftGateEvaluator, DriftGateEvaluator>();
|
||||
builder.Services.AddSingleton<InMemoryGateEvaluationQueue>();
|
||||
builder.Services.AddSingleton<IGateEvaluationQueue>(sp => sp.GetRequiredService<InMemoryGateEvaluationQueue>());
|
||||
builder.Services.AddHostedService<GateEvaluationWorker>();
|
||||
|
||||
// Gate bypass audit services (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration, Task: CICD-GATE-06)
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Audit.IGateBypassAuditRepository,
|
||||
StellaOps.Policy.Audit.InMemoryGateBypassAuditRepository>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.GateBypassAuditOptions>();
|
||||
builder.Services.AddScoped<StellaOps.Policy.Engine.Services.IGateBypassAuditor,
|
||||
StellaOps.Policy.Engine.Services.GateBypassAuditor>();
|
||||
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
|
||||
@@ -497,6 +513,12 @@ app.MapExceptionEndpoints();
|
||||
// Delta management endpoints
|
||||
app.MapDeltasEndpoints();
|
||||
|
||||
// Gate evaluation endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
||||
app.MapGateEndpoints();
|
||||
|
||||
// Registry webhook endpoints (Sprint: SPRINT_20251226_001_BE_cicd_gate_integration)
|
||||
app.MapRegistryWebhooks();
|
||||
|
||||
app.Run();
|
||||
|
||||
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryGateEvaluationQueue.cs
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-02 - Gate evaluation queue implementation
|
||||
// Description: In-memory queue for gate evaluation jobs with background processing
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Gateway.Endpoints;
|
||||
|
||||
namespace StellaOps.Policy.Gateway.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of the gate evaluation queue.
|
||||
/// Uses System.Threading.Channels for async producer-consumer pattern.
|
||||
/// </summary>
|
||||
public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
|
||||
{
|
||||
private readonly Channel<GateEvaluationJob> _channel;
|
||||
private readonly ILogger<InMemoryGateEvaluationQueue> _logger;
|
||||
|
||||
public InMemoryGateEvaluationQueue(ILogger<InMemoryGateEvaluationQueue> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_logger = logger;
|
||||
|
||||
// Bounded channel to prevent unbounded memory growth
|
||||
_channel = Channel.CreateBounded<GateEvaluationJob>(new BoundedChannelOptions(1000)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> EnqueueAsync(GateEvaluationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var jobId = GenerateJobId();
|
||||
var job = new GateEvaluationJob
|
||||
{
|
||||
JobId = jobId,
|
||||
Request = request,
|
||||
QueuedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _channel.Writer.WriteAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Enqueued gate evaluation job {JobId} for {Repository}@{Digest}",
|
||||
jobId,
|
||||
request.Repository,
|
||||
request.ImageDigest);
|
||||
|
||||
return jobId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the channel reader for consuming jobs.
|
||||
/// </summary>
|
||||
public ChannelReader<GateEvaluationJob> Reader => _channel.Reader;
|
||||
|
||||
private static string GenerateJobId()
|
||||
{
|
||||
// Format: gate-{timestamp}-{random}
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var random = Guid.NewGuid().ToString("N")[..8];
|
||||
return $"gate-{timestamp}-{random}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A gate evaluation job in the queue.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluationJob
|
||||
{
|
||||
public required string JobId { get; init; }
|
||||
public required GateEvaluationRequest Request { get; init; }
|
||||
public required DateTimeOffset QueuedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background service that processes gate evaluation jobs from the queue.
|
||||
/// Orchestrates: image analysis → drift delta computation → gate evaluation.
|
||||
/// </summary>
|
||||
public sealed class GateEvaluationWorker : BackgroundService
|
||||
{
|
||||
private readonly InMemoryGateEvaluationQueue _queue;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<GateEvaluationWorker> _logger;
|
||||
|
||||
public GateEvaluationWorker(
|
||||
InMemoryGateEvaluationQueue queue,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<GateEvaluationWorker> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(queue);
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_queue = queue;
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Gate evaluation worker starting");
|
||||
|
||||
await foreach (var job in _queue.Reader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessJobAsync(job, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error processing gate evaluation job {JobId} for {Repository}@{Digest}",
|
||||
job.JobId,
|
||||
job.Request.Repository,
|
||||
job.Request.ImageDigest);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Gate evaluation worker stopping");
|
||||
}
|
||||
|
||||
private async Task ProcessJobAsync(GateEvaluationJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Processing gate evaluation job {JobId} for {Repository}@{Digest}",
|
||||
job.JobId,
|
||||
job.Request.Repository,
|
||||
job.Request.ImageDigest);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var evaluator = scope.ServiceProvider.GetRequiredService<IDriftGateEvaluator>();
|
||||
|
||||
// Build a minimal context for the gate evaluation.
|
||||
// In production, this would involve:
|
||||
// 1. Fetching or triggering a scan of the image
|
||||
// 2. Computing the reachability delta against the baseline
|
||||
// 3. Building the DriftGateContext with actual metrics
|
||||
//
|
||||
// For now, we create a placeholder context that represents "no drift detected"
|
||||
// which allows the gate to pass. The full implementation requires Scanner integration.
|
||||
var driftContext = new DriftGateContext
|
||||
{
|
||||
DeltaReachable = 0,
|
||||
DeltaUnreachable = 0,
|
||||
HasKevReachable = false,
|
||||
BaseScanId = job.Request.BaselineRef,
|
||||
HeadScanId = job.Request.ImageDigest
|
||||
};
|
||||
|
||||
var evalRequest = new DriftGateRequest
|
||||
{
|
||||
Context = driftContext,
|
||||
PolicyId = null, // Use default policy
|
||||
AllowOverride = false
|
||||
};
|
||||
|
||||
var result = await evaluator.EvaluateAsync(evalRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Gate evaluation {JobId} completed: Decision={Decision}, GateCount={GateCount}",
|
||||
job.JobId,
|
||||
result.Decision,
|
||||
result.Gates.Length);
|
||||
|
||||
// TODO: Store result and notify via webhook/event
|
||||
// This will be implemented in CICD-GATE-03
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
-- =============================================================================
|
||||
-- 012_budget_ledger.sql
|
||||
-- Sprint: SPRINT_20251226_002_BE_budget_enforcement
|
||||
-- Task: BUDGET-01 - Create budget_ledger PostgreSQL table
|
||||
-- Description: Risk budget tracking tables
|
||||
-- =============================================================================
|
||||
|
||||
-- Budget ledger: tracks risk budget allocation and consumption per service/window
|
||||
CREATE TABLE IF NOT EXISTS policy.budget_ledger (
|
||||
budget_id VARCHAR(256) PRIMARY KEY,
|
||||
service_id VARCHAR(128) NOT NULL,
|
||||
tenant_id VARCHAR(64),
|
||||
tier INT NOT NULL DEFAULT 1,
|
||||
window VARCHAR(16) NOT NULL,
|
||||
allocated INT NOT NULL,
|
||||
consumed INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'green',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Composite unique constraint
|
||||
CONSTRAINT uq_budget_ledger_service_window UNIQUE (service_id, window)
|
||||
);
|
||||
|
||||
-- Budget entries: individual consumption records
|
||||
CREATE TABLE IF NOT EXISTS policy.budget_entries (
|
||||
entry_id VARCHAR(64) PRIMARY KEY,
|
||||
service_id VARCHAR(128) NOT NULL,
|
||||
window VARCHAR(16) NOT NULL,
|
||||
release_id VARCHAR(128) NOT NULL,
|
||||
risk_points INT NOT NULL,
|
||||
reason VARCHAR(512),
|
||||
is_exception BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
penalty_points INT NOT NULL DEFAULT 0,
|
||||
consumed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
consumed_by VARCHAR(256),
|
||||
|
||||
-- Foreign key to ledger (soft reference via service_id + window)
|
||||
CONSTRAINT fk_budget_entries_ledger FOREIGN KEY (service_id, window)
|
||||
REFERENCES policy.budget_ledger (service_id, window) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes for efficient queries
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_ledger_service_id ON policy.budget_ledger (service_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_ledger_tenant_id ON policy.budget_ledger (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_ledger_window ON policy.budget_ledger (window);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_ledger_status ON policy.budget_ledger (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_entries_service_window ON policy.budget_entries (service_id, window);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_entries_release_id ON policy.budget_entries (release_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_entries_consumed_at ON policy.budget_entries (consumed_at);
|
||||
|
||||
-- Enable Row-Level Security
|
||||
ALTER TABLE policy.budget_ledger ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE policy.budget_entries ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policies for tenant isolation
|
||||
CREATE POLICY budget_ledger_tenant_isolation ON policy.budget_ledger
|
||||
FOR ALL
|
||||
USING (tenant_id = current_setting('app.tenant_id', TRUE) OR tenant_id IS NULL);
|
||||
|
||||
CREATE POLICY budget_entries_tenant_isolation ON policy.budget_entries
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM policy.budget_ledger bl
|
||||
WHERE bl.service_id = budget_entries.service_id
|
||||
AND bl.window = budget_entries.window
|
||||
AND (bl.tenant_id = current_setting('app.tenant_id', TRUE) OR bl.tenant_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE policy.budget_ledger IS 'Risk budget allocation and consumption per service/window';
|
||||
COMMENT ON TABLE policy.budget_entries IS 'Individual budget consumption entries';
|
||||
COMMENT ON COLUMN policy.budget_ledger.tier IS 'Service criticality tier: 0=Internal, 1=Customer-facing non-critical, 2=Customer-facing critical, 3=Safety/financial critical';
|
||||
COMMENT ON COLUMN policy.budget_ledger.status IS 'Budget status: green (<40%), yellow (40-69%), red (70-99%), exhausted (>=100%)';
|
||||
COMMENT ON COLUMN policy.budget_entries.is_exception IS 'Whether this was a break-glass/exception release';
|
||||
COMMENT ON COLUMN policy.budget_entries.penalty_points IS 'Additional penalty points for exception releases';
|
||||
@@ -0,0 +1,174 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BudgetLedgerEntity.cs
|
||||
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
|
||||
// Task: BUDGET-01 - Create budget_ledger PostgreSQL table
|
||||
// Description: Entity for risk budget tracking
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a risk budget for a service within a time window.
|
||||
/// Maps to policy.budget_ledger table.
|
||||
/// </summary>
|
||||
[Table("budget_ledger", Schema = "policy")]
|
||||
public sealed class BudgetLedgerEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key - composite of service_id and window.
|
||||
/// Format: "budget:{service_id}:{window}"
|
||||
/// </summary>
|
||||
[Key]
|
||||
[MaxLength(256)]
|
||||
[Column("budget_id")]
|
||||
public required string BudgetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Service or product identifier.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
[Column("service_id")]
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier for multi-tenant deployments.
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
[Column("tenant_id")]
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Service criticality tier (0-3).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("tier")]
|
||||
public int Tier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Budget window identifier (e.g., "2025-12" for monthly).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(16)]
|
||||
[Column("window")]
|
||||
public required string Window { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total risk points allocated for this window.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("allocated")]
|
||||
public int Allocated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk points consumed so far.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("consumed")]
|
||||
public int Consumed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current budget status (green, yellow, red, exhausted).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(16)]
|
||||
[Column("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this budget was created.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this budget was last updated.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("updated_at")]
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity representing a budget consumption entry.
|
||||
/// Maps to policy.budget_entries table.
|
||||
/// </summary>
|
||||
[Table("budget_entries", Schema = "policy")]
|
||||
public sealed class BudgetEntryEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key - unique entry identifier.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[MaxLength(64)]
|
||||
[Column("entry_id")]
|
||||
public required string EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Service identifier.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
[Column("service_id")]
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Budget window (e.g., "2025-12").
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(16)]
|
||||
[Column("window")]
|
||||
public required string Window { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Release or deployment identifier that consumed points.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
[Column("release_id")]
|
||||
public required string ReleaseId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk points consumed by this entry.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("risk_points")]
|
||||
public int RiskPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for consumption (optional).
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
[Column("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this was an exception/break-glass entry.
|
||||
/// </summary>
|
||||
[Column("is_exception")]
|
||||
public bool IsException { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Penalty points added (for exceptions).
|
||||
/// </summary>
|
||||
[Column("penalty_points")]
|
||||
public int PenaltyPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this entry was recorded.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("consumed_at")]
|
||||
public DateTimeOffset ConsumedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor who recorded this entry.
|
||||
/// </summary>
|
||||
[MaxLength(256)]
|
||||
[Column("consumed_by")]
|
||||
public string? ConsumedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresBudgetStore.cs
|
||||
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
|
||||
// Task: BUDGET-02 - Implement BudgetLedgerRepository with CRUD + consumption
|
||||
// Description: PostgreSQL implementation of IBudgetStore
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Gates;
|
||||
using StellaOps.Policy.Storage.Postgres.Models;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of budget storage.
|
||||
/// </summary>
|
||||
public sealed class PostgresBudgetStore : RepositoryBase<PolicyDataSource>, IBudgetStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new PostgreSQL budget store.
|
||||
/// </summary>
|
||||
public PostgresBudgetStore(PolicyDataSource dataSource, ILogger<PostgresBudgetStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RiskBudget?> GetAsync(string serviceId, string window, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
|
||||
FROM policy.budget_ledger
|
||||
WHERE service_id = @service_id AND window = @window
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
null,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "service_id", serviceId);
|
||||
AddParameter(cmd, "window", window);
|
||||
},
|
||||
MapRiskBudget,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateAsync(RiskBudget budget, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.budget_ledger (
|
||||
budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
@budget_id, @service_id, @tenant_id, @tier, @window, @allocated, @consumed, @status, @created_at, @updated_at
|
||||
)
|
||||
ON CONFLICT (service_id, window) DO NOTHING
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
null,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "budget_id", budget.BudgetId);
|
||||
AddParameter(cmd, "service_id", budget.ServiceId);
|
||||
AddParameter(cmd, "tenant_id", (object?)null);
|
||||
AddParameter(cmd, "tier", (int)budget.Tier);
|
||||
AddParameter(cmd, "window", budget.Window);
|
||||
AddParameter(cmd, "allocated", budget.Allocated);
|
||||
AddParameter(cmd, "consumed", budget.Consumed);
|
||||
AddParameter(cmd, "status", budget.Status.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "created_at", budget.UpdatedAt);
|
||||
AddParameter(cmd, "updated_at", budget.UpdatedAt);
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateAsync(RiskBudget budget, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE policy.budget_ledger
|
||||
SET allocated = @allocated,
|
||||
consumed = @consumed,
|
||||
status = @status,
|
||||
updated_at = @updated_at
|
||||
WHERE service_id = @service_id AND window = @window
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
null,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "service_id", budget.ServiceId);
|
||||
AddParameter(cmd, "window", budget.Window);
|
||||
AddParameter(cmd, "allocated", budget.Allocated);
|
||||
AddParameter(cmd, "consumed", budget.Consumed);
|
||||
AddParameter(cmd, "status", budget.Status.ToString().ToLowerInvariant());
|
||||
AddParameter(cmd, "updated_at", budget.UpdatedAt);
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AddEntryAsync(BudgetEntry entry, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.budget_entries (
|
||||
entry_id, service_id, window, release_id, risk_points, reason, is_exception, penalty_points, consumed_at, consumed_by
|
||||
)
|
||||
VALUES (
|
||||
@entry_id, @service_id, @window, @release_id, @risk_points, @reason, @is_exception, @penalty_points, @consumed_at, @consumed_by
|
||||
)
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
null,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "entry_id", entry.EntryId);
|
||||
AddParameter(cmd, "service_id", entry.ServiceId);
|
||||
AddParameter(cmd, "window", entry.Window);
|
||||
AddParameter(cmd, "release_id", entry.ReleaseId);
|
||||
AddParameter(cmd, "risk_points", entry.RiskPoints);
|
||||
AddParameter(cmd, "reason", (object?)null);
|
||||
AddParameter(cmd, "is_exception", false);
|
||||
AddParameter(cmd, "penalty_points", 0);
|
||||
AddParameter(cmd, "consumed_at", entry.ConsumedAt);
|
||||
AddParameter(cmd, "consumed_by", (object?)null);
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<BudgetEntry>> GetEntriesAsync(string serviceId, string window, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT entry_id, service_id, window, release_id, risk_points, consumed_at
|
||||
FROM policy.budget_entries
|
||||
WHERE service_id = @service_id AND window = @window
|
||||
ORDER BY consumed_at DESC
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
null,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "service_id", serviceId);
|
||||
AddParameter(cmd, "window", window);
|
||||
},
|
||||
MapBudgetEntry,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RiskBudget>> ListAsync(BudgetStatus? status, ServiceTier? tier, int limit, CancellationToken ct)
|
||||
{
|
||||
var sql = """
|
||||
SELECT budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
|
||||
FROM policy.budget_ledger
|
||||
WHERE 1=1
|
||||
""";
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
sql += " AND status = @status";
|
||||
}
|
||||
if (tier.HasValue)
|
||||
{
|
||||
sql += " AND tier = @tier";
|
||||
}
|
||||
|
||||
sql += " ORDER BY updated_at DESC LIMIT @limit";
|
||||
|
||||
return await QueryAsync(
|
||||
null,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
if (status.HasValue)
|
||||
{
|
||||
AddParameter(cmd, "status", status.Value.ToString().ToLowerInvariant());
|
||||
}
|
||||
if (tier.HasValue)
|
||||
{
|
||||
AddParameter(cmd, "tier", (int)tier.Value);
|
||||
}
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapRiskBudget,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all budgets for a tenant within a time range.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RiskBudget>> GetBudgetsByWindowAsync(
|
||||
string? tenantId,
|
||||
string windowStart,
|
||||
string windowEnd,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sql = """
|
||||
SELECT budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
|
||||
FROM policy.budget_ledger
|
||||
WHERE window >= @window_start AND window <= @window_end
|
||||
""";
|
||||
|
||||
if (tenantId != null)
|
||||
{
|
||||
sql += " AND tenant_id = @tenant_id";
|
||||
}
|
||||
|
||||
sql += " ORDER BY window DESC, service_id";
|
||||
|
||||
return await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "window_start", windowStart);
|
||||
AddParameter(cmd, "window_end", windowEnd);
|
||||
if (tenantId != null)
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
}
|
||||
},
|
||||
MapRiskBudget,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get budgets by status.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RiskBudget>> GetBudgetsByStatusAsync(
|
||||
BudgetStatus status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
|
||||
FROM policy.budget_ledger
|
||||
WHERE status = @status
|
||||
ORDER BY updated_at DESC
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
null,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "status", status.ToString().ToLowerInvariant()),
|
||||
MapRiskBudget,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset budgets for a new window.
|
||||
/// </summary>
|
||||
public async Task<int> ResetForNewWindowAsync(string newWindow, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.budget_ledger (
|
||||
budget_id, service_id, tenant_id, tier, window, allocated, consumed, status, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
CONCAT('budget:', service_id, ':', @new_window),
|
||||
service_id,
|
||||
tenant_id,
|
||||
tier,
|
||||
@new_window,
|
||||
allocated, -- Same allocation as previous window
|
||||
0, -- Reset consumed to 0
|
||||
'green', -- Reset status to green
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM policy.budget_ledger
|
||||
WHERE window = (
|
||||
SELECT MAX(window) FROM policy.budget_ledger WHERE window < @new_window
|
||||
)
|
||||
ON CONFLICT (service_id, window) DO NOTHING
|
||||
""";
|
||||
|
||||
return await ExecuteAsync(
|
||||
null,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "new_window", newWindow),
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static RiskBudget MapRiskBudget(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
BudgetId = reader.GetString(reader.GetOrdinal("budget_id")),
|
||||
ServiceId = reader.GetString(reader.GetOrdinal("service_id")),
|
||||
Tier = (ServiceTier)reader.GetInt32(reader.GetOrdinal("tier")),
|
||||
Window = reader.GetString(reader.GetOrdinal("window")),
|
||||
Allocated = reader.GetInt32(reader.GetOrdinal("allocated")),
|
||||
Consumed = reader.GetInt32(reader.GetOrdinal("consumed")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
|
||||
};
|
||||
|
||||
private static BudgetEntry MapBudgetEntry(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
EntryId = reader.GetString(reader.GetOrdinal("entry_id")),
|
||||
ServiceId = reader.GetString(reader.GetOrdinal("service_id")),
|
||||
Window = reader.GetString(reader.GetOrdinal("window")),
|
||||
ReleaseId = reader.GetString(reader.GetOrdinal("release_id")),
|
||||
RiskPoints = reader.GetInt32(reader.GetOrdinal("risk_points")),
|
||||
ConsumedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("consumed_at"))
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,9 @@ using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using IAuditableExceptionRepository = StellaOps.Policy.Exceptions.Repositories.IExceptionRepository;
|
||||
// Use local repository interfaces (not the ones from StellaOps.Policy.Storage or StellaOps.Policy)
|
||||
using ILocalRiskProfileRepository = StellaOps.Policy.Storage.Postgres.Repositories.IRiskProfileRepository;
|
||||
using ILocalPolicyAuditRepository = StellaOps.Policy.Storage.Postgres.Repositories.IPolicyAuditRepository;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres;
|
||||
|
||||
@@ -32,13 +35,13 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IPackRepository, PackRepository>();
|
||||
services.AddScoped<IPackVersionRepository, PackVersionRepository>();
|
||||
services.AddScoped<IRuleRepository, RuleRepository>();
|
||||
services.AddScoped<IRiskProfileRepository, RiskProfileRepository>();
|
||||
services.AddScoped<ILocalRiskProfileRepository, RiskProfileRepository>();
|
||||
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
|
||||
services.AddScoped<IExceptionRepository, ExceptionRepository>();
|
||||
services.AddScoped<IAuditableExceptionRepository, PostgresExceptionObjectRepository>();
|
||||
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
|
||||
services.AddScoped<IExplanationRepository, ExplanationRepository>();
|
||||
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
|
||||
services.AddScoped<ILocalPolicyAuditRepository, PolicyAuditRepository>();
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
@@ -65,13 +68,13 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IPackRepository, PackRepository>();
|
||||
services.AddScoped<IPackVersionRepository, PackVersionRepository>();
|
||||
services.AddScoped<IRuleRepository, RuleRepository>();
|
||||
services.AddScoped<IRiskProfileRepository, RiskProfileRepository>();
|
||||
services.AddScoped<ILocalRiskProfileRepository, RiskProfileRepository>();
|
||||
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
|
||||
services.AddScoped<IExceptionRepository, ExceptionRepository>();
|
||||
services.AddScoped<IAuditableExceptionRepository, PostgresExceptionObjectRepository>();
|
||||
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
|
||||
services.AddScoped<IExplanationRepository, ExplanationRepository>();
|
||||
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
|
||||
services.AddScoped<ILocalPolicyAuditRepository, PolicyAuditRepository>();
|
||||
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
|
||||
services.AddScoped<IViolationEventRepository, ViolationEventRepository>();
|
||||
services.AddScoped<IConflictRepository, ConflictRepository>();
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Policy\StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// GateBypassAuditEntry.cs
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-06 - Gate bypass audit logging
|
||||
// Description: Audit entry for gate bypass/override events
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Audit entry for gate bypass/override events.
|
||||
/// Records who, when, and why a gate was overridden.
|
||||
/// </summary>
|
||||
public sealed record GateBypassAuditEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this audit entry.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bypass occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The gate decision ID that was bypassed.
|
||||
/// </summary>
|
||||
public required string DecisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The image digest being evaluated.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The repository name.
|
||||
/// </summary>
|
||||
public string? Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The tag, if any.
|
||||
/// </summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The baseline reference used for comparison.
|
||||
/// </summary>
|
||||
public string? BaselineRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The original gate decision before bypass.
|
||||
/// </summary>
|
||||
public required string OriginalDecision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The decision after bypass (typically "Allow").
|
||||
/// </summary>
|
||||
public required string FinalDecision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which gate(s) were bypassed.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> BypassedGates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The identity of the user/service that requested the bypass.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject identifier from the auth token.
|
||||
/// </summary>
|
||||
public string? ActorSubject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The email associated with the actor, if available.
|
||||
/// </summary>
|
||||
public string? ActorEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The IP address of the requester.
|
||||
/// </summary>
|
||||
public string? ActorIpAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The justification provided for the bypass.
|
||||
/// </summary>
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The policy ID that was being evaluated.
|
||||
/// </summary>
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The source of the gate request (e.g., "cli", "api", "webhook").
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The CI/CD context, if available (e.g., "github-actions", "gitlab-ci").
|
||||
/// </summary>
|
||||
public string? CiContext { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the bypass.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bypass type classification.
|
||||
/// </summary>
|
||||
public enum GateBypassType
|
||||
{
|
||||
/// <summary>
|
||||
/// Override applied to a warning-level gate.
|
||||
/// </summary>
|
||||
WarningOverride,
|
||||
|
||||
/// <summary>
|
||||
/// Override applied to a blocking gate (requires elevated permission).
|
||||
/// </summary>
|
||||
BlockOverride,
|
||||
|
||||
/// <summary>
|
||||
/// Emergency bypass with elevated privileges.
|
||||
/// </summary>
|
||||
EmergencyBypass,
|
||||
|
||||
/// <summary>
|
||||
/// Time-limited bypass approval.
|
||||
/// </summary>
|
||||
TimeLimitedApproval
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IGateBypassAuditRepository.cs
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-06 - Gate bypass audit logging
|
||||
// Description: Repository interface for gate bypass audit entries
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for persisting and querying gate bypass audit entries.
|
||||
/// </summary>
|
||||
public interface IGateBypassAuditRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a gate bypass audit entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The audit entry to record.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task AddAsync(GateBypassAuditEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a bypass audit entry by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The entry ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The entry if found, null otherwise.</returns>
|
||||
Task<GateBypassAuditEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets bypass audit entries by decision ID.
|
||||
/// </summary>
|
||||
/// <param name="decisionId">The gate decision ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of bypass entries for the decision.</returns>
|
||||
Task<IReadOnlyList<GateBypassAuditEntry>> GetByDecisionIdAsync(
|
||||
string decisionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets bypass audit entries by actor.
|
||||
/// </summary>
|
||||
/// <param name="actor">The actor identifier.</param>
|
||||
/// <param name="limit">Maximum entries to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of bypass entries for the actor.</returns>
|
||||
Task<IReadOnlyList<GateBypassAuditEntry>> GetByActorAsync(
|
||||
string actor,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets bypass audit entries for an image digest.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">The image digest.</param>
|
||||
/// <param name="limit">Maximum entries to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of bypass entries for the image.</returns>
|
||||
Task<IReadOnlyList<GateBypassAuditEntry>> GetByImageDigestAsync(
|
||||
string imageDigest,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists recent bypass audit entries.
|
||||
/// </summary>
|
||||
/// <param name="limit">Maximum entries to return.</param>
|
||||
/// <param name="offset">Number of entries to skip.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of recent bypass entries.</returns>
|
||||
Task<IReadOnlyList<GateBypassAuditEntry>> ListRecentAsync(
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists bypass audit entries within a time range.
|
||||
/// </summary>
|
||||
/// <param name="from">Start of time range (inclusive).</param>
|
||||
/// <param name="to">End of time range (exclusive).</param>
|
||||
/// <param name="limit">Maximum entries to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of bypass entries in the time range.</returns>
|
||||
Task<IReadOnlyList<GateBypassAuditEntry>> ListByTimeRangeAsync(
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit = 1000,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts bypass audit entries for an actor within a time window.
|
||||
/// Used for rate limiting and abuse detection.
|
||||
/// </summary>
|
||||
/// <param name="actor">The actor identifier.</param>
|
||||
/// <param name="since">Start of time window.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Count of bypass entries.</returns>
|
||||
Task<int> CountByActorSinceAsync(
|
||||
string actor,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryGateBypassAuditRepository.cs
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-06 - Gate bypass audit logging
|
||||
// Description: In-memory implementation of gate bypass audit repository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IGateBypassAuditRepository"/>.
|
||||
/// Suitable for development and testing. Production should use PostgreSQL.
|
||||
/// </summary>
|
||||
public sealed class InMemoryGateBypassAuditRepository : IGateBypassAuditRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, GateBypassAuditEntry> _entries = new();
|
||||
private readonly int _maxEntries;
|
||||
|
||||
public InMemoryGateBypassAuditRepository(int maxEntries = 10000)
|
||||
{
|
||||
_maxEntries = maxEntries;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(GateBypassAuditEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
// Enforce max entries by removing oldest if at capacity
|
||||
while (_entries.Count >= _maxEntries)
|
||||
{
|
||||
var oldest = _entries.Values
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (oldest is not null)
|
||||
{
|
||||
_entries.TryRemove(oldest.Id, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_entries[entry.Id] = entry;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<GateBypassAuditEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries.TryGetValue(id, out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<GateBypassAuditEntry>> GetByDecisionIdAsync(
|
||||
string decisionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = _entries.Values
|
||||
.Where(e => e.DecisionId == decisionId)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<GateBypassAuditEntry>>(entries);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<GateBypassAuditEntry>> GetByActorAsync(
|
||||
string actor,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = _entries.Values
|
||||
.Where(e => e.Actor == actor)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<GateBypassAuditEntry>>(entries);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<GateBypassAuditEntry>> GetByImageDigestAsync(
|
||||
string imageDigest,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = _entries.Values
|
||||
.Where(e => e.ImageDigest == imageDigest)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<GateBypassAuditEntry>>(entries);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<GateBypassAuditEntry>> ListRecentAsync(
|
||||
int limit = 100,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = _entries.Values
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<GateBypassAuditEntry>>(entries);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<GateBypassAuditEntry>> ListByTimeRangeAsync(
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
int limit = 1000,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = _entries.Values
|
||||
.Where(e => e.Timestamp >= from && e.Timestamp < to)
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<GateBypassAuditEntry>>(entries);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountByActorSinceAsync(
|
||||
string actor,
|
||||
DateTimeOffset since,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = _entries.Values
|
||||
.Count(e => e.Actor == actor && e.Timestamp >= since);
|
||||
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,7 @@ public interface IBudgetStore
|
||||
Task UpdateAsync(RiskBudget budget, CancellationToken ct);
|
||||
Task AddEntryAsync(BudgetEntry entry, CancellationToken ct);
|
||||
Task<IReadOnlyList<BudgetEntry>> GetEntriesAsync(string serviceId, string window, CancellationToken ct);
|
||||
Task<IReadOnlyList<RiskBudget>> ListAsync(BudgetStatus? status = null, ServiceTier? tier = null, int limit = 50, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -275,4 +276,23 @@ public sealed class InMemoryBudgetStore : IBudgetStore
|
||||
return Task.FromResult<IReadOnlyList<BudgetEntry>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RiskBudget>> ListAsync(BudgetStatus? status, ServiceTier? tier, int limit, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
lock (_lock)
|
||||
{
|
||||
var query = _budgets.Values.AsEnumerable();
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(b => b.Status == status.Value);
|
||||
}
|
||||
if (tier.HasValue)
|
||||
{
|
||||
query = query.Where(b => b.Tier == tier.Value);
|
||||
}
|
||||
var result = query.Take(limit).ToList();
|
||||
return Task.FromResult<IReadOnlyList<RiskBudget>>(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BudgetThresholdNotifier.cs
|
||||
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
|
||||
// Task: BUDGET-06-07 - Budget threshold notifications
|
||||
// Description: Publishes notification events when budget thresholds are crossed
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes notification events when budget thresholds are crossed.
|
||||
/// </summary>
|
||||
public sealed class BudgetThresholdNotifier
|
||||
{
|
||||
private readonly INotifyEventPublisher _publisher;
|
||||
private readonly ILogger<BudgetThresholdNotifier> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Thresholds for different budget status levels.
|
||||
/// </summary>
|
||||
public static class Thresholds
|
||||
{
|
||||
/// <summary>Yellow threshold: 40%</summary>
|
||||
public const decimal Yellow = 0.40m;
|
||||
/// <summary>Red threshold: 70%</summary>
|
||||
public const decimal Red = 0.70m;
|
||||
/// <summary>Exhausted threshold: 100%</summary>
|
||||
public const decimal Exhausted = 1.00m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new budget threshold notifier.
|
||||
/// </summary>
|
||||
public BudgetThresholdNotifier(
|
||||
INotifyEventPublisher publisher,
|
||||
ILogger<BudgetThresholdNotifier> logger)
|
||||
{
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if status has crossed a threshold and publish notification if needed.
|
||||
/// </summary>
|
||||
/// <param name="before">Budget status before the change.</param>
|
||||
/// <param name="after">Budget status after the change.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public async Task NotifyIfThresholdCrossedAsync(
|
||||
RiskBudget before,
|
||||
RiskBudget after,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Check if status has worsened
|
||||
if (after.Status > before.Status)
|
||||
{
|
||||
await PublishThresholdCrossedAsync(before, after, tenantId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publish a warning notification when approaching threshold.
|
||||
/// </summary>
|
||||
public async Task NotifyWarningAsync(
|
||||
RiskBudget budget,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (budget.Status >= BudgetStatus.Yellow)
|
||||
{
|
||||
var payload = CreatePayload(budget, "warning");
|
||||
await _publisher.PublishAsync(
|
||||
BudgetEventKinds.PolicyBudgetWarning,
|
||||
tenantId,
|
||||
payload,
|
||||
ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published budget warning for {ServiceId}: {PercentageUsed}% consumed",
|
||||
budget.ServiceId,
|
||||
budget.PercentageUsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publish an exceeded notification when budget is exhausted.
|
||||
/// </summary>
|
||||
public async Task NotifyExceededAsync(
|
||||
RiskBudget budget,
|
||||
string tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var payload = CreatePayload(budget, "exceeded");
|
||||
await _publisher.PublishAsync(
|
||||
BudgetEventKinds.PolicyBudgetExceeded,
|
||||
tenantId,
|
||||
payload,
|
||||
ct);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Published budget exceeded for {ServiceId}: {PercentageUsed}% consumed",
|
||||
budget.ServiceId,
|
||||
budget.PercentageUsed);
|
||||
}
|
||||
|
||||
private async Task PublishThresholdCrossedAsync(
|
||||
RiskBudget before,
|
||||
RiskBudget after,
|
||||
string tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var eventKind = after.Status == BudgetStatus.Exhausted
|
||||
? BudgetEventKinds.PolicyBudgetExceeded
|
||||
: BudgetEventKinds.PolicyBudgetWarning;
|
||||
|
||||
var payload = CreatePayload(after, after.Status.ToString().ToLowerInvariant());
|
||||
payload["previousStatus"] = before.Status.ToString().ToLowerInvariant();
|
||||
|
||||
await _publisher.PublishAsync(eventKind, tenantId, payload, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published budget threshold crossed for {ServiceId}: {PreviousStatus} -> {NewStatus}",
|
||||
after.ServiceId,
|
||||
before.Status,
|
||||
after.Status);
|
||||
}
|
||||
|
||||
private static JsonObject CreatePayload(RiskBudget budget, string severity)
|
||||
{
|
||||
return new JsonObject
|
||||
{
|
||||
["budgetId"] = budget.BudgetId,
|
||||
["serviceId"] = budget.ServiceId,
|
||||
["tier"] = budget.Tier.ToString().ToLowerInvariant(),
|
||||
["window"] = budget.Window,
|
||||
["allocated"] = budget.Allocated,
|
||||
["consumed"] = budget.Consumed,
|
||||
["remaining"] = budget.Remaining,
|
||||
["percentageUsed"] = budget.PercentageUsed,
|
||||
["status"] = budget.Status.ToString().ToLowerInvariant(),
|
||||
["severity"] = severity,
|
||||
["timestamp"] = DateTimeOffset.UtcNow.ToString("O")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known budget event kinds.
|
||||
/// </summary>
|
||||
public static class BudgetEventKinds
|
||||
{
|
||||
/// <summary>Budget warning threshold crossed.</summary>
|
||||
public const string PolicyBudgetWarning = "policy.budget.warning";
|
||||
/// <summary>Budget exhausted.</summary>
|
||||
public const string PolicyBudgetExceeded = "policy.budget.exceeded";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for publishing notification events.
|
||||
/// </summary>
|
||||
public interface INotifyEventPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publish a notification event.
|
||||
/// </summary>
|
||||
/// <param name="eventKind">Event kind identifier.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="payload">Event payload.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task PublishAsync(
|
||||
string eventKind,
|
||||
string tenantId,
|
||||
JsonNode payload,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EarnedCapacityReplenishment.cs
|
||||
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
|
||||
// Task: BUDGET-10 - Earned capacity replenishment
|
||||
// Description: Grants budget increases based on performance improvement over time
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates service performance metrics to determine earned budget increases.
|
||||
/// If MTTR and CFR improve for 2 consecutive windows, grants 10-20% budget increase.
|
||||
/// </summary>
|
||||
public sealed class EarnedCapacityEvaluator
|
||||
{
|
||||
private readonly IPerformanceMetricsStore _metricsStore;
|
||||
private readonly IBudgetStore _budgetStore;
|
||||
private readonly EarnedCapacityOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new earned capacity evaluator.
|
||||
/// </summary>
|
||||
public EarnedCapacityEvaluator(
|
||||
IPerformanceMetricsStore metricsStore,
|
||||
IBudgetStore budgetStore,
|
||||
EarnedCapacityOptions? options = null)
|
||||
{
|
||||
_metricsStore = metricsStore ?? throw new ArgumentNullException(nameof(metricsStore));
|
||||
_budgetStore = budgetStore ?? throw new ArgumentNullException(nameof(budgetStore));
|
||||
_options = options ?? new EarnedCapacityOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate if a service qualifies for earned capacity increase.
|
||||
/// </summary>
|
||||
/// <param name="serviceId">Service identifier.</param>
|
||||
/// <param name="currentWindow">Current budget window.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Evaluation result with eligibility and recommended increase.</returns>
|
||||
public async Task<EarnedCapacityResult> EvaluateAsync(
|
||||
string serviceId,
|
||||
string currentWindow,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serviceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(currentWindow);
|
||||
|
||||
// Get historical windows to evaluate (current + 2 previous)
|
||||
var windows = GetWindowSequence(currentWindow, _options.RequiredImprovementWindows + 1);
|
||||
|
||||
// Fetch metrics for each window
|
||||
var metricsHistory = new List<WindowMetrics>();
|
||||
foreach (var window in windows)
|
||||
{
|
||||
var metrics = await _metricsStore.GetMetricsAsync(serviceId, window, ct);
|
||||
if (metrics != null)
|
||||
{
|
||||
metricsHistory.Add(metrics);
|
||||
}
|
||||
}
|
||||
|
||||
// Need at least 3 windows of data (current + 2 prior)
|
||||
if (metricsHistory.Count < _options.RequiredImprovementWindows + 1)
|
||||
{
|
||||
return EarnedCapacityResult.NotEligible(
|
||||
serviceId,
|
||||
EarnedCapacityIneligibilityReason.InsufficientHistory,
|
||||
$"Requires {_options.RequiredImprovementWindows + 1} windows of data, found {metricsHistory.Count}");
|
||||
}
|
||||
|
||||
// Order by window (oldest first)
|
||||
metricsHistory = metricsHistory.OrderBy(m => m.Window).ToList();
|
||||
|
||||
// Check for consistent improvement
|
||||
var improvementCheck = CheckConsecutiveImprovement(metricsHistory);
|
||||
if (!improvementCheck.IsImproving)
|
||||
{
|
||||
return EarnedCapacityResult.NotEligible(
|
||||
serviceId,
|
||||
EarnedCapacityIneligibilityReason.NoImprovement,
|
||||
improvementCheck.Reason);
|
||||
}
|
||||
|
||||
// Calculate recommended increase based on improvement magnitude
|
||||
var increasePercentage = CalculateIncreasePercentage(
|
||||
improvementCheck.MttrImprovementPercent,
|
||||
improvementCheck.CfrImprovementPercent);
|
||||
|
||||
// Get current budget to calculate actual points
|
||||
var currentBudget = await _budgetStore.GetAsync(serviceId, currentWindow, ct);
|
||||
var currentAllocation = currentBudget?.Allocated
|
||||
?? DefaultBudgetAllocations.GetMonthlyAllocation(ServiceTier.CustomerFacingNonCritical);
|
||||
|
||||
var additionalPoints = (int)Math.Ceiling(currentAllocation * increasePercentage / 100m);
|
||||
|
||||
return EarnedCapacityResult.Eligible(
|
||||
serviceId,
|
||||
increasePercentage,
|
||||
additionalPoints,
|
||||
improvementCheck.MttrImprovementPercent,
|
||||
improvementCheck.CfrImprovementPercent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply an earned capacity increase to a service's budget.
|
||||
/// </summary>
|
||||
public async Task<RiskBudget> ApplyIncreaseAsync(
|
||||
string serviceId,
|
||||
string window,
|
||||
int additionalPoints,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var budget = await _budgetStore.GetAsync(serviceId, window, ct)
|
||||
?? throw new InvalidOperationException($"Budget not found for service {serviceId} window {window}");
|
||||
|
||||
var updatedBudget = budget with
|
||||
{
|
||||
Allocated = budget.Allocated + additionalPoints,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _budgetStore.UpdateAsync(updatedBudget, ct);
|
||||
return updatedBudget;
|
||||
}
|
||||
|
||||
private ImprovementCheckResult CheckConsecutiveImprovement(List<WindowMetrics> orderedMetrics)
|
||||
{
|
||||
// Compare each window to its predecessor
|
||||
decimal totalMttrImprovement = 0;
|
||||
decimal totalCfrImprovement = 0;
|
||||
int improvingWindows = 0;
|
||||
|
||||
for (int i = 1; i < orderedMetrics.Count; i++)
|
||||
{
|
||||
var prev = orderedMetrics[i - 1];
|
||||
var curr = orderedMetrics[i];
|
||||
|
||||
// Calculate MTTR improvement (lower is better)
|
||||
var mttrImproved = prev.MttrHours > 0 && curr.MttrHours < prev.MttrHours;
|
||||
var mttrImprovementPct = prev.MttrHours > 0
|
||||
? (prev.MttrHours - curr.MttrHours) / prev.MttrHours * 100
|
||||
: 0;
|
||||
|
||||
// Calculate CFR improvement (lower is better)
|
||||
var cfrImproved = prev.ChangeFailureRate > 0 && curr.ChangeFailureRate < prev.ChangeFailureRate;
|
||||
var cfrImprovementPct = prev.ChangeFailureRate > 0
|
||||
? (prev.ChangeFailureRate - curr.ChangeFailureRate) / prev.ChangeFailureRate * 100
|
||||
: 0;
|
||||
|
||||
// Both metrics must improve (or at least not regress significantly)
|
||||
if (mttrImproved || (mttrImprovementPct >= -_options.RegressionTolerancePercent))
|
||||
{
|
||||
totalMttrImprovement += mttrImprovementPct;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new ImprovementCheckResult(
|
||||
false,
|
||||
$"MTTR regressed in window {curr.Window}: {prev.MttrHours:F1}h -> {curr.MttrHours:F1}h",
|
||||
0, 0);
|
||||
}
|
||||
|
||||
if (cfrImproved || (cfrImprovementPct >= -_options.RegressionTolerancePercent))
|
||||
{
|
||||
totalCfrImprovement += cfrImprovementPct;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new ImprovementCheckResult(
|
||||
false,
|
||||
$"CFR regressed in window {curr.Window}: {prev.ChangeFailureRate:F1}% -> {curr.ChangeFailureRate:F1}%",
|
||||
0, 0);
|
||||
}
|
||||
|
||||
// At least one metric must actually improve
|
||||
if (mttrImproved || cfrImproved)
|
||||
{
|
||||
improvingWindows++;
|
||||
}
|
||||
}
|
||||
|
||||
// Need improvement for required consecutive windows
|
||||
if (improvingWindows < _options.RequiredImprovementWindows)
|
||||
{
|
||||
return new ImprovementCheckResult(
|
||||
false,
|
||||
$"Required {_options.RequiredImprovementWindows} improving windows, found {improvingWindows}",
|
||||
0, 0);
|
||||
}
|
||||
|
||||
// Average improvement across windows
|
||||
var avgMttrImprovement = totalMttrImprovement / (orderedMetrics.Count - 1);
|
||||
var avgCfrImprovement = totalCfrImprovement / (orderedMetrics.Count - 1);
|
||||
|
||||
return new ImprovementCheckResult(true, null, avgMttrImprovement, avgCfrImprovement);
|
||||
}
|
||||
|
||||
private decimal CalculateIncreasePercentage(decimal mttrImprovement, decimal cfrImprovement)
|
||||
{
|
||||
// Average of both improvements, clamped to min/max
|
||||
var avgImprovement = (mttrImprovement + cfrImprovement) / 2;
|
||||
|
||||
// Scale: 10% improvement in metrics -> 10% budget increase
|
||||
// 20%+ improvement -> 20% budget increase (capped)
|
||||
var increase = Math.Min(avgImprovement, _options.MaxIncreasePercent);
|
||||
return Math.Max(increase, _options.MinIncreasePercent);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetWindowSequence(string currentWindow, int count)
|
||||
{
|
||||
// Parse window format "YYYY-MM"
|
||||
var windows = new List<string> { currentWindow };
|
||||
|
||||
if (currentWindow.Length >= 7 && currentWindow[4] == '-')
|
||||
{
|
||||
if (int.TryParse(currentWindow[..4], out var year) &&
|
||||
int.TryParse(currentWindow[5..7], out var month))
|
||||
{
|
||||
for (int i = 1; i < count; i++)
|
||||
{
|
||||
month--;
|
||||
if (month < 1)
|
||||
{
|
||||
month = 12;
|
||||
year--;
|
||||
}
|
||||
windows.Add($"{year:D4}-{month:D2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return windows;
|
||||
}
|
||||
|
||||
private sealed record ImprovementCheckResult(
|
||||
bool IsImproving,
|
||||
string? Reason,
|
||||
decimal MttrImprovementPercent,
|
||||
decimal CfrImprovementPercent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of earned capacity evaluation.
|
||||
/// </summary>
|
||||
public sealed record EarnedCapacityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Service identifier.
|
||||
/// </summary>
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the service is eligible for an increase.
|
||||
/// </summary>
|
||||
public required bool IsEligible { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason if not eligible.
|
||||
/// </summary>
|
||||
public EarnedCapacityIneligibilityReason? IneligibilityReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of ineligibility.
|
||||
/// </summary>
|
||||
public string? IneligibilityDescription { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recommended increase percentage (10-20%).
|
||||
/// </summary>
|
||||
public decimal IncreasePercentage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recommended additional points to allocate.
|
||||
/// </summary>
|
||||
public int AdditionalPoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// MTTR improvement over evaluation period.
|
||||
/// </summary>
|
||||
public decimal MttrImprovementPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CFR improvement over evaluation period.
|
||||
/// </summary>
|
||||
public decimal CfrImprovementPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a not-eligible result.
|
||||
/// </summary>
|
||||
public static EarnedCapacityResult NotEligible(
|
||||
string serviceId,
|
||||
EarnedCapacityIneligibilityReason reason,
|
||||
string description) => new()
|
||||
{
|
||||
ServiceId = serviceId,
|
||||
IsEligible = false,
|
||||
IneligibilityReason = reason,
|
||||
IneligibilityDescription = description
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create an eligible result.
|
||||
/// </summary>
|
||||
public static EarnedCapacityResult Eligible(
|
||||
string serviceId,
|
||||
decimal increasePercentage,
|
||||
int additionalPoints,
|
||||
decimal mttrImprovement,
|
||||
decimal cfrImprovement) => new()
|
||||
{
|
||||
ServiceId = serviceId,
|
||||
IsEligible = true,
|
||||
IncreasePercentage = increasePercentage,
|
||||
AdditionalPoints = additionalPoints,
|
||||
MttrImprovementPercent = mttrImprovement,
|
||||
CfrImprovementPercent = cfrImprovement
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reasons why a service is not eligible for earned capacity.
|
||||
/// </summary>
|
||||
public enum EarnedCapacityIneligibilityReason
|
||||
{
|
||||
/// <summary>
|
||||
/// Not enough historical data.
|
||||
/// </summary>
|
||||
InsufficientHistory,
|
||||
|
||||
/// <summary>
|
||||
/// Metrics did not improve.
|
||||
/// </summary>
|
||||
NoImprovement,
|
||||
|
||||
/// <summary>
|
||||
/// Service is in probation period.
|
||||
/// </summary>
|
||||
InProbation,
|
||||
|
||||
/// <summary>
|
||||
/// Manual override preventing increase.
|
||||
/// </summary>
|
||||
ManualOverride
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performance metrics for a service in a budget window.
|
||||
/// </summary>
|
||||
public sealed record WindowMetrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Service identifier.
|
||||
/// </summary>
|
||||
public required string ServiceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Budget window.
|
||||
/// </summary>
|
||||
public required string Window { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mean Time to Remediate in hours.
|
||||
/// </summary>
|
||||
public required decimal MttrHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Change Failure Rate as percentage (0-100).
|
||||
/// </summary>
|
||||
public required decimal ChangeFailureRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of deployments in the window.
|
||||
/// </summary>
|
||||
public int DeploymentCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of vulnerabilities remediated.
|
||||
/// </summary>
|
||||
public int VulnerabilitiesRemediated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When metrics were calculated.
|
||||
/// </summary>
|
||||
public DateTimeOffset CalculatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for earned capacity evaluation.
|
||||
/// </summary>
|
||||
public sealed class EarnedCapacityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of consecutive improving windows required.
|
||||
/// Default: 2.
|
||||
/// </summary>
|
||||
public int RequiredImprovementWindows { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum budget increase percentage.
|
||||
/// Default: 10%.
|
||||
/// </summary>
|
||||
public decimal MinIncreasePercent { get; set; } = 10m;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum budget increase percentage.
|
||||
/// Default: 20%.
|
||||
/// </summary>
|
||||
public decimal MaxIncreasePercent { get; set; } = 20m;
|
||||
|
||||
/// <summary>
|
||||
/// Tolerance for minor regression before disqualifying.
|
||||
/// Default: 5% (allows 5% regression without disqualifying).
|
||||
/// </summary>
|
||||
public decimal RegressionTolerancePercent { get; set; } = 5m;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for performance metrics storage.
|
||||
/// </summary>
|
||||
public interface IPerformanceMetricsStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Get metrics for a service in a specific window.
|
||||
/// </summary>
|
||||
Task<WindowMetrics?> GetMetricsAsync(
|
||||
string serviceId,
|
||||
string window,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Save or update metrics for a service.
|
||||
/// </summary>
|
||||
Task SaveMetricsAsync(
|
||||
WindowMetrics metrics,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// List metrics for a service across windows.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<WindowMetrics>> ListMetricsAsync(
|
||||
string serviceId,
|
||||
int windowCount,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
// IBudgetStore is defined in BudgetLedger.cs
|
||||
@@ -0,0 +1,420 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BudgetEnforcementIntegrationTests.cs
|
||||
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
|
||||
// Task: BUDGET-11 - Integration tests for budget enforcement
|
||||
// Description: Integration tests for window reset, consumption, threshold transitions, notifications
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
public sealed class BudgetEnforcementIntegrationTests
|
||||
{
|
||||
private readonly InMemoryBudgetStore _store = new();
|
||||
private readonly BudgetLedger _ledger;
|
||||
|
||||
public BudgetEnforcementIntegrationTests()
|
||||
{
|
||||
_ledger = new BudgetLedger(_store, NullLogger<BudgetLedger>.Instance);
|
||||
}
|
||||
|
||||
#region Window Management Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Budget_DifferentWindows_AreIndependent()
|
||||
{
|
||||
// Arrange: Create budgets for two different windows
|
||||
var serviceId = "window-test-service";
|
||||
var window1 = "2025-01";
|
||||
var window2 = "2025-02";
|
||||
|
||||
// Act: Create and consume in window 1
|
||||
var budget1 = await _ledger.GetBudgetAsync(serviceId, window1);
|
||||
await _ledger.ConsumeAsync(serviceId, 50, "release-jan");
|
||||
|
||||
// Create new budget in window 2 (simulating monthly reset)
|
||||
var budget2 = await _ledger.GetBudgetAsync(serviceId, window2);
|
||||
|
||||
// Assert: Window 2 should start fresh
|
||||
budget2.Consumed.Should().Be(0);
|
||||
budget2.Allocated.Should().Be(200); // Default tier 1 allocation
|
||||
budget2.Status.Should().Be(BudgetStatus.Green);
|
||||
|
||||
// Window 1 should still have consumption
|
||||
var budget1Again = await _ledger.GetBudgetAsync(serviceId, window1);
|
||||
budget1Again.Consumed.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Budget_WindowReset_DoesNotCarryOver()
|
||||
{
|
||||
// Arrange: Heavily consume in current window
|
||||
var serviceId = "reset-test-service";
|
||||
var currentWindow = DateTimeOffset.UtcNow.ToString("yyyy-MM");
|
||||
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId, currentWindow);
|
||||
await _ledger.ConsumeAsync(serviceId, 150, "heavy-release");
|
||||
|
||||
// Simulate next month
|
||||
var nextWindow = DateTimeOffset.UtcNow.AddMonths(1).ToString("yyyy-MM");
|
||||
|
||||
// Act: Get budget for next window
|
||||
var nextBudget = await _ledger.GetBudgetAsync(serviceId, nextWindow);
|
||||
|
||||
// Assert: No carry-over
|
||||
nextBudget.Consumed.Should().Be(0);
|
||||
nextBudget.Remaining.Should().Be(200);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consumption Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_MultipleReleases_AccumulatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "multi-release-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act: Multiple consumption operations
|
||||
await _ledger.ConsumeAsync(serviceId, 20, "release-1");
|
||||
await _ledger.ConsumeAsync(serviceId, 15, "release-2");
|
||||
await _ledger.ConsumeAsync(serviceId, 30, "release-3");
|
||||
|
||||
// Assert
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Consumed.Should().Be(65);
|
||||
budget.Remaining.Should().Be(135);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_UpToExactLimit_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "exact-limit-service";
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act: Consume exactly to the limit
|
||||
var result = await _ledger.ConsumeAsync(serviceId, 200, "max-release");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Budget.Consumed.Should().Be(200);
|
||||
result.Budget.Remaining.Should().Be(0);
|
||||
result.Budget.Status.Should().Be(BudgetStatus.Exhausted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_AttemptOverBudget_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "over-budget-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 180, "heavy-release");
|
||||
|
||||
// Act: Try to consume more than remaining
|
||||
var result = await _ledger.ConsumeAsync(serviceId, 25, "overflow-release");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("Insufficient");
|
||||
|
||||
// Budget should remain unchanged
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Consumed.Should().Be(180);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_ZeroPoints_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "zero-point-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act
|
||||
var result = await _ledger.ConsumeAsync(serviceId, 0, "no-risk-release");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Budget.Consumed.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Threshold Transition Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ThresholdTransition_GreenToYellow()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "threshold-gy-service";
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Status.Should().Be(BudgetStatus.Green);
|
||||
|
||||
// Act: Consume 40% (threshold boundary)
|
||||
await _ledger.ConsumeAsync(serviceId, 80, "transition-release");
|
||||
|
||||
// Assert
|
||||
var updatedBudget = await _ledger.GetBudgetAsync(serviceId);
|
||||
updatedBudget.Status.Should().Be(BudgetStatus.Yellow);
|
||||
updatedBudget.PercentageUsed.Should().Be(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThresholdTransition_YellowToRed()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "threshold-yr-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 80, "initial-release");
|
||||
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Status.Should().Be(BudgetStatus.Yellow);
|
||||
|
||||
// Act: Consume to 70% (threshold boundary)
|
||||
await _ledger.ConsumeAsync(serviceId, 60, "transition-release");
|
||||
|
||||
// Assert
|
||||
var updatedBudget = await _ledger.GetBudgetAsync(serviceId);
|
||||
updatedBudget.Status.Should().Be(BudgetStatus.Red);
|
||||
updatedBudget.PercentageUsed.Should().Be(70);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThresholdTransition_RedToExhausted()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "threshold-re-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 140, "heavy-release");
|
||||
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Status.Should().Be(BudgetStatus.Red);
|
||||
|
||||
// Act: Consume to 100%
|
||||
await _ledger.ConsumeAsync(serviceId, 60, "final-release");
|
||||
|
||||
// Assert
|
||||
var updatedBudget = await _ledger.GetBudgetAsync(serviceId);
|
||||
updatedBudget.Status.Should().Be(BudgetStatus.Exhausted);
|
||||
updatedBudget.PercentageUsed.Should().Be(100);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, BudgetStatus.Green)]
|
||||
[InlineData(39, BudgetStatus.Green)]
|
||||
[InlineData(40, BudgetStatus.Yellow)]
|
||||
[InlineData(69, BudgetStatus.Yellow)]
|
||||
[InlineData(70, BudgetStatus.Red)]
|
||||
[InlineData(99, BudgetStatus.Red)]
|
||||
[InlineData(100, BudgetStatus.Exhausted)]
|
||||
public async Task ThresholdBoundaries_AreCorrect(int percentageConsumed, BudgetStatus expectedStatus)
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = $"boundary-{percentageConsumed}-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act: Consume to specific percentage (200 * percentage / 100)
|
||||
var pointsToConsume = 200 * percentageConsumed / 100;
|
||||
if (pointsToConsume > 0)
|
||||
{
|
||||
await _ledger.ConsumeAsync(serviceId, pointsToConsume, "boundary-release");
|
||||
}
|
||||
|
||||
// Assert
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Status.Should().Be(expectedStatus);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Earned Capacity Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AdjustAllocation_IncreasesCapacity_ChangesThreshold()
|
||||
{
|
||||
// Arrange: Start in Red status
|
||||
var serviceId = "capacity-increase-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 150, "heavy-release"); // 75% = Red
|
||||
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Status.Should().Be(BudgetStatus.Red);
|
||||
|
||||
// Act: Add earned capacity
|
||||
var adjusted = await _ledger.AdjustAllocationAsync(serviceId, 50, "earned capacity");
|
||||
|
||||
// Assert: Status should improve
|
||||
adjusted.Allocated.Should().Be(250);
|
||||
adjusted.PercentageUsed.Should().Be(60); // 150/250 = 60%
|
||||
adjusted.Status.Should().Be(BudgetStatus.Yellow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdjustAllocation_DecreaseCapacity_ChangesThreshold()
|
||||
{
|
||||
// Arrange: Start in Yellow status
|
||||
var serviceId = "capacity-decrease-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 80, "initial-release"); // 40% = Yellow
|
||||
|
||||
// Act: Reduce capacity (penalty)
|
||||
var adjusted = await _ledger.AdjustAllocationAsync(serviceId, -50, "incident penalty");
|
||||
|
||||
// Assert: Status should worsen
|
||||
adjusted.Allocated.Should().Be(150);
|
||||
adjusted.PercentageUsed.Should().BeApproximately(53.33m, 0.1m); // 80/150
|
||||
adjusted.Status.Should().Be(BudgetStatus.Yellow);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region History and Audit Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_ReturnsAllEntriesForWindow()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "history-service";
|
||||
var window = DateTimeOffset.UtcNow.ToString("yyyy-MM");
|
||||
await _ledger.GetBudgetAsync(serviceId, window);
|
||||
|
||||
// Act: Create multiple entries
|
||||
await _ledger.ConsumeAsync(serviceId, 10, "release-1");
|
||||
await _ledger.ConsumeAsync(serviceId, 20, "release-2");
|
||||
await _ledger.ConsumeAsync(serviceId, 30, "release-3");
|
||||
|
||||
var history = await _ledger.GetHistoryAsync(serviceId, window);
|
||||
|
||||
// Assert
|
||||
history.Should().HaveCount(3);
|
||||
history.Should().Contain(e => e.ReleaseId == "release-1" && e.RiskPoints == 10);
|
||||
history.Should().Contain(e => e.ReleaseId == "release-2" && e.RiskPoints == 20);
|
||||
history.Should().Contain(e => e.ReleaseId == "release-3" && e.RiskPoints == 30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_EmptyForNewService()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "new-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act
|
||||
var history = await _ledger.GetHistoryAsync(serviceId);
|
||||
|
||||
// Assert
|
||||
history.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_DifferentWindows_AreIsolated()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "multi-window-history";
|
||||
var window1 = "2025-01";
|
||||
var window2 = "2025-02";
|
||||
|
||||
await _ledger.GetBudgetAsync(serviceId, window1);
|
||||
await _store.AddEntryAsync(new BudgetEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString(),
|
||||
ServiceId = serviceId,
|
||||
Window = window1,
|
||||
ReleaseId = "jan-release",
|
||||
RiskPoints = 50,
|
||||
ConsumedAt = DateTimeOffset.UtcNow
|
||||
}, CancellationToken.None);
|
||||
|
||||
await _ledger.GetBudgetAsync(serviceId, window2);
|
||||
await _store.AddEntryAsync(new BudgetEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString(),
|
||||
ServiceId = serviceId,
|
||||
Window = window2,
|
||||
ReleaseId = "feb-release",
|
||||
RiskPoints = 30,
|
||||
ConsumedAt = DateTimeOffset.UtcNow
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var historyW1 = await _ledger.GetHistoryAsync(serviceId, window1);
|
||||
var historyW2 = await _ledger.GetHistoryAsync(serviceId, window2);
|
||||
|
||||
// Assert
|
||||
historyW1.Should().HaveCount(1);
|
||||
historyW1[0].ReleaseId.Should().Be("jan-release");
|
||||
|
||||
historyW2.Should().HaveCount(1);
|
||||
historyW2[0].ReleaseId.Should().Be("feb-release");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tier-Based Allocation Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(ServiceTier.Internal, 300)]
|
||||
[InlineData(ServiceTier.CustomerFacingNonCritical, 200)]
|
||||
[InlineData(ServiceTier.CustomerFacingCritical, 120)]
|
||||
[InlineData(ServiceTier.SafetyCritical, 80)]
|
||||
public void DefaultAllocations_MatchTierRiskProfile(ServiceTier tier, int expectedAllocation)
|
||||
{
|
||||
// Assert
|
||||
DefaultBudgetAllocations.GetMonthlyAllocation(tier).Should().Be(expectedAllocation);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Concurrent Access Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentConsumption_IsThreadSafe()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "concurrent-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act: Concurrent consumption attempts
|
||||
var tasks = Enumerable.Range(0, 10)
|
||||
.Select(i => _ledger.ConsumeAsync(serviceId, 5, $"release-{i}"))
|
||||
.ToList();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert: All should succeed, total consumed should be 50
|
||||
results.Should().OnlyContain(r => r.IsSuccess);
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Consumed.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentConsumption_RespectsLimit()
|
||||
{
|
||||
// Arrange: Set up a budget with limited capacity
|
||||
var serviceId = "limited-concurrent-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 180, "initial-large-release");
|
||||
|
||||
// Act: Concurrent attempts that would exceed limit
|
||||
var tasks = Enumerable.Range(0, 5)
|
||||
.Select(i => _ledger.ConsumeAsync(serviceId, 10, $"concurrent-{i}"))
|
||||
.ToList();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert: At least some should fail (only 20 remaining)
|
||||
results.Count(r => r.IsSuccess).Should().BeLessThanOrEqualTo(2);
|
||||
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Consumed.Should().BeLessThanOrEqualTo(200);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CicdGateIntegrationTests.cs
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-09 - Integration tests: Zastava webhook -> Scheduler -> Policy Engine -> verdict
|
||||
// Description: End-to-end integration tests for CI/CD release gate workflow
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the CI/CD gate evaluation workflow.
|
||||
/// Tests the complete flow from webhook trigger through verdict.
|
||||
/// </summary>
|
||||
public class CicdGateIntegrationTests
|
||||
{
|
||||
private readonly PolicyGateOptions _options;
|
||||
private readonly IOptionsMonitor<PolicyGateOptions> _optionsMonitor;
|
||||
|
||||
public CicdGateIntegrationTests()
|
||||
{
|
||||
_options = new PolicyGateOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultTimeout = TimeSpan.FromSeconds(60),
|
||||
AllowBypassWithJustification = true,
|
||||
MinimumJustificationLength = 20
|
||||
};
|
||||
|
||||
var monitor = Substitute.For<IOptionsMonitor<PolicyGateOptions>>();
|
||||
monitor.CurrentValue.Returns(_options);
|
||||
monitor.Get(Arg.Any<string?>()).Returns(_options);
|
||||
_optionsMonitor = monitor;
|
||||
}
|
||||
|
||||
#region Gate Evaluation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_NewImageWithNoDelta_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0001",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "CU",
|
||||
UncertaintyTier = "T4",
|
||||
GraphHash = "blake3:abc",
|
||||
PathLength = -1,
|
||||
Confidence = 0.95
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Allow);
|
||||
decision.BlockedBy.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_NewCriticalVulnerability_ReturnsBlock()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0002",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "SR", // Supplier reachable - should block
|
||||
UncertaintyTier = "T1",
|
||||
GraphHash = "blake3:def",
|
||||
PathLength = 3,
|
||||
RiskScore = 9.8
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
decision.BlockedBy.Should().Be("LatticeState");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_HighUncertainty_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0003",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "CU",
|
||||
UncertaintyTier = "T2", // High uncertainty - should warn
|
||||
GraphHash = "blake3:ghi",
|
||||
PathLength = -1,
|
||||
Confidence = 0.6
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Warn);
|
||||
decision.Advisory.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bypass and Override Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_BlockWithValidBypass_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0004",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "SR",
|
||||
AllowOverride = true,
|
||||
OverrideJustification = "Emergency hotfix approved by security team - JIRA-12345"
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Warn);
|
||||
decision.Advisory.Should().Contain("Override accepted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_BlockWithInvalidBypass_RemainsBlocked()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0005",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "SR",
|
||||
AllowOverride = true,
|
||||
OverrideJustification = "yolo" // Too short
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exit Code Tests
|
||||
|
||||
[Fact]
|
||||
public void GateExitCode_Pass_ReturnsZero()
|
||||
{
|
||||
// Arrange & Act
|
||||
var exitCode = MapDecisionToExitCode(PolicyGateDecisionType.Allow);
|
||||
|
||||
// Assert
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateExitCode_Warn_ReturnsOne()
|
||||
{
|
||||
// Arrange & Act
|
||||
var exitCode = MapDecisionToExitCode(PolicyGateDecisionType.Warn);
|
||||
|
||||
// Assert
|
||||
exitCode.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateExitCode_Block_ReturnsTwo()
|
||||
{
|
||||
// Arrange & Act
|
||||
var exitCode = MapDecisionToExitCode(PolicyGateDecisionType.Block);
|
||||
|
||||
// Assert
|
||||
exitCode.Should().Be(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Batch Evaluation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateBatch_MultipleVulnerabilities_ReturnsWorstCase()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var requests = new[]
|
||||
{
|
||||
CreateRequest("not_affected", "CU", "T4"), // Pass
|
||||
CreateRequest("not_affected", "CU", "T2"), // Warn
|
||||
CreateRequest("not_affected", "SR", "T1") // Block
|
||||
};
|
||||
|
||||
// Act
|
||||
var decisions = new List<PolicyGateDecision>();
|
||||
foreach (var request in requests)
|
||||
{
|
||||
decisions.Add(await evaluator.EvaluateAsync(request));
|
||||
}
|
||||
|
||||
var worstDecision = decisions
|
||||
.OrderByDescending(d => (int)d.Decision)
|
||||
.First();
|
||||
|
||||
// Assert
|
||||
worstDecision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateBatch_AllPass_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var requests = new[]
|
||||
{
|
||||
CreateRequest("not_affected", "CU", "T4"),
|
||||
CreateRequest("not_affected", "CU", "T4"),
|
||||
CreateRequest("affected", "CR", "T4")
|
||||
};
|
||||
|
||||
// Act
|
||||
var decisions = new List<PolicyGateDecision>();
|
||||
foreach (var request in requests)
|
||||
{
|
||||
decisions.Add(await evaluator.EvaluateAsync(request));
|
||||
}
|
||||
|
||||
// Assert
|
||||
decisions.All(d => d.Decision == PolicyGateDecisionType.Allow).Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Audit Trail Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_CreatesAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0006",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "CU",
|
||||
UncertaintyTier = "T4",
|
||||
GraphHash = "blake3:xyz"
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.GateId.Should().NotBeNullOrEmpty();
|
||||
decision.Subject.Should().NotBeNull();
|
||||
decision.Subject.VulnId.Should().Be("CVE-2025-0006");
|
||||
decision.Evidence.Should().NotBeNull();
|
||||
decision.Gates.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_BypassAttempt_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0007",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "SR",
|
||||
AllowOverride = true,
|
||||
OverrideJustification = "Production hotfix required - incident INC-9999 - approved by CISO"
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert - bypass should be recorded
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Warn);
|
||||
decision.Advisory.Should().Contain("Override");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Disabled Gate Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_WhenDisabled_ReturnsAllow()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PolicyGateOptions { Enabled = false };
|
||||
var monitor = Substitute.For<IOptionsMonitor<PolicyGateOptions>>();
|
||||
monitor.CurrentValue.Returns(options);
|
||||
monitor.Get(Arg.Any<string?>()).Returns(options);
|
||||
|
||||
var evaluator = new PolicyGateEvaluator(
|
||||
monitor,
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGateEvaluator>.Instance);
|
||||
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0008",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "CR", // Would normally block
|
||||
UncertaintyTier = "T1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Allow);
|
||||
decision.Advisory.Should().Contain("disabled");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Baseline Comparison Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_NewVulnNotInBaseline_ReturnsBlock()
|
||||
{
|
||||
// Arrange - new critical vuln not present in baseline
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-NEW",
|
||||
Purl = "pkg:oci/myapp@sha256:newimage",
|
||||
RequestedStatus = "affected",
|
||||
LatticeState = "CR",
|
||||
UncertaintyTier = "T1",
|
||||
RiskScore = 9.5,
|
||||
// No baseline reference = new finding
|
||||
IsNewFinding = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert - new critical findings should warn at minimum
|
||||
decision.Decision.Should().BeOneOf(PolicyGateDecisionType.Warn, PolicyGateDecisionType.Block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_VulnExistsInBaseline_ReturnsAllow()
|
||||
{
|
||||
// Arrange - existing vuln already in baseline
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-EXISTING",
|
||||
Purl = "pkg:oci/myapp@sha256:newimage",
|
||||
RequestedStatus = "affected",
|
||||
LatticeState = "CR",
|
||||
UncertaintyTier = "T4",
|
||||
RiskScore = 7.0,
|
||||
IsNewFinding = false // Already in baseline
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert - existing findings should pass
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Allow);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// Helper methods
|
||||
|
||||
private PolicyGateEvaluator CreateEvaluator()
|
||||
{
|
||||
return new PolicyGateEvaluator(
|
||||
_optionsMonitor,
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGateEvaluator>.Instance);
|
||||
}
|
||||
|
||||
private static PolicyGateRequest CreateRequest(
|
||||
string status,
|
||||
string latticeState,
|
||||
string uncertaintyTier)
|
||||
{
|
||||
return new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = $"CVE-2025-{Guid.NewGuid():N}",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = status,
|
||||
LatticeState = latticeState,
|
||||
UncertaintyTier = uncertaintyTier,
|
||||
GraphHash = "blake3:test",
|
||||
PathLength = -1,
|
||||
Confidence = 0.9
|
||||
};
|
||||
}
|
||||
|
||||
private static int MapDecisionToExitCode(PolicyGateDecisionType decision)
|
||||
{
|
||||
return decision switch
|
||||
{
|
||||
PolicyGateDecisionType.Allow => 0,
|
||||
PolicyGateDecisionType.Warn => 1,
|
||||
PolicyGateDecisionType.Block => 2,
|
||||
_ => 10
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additional tests for webhook-triggered gate evaluations.
|
||||
/// </summary>
|
||||
public class WebhookGateIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void DockerRegistryWebhook_ParsesDigest_Correctly()
|
||||
{
|
||||
// Arrange
|
||||
var webhookPayload = """
|
||||
{
|
||||
"events": [{
|
||||
"action": "push",
|
||||
"target": {
|
||||
"repository": "myapp",
|
||||
"digest": "sha256:abc123def456"
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var parsed = System.Text.Json.JsonDocument.Parse(webhookPayload);
|
||||
var events = parsed.RootElement.GetProperty("events");
|
||||
var firstEvent = events[0];
|
||||
var digest = firstEvent.GetProperty("target").GetProperty("digest").GetString();
|
||||
|
||||
// Assert
|
||||
digest.Should().Be("sha256:abc123def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HarborWebhook_ParsesDigest_Correctly()
|
||||
{
|
||||
// Arrange
|
||||
var webhookPayload = """
|
||||
{
|
||||
"type": "PUSH_ARTIFACT",
|
||||
"event_data": {
|
||||
"resources": [{
|
||||
"digest": "sha256:xyz789abc123",
|
||||
"resource_url": "harbor.example.com/myproject/myapp@sha256:xyz789abc123"
|
||||
}]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var parsed = System.Text.Json.JsonDocument.Parse(webhookPayload);
|
||||
var resources = parsed.RootElement
|
||||
.GetProperty("event_data")
|
||||
.GetProperty("resources");
|
||||
var digest = resources[0].GetProperty("digest").GetString();
|
||||
|
||||
// Assert
|
||||
digest.Should().Be("sha256:xyz789abc123");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user