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