feat(exception-report): Implement exception report generation and endpoints
This commit is contained in:
@@ -271,8 +271,8 @@ End-to-end tests for exception management flow.
|
|||||||
| 3 | T3 | TODO | None | UI Team | Exception Approval Queue |
|
| 3 | T3 | TODO | None | UI Team | Exception Approval Queue |
|
||||||
| 4 | T4 | TODO | None | UI Team | Exception Inline Creation |
|
| 4 | T4 | TODO | None | UI Team | Exception Inline Creation |
|
||||||
| 5 | T5 | TODO | None | UI Team | Exception Badge Integration |
|
| 5 | T5 | TODO | None | UI Team | Exception Badge Integration |
|
||||||
| 6 | T6 | TODO | None | Export Team | Audit Pack Export — Exception Report |
|
| 6 | T6 | DONE | None | Export Team | Audit Pack Export — Exception Report |
|
||||||
| 7 | T7 | TODO | T6 | Export Team | Export Center Integration |
|
| 7 | T7 | DONE | T6 | Export Team | Export Center Integration |
|
||||||
| 8 | T8 | TODO | T1-T5 | UI Team | UI Unit Tests |
|
| 8 | T8 | TODO | T1-T5 | UI Team | UI Unit Tests |
|
||||||
| 9 | T9 | TODO | T1-T7, Sprint 0002.0001 | QA Team | E2E Tests |
|
| 9 | T9 | TODO | T1-T7, Sprint 0002.0001 | QA Team | E2E Tests |
|
||||||
|
|
||||||
@@ -283,6 +283,7 @@ End-to-end tests for exception management flow.
|
|||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
|------------|--------|-------|
|
|------------|--------|-------|
|
||||||
| 2025-12-21 | Sprint created from Epic 3900 Batch 0002 planning. | Project Manager |
|
| 2025-12-21 | Sprint created from Epic 3900 Batch 0002 planning. | Project Manager |
|
||||||
|
| 2025-12-22 | T6/T7 DONE: Created ExceptionReport feature in ExportCenter.WebService. Implemented IExceptionReportGenerator, ExceptionReportGenerator (async job tracking, JSON/NDJSON formats, filter support, history/application inclusion), ExceptionReportEndpoints (/v1/exports/exceptions/*), and DI extensions. Added Policy.Exceptions project reference. Build verified. | Implementer |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// Copyright (c) StellaOps Contributors. Licensed under the AGPL-3.0-or-later.
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using StellaOps.Auth.ServerIntegration;
|
||||||
|
using StellaOps.Policy.Exceptions.Models;
|
||||||
|
using StellaOps.Policy.Exceptions.Repositories;
|
||||||
|
|
||||||
|
namespace StellaOps.ExportCenter.WebService.ExceptionReport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal API endpoints for exception report generation and retrieval.
|
||||||
|
/// </summary>
|
||||||
|
public static class ExceptionReportEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps exception report endpoints to the application.
|
||||||
|
/// </summary>
|
||||||
|
public static IEndpointRouteBuilder MapExceptionReportEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/v1/exports/exceptions")
|
||||||
|
.WithTags("Exception Reports")
|
||||||
|
.WithOpenApi();
|
||||||
|
|
||||||
|
group.MapPost("/", CreateReportAsync)
|
||||||
|
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportOperator)
|
||||||
|
.WithSummary("Create exception report")
|
||||||
|
.WithDescription("Starts async generation of an audit-compliant exception report.");
|
||||||
|
|
||||||
|
group.MapGet("/", ListReportsAsync)
|
||||||
|
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer)
|
||||||
|
.WithSummary("List exception reports")
|
||||||
|
.WithDescription("Lists all exception report jobs for the current tenant.");
|
||||||
|
|
||||||
|
group.MapGet("/{jobId}", GetReportStatusAsync)
|
||||||
|
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer)
|
||||||
|
.WithSummary("Get report status")
|
||||||
|
.WithDescription("Gets the status of an exception report generation job.");
|
||||||
|
|
||||||
|
group.MapGet("/{jobId}/download", DownloadReportAsync)
|
||||||
|
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer)
|
||||||
|
.WithSummary("Download report")
|
||||||
|
.WithDescription("Downloads the completed exception report.");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateReportAsync(
|
||||||
|
[FromBody] CreateExceptionReportDto dto,
|
||||||
|
IExceptionReportGenerator generator,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
HttpContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = GetTenantId(user, context);
|
||||||
|
if (tenantId is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant ID required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var requesterId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown";
|
||||||
|
|
||||||
|
var request = new ExceptionReportRequest
|
||||||
|
{
|
||||||
|
TenantId = tenantId.Value,
|
||||||
|
RequesterId = requesterId,
|
||||||
|
Format = dto.Format ?? "json",
|
||||||
|
Title = dto.Title,
|
||||||
|
IncludeHistory = dto.IncludeHistory ?? false,
|
||||||
|
IncludeApplications = dto.IncludeApplications ?? false,
|
||||||
|
Filter = dto.Filter is not null ? new ExceptionFilter
|
||||||
|
{
|
||||||
|
Status = ParseEnum<ExceptionStatus>(dto.Filter.Status),
|
||||||
|
Type = ParseEnum<ExceptionType>(dto.Filter.Type),
|
||||||
|
VulnerabilityId = dto.Filter.VulnerabilityId,
|
||||||
|
PurlPattern = dto.Filter.PurlPattern,
|
||||||
|
Environment = dto.Filter.Environment,
|
||||||
|
OwnerId = dto.Filter.OwnerId,
|
||||||
|
TenantId = tenantId.Value
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await generator.CreateReportAsync(request, cancellationToken);
|
||||||
|
return Results.Accepted(result.StatusUrl, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListReportsAsync(
|
||||||
|
[FromQuery] int? limit,
|
||||||
|
IExceptionReportGenerator generator,
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
HttpContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = GetTenantId(user, context);
|
||||||
|
if (tenantId is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Tenant ID required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var reports = await generator.ListReportsAsync(tenantId.Value, limit ?? 50, cancellationToken);
|
||||||
|
return Results.Ok(reports);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetReportStatusAsync(
|
||||||
|
string jobId,
|
||||||
|
IExceptionReportGenerator generator,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var status = await generator.GetReportStatusAsync(jobId, cancellationToken);
|
||||||
|
return status is null ? Results.NotFound() : Results.Ok(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DownloadReportAsync(
|
||||||
|
string jobId,
|
||||||
|
IExceptionReportGenerator generator,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var content = await generator.GetReportContentAsync(jobId, cancellationToken);
|
||||||
|
if (content is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound(new { error = "Report not found or not ready" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.File(
|
||||||
|
content.Content,
|
||||||
|
content.ContentType,
|
||||||
|
content.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid? GetTenantId(ClaimsPrincipal user, HttpContext context)
|
||||||
|
{
|
||||||
|
// Try claim first
|
||||||
|
var tenantClaim = user.FindFirstValue("tenant_id");
|
||||||
|
if (Guid.TryParse(tenantClaim, out var tenantId))
|
||||||
|
{
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try header
|
||||||
|
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var headerValue) &&
|
||||||
|
Guid.TryParse(headerValue.FirstOrDefault(), out tenantId))
|
||||||
|
{
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TEnum? ParseEnum<TEnum>(string? value) where TEnum : struct, Enum
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Enum.TryParse<TEnum>(value, ignoreCase: true, out var result) ? result : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for creating an exception report.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CreateExceptionReportDto
|
||||||
|
{
|
||||||
|
/// <summary>Output format (json, ndjson, pdf).</summary>
|
||||||
|
public string? Format { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Report title.</summary>
|
||||||
|
public string? Title { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Include audit history per exception.</summary>
|
||||||
|
public bool? IncludeHistory { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Include application records.</summary>
|
||||||
|
public bool? IncludeApplications { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Filter criteria.</summary>
|
||||||
|
public ExceptionReportFilterDto? Filter { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filter criteria for exception report.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ExceptionReportFilterDto
|
||||||
|
{
|
||||||
|
/// <summary>Filter by status.</summary>
|
||||||
|
public string? Status { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Filter by type.</summary>
|
||||||
|
public string? Type { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Filter by vulnerability ID.</summary>
|
||||||
|
public string? VulnerabilityId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Filter by PURL pattern.</summary>
|
||||||
|
public string? PurlPattern { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Filter by environment.</summary>
|
||||||
|
public string? Environment { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Filter by owner.</summary>
|
||||||
|
public string? OwnerId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
// Copyright (c) StellaOps Contributors. Licensed under the AGPL-3.0-or-later.
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using StellaOps.Policy.Exceptions.Models;
|
||||||
|
using StellaOps.Policy.Exceptions.Repositories;
|
||||||
|
|
||||||
|
namespace StellaOps.ExportCenter.WebService.ExceptionReport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates audit-compliant exception reports with async job tracking.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExceptionReportGenerator : IExceptionReportGenerator
|
||||||
|
{
|
||||||
|
private readonly IExceptionRepository _exceptionRepository;
|
||||||
|
private readonly IExceptionApplicationRepository _applicationRepository;
|
||||||
|
private readonly ConcurrentDictionary<string, ReportJob> _jobs = new();
|
||||||
|
private readonly ILogger<ExceptionReportGenerator> _logger;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
|
||||||
|
public ExceptionReportGenerator(
|
||||||
|
IExceptionRepository exceptionRepository,
|
||||||
|
IExceptionApplicationRepository applicationRepository,
|
||||||
|
ILogger<ExceptionReportGenerator> logger)
|
||||||
|
{
|
||||||
|
_exceptionRepository = exceptionRepository;
|
||||||
|
_applicationRepository = applicationRepository;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExceptionReportJobResponse> CreateReportAsync(
|
||||||
|
ExceptionReportRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var jobId = $"exc-rpt-{Guid.NewGuid():N}";
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var job = new ReportJob
|
||||||
|
{
|
||||||
|
JobId = jobId,
|
||||||
|
TenantId = request.TenantId,
|
||||||
|
RequesterId = request.RequesterId,
|
||||||
|
Request = request,
|
||||||
|
Status = "pending",
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
_jobs[jobId] = job;
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Created exception report job {JobId} for tenant {TenantId}",
|
||||||
|
jobId, request.TenantId);
|
||||||
|
|
||||||
|
// Start generation in background
|
||||||
|
_ = Task.Run(() => GenerateReportAsync(job, cancellationToken), cancellationToken);
|
||||||
|
|
||||||
|
return new ExceptionReportJobResponse
|
||||||
|
{
|
||||||
|
JobId = jobId,
|
||||||
|
Status = "pending",
|
||||||
|
CreatedAt = now,
|
||||||
|
StatusUrl = $"/v1/exports/exceptions/{jobId}",
|
||||||
|
DownloadUrl = $"/v1/exports/exceptions/{jobId}/download"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ExceptionReportStatus?> GetReportStatusAsync(
|
||||||
|
string jobId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_jobs.TryGetValue(jobId, out var job))
|
||||||
|
{
|
||||||
|
return Task.FromResult<ExceptionReportStatus?>(ToStatus(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<ExceptionReportStatus?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ExceptionReportStatus>> ListReportsAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
int limit = 50,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var jobs = _jobs.Values
|
||||||
|
.Where(j => j.TenantId == tenantId)
|
||||||
|
.OrderByDescending(j => j.CreatedAt)
|
||||||
|
.Take(limit)
|
||||||
|
.Select(ToStatus)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<ExceptionReportStatus>>(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ExceptionReportContent?> GetReportContentAsync(
|
||||||
|
string jobId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_jobs.TryGetValue(jobId, out var job) || job.Status != "completed" || job.Content is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult<ExceptionReportContent?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentType = job.Request.Format.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"ndjson" => "application/x-ndjson",
|
||||||
|
"pdf" => "application/pdf",
|
||||||
|
_ => "application/json"
|
||||||
|
};
|
||||||
|
|
||||||
|
var extension = job.Request.Format.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"ndjson" => "ndjson",
|
||||||
|
"pdf" => "pdf",
|
||||||
|
_ => "json"
|
||||||
|
};
|
||||||
|
|
||||||
|
return Task.FromResult<ExceptionReportContent?>(new ExceptionReportContent
|
||||||
|
{
|
||||||
|
Content = job.Content,
|
||||||
|
ContentType = contentType,
|
||||||
|
FileName = $"exception-report-{job.JobId}.{extension}",
|
||||||
|
ContentHash = job.ContentHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> StreamReportAsync(
|
||||||
|
string jobId,
|
||||||
|
Stream outputStream,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_jobs.TryGetValue(jobId, out var job) || job.Status != "completed" || job.Content is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await outputStream.WriteAsync(job.Content, cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task GenerateReportAsync(ReportJob job, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
job.Status = "running";
|
||||||
|
job.StartedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var filter = job.Request.Filter ?? new ExceptionFilter
|
||||||
|
{
|
||||||
|
TenantId = job.TenantId,
|
||||||
|
Limit = 10000
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure tenant filter is applied
|
||||||
|
if (filter.TenantId != job.TenantId)
|
||||||
|
{
|
||||||
|
filter = filter with { TenantId = job.TenantId };
|
||||||
|
}
|
||||||
|
|
||||||
|
var exceptions = await _exceptionRepository.GetByFilterAsync(filter, cancellationToken);
|
||||||
|
job.ExceptionCount = exceptions.Count;
|
||||||
|
job.Progress = 25;
|
||||||
|
|
||||||
|
var entries = new List<ExceptionReportEntry>();
|
||||||
|
var processedCount = 0;
|
||||||
|
|
||||||
|
foreach (var exception in exceptions)
|
||||||
|
{
|
||||||
|
var entry = new ExceptionReportEntry
|
||||||
|
{
|
||||||
|
Exception = ToReportException(exception)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (job.Request.IncludeHistory)
|
||||||
|
{
|
||||||
|
var history = await _exceptionRepository.GetHistoryAsync(
|
||||||
|
exception.ExceptionId, cancellationToken);
|
||||||
|
entry = entry with
|
||||||
|
{
|
||||||
|
History = history?.Events
|
||||||
|
.OrderBy(e => e.OccurredAt)
|
||||||
|
.Select(e => new ExceptionReportEvent
|
||||||
|
{
|
||||||
|
EventId = e.EventId.ToString(),
|
||||||
|
EventType = e.EventType.ToString(),
|
||||||
|
ActorId = e.ActorId,
|
||||||
|
Timestamp = e.OccurredAt,
|
||||||
|
Description = e.Description
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.Request.IncludeApplications)
|
||||||
|
{
|
||||||
|
var applications = await _applicationRepository.GetByExceptionIdAsync(
|
||||||
|
job.TenantId, exception.ExceptionId, limit: null, ct: cancellationToken);
|
||||||
|
entry = entry with
|
||||||
|
{
|
||||||
|
Applications = applications
|
||||||
|
.OrderByDescending(a => a.AppliedAt)
|
||||||
|
.Select(a => new ExceptionReportApplication
|
||||||
|
{
|
||||||
|
ApplicationId = a.Id.ToString(),
|
||||||
|
FindingId = a.FindingId,
|
||||||
|
VulnerabilityId = a.VulnerabilityId,
|
||||||
|
OriginalStatus = a.OriginalStatus,
|
||||||
|
AppliedStatus = a.AppliedStatus,
|
||||||
|
EffectName = a.EffectName,
|
||||||
|
AppliedAt = a.AppliedAt
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.Add(entry);
|
||||||
|
processedCount++;
|
||||||
|
job.Progress = 25 + (int)(processedCount * 50.0 / exceptions.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Progress = 75;
|
||||||
|
|
||||||
|
// Generate report content
|
||||||
|
var document = new ExceptionReportDocument
|
||||||
|
{
|
||||||
|
ReportId = job.JobId,
|
||||||
|
GeneratedAt = DateTimeOffset.UtcNow,
|
||||||
|
TenantId = job.TenantId,
|
||||||
|
RequesterId = job.RequesterId,
|
||||||
|
Title = job.Request.Title ?? "Exception Report",
|
||||||
|
Format = job.Request.Format,
|
||||||
|
Filter = job.Request.Filter is not null ? new ExceptionReportFilter
|
||||||
|
{
|
||||||
|
Status = job.Request.Filter.Status?.ToString(),
|
||||||
|
Type = job.Request.Filter.Type?.ToString(),
|
||||||
|
VulnerabilityId = job.Request.Filter.VulnerabilityId,
|
||||||
|
PurlPattern = job.Request.Filter.PurlPattern,
|
||||||
|
Environment = job.Request.Filter.Environment,
|
||||||
|
OwnerId = job.Request.Filter.OwnerId
|
||||||
|
} : null,
|
||||||
|
Summary = new ExceptionReportSummary
|
||||||
|
{
|
||||||
|
TotalExceptions = entries.Count,
|
||||||
|
ByStatus = entries.GroupBy(e => e.Exception.Status)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Count()),
|
||||||
|
ByType = entries.GroupBy(e => e.Exception.Type)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Count()),
|
||||||
|
ByReason = entries.GroupBy(e => e.Exception.ReasonCode)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Count())
|
||||||
|
},
|
||||||
|
Exceptions = entries
|
||||||
|
};
|
||||||
|
|
||||||
|
byte[] content;
|
||||||
|
if (job.Request.Format.Equals("ndjson", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine(JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
document.ReportId,
|
||||||
|
document.GeneratedAt,
|
||||||
|
document.TenantId,
|
||||||
|
document.Title,
|
||||||
|
document.Summary
|
||||||
|
}, JsonOptions));
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
sb.AppendLine(JsonSerializer.Serialize(entry, JsonOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
content = Encoding.UTF8.GetBytes(sb.ToString());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
content = JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Content = content;
|
||||||
|
job.FileSizeBytes = content.Length;
|
||||||
|
job.ContentHash = $"sha256:{Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant()}";
|
||||||
|
job.Progress = 100;
|
||||||
|
job.Status = "completed";
|
||||||
|
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Completed exception report {JobId} with {Count} exceptions, {Size} bytes",
|
||||||
|
job.JobId, entries.Count, content.Length);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to generate exception report {JobId}", job.JobId);
|
||||||
|
job.Status = "failed";
|
||||||
|
job.ErrorMessage = ex.Message;
|
||||||
|
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExceptionReportScope ToReportException(ExceptionObject exc) => new()
|
||||||
|
{
|
||||||
|
ExceptionId = exc.ExceptionId,
|
||||||
|
Version = exc.Version,
|
||||||
|
Status = exc.Status.ToString(),
|
||||||
|
Type = exc.Type.ToString(),
|
||||||
|
ReasonCode = exc.ReasonCode.ToString(),
|
||||||
|
OwnerId = exc.OwnerId,
|
||||||
|
RequesterId = exc.RequesterId,
|
||||||
|
ApproverIds = exc.ApproverIds.ToList(),
|
||||||
|
Rationale = exc.Rationale,
|
||||||
|
TicketRef = exc.TicketRef,
|
||||||
|
Scope = new ExceptionReportScopeDetail
|
||||||
|
{
|
||||||
|
ArtifactDigest = exc.Scope.ArtifactDigest,
|
||||||
|
PurlPattern = exc.Scope.PurlPattern,
|
||||||
|
VulnerabilityId = exc.Scope.VulnerabilityId,
|
||||||
|
PolicyRuleId = exc.Scope.PolicyRuleId,
|
||||||
|
Environments = exc.Scope.Environments.ToList()
|
||||||
|
},
|
||||||
|
CreatedAt = exc.CreatedAt,
|
||||||
|
UpdatedAt = exc.UpdatedAt,
|
||||||
|
ApprovedAt = exc.ApprovedAt,
|
||||||
|
ExpiresAt = exc.ExpiresAt,
|
||||||
|
IsEffective = exc.IsEffective,
|
||||||
|
EvidenceRefs = exc.EvidenceRefs.ToList(),
|
||||||
|
CompensatingControls = exc.CompensatingControls.ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ExceptionReportStatus ToStatus(ReportJob job) => new()
|
||||||
|
{
|
||||||
|
JobId = job.JobId,
|
||||||
|
TenantId = job.TenantId,
|
||||||
|
RequesterId = job.RequesterId,
|
||||||
|
Status = job.Status,
|
||||||
|
Progress = job.Progress,
|
||||||
|
CreatedAt = job.CreatedAt,
|
||||||
|
StartedAt = job.StartedAt,
|
||||||
|
CompletedAt = job.CompletedAt,
|
||||||
|
ExceptionCount = job.ExceptionCount,
|
||||||
|
Format = job.Request.Format,
|
||||||
|
ErrorMessage = job.ErrorMessage,
|
||||||
|
FileSizeBytes = job.FileSizeBytes
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class ReportJob
|
||||||
|
{
|
||||||
|
public required string JobId { get; init; }
|
||||||
|
public required Guid TenantId { get; init; }
|
||||||
|
public required string RequesterId { get; init; }
|
||||||
|
public required ExceptionReportRequest Request { get; init; }
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public string Status { get; set; } = "pending";
|
||||||
|
public int Progress { get; set; }
|
||||||
|
public DateTimeOffset? StartedAt { get; set; }
|
||||||
|
public DateTimeOffset? CompletedAt { get; set; }
|
||||||
|
public int? ExceptionCount { get; set; }
|
||||||
|
public byte[]? Content { get; set; }
|
||||||
|
public string? ContentHash { get; set; }
|
||||||
|
public long? FileSizeBytes { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report document models
|
||||||
|
internal sealed record ExceptionReportDocument
|
||||||
|
{
|
||||||
|
public required string ReportId { get; init; }
|
||||||
|
public required DateTimeOffset GeneratedAt { get; init; }
|
||||||
|
public required Guid TenantId { get; init; }
|
||||||
|
public required string RequesterId { get; init; }
|
||||||
|
public required string Title { get; init; }
|
||||||
|
public required string Format { get; init; }
|
||||||
|
public ExceptionReportFilter? Filter { get; init; }
|
||||||
|
public required ExceptionReportSummary Summary { get; init; }
|
||||||
|
public required List<ExceptionReportEntry> Exceptions { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ExceptionReportFilter
|
||||||
|
{
|
||||||
|
public string? Status { get; init; }
|
||||||
|
public string? Type { get; init; }
|
||||||
|
public string? VulnerabilityId { get; init; }
|
||||||
|
public string? PurlPattern { get; init; }
|
||||||
|
public string? Environment { get; init; }
|
||||||
|
public string? OwnerId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ExceptionReportSummary
|
||||||
|
{
|
||||||
|
public int TotalExceptions { get; init; }
|
||||||
|
public Dictionary<string, int> ByStatus { get; init; } = new();
|
||||||
|
public Dictionary<string, int> ByType { get; init; } = new();
|
||||||
|
public Dictionary<string, int> ByReason { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ExceptionReportEntry
|
||||||
|
{
|
||||||
|
public required ExceptionReportScope Exception { get; init; }
|
||||||
|
public List<ExceptionReportEvent>? History { get; init; }
|
||||||
|
public List<ExceptionReportApplication>? Applications { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ExceptionReportScope
|
||||||
|
{
|
||||||
|
public required string ExceptionId { get; init; }
|
||||||
|
public required int Version { get; init; }
|
||||||
|
public required string Status { get; init; }
|
||||||
|
public required string Type { get; init; }
|
||||||
|
public required string ReasonCode { get; init; }
|
||||||
|
public required string OwnerId { get; init; }
|
||||||
|
public required string RequesterId { get; init; }
|
||||||
|
public List<string> ApproverIds { get; init; } = new();
|
||||||
|
public required string Rationale { get; init; }
|
||||||
|
public string? TicketRef { get; init; }
|
||||||
|
public required ExceptionReportScopeDetail Scope { get; init; }
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public required DateTimeOffset UpdatedAt { get; init; }
|
||||||
|
public DateTimeOffset? ApprovedAt { get; init; }
|
||||||
|
public required DateTimeOffset ExpiresAt { get; init; }
|
||||||
|
public required bool IsEffective { get; init; }
|
||||||
|
public List<string> EvidenceRefs { get; init; } = new();
|
||||||
|
public List<string> CompensatingControls { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ExceptionReportScopeDetail
|
||||||
|
{
|
||||||
|
public string? ArtifactDigest { get; init; }
|
||||||
|
public string? PurlPattern { get; init; }
|
||||||
|
public string? VulnerabilityId { get; init; }
|
||||||
|
public string? PolicyRuleId { get; init; }
|
||||||
|
public List<string> Environments { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ExceptionReportEvent
|
||||||
|
{
|
||||||
|
public required string EventId { get; init; }
|
||||||
|
public required string EventType { get; init; }
|
||||||
|
public required string ActorId { get; init; }
|
||||||
|
public required DateTimeOffset Timestamp { get; init; }
|
||||||
|
public string? Description { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ExceptionReportApplication
|
||||||
|
{
|
||||||
|
public required string ApplicationId { get; init; }
|
||||||
|
public required string FindingId { get; init; }
|
||||||
|
public string? VulnerabilityId { get; init; }
|
||||||
|
public required string OriginalStatus { get; init; }
|
||||||
|
public required string AppliedStatus { get; init; }
|
||||||
|
public required string EffectName { get; init; }
|
||||||
|
public required DateTimeOffset AppliedAt { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Copyright (c) StellaOps Contributors. Licensed under the AGPL-3.0-or-later.
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace StellaOps.ExportCenter.WebService.ExceptionReport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service collection extensions for exception report services.
|
||||||
|
/// </summary>
|
||||||
|
public static class ExceptionReportServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds exception report generation services.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection.</param>
|
||||||
|
/// <returns>The service collection for chaining.</returns>
|
||||||
|
public static IServiceCollection AddExceptionReportServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<IExceptionReportGenerator, ExceptionReportGenerator>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Copyright (c) StellaOps Contributors. Licensed under the AGPL-3.0-or-later.
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
using StellaOps.Policy.Exceptions.Repositories;
|
||||||
|
|
||||||
|
namespace StellaOps.ExportCenter.WebService.ExceptionReport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates audit-compliant exception reports with async job tracking.
|
||||||
|
/// </summary>
|
||||||
|
public interface IExceptionReportGenerator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new exception report generation job.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Report generation request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Job ID and status for tracking.</returns>
|
||||||
|
Task<ExceptionReportJobResponse> CreateReportAsync(
|
||||||
|
ExceptionReportRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the status of a report generation job.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobId">The job ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Job status or null if not found.</returns>
|
||||||
|
Task<ExceptionReportStatus?> GetReportStatusAsync(
|
||||||
|
string jobId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists all report jobs for a tenant.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tenantId">Tenant ID filter.</param>
|
||||||
|
/// <param name="limit">Maximum results.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>List of report jobs.</returns>
|
||||||
|
Task<IReadOnlyList<ExceptionReportStatus>> ListReportsAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
int limit = 50,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the report content as a byte array.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobId">The job ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Report content and metadata, or null if not ready/found.</returns>
|
||||||
|
Task<ExceptionReportContent?> GetReportContentAsync(
|
||||||
|
string jobId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams report content for large reports.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="jobId">The job ID.</param>
|
||||||
|
/// <param name="outputStream">Stream to write to.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>True if streamed successfully, false if not ready/found.</returns>
|
||||||
|
Task<bool> StreamReportAsync(
|
||||||
|
string jobId,
|
||||||
|
Stream outputStream,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to generate an exception report.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ExceptionReportRequest
|
||||||
|
{
|
||||||
|
/// <summary>Tenant ID for multi-tenant isolation.</summary>
|
||||||
|
public required Guid TenantId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>User requesting the report.</summary>
|
||||||
|
public required string RequesterId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Output format (json, ndjson, pdf).</summary>
|
||||||
|
public string Format { get; init; } = "json";
|
||||||
|
|
||||||
|
/// <summary>Filter criteria for exceptions to include.</summary>
|
||||||
|
public ExceptionFilter? Filter { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Include full audit history per exception.</summary>
|
||||||
|
public bool IncludeHistory { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>Include application records (where exceptions were applied).</summary>
|
||||||
|
public bool IncludeApplications { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>Report title for metadata.</summary>
|
||||||
|
public string? Title { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response when creating a report job.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ExceptionReportJobResponse
|
||||||
|
{
|
||||||
|
/// <summary>Unique job identifier.</summary>
|
||||||
|
public required string JobId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Current status.</summary>
|
||||||
|
public required string Status { get; init; }
|
||||||
|
|
||||||
|
/// <summary>When the job was created.</summary>
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>URL to check status.</summary>
|
||||||
|
public string? StatusUrl { get; init; }
|
||||||
|
|
||||||
|
/// <summary>URL to download when complete.</summary>
|
||||||
|
public string? DownloadUrl { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Status of a report generation job.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ExceptionReportStatus
|
||||||
|
{
|
||||||
|
/// <summary>Unique job identifier.</summary>
|
||||||
|
public required string JobId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Tenant that owns this job.</summary>
|
||||||
|
public required Guid TenantId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>User who requested the report.</summary>
|
||||||
|
public required string RequesterId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Current status (pending, running, completed, failed).</summary>
|
||||||
|
public required string Status { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Progress percentage (0-100).</summary>
|
||||||
|
public int Progress { get; init; }
|
||||||
|
|
||||||
|
/// <summary>When the job was created.</summary>
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>When the job started processing.</summary>
|
||||||
|
public DateTimeOffset? StartedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>When the job completed.</summary>
|
||||||
|
public DateTimeOffset? CompletedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Number of exceptions included.</summary>
|
||||||
|
public int? ExceptionCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Output format.</summary>
|
||||||
|
public required string Format { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Error message if failed.</summary>
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
|
/// <summary>File size in bytes when complete.</summary>
|
||||||
|
public long? FileSizeBytes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Report content with metadata.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ExceptionReportContent
|
||||||
|
{
|
||||||
|
/// <summary>Report content bytes.</summary>
|
||||||
|
public required byte[] Content { get; init; }
|
||||||
|
|
||||||
|
/// <summary>MIME type.</summary>
|
||||||
|
public required string ContentType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Suggested filename.</summary>
|
||||||
|
public required string FileName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Content-addressed hash of the report.</summary>
|
||||||
|
public string? ContentHash { get; init; }
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ using StellaOps.ExportCenter.WebService.Incident;
|
|||||||
using StellaOps.ExportCenter.WebService.RiskBundle;
|
using StellaOps.ExportCenter.WebService.RiskBundle;
|
||||||
using StellaOps.ExportCenter.WebService.SimulationExport;
|
using StellaOps.ExportCenter.WebService.SimulationExport;
|
||||||
using StellaOps.ExportCenter.WebService.AuditBundle;
|
using StellaOps.ExportCenter.WebService.AuditBundle;
|
||||||
|
using StellaOps.ExportCenter.WebService.ExceptionReport;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -76,6 +77,9 @@ builder.Services.AddSimulationExport();
|
|||||||
// Audit bundle job handler
|
// Audit bundle job handler
|
||||||
builder.Services.AddAuditBundleJobHandler();
|
builder.Services.AddAuditBundleJobHandler();
|
||||||
|
|
||||||
|
// Exception report services
|
||||||
|
builder.Services.AddExceptionReportServices();
|
||||||
|
|
||||||
// Export API services (profiles, runs, artifacts)
|
// Export API services (profiles, runs, artifacts)
|
||||||
builder.Services.AddExportApiServices(options =>
|
builder.Services.AddExportApiServices(options =>
|
||||||
{
|
{
|
||||||
@@ -119,6 +123,9 @@ app.MapSimulationExportEndpoints();
|
|||||||
// Audit bundle endpoints
|
// Audit bundle endpoints
|
||||||
app.MapAuditBundleEndpoints();
|
app.MapAuditBundleEndpoints();
|
||||||
|
|
||||||
|
// Exception report endpoints
|
||||||
|
app.MapExceptionReportEndpoints();
|
||||||
|
|
||||||
// Export API endpoints (profiles, runs, artifacts, SSE)
|
// Export API endpoints (profiles, runs, artifacts, SSE)
|
||||||
app.MapExportApiEndpoints();
|
app.MapExportApiEndpoints();
|
||||||
|
|
||||||
|
|||||||
@@ -21,5 +21,6 @@
|
|||||||
<ProjectReference Include="..\..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
<ProjectReference Include="..\..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||||
<ProjectReference Include="..\..\..\TimelineIndexer\StellaOps.TimelineIndexer\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj" />
|
<ProjectReference Include="..\..\..\TimelineIndexer\StellaOps.TimelineIndexer\StellaOps.TimelineIndexer.Core\StellaOps.TimelineIndexer.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\Policy\__Libraries\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user