Refactor code structure and optimize performance across multiple modules
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user