Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Gateway/Endpoints/ExceptionApprovalEndpoints.cs

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; }
}