874 lines
31 KiB
C#
874 lines
31 KiB
C#
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Sprint: SPRINT_20251226_003_BE_exception_approval
|
|
// Task: EXCEPT-05, EXCEPT-06, EXCEPT-07 - Exception approval API endpoints
|
|
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Auth.Abstractions;
|
|
using StellaOps.Auth.ServerIntegration;
|
|
using StellaOps.Policy.Engine.Services;
|
|
using StellaOps.Policy.Storage.Postgres.Models;
|
|
using StellaOps.Policy.Storage.Postgres.Repositories;
|
|
|
|
namespace StellaOps.Policy.Gateway.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Exception approval workflow API endpoints.
|
|
/// </summary>
|
|
public static class ExceptionApprovalEndpoints
|
|
{
|
|
/// <summary>
|
|
/// Maps exception approval endpoints to the application.
|
|
/// </summary>
|
|
public static void MapExceptionApprovalEndpoints(this WebApplication app)
|
|
{
|
|
var exceptions = app.MapGroup("/api/v1/policy/exception")
|
|
.WithTags("Exception Approvals");
|
|
|
|
// POST /api/v1/policy/exception/request - Create a new exception approval request
|
|
exceptions.MapPost("/request", CreateApprovalRequestAsync)
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRequest))
|
|
.WithName("CreateExceptionApprovalRequest")
|
|
.WithDescription("Create a new exception approval request");
|
|
|
|
// GET /api/v1/policy/exception/request/{requestId} - Get an approval request
|
|
exceptions.MapGet("/request/{requestId}", GetApprovalRequestAsync)
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
|
|
.WithName("GetExceptionApprovalRequest")
|
|
.WithDescription("Get an exception approval request by ID");
|
|
|
|
// GET /api/v1/policy/exception/requests - List approval requests
|
|
exceptions.MapGet("/requests", ListApprovalRequestsAsync)
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
|
|
.WithName("ListExceptionApprovalRequests")
|
|
.WithDescription("List exception approval requests for the tenant");
|
|
|
|
// GET /api/v1/policy/exception/pending - List pending approvals for current user
|
|
exceptions.MapGet("/pending", ListPendingApprovalsAsync)
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsApprove))
|
|
.WithName("ListPendingApprovals")
|
|
.WithDescription("List pending exception approvals for the current user");
|
|
|
|
// POST /api/v1/policy/exception/{requestId}/approve - Approve an exception request
|
|
exceptions.MapPost("/{requestId}/approve", ApproveRequestAsync)
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsApprove))
|
|
.WithName("ApproveExceptionRequest")
|
|
.WithDescription("Approve an exception request");
|
|
|
|
// POST /api/v1/policy/exception/{requestId}/reject - Reject an exception request
|
|
exceptions.MapPost("/{requestId}/reject", RejectRequestAsync)
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsApprove))
|
|
.WithName("RejectExceptionRequest")
|
|
.WithDescription("Reject an exception request with a reason");
|
|
|
|
// POST /api/v1/policy/exception/{requestId}/cancel - Cancel an exception request
|
|
exceptions.MapPost("/{requestId}/cancel", CancelRequestAsync)
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRequest))
|
|
.WithName("CancelExceptionRequest")
|
|
.WithDescription("Cancel an exception request (requestor only)");
|
|
|
|
// GET /api/v1/policy/exception/{requestId}/audit - Get audit trail for a request
|
|
exceptions.MapGet("/{requestId}/audit", GetAuditTrailAsync)
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
|
|
.WithName("GetExceptionApprovalAudit")
|
|
.WithDescription("Get the audit trail for an exception approval request");
|
|
|
|
// GET /api/v1/policy/exception/rules - Get approval rules for the tenant
|
|
exceptions.MapGet("/rules", GetApprovalRulesAsync)
|
|
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.ExceptionsRead))
|
|
.WithName("GetExceptionApprovalRules")
|
|
.WithDescription("Get exception approval rules for the tenant");
|
|
}
|
|
|
|
// ========================================================================
|
|
// Endpoint Handlers
|
|
// ========================================================================
|
|
|
|
private static async Task<IResult> CreateApprovalRequestAsync(
|
|
HttpContext httpContext,
|
|
CreateApprovalRequestDto request,
|
|
IExceptionApprovalRepository repository,
|
|
IExceptionApprovalRulesService rulesService,
|
|
ILogger<ExceptionApprovalRequestEntity> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Request body required",
|
|
Status = 400
|
|
});
|
|
}
|
|
|
|
var tenantId = GetTenantId(httpContext);
|
|
var requestorId = GetActorId(httpContext);
|
|
|
|
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(requestorId))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
// Generate request ID
|
|
var requestId = $"EAR-{DateTimeOffset.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
|
|
|
// Parse gate level
|
|
if (!Enum.TryParse<GateLevel>(request.GateLevel, ignoreCase: true, out var gateLevel))
|
|
{
|
|
gateLevel = GateLevel.G1; // Default to G1 if not specified
|
|
}
|
|
|
|
// Parse reason code
|
|
if (!Enum.TryParse<ExceptionReasonCode>(request.ReasonCode, ignoreCase: true, out var reasonCode))
|
|
{
|
|
reasonCode = ExceptionReasonCode.Other;
|
|
}
|
|
|
|
// Get requirements for validation
|
|
var requirements = await rulesService.GetRequirementsAsync(tenantId, gateLevel, cancellationToken);
|
|
|
|
// Validate TTL
|
|
var requestedTtl = request.RequestedTtlDays ?? 30;
|
|
if (requestedTtl > requirements.MaxTtlDays)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Invalid TTL",
|
|
Status = 400,
|
|
Detail = $"Requested TTL ({requestedTtl} days) exceeds maximum allowed ({requirements.MaxTtlDays} days) for gate level {gateLevel}."
|
|
});
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var entity = new ExceptionApprovalRequestEntity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
RequestId = requestId,
|
|
TenantId = tenantId,
|
|
ExceptionId = request.ExceptionId,
|
|
RequestorId = requestorId,
|
|
RequiredApproverIds = request.RequiredApproverIds?.ToArray() ?? [],
|
|
ApprovedByIds = [],
|
|
Status = ApprovalRequestStatus.Pending,
|
|
GateLevel = gateLevel,
|
|
Justification = request.Justification ?? string.Empty,
|
|
Rationale = request.Rationale,
|
|
ReasonCode = reasonCode,
|
|
EvidenceRefs = JsonSerializer.Serialize(request.EvidenceRefs ?? []),
|
|
CompensatingControls = JsonSerializer.Serialize(request.CompensatingControls ?? []),
|
|
TicketRef = request.TicketRef,
|
|
VulnerabilityId = request.VulnerabilityId,
|
|
PurlPattern = request.PurlPattern,
|
|
ArtifactDigest = request.ArtifactDigest,
|
|
ImagePattern = request.ImagePattern,
|
|
Environments = request.Environments?.ToArray() ?? [],
|
|
RequestedTtlDays = requestedTtl,
|
|
CreatedAt = now,
|
|
RequestExpiresAt = now.AddDays(7), // 7-day approval window
|
|
ExceptionExpiresAt = now.AddDays(requestedTtl),
|
|
Metadata = request.Metadata is not null ? JsonSerializer.Serialize(request.Metadata) : "{}",
|
|
Version = 1,
|
|
UpdatedAt = now
|
|
};
|
|
|
|
// Validate request
|
|
var validation = await rulesService.ValidateRequestAsync(entity, cancellationToken);
|
|
if (!validation.IsValid)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Validation failed",
|
|
Status = 400,
|
|
Detail = string.Join("; ", validation.Errors)
|
|
});
|
|
}
|
|
|
|
// Check if auto-approve is allowed
|
|
if (await rulesService.CanAutoApproveAsync(entity, cancellationToken))
|
|
{
|
|
entity = entity with
|
|
{
|
|
Status = ApprovalRequestStatus.Approved,
|
|
ResolvedAt = now
|
|
};
|
|
|
|
logger.LogInformation(
|
|
"Exception request {RequestId} auto-approved (gate level {GateLevel})",
|
|
requestId,
|
|
gateLevel);
|
|
}
|
|
|
|
// Create the request
|
|
var created = await repository.CreateRequestAsync(entity, cancellationToken);
|
|
|
|
// Record audit entry
|
|
await repository.RecordAuditAsync(new ExceptionApprovalAuditEntity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
RequestId = requestId,
|
|
TenantId = tenantId,
|
|
SequenceNumber = 1,
|
|
ActionType = "requested",
|
|
ActorId = requestorId,
|
|
OccurredAt = now,
|
|
PreviousStatus = null,
|
|
NewStatus = created.Status.ToString().ToLowerInvariant(),
|
|
Description = $"Exception approval request created for {request.VulnerabilityId ?? request.PurlPattern ?? "general exception"}",
|
|
Details = JsonSerializer.Serialize(new { gateLevel = gateLevel.ToString(), reasonCode = reasonCode.ToString() }),
|
|
ClientInfo = BuildClientInfo(httpContext)
|
|
}, cancellationToken);
|
|
|
|
logger.LogInformation(
|
|
"Exception approval request created: {RequestId}, GateLevel={GateLevel}, Requestor={Requestor}",
|
|
requestId,
|
|
gateLevel,
|
|
requestorId);
|
|
|
|
return Results.Created(
|
|
$"/api/v1/policy/exception/request/{requestId}",
|
|
MapToDto(created, validation.Warnings));
|
|
}
|
|
|
|
private static async Task<IResult> GetApprovalRequestAsync(
|
|
HttpContext httpContext,
|
|
string requestId,
|
|
IExceptionApprovalRepository repository,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(httpContext);
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var request = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
|
|
if (request is null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Request not found",
|
|
Status = 404,
|
|
Detail = $"Exception approval request '{requestId}' not found."
|
|
});
|
|
}
|
|
|
|
return Results.Ok(MapToDto(request));
|
|
}
|
|
|
|
private static async Task<IResult> ListApprovalRequestsAsync(
|
|
HttpContext httpContext,
|
|
IExceptionApprovalRepository repository,
|
|
[FromQuery] string? status,
|
|
[FromQuery] int limit = 100,
|
|
[FromQuery] int offset = 0,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var tenantId = GetTenantId(httpContext);
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
ApprovalRequestStatus? statusFilter = null;
|
|
if (!string.IsNullOrWhiteSpace(status) &&
|
|
Enum.TryParse<ApprovalRequestStatus>(status, ignoreCase: true, out var parsed))
|
|
{
|
|
statusFilter = parsed;
|
|
}
|
|
|
|
var requests = await repository.ListRequestsAsync(
|
|
tenantId,
|
|
statusFilter,
|
|
Math.Min(limit, 500),
|
|
Math.Max(offset, 0),
|
|
cancellationToken);
|
|
|
|
return Results.Ok(new ApprovalRequestListResponse
|
|
{
|
|
Items = requests.Select(r => MapToSummaryDto(r)).ToList(),
|
|
Limit = limit,
|
|
Offset = offset
|
|
});
|
|
}
|
|
|
|
private static async Task<IResult> ListPendingApprovalsAsync(
|
|
HttpContext httpContext,
|
|
IExceptionApprovalRepository repository,
|
|
[FromQuery] int limit = 100,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var tenantId = GetTenantId(httpContext);
|
|
var approverId = GetActorId(httpContext);
|
|
|
|
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(approverId))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var requests = await repository.ListPendingForApproverAsync(
|
|
tenantId,
|
|
approverId,
|
|
Math.Min(limit, 500),
|
|
cancellationToken);
|
|
|
|
return Results.Ok(new ApprovalRequestListResponse
|
|
{
|
|
Items = requests.Select(r => MapToSummaryDto(r)).ToList(),
|
|
Limit = limit,
|
|
Offset = 0
|
|
});
|
|
}
|
|
|
|
private static async Task<IResult> ApproveRequestAsync(
|
|
HttpContext httpContext,
|
|
string requestId,
|
|
ApproveRequestDto? request,
|
|
IExceptionApprovalRepository repository,
|
|
IExceptionApprovalRulesService rulesService,
|
|
ILogger<ExceptionApprovalRequestEntity> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(httpContext);
|
|
var approverId = GetActorId(httpContext);
|
|
var approverRoles = GetActorRoles(httpContext);
|
|
|
|
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(approverId))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var existing = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
|
|
if (existing is null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Request not found",
|
|
Status = 404,
|
|
Detail = $"Exception approval request '{requestId}' not found."
|
|
});
|
|
}
|
|
|
|
// Validate approval action
|
|
var validation = await rulesService.ValidateApprovalActionAsync(
|
|
existing,
|
|
approverId,
|
|
approverRoles,
|
|
cancellationToken);
|
|
|
|
if (!validation.IsValid)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Approval not allowed",
|
|
Status = 400,
|
|
Detail = string.Join("; ", validation.Errors)
|
|
});
|
|
}
|
|
|
|
var approved = await repository.ApproveAsync(
|
|
tenantId,
|
|
requestId,
|
|
approverId,
|
|
request?.Comment,
|
|
cancellationToken);
|
|
|
|
if (approved is null)
|
|
{
|
|
return Results.Problem(new ProblemDetails
|
|
{
|
|
Title = "Approval failed",
|
|
Status = 500,
|
|
Detail = "Failed to record approval. The request may have been modified."
|
|
});
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Exception request {RequestId} approved by {ApproverId}, CompletesWorkflow={Completes}",
|
|
requestId,
|
|
approverId,
|
|
validation.CompletesWorkflow);
|
|
|
|
return Results.Ok(MapToDto(approved));
|
|
}
|
|
|
|
private static async Task<IResult> RejectRequestAsync(
|
|
HttpContext httpContext,
|
|
string requestId,
|
|
RejectRequestDto request,
|
|
IExceptionApprovalRepository repository,
|
|
ILogger<ExceptionApprovalRequestEntity> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request is null || string.IsNullOrWhiteSpace(request.Reason))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Rejection reason required",
|
|
Status = 400,
|
|
Detail = "A reason is required when rejecting an exception request."
|
|
});
|
|
}
|
|
|
|
var tenantId = GetTenantId(httpContext);
|
|
var rejectorId = GetActorId(httpContext);
|
|
|
|
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(rejectorId))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var existing = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
|
|
if (existing is null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Request not found",
|
|
Status = 404,
|
|
Detail = $"Exception approval request '{requestId}' not found."
|
|
});
|
|
}
|
|
|
|
if (existing.Status != ApprovalRequestStatus.Pending &&
|
|
existing.Status != ApprovalRequestStatus.Partial)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Cannot reject",
|
|
Status = 400,
|
|
Detail = $"Request is in {existing.Status} status and cannot be rejected."
|
|
});
|
|
}
|
|
|
|
var rejected = await repository.RejectAsync(
|
|
tenantId,
|
|
requestId,
|
|
rejectorId,
|
|
request.Reason,
|
|
cancellationToken);
|
|
|
|
if (rejected is null)
|
|
{
|
|
return Results.Problem(new ProblemDetails
|
|
{
|
|
Title = "Rejection failed",
|
|
Status = 500,
|
|
Detail = "Failed to record rejection. The request may have been modified."
|
|
});
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Exception request {RequestId} rejected by {RejectorId}: {Reason}",
|
|
requestId,
|
|
rejectorId,
|
|
request.Reason);
|
|
|
|
return Results.Ok(MapToDto(rejected));
|
|
}
|
|
|
|
private static async Task<IResult> CancelRequestAsync(
|
|
HttpContext httpContext,
|
|
string requestId,
|
|
CancelRequestDto? request,
|
|
IExceptionApprovalRepository repository,
|
|
ILogger<ExceptionApprovalRequestEntity> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(httpContext);
|
|
var actorId = GetActorId(httpContext);
|
|
|
|
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(actorId))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var existing = await repository.GetRequestAsync(tenantId, requestId, cancellationToken);
|
|
if (existing is null)
|
|
{
|
|
return Results.NotFound(new ProblemDetails
|
|
{
|
|
Title = "Request not found",
|
|
Status = 404,
|
|
Detail = $"Exception approval request '{requestId}' not found."
|
|
});
|
|
}
|
|
|
|
// Only requestor can cancel
|
|
if (!string.Equals(existing.RequestorId, actorId, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
|
|
var cancelled = await repository.CancelRequestAsync(
|
|
tenantId,
|
|
requestId,
|
|
actorId,
|
|
request?.Reason,
|
|
cancellationToken);
|
|
|
|
if (!cancelled)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "Cannot cancel",
|
|
Status = 400,
|
|
Detail = "Request cannot be cancelled. It may already be resolved."
|
|
});
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Exception request {RequestId} cancelled by {ActorId}",
|
|
requestId,
|
|
actorId);
|
|
|
|
return Results.NoContent();
|
|
}
|
|
|
|
private static async Task<IResult> GetAuditTrailAsync(
|
|
HttpContext httpContext,
|
|
string requestId,
|
|
IExceptionApprovalRepository repository,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(httpContext);
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var audit = await repository.GetAuditTrailAsync(tenantId, requestId, cancellationToken);
|
|
|
|
return Results.Ok(new AuditTrailResponse
|
|
{
|
|
RequestId = requestId,
|
|
Entries = audit.Select(MapToAuditDto).ToList()
|
|
});
|
|
}
|
|
|
|
private static async Task<IResult> GetApprovalRulesAsync(
|
|
HttpContext httpContext,
|
|
IExceptionApprovalRepository repository,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(httpContext);
|
|
if (string.IsNullOrWhiteSpace(tenantId))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var rules = await repository.ListApprovalRulesAsync(tenantId, cancellationToken);
|
|
|
|
return Results.Ok(new ApprovalRulesResponse
|
|
{
|
|
Rules = rules.Select(MapToRuleDto).ToList()
|
|
});
|
|
}
|
|
|
|
// ========================================================================
|
|
// Helper Methods
|
|
// ========================================================================
|
|
|
|
private static string? GetTenantId(HttpContext httpContext)
|
|
{
|
|
return httpContext.User.Claims.FirstOrDefault(c => c.Type == "tenant_id")?.Value
|
|
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
|
}
|
|
|
|
private static string? GetActorId(HttpContext httpContext)
|
|
{
|
|
return httpContext.User.Claims.FirstOrDefault(c => c.Type == "sub")?.Value
|
|
?? httpContext.User.Identity?.Name;
|
|
}
|
|
|
|
private static HashSet<string> GetActorRoles(HttpContext httpContext)
|
|
{
|
|
return httpContext.User.Claims
|
|
.Where(c => c.Type == "role" || c.Type == "roles")
|
|
.Select(c => c.Value)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string BuildClientInfo(HttpContext httpContext)
|
|
{
|
|
var info = new Dictionary<string, string?>
|
|
{
|
|
["ip"] = httpContext.Connection.RemoteIpAddress?.ToString(),
|
|
["user_agent"] = httpContext.Request.Headers.UserAgent.FirstOrDefault(),
|
|
["correlation_id"] = httpContext.Request.Headers["X-Correlation-Id"].FirstOrDefault()
|
|
};
|
|
return JsonSerializer.Serialize(info);
|
|
}
|
|
|
|
private static ApprovalRequestDto MapToDto(
|
|
ExceptionApprovalRequestEntity entity,
|
|
IReadOnlyList<string>? warnings = null)
|
|
{
|
|
return new ApprovalRequestDto
|
|
{
|
|
RequestId = entity.RequestId,
|
|
Id = entity.Id,
|
|
TenantId = entity.TenantId,
|
|
ExceptionId = entity.ExceptionId,
|
|
RequestorId = entity.RequestorId,
|
|
RequiredApproverIds = entity.RequiredApproverIds,
|
|
ApprovedByIds = entity.ApprovedByIds,
|
|
RejectedById = entity.RejectedById,
|
|
Status = entity.Status.ToString(),
|
|
GateLevel = entity.GateLevel.ToString(),
|
|
Justification = entity.Justification,
|
|
Rationale = entity.Rationale,
|
|
ReasonCode = entity.ReasonCode.ToString(),
|
|
EvidenceRefs = ParseJsonArray(entity.EvidenceRefs),
|
|
CompensatingControls = ParseJsonArray(entity.CompensatingControls),
|
|
TicketRef = entity.TicketRef,
|
|
VulnerabilityId = entity.VulnerabilityId,
|
|
PurlPattern = entity.PurlPattern,
|
|
ArtifactDigest = entity.ArtifactDigest,
|
|
ImagePattern = entity.ImagePattern,
|
|
Environments = entity.Environments,
|
|
RequestedTtlDays = entity.RequestedTtlDays,
|
|
CreatedAt = entity.CreatedAt,
|
|
RequestExpiresAt = entity.RequestExpiresAt,
|
|
ExceptionExpiresAt = entity.ExceptionExpiresAt,
|
|
ResolvedAt = entity.ResolvedAt,
|
|
RejectionReason = entity.RejectionReason,
|
|
Version = entity.Version,
|
|
UpdatedAt = entity.UpdatedAt,
|
|
Warnings = warnings ?? []
|
|
};
|
|
}
|
|
|
|
private static ApprovalRequestSummaryDto MapToSummaryDto(ExceptionApprovalRequestEntity entity)
|
|
{
|
|
return new ApprovalRequestSummaryDto
|
|
{
|
|
RequestId = entity.RequestId,
|
|
Status = entity.Status.ToString(),
|
|
GateLevel = entity.GateLevel.ToString(),
|
|
RequestorId = entity.RequestorId,
|
|
VulnerabilityId = entity.VulnerabilityId,
|
|
PurlPattern = entity.PurlPattern,
|
|
ReasonCode = entity.ReasonCode.ToString(),
|
|
CreatedAt = entity.CreatedAt,
|
|
RequestExpiresAt = entity.RequestExpiresAt,
|
|
ApprovedCount = entity.ApprovedByIds.Length,
|
|
RequiredCount = entity.RequiredApproverIds.Length
|
|
};
|
|
}
|
|
|
|
private static AuditEntryDto MapToAuditDto(ExceptionApprovalAuditEntity entity)
|
|
{
|
|
return new AuditEntryDto
|
|
{
|
|
Id = entity.Id,
|
|
SequenceNumber = entity.SequenceNumber,
|
|
ActionType = entity.ActionType,
|
|
ActorId = entity.ActorId,
|
|
OccurredAt = entity.OccurredAt,
|
|
PreviousStatus = entity.PreviousStatus,
|
|
NewStatus = entity.NewStatus,
|
|
Description = entity.Description
|
|
};
|
|
}
|
|
|
|
private static ApprovalRuleDto MapToRuleDto(ExceptionApprovalRuleEntity entity)
|
|
{
|
|
return new ApprovalRuleDto
|
|
{
|
|
Id = entity.Id,
|
|
Name = entity.Name,
|
|
Description = entity.Description,
|
|
GateLevel = entity.GateLevel.ToString(),
|
|
MinApprovers = entity.MinApprovers,
|
|
RequiredRoles = entity.RequiredRoles,
|
|
MaxTtlDays = entity.MaxTtlDays,
|
|
AllowSelfApproval = entity.AllowSelfApproval,
|
|
RequireEvidence = entity.RequireEvidence,
|
|
RequireCompensatingControls = entity.RequireCompensatingControls,
|
|
MinRationaleLength = entity.MinRationaleLength,
|
|
Enabled = entity.Enabled
|
|
};
|
|
}
|
|
|
|
private static List<string> ParseJsonArray(string json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json) || json == "[]")
|
|
return [];
|
|
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
|
|
}
|
|
catch
|
|
{
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// DTO Models
|
|
// ============================================================================
|
|
|
|
/// <summary>
|
|
/// Request to create an exception approval request.
|
|
/// </summary>
|
|
public sealed record CreateApprovalRequestDto
|
|
{
|
|
public string? ExceptionId { get; init; }
|
|
public required string Justification { get; init; }
|
|
public string? Rationale { get; init; }
|
|
public string? GateLevel { get; init; }
|
|
public string? ReasonCode { get; init; }
|
|
public List<string>? EvidenceRefs { get; init; }
|
|
public List<string>? CompensatingControls { get; init; }
|
|
public string? TicketRef { get; init; }
|
|
public string? VulnerabilityId { get; init; }
|
|
public string? PurlPattern { get; init; }
|
|
public string? ArtifactDigest { get; init; }
|
|
public string? ImagePattern { get; init; }
|
|
public List<string>? Environments { get; init; }
|
|
public List<string>? RequiredApproverIds { get; init; }
|
|
public int? RequestedTtlDays { get; init; }
|
|
public Dictionary<string, string>? Metadata { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request to approve an exception.
|
|
/// </summary>
|
|
public sealed record ApproveRequestDto
|
|
{
|
|
public string? Comment { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request to reject an exception.
|
|
/// </summary>
|
|
public sealed record RejectRequestDto
|
|
{
|
|
public required string Reason { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request to cancel an exception request.
|
|
/// </summary>
|
|
public sealed record CancelRequestDto
|
|
{
|
|
public string? Reason { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Full approval request response.
|
|
/// </summary>
|
|
public sealed record ApprovalRequestDto
|
|
{
|
|
public required string RequestId { get; init; }
|
|
public Guid Id { get; init; }
|
|
public required string TenantId { get; init; }
|
|
public string? ExceptionId { get; init; }
|
|
public required string RequestorId { get; init; }
|
|
public string[] RequiredApproverIds { get; init; } = [];
|
|
public string[] ApprovedByIds { get; init; } = [];
|
|
public string? RejectedById { get; init; }
|
|
public required string Status { get; init; }
|
|
public required string GateLevel { get; init; }
|
|
public required string Justification { get; init; }
|
|
public string? Rationale { get; init; }
|
|
public required string ReasonCode { get; init; }
|
|
public List<string> EvidenceRefs { get; init; } = [];
|
|
public List<string> CompensatingControls { get; init; } = [];
|
|
public string? TicketRef { get; init; }
|
|
public string? VulnerabilityId { get; init; }
|
|
public string? PurlPattern { get; init; }
|
|
public string? ArtifactDigest { get; init; }
|
|
public string? ImagePattern { get; init; }
|
|
public string[] Environments { get; init; } = [];
|
|
public int RequestedTtlDays { get; init; }
|
|
public DateTimeOffset CreatedAt { get; init; }
|
|
public DateTimeOffset RequestExpiresAt { get; init; }
|
|
public DateTimeOffset? ExceptionExpiresAt { get; init; }
|
|
public DateTimeOffset? ResolvedAt { get; init; }
|
|
public string? RejectionReason { get; init; }
|
|
public int Version { get; init; }
|
|
public DateTimeOffset UpdatedAt { get; init; }
|
|
public IReadOnlyList<string> Warnings { get; init; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Summary approval request for listings.
|
|
/// </summary>
|
|
public sealed record ApprovalRequestSummaryDto
|
|
{
|
|
public required string RequestId { get; init; }
|
|
public required string Status { get; init; }
|
|
public required string GateLevel { get; init; }
|
|
public required string RequestorId { get; init; }
|
|
public string? VulnerabilityId { get; init; }
|
|
public string? PurlPattern { get; init; }
|
|
public required string ReasonCode { get; init; }
|
|
public DateTimeOffset CreatedAt { get; init; }
|
|
public DateTimeOffset RequestExpiresAt { get; init; }
|
|
public int ApprovedCount { get; init; }
|
|
public int RequiredCount { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Approval request list response.
|
|
/// </summary>
|
|
public sealed record ApprovalRequestListResponse
|
|
{
|
|
public required IReadOnlyList<ApprovalRequestSummaryDto> Items { get; init; }
|
|
public int Limit { get; init; }
|
|
public int Offset { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Audit entry response.
|
|
/// </summary>
|
|
public sealed record AuditEntryDto
|
|
{
|
|
public Guid Id { get; init; }
|
|
public int SequenceNumber { get; init; }
|
|
public required string ActionType { get; init; }
|
|
public required string ActorId { get; init; }
|
|
public DateTimeOffset OccurredAt { get; init; }
|
|
public string? PreviousStatus { get; init; }
|
|
public required string NewStatus { get; init; }
|
|
public string? Description { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Audit trail response.
|
|
/// </summary>
|
|
public sealed record AuditTrailResponse
|
|
{
|
|
public required string RequestId { get; init; }
|
|
public required IReadOnlyList<AuditEntryDto> Entries { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Approval rule response.
|
|
/// </summary>
|
|
public sealed record ApprovalRuleDto
|
|
{
|
|
public Guid Id { get; init; }
|
|
public required string Name { get; init; }
|
|
public string? Description { get; init; }
|
|
public required string GateLevel { get; init; }
|
|
public int MinApprovers { get; init; }
|
|
public string[] RequiredRoles { get; init; } = [];
|
|
public int MaxTtlDays { get; init; }
|
|
public bool AllowSelfApproval { get; init; }
|
|
public bool RequireEvidence { get; init; }
|
|
public bool RequireCompensatingControls { get; init; }
|
|
public int MinRationaleLength { get; init; }
|
|
public bool Enabled { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Approval rules list response.
|
|
/// </summary>
|
|
public sealed record ApprovalRulesResponse
|
|
{
|
|
public required IReadOnlyList<ApprovalRuleDto> Rules { get; init; }
|
|
}
|