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;
|
||||
}
|
||||
Reference in New Issue
Block a user