Refactor code structure and optimize performance across multiple modules

This commit is contained in:
StellaOps Bot
2025-12-26 20:03:22 +02:00
parent c786faae84
commit b4fc66feb6
3353 changed files with 88254 additions and 1590657 deletions

View File

@@ -0,0 +1,698 @@
// -----------------------------------------------------------------------------
// ExceptionApprovalRulesService.cs
// Sprint: SPRINT_20251226_003_BE_exception_approval
// Task: EXCEPT-03 - Approval rules engine (G1/G2/G3+ levels)
// Description: Service for validating exception approval requests against
// gate-level rules and determining approval requirements
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
namespace StellaOps.Policy.Engine.Services;
/// <summary>
/// Approval requirements for a gate level.
/// </summary>
public sealed record ApprovalRequirements
{
/// <summary>
/// Gate level these requirements apply to.
/// </summary>
public required GateLevel GateLevel { get; init; }
/// <summary>
/// Minimum number of approvers required.
/// </summary>
public int MinApprovers { get; init; } = 1;
/// <summary>
/// Required approver roles (any of these can approve for their slot).
/// </summary>
public IReadOnlyList<string> RequiredRoles { get; init; } = [];
/// <summary>
/// Maximum TTL allowed in days.
/// </summary>
public int MaxTtlDays { get; init; } = 30;
/// <summary>
/// Whether self-approval is allowed.
/// </summary>
public bool AllowSelfApproval { get; init; }
/// <summary>
/// Whether evidence is required.
/// </summary>
public bool RequireEvidence { get; init; }
/// <summary>
/// Whether compensating controls are required.
/// </summary>
public bool RequireCompensatingControls { get; init; }
/// <summary>
/// Minimum rationale length.
/// </summary>
public int MinRationaleLength { get; init; }
/// <summary>
/// Creates default requirements for a gate level.
/// </summary>
public static ApprovalRequirements GetDefault(GateLevel level) => level switch
{
GateLevel.G0 => new ApprovalRequirements
{
GateLevel = level,
MinApprovers = 0,
MaxTtlDays = 90,
AllowSelfApproval = true,
RequireEvidence = false,
MinRationaleLength = 0
},
GateLevel.G1 => new ApprovalRequirements
{
GateLevel = level,
MinApprovers = 1,
MaxTtlDays = 60,
AllowSelfApproval = true,
RequireEvidence = false,
MinRationaleLength = 20
},
GateLevel.G2 => new ApprovalRequirements
{
GateLevel = level,
MinApprovers = 1,
RequiredRoles = ["code-owner"],
MaxTtlDays = 30,
AllowSelfApproval = false,
RequireEvidence = true,
MinRationaleLength = 50
},
GateLevel.G3 => new ApprovalRequirements
{
GateLevel = level,
MinApprovers = 2,
RequiredRoles = ["delivery-manager", "product-manager"],
MaxTtlDays = 14,
AllowSelfApproval = false,
RequireEvidence = true,
RequireCompensatingControls = true,
MinRationaleLength = 100
},
GateLevel.G4 => new ApprovalRequirements
{
GateLevel = level,
MinApprovers = 3,
RequiredRoles = ["ciso", "delivery-manager", "product-manager"],
MaxTtlDays = 7,
AllowSelfApproval = false,
RequireEvidence = true,
RequireCompensatingControls = true,
MinRationaleLength = 200
},
_ => new ApprovalRequirements
{
GateLevel = level,
MinApprovers = 1,
MaxTtlDays = 30
}
};
}
/// <summary>
/// Validation result for an approval request.
/// </summary>
public sealed record ApprovalRequestValidationResult
{
/// <summary>
/// Whether the request is valid.
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// Validation errors (if any).
/// </summary>
public IReadOnlyList<string> Errors { get; init; } = [];
/// <summary>
/// Warnings (non-blocking issues).
/// </summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
/// <summary>
/// The determined gate level.
/// </summary>
public GateLevel DeterminedGateLevel { get; init; }
/// <summary>
/// The requirements for this gate level.
/// </summary>
public ApprovalRequirements? Requirements { get; init; }
/// <summary>
/// Success result.
/// </summary>
public static ApprovalRequestValidationResult Success(
GateLevel level,
ApprovalRequirements requirements) => new()
{
IsValid = true,
DeterminedGateLevel = level,
Requirements = requirements
};
/// <summary>
/// Failure result.
/// </summary>
public static ApprovalRequestValidationResult Failure(params string[] errors) => new()
{
IsValid = false,
Errors = errors
};
}
/// <summary>
/// Validation result for an approval action.
/// </summary>
public sealed record ApprovalActionValidationResult
{
/// <summary>
/// Whether the action is valid.
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// Validation errors (if any).
/// </summary>
public IReadOnlyList<string> Errors { get; init; } = [];
/// <summary>
/// Whether this approval completes the workflow.
/// </summary>
public bool CompletesWorkflow { get; init; }
/// <summary>
/// Remaining approvers needed after this action.
/// </summary>
public int RemainingApproversNeeded { get; init; }
/// <summary>
/// Roles that still need to approve.
/// </summary>
public IReadOnlyList<string> RemainingRequiredRoles { get; init; } = [];
/// <summary>
/// Success result.
/// </summary>
public static ApprovalActionValidationResult Success(
bool completesWorkflow,
int remainingApprovers = 0,
IReadOnlyList<string>? remainingRoles = null) => new()
{
IsValid = true,
CompletesWorkflow = completesWorkflow,
RemainingApproversNeeded = remainingApprovers,
RemainingRequiredRoles = remainingRoles ?? []
};
/// <summary>
/// Failure result.
/// </summary>
public static ApprovalActionValidationResult Failure(params string[] errors) => new()
{
IsValid = false,
Errors = errors
};
}
/// <summary>
/// Interface for exception approval rules engine.
/// </summary>
public interface IExceptionApprovalRulesService
{
/// <summary>
/// Gets approval requirements for a gate level.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="gateLevel">The gate level.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Approval requirements for the gate level.</returns>
Task<ApprovalRequirements> GetRequirementsAsync(
string tenantId,
GateLevel gateLevel,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates an approval request against the rules.
/// </summary>
/// <param name="request">The approval request to validate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Validation result.</returns>
Task<ApprovalRequestValidationResult> ValidateRequestAsync(
ExceptionApprovalRequestEntity request,
CancellationToken cancellationToken = default);
/// <summary>
/// Validates an approval action.
/// </summary>
/// <param name="request">The approval request being acted upon.</param>
/// <param name="approverId">The approver performing the action.</param>
/// <param name="approverRoles">Roles of the approver.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Validation result.</returns>
Task<ApprovalActionValidationResult> ValidateApprovalActionAsync(
ExceptionApprovalRequestEntity request,
string approverId,
IReadOnlySet<string> approverRoles,
CancellationToken cancellationToken = default);
/// <summary>
/// Determines if a request can be auto-approved based on gate level.
/// </summary>
/// <param name="request">The approval request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if auto-approval is allowed.</returns>
Task<bool> CanAutoApproveAsync(
ExceptionApprovalRequestEntity request,
CancellationToken cancellationToken = default);
/// <summary>
/// Determines required approvers for a request based on gate level and rules.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="gateLevel">The gate level.</param>
/// <param name="requestorId">The requestor (excluded from approvers if self-approval not allowed).</param>
/// <param name="availableApprovers">Available approvers with their roles.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of suggested approver IDs.</returns>
Task<IReadOnlyList<string>> DetermineRequiredApproversAsync(
string tenantId,
GateLevel gateLevel,
string requestorId,
IReadOnlyDictionary<string, IReadOnlySet<string>> availableApprovers,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Configuration options for approval rules.
/// </summary>
public sealed class ExceptionApprovalRulesOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:ExceptionApproval";
/// <summary>
/// Default request expiry in days (how long a request can remain pending).
/// </summary>
public int DefaultRequestExpiryDays { get; set; } = 7;
/// <summary>
/// Whether to use tenant-specific rules (falling back to defaults if not found).
/// </summary>
public bool UseTenantRules { get; set; } = true;
/// <summary>
/// Minimum justification length for any request.
/// </summary>
public int MinJustificationLength { get; set; } = 10;
}
/// <summary>
/// Default implementation of exception approval rules engine.
/// </summary>
public sealed class ExceptionApprovalRulesService : IExceptionApprovalRulesService
{
private readonly IExceptionApprovalRepository _repository;
private readonly ExceptionApprovalRulesOptions _options;
private readonly ILogger<ExceptionApprovalRulesService> _logger;
private readonly TimeProvider _timeProvider;
public ExceptionApprovalRulesService(
IExceptionApprovalRepository repository,
IOptions<ExceptionApprovalRulesOptions>? options,
ILogger<ExceptionApprovalRulesService> logger,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(logger);
_repository = repository;
_options = options?.Value ?? new ExceptionApprovalRulesOptions();
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<ApprovalRequirements> GetRequirementsAsync(
string tenantId,
GateLevel gateLevel,
CancellationToken cancellationToken = default)
{
if (_options.UseTenantRules)
{
var rule = await _repository.GetApprovalRuleAsync(tenantId, gateLevel, cancellationToken)
.ConfigureAwait(false);
if (rule is not null && rule.Enabled)
{
_logger.LogDebug(
"Using tenant-specific approval rule for tenant {TenantId}, gate level {GateLevel}",
tenantId,
gateLevel);
return MapRuleToRequirements(rule);
}
}
_logger.LogDebug(
"Using default approval requirements for gate level {GateLevel}",
gateLevel);
return ApprovalRequirements.GetDefault(gateLevel);
}
/// <inheritdoc />
public async Task<ApprovalRequestValidationResult> ValidateRequestAsync(
ExceptionApprovalRequestEntity request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var errors = new List<string>();
var warnings = new List<string>();
var requirements = await GetRequirementsAsync(
request.TenantId,
request.GateLevel,
cancellationToken).ConfigureAwait(false);
// Validate justification length
if (string.IsNullOrWhiteSpace(request.Justification))
{
errors.Add("Justification is required.");
}
else if (request.Justification.Length < _options.MinJustificationLength)
{
errors.Add($"Justification must be at least {_options.MinJustificationLength} characters.");
}
// Validate rationale for higher gate levels
if (requirements.MinRationaleLength > 0)
{
if (string.IsNullOrWhiteSpace(request.Rationale))
{
errors.Add($"Rationale is required for gate level {request.GateLevel}.");
}
else if (request.Rationale.Length < requirements.MinRationaleLength)
{
errors.Add($"Rationale must be at least {requirements.MinRationaleLength} characters for gate level {request.GateLevel}.");
}
}
// Validate evidence
if (requirements.RequireEvidence)
{
var evidenceRefs = ParseJsonArray(request.EvidenceRefs);
if (evidenceRefs.Count == 0)
{
errors.Add($"Evidence is required for gate level {request.GateLevel}.");
}
}
// Validate compensating controls
if (requirements.RequireCompensatingControls)
{
var controls = ParseJsonArray(request.CompensatingControls);
if (controls.Count == 0)
{
errors.Add($"Compensating controls are required for gate level {request.GateLevel}.");
}
}
// Validate TTL
if (request.RequestedTtlDays > requirements.MaxTtlDays)
{
errors.Add($"Requested TTL ({request.RequestedTtlDays} days) exceeds maximum allowed ({requirements.MaxTtlDays} days) for gate level {request.GateLevel}.");
}
// Validate required approvers
if (request.RequiredApproverIds.Length < requirements.MinApprovers)
{
errors.Add($"At least {requirements.MinApprovers} approver(s) required for gate level {request.GateLevel}.");
}
// Check for scope - at least one scope constraint must be specified
if (string.IsNullOrWhiteSpace(request.VulnerabilityId) &&
string.IsNullOrWhiteSpace(request.PurlPattern) &&
string.IsNullOrWhiteSpace(request.ArtifactDigest) &&
string.IsNullOrWhiteSpace(request.ImagePattern))
{
warnings.Add("No scope constraints specified. Exception will apply broadly.");
}
if (errors.Count > 0)
{
return new ApprovalRequestValidationResult
{
IsValid = false,
Errors = errors,
Warnings = warnings,
DeterminedGateLevel = request.GateLevel
};
}
return new ApprovalRequestValidationResult
{
IsValid = true,
Warnings = warnings,
DeterminedGateLevel = request.GateLevel,
Requirements = requirements
};
}
/// <inheritdoc />
public async Task<ApprovalActionValidationResult> ValidateApprovalActionAsync(
ExceptionApprovalRequestEntity request,
string approverId,
IReadOnlySet<string> approverRoles,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(approverId);
var errors = new List<string>();
var requirements = await GetRequirementsAsync(
request.TenantId,
request.GateLevel,
cancellationToken).ConfigureAwait(false);
// Check if request is in a valid state for approval
if (request.Status != ApprovalRequestStatus.Pending &&
request.Status != ApprovalRequestStatus.Partial)
{
errors.Add($"Request is in {request.Status} status and cannot be approved.");
return ApprovalActionValidationResult.Failure([.. errors]);
}
// Check if already approved by this approver
if (request.ApprovedByIds.Contains(approverId, StringComparer.OrdinalIgnoreCase))
{
errors.Add("You have already approved this request.");
return ApprovalActionValidationResult.Failure([.. errors]);
}
// Check self-approval
if (!requirements.AllowSelfApproval &&
string.Equals(request.RequestorId, approverId, StringComparison.OrdinalIgnoreCase))
{
errors.Add($"Self-approval is not allowed for gate level {request.GateLevel}.");
return ApprovalActionValidationResult.Failure([.. errors]);
}
// Check required approvers list
if (request.RequiredApproverIds.Length > 0 &&
!request.RequiredApproverIds.Contains(approverId, StringComparer.OrdinalIgnoreCase))
{
errors.Add("You are not in the required approvers list for this request.");
return ApprovalActionValidationResult.Failure([.. errors]);
}
// Check required roles
var satisfiedRoles = new List<string>();
var remainingRoles = new List<string>();
if (requirements.RequiredRoles.Count > 0)
{
// Get roles already satisfied by previous approvers
// For simplicity, we track which roles the current approver satisfies
foreach (var requiredRole in requirements.RequiredRoles)
{
if (approverRoles.Contains(requiredRole))
{
satisfiedRoles.Add(requiredRole);
}
else
{
// Check if this role was satisfied by a previous approver
// This would require additional context - for now, just track remaining
remainingRoles.Add(requiredRole);
}
}
}
// Calculate remaining approvers needed
var approvedCount = request.ApprovedByIds.Length + 1; // +1 for current approver
var remainingApprovers = Math.Max(0, requirements.MinApprovers - approvedCount);
var completesWorkflow = remainingApprovers == 0 && remainingRoles.Count == 0;
if (errors.Count > 0)
{
return ApprovalActionValidationResult.Failure([.. errors]);
}
return ApprovalActionValidationResult.Success(
completesWorkflow,
remainingApprovers,
remainingRoles);
}
/// <inheritdoc />
public async Task<bool> CanAutoApproveAsync(
ExceptionApprovalRequestEntity request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var requirements = await GetRequirementsAsync(
request.TenantId,
request.GateLevel,
cancellationToken).ConfigureAwait(false);
// Auto-approve if no approvers required (G0 level typically)
if (requirements.MinApprovers == 0)
{
_logger.LogDebug(
"Request {RequestId} can be auto-approved (gate level {GateLevel} requires 0 approvers)",
request.RequestId,
request.GateLevel);
return true;
}
return false;
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> DetermineRequiredApproversAsync(
string tenantId,
GateLevel gateLevel,
string requestorId,
IReadOnlyDictionary<string, IReadOnlySet<string>> availableApprovers,
CancellationToken cancellationToken = default)
{
var requirements = await GetRequirementsAsync(tenantId, gateLevel, cancellationToken)
.ConfigureAwait(false);
if (requirements.MinApprovers == 0)
{
return [];
}
var selectedApprovers = new List<string>();
var requiredRolesCovered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// First pass: select approvers that cover required roles
foreach (var requiredRole in requirements.RequiredRoles)
{
if (requiredRolesCovered.Contains(requiredRole))
continue;
foreach (var (approverId, roles) in availableApprovers)
{
// Skip requestor if self-approval not allowed
if (!requirements.AllowSelfApproval &&
string.Equals(approverId, requestorId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Skip if already selected
if (selectedApprovers.Contains(approverId, StringComparer.OrdinalIgnoreCase))
continue;
if (roles.Contains(requiredRole))
{
selectedApprovers.Add(approverId);
// Mark all roles this approver covers
foreach (var role in roles)
{
requiredRolesCovered.Add(role);
}
break;
}
}
}
// Second pass: add more approvers if minimum not met
while (selectedApprovers.Count < requirements.MinApprovers)
{
var addedApprover = false;
foreach (var (approverId, _) in availableApprovers)
{
if (!requirements.AllowSelfApproval &&
string.Equals(approverId, requestorId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!selectedApprovers.Contains(approverId, StringComparer.OrdinalIgnoreCase))
{
selectedApprovers.Add(approverId);
addedApprover = true;
break;
}
}
// No more approvers available
if (!addedApprover)
break;
}
return selectedApprovers;
}
private static ApprovalRequirements MapRuleToRequirements(ExceptionApprovalRuleEntity rule)
{
return new ApprovalRequirements
{
GateLevel = rule.GateLevel,
MinApprovers = rule.MinApprovers,
RequiredRoles = rule.RequiredRoles,
MaxTtlDays = rule.MaxTtlDays,
AllowSelfApproval = rule.AllowSelfApproval,
RequireEvidence = rule.RequireEvidence,
RequireCompensatingControls = rule.RequireCompensatingControls,
MinRationaleLength = rule.MinRationaleLength
};
}
private static IReadOnlyList<string> ParseJsonArray(string json)
{
if (string.IsNullOrWhiteSpace(json) || json == "[]")
return [];
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
}