Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Engine/Endpoints/RiskBudgetEndpoints.cs

335 lines
14 KiB
C#

// -----------------------------------------------------------------------------
// 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.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Engine.Tenancy;
using StellaOps.Policy.Gates;
namespace StellaOps.Policy.Engine.Endpoints;
/// <summary>
/// API endpoints for risk budget management.
/// POL-TEN-03: Tenant enforcement via ITenantContextAccessor.
/// </summary>
internal static class RiskBudgetEndpoints
{
public static IEndpointRouteBuilder MapRiskBudgets(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/policy/budget")
.WithTags("Risk Budgets")
.RequireTenantContext();
group.MapGet("/status/{serviceId}", GetBudgetStatus)
.WithName("GetRiskBudgetStatus")
.WithSummary("Get current risk budget status for a service.")
.WithDescription("Retrieve the current risk budget status for a specific service, including allocated capacity, consumed points, remaining headroom, and enforcement status for the requested or active budget window.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<RiskBudgetStatusResponse>(StatusCodes.Status200OK);
group.MapPost("/consume", ConsumeBudget)
.WithName("ConsumeRiskBudget")
.WithSummary("Record budget consumption after a release.")
.WithDescription("Record the risk point consumption for a completed release, deducting the specified points from the service's active budget window ledger. Returns the updated budget state including remaining headroom and enforcement status.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate))
.Produces<BudgetConsumeResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapPost("/check", CheckRelease)
.WithName("CheckRelease")
.WithSummary("Check if a release can proceed given current budget.")
.WithDescription("Evaluate whether a proposed release can proceed given the service's current risk budget, operational context (change freeze, incident state, deployment window), and mitigation factors (feature flags, canary deployment, rollback plan). Returns the required gate level, projected risk points, and pre/post budget state.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<ReleaseCheckResponse>(StatusCodes.Status200OK);
group.MapGet("/history/{serviceId}", GetBudgetHistory)
.WithName("GetBudgetHistory")
.WithSummary("Get budget consumption history for a service.")
.WithDescription("Retrieve the chronological list of risk budget consumption entries for a service within the specified or current budget window, showing each release's risk point cost and timestamp for audit and trend analysis.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<BudgetHistoryResponse>(StatusCodes.Status200OK);
group.MapPost("/adjust", AdjustBudget)
.WithName("AdjustBudget")
.WithSummary("Adjust budget allocation (earned capacity or manual override).")
.WithDescription("Apply a positive or negative adjustment to a service's allocated risk budget, supporting earned-capacity rewards and administrative corrections. The adjustment reason is recorded for the audit ledger.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyEdit))
.Produces<RiskBudgetStatusResponse>(StatusCodes.Status200OK)
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
group.MapGet("/list", ListBudgets)
.WithName("ListRiskBudgets")
.WithSummary("List all risk budgets with optional filtering.")
.WithDescription("List risk budgets across services with optional filtering by enforcement status and budget window, returning current allocation, consumption, and remaining headroom for each service to support dashboard and compliance reporting.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead))
.Produces<BudgetListResponse>(StatusCodes.Status200OK);
return endpoints;
}
private static async Task<Ok<RiskBudgetStatusResponse>> GetBudgetStatus(
string serviceId,
[FromQuery] string? window,
ITenantContextAccessor tenantAccessor,
IBudgetLedger ledger,
CancellationToken ct)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
// POL-TEN-03: tenantId available for downstream scoping when repository layer is wired.
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,
ITenantContextAccessor tenantAccessor,
IBudgetLedger ledger,
CancellationToken ct)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
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,
ITenantContextAccessor tenantAccessor,
IBudgetConstraintEnforcer enforcer,
CancellationToken ct)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
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,
ITenantContextAccessor tenantAccessor,
IBudgetLedger ledger,
CancellationToken ct)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
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,
ITenantContextAccessor tenantAccessor,
IBudgetLedger ledger,
CancellationToken ct)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
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,
ITenantContextAccessor tenantAccessor = default!)
{
var tenantId = tenantAccessor.TenantContext!.TenantId;
// POL-TEN-03: tenantId available for downstream scoping when repository layer is wired.
// 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