// 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; /// /// Exception approval workflow API endpoints. /// public static class ExceptionApprovalEndpoints { /// /// Maps exception approval endpoints to the application. /// 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 CreateApprovalRequestAsync( HttpContext httpContext, CreateApprovalRequestDto request, IExceptionApprovalRepository repository, IExceptionApprovalRulesService rulesService, ILogger 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(request.GateLevel, ignoreCase: true, out var gateLevel)) { gateLevel = GateLevel.G1; // Default to G1 if not specified } // Parse reason code if (!Enum.TryParse(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 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 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(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 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 ApproveRequestAsync( HttpContext httpContext, string requestId, ApproveRequestDto? request, IExceptionApprovalRepository repository, IExceptionApprovalRulesService rulesService, ILogger 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 RejectRequestAsync( HttpContext httpContext, string requestId, RejectRequestDto request, IExceptionApprovalRepository repository, ILogger 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 CancelRequestAsync( HttpContext httpContext, string requestId, CancelRequestDto? request, IExceptionApprovalRepository repository, ILogger 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 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 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 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 { ["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? 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 ParseJsonArray(string json) { if (string.IsNullOrWhiteSpace(json) || json == "[]") return []; try { return JsonSerializer.Deserialize>(json) ?? []; } catch { return []; } } } // ============================================================================ // DTO Models // ============================================================================ /// /// Request to create an exception approval request. /// 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? EvidenceRefs { get; init; } public List? 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? Environments { get; init; } public List? RequiredApproverIds { get; init; } public int? RequestedTtlDays { get; init; } public Dictionary? Metadata { get; init; } } /// /// Request to approve an exception. /// public sealed record ApproveRequestDto { public string? Comment { get; init; } } /// /// Request to reject an exception. /// public sealed record RejectRequestDto { public required string Reason { get; init; } } /// /// Request to cancel an exception request. /// public sealed record CancelRequestDto { public string? Reason { get; init; } } /// /// Full approval request response. /// 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 EvidenceRefs { get; init; } = []; public List 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 Warnings { get; init; } = []; } /// /// Summary approval request for listings. /// 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; } } /// /// Approval request list response. /// public sealed record ApprovalRequestListResponse { public required IReadOnlyList Items { get; init; } public int Limit { get; init; } public int Offset { get; init; } } /// /// Audit entry response. /// 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; } } /// /// Audit trail response. /// public sealed record AuditTrailResponse { public required string RequestId { get; init; } public required IReadOnlyList Entries { get; init; } } /// /// Approval rule response. /// 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; } } /// /// Approval rules list response. /// public sealed record ApprovalRulesResponse { public required IReadOnlyList Rules { get; init; } }