From 9a1572e11eb21f49bc10b1c8fbb8908aa16356d6 Mon Sep 17 00:00:00 2001 From: StellaOps Bot Date: Mon, 22 Dec 2025 08:00:44 +0200 Subject: [PATCH] feat(exception-report): Implement exception report generation and endpoints --- .../SPRINT_3900_0002_0002_ui_audit_export.md | 5 +- .../ExceptionReportEndpoints.cs | 204 ++++++++ .../ExceptionReportGenerator.cs | 459 ++++++++++++++++++ ...eptionReportServiceCollectionExtensions.cs | 21 + .../IExceptionReportGenerator.cs | 174 +++++++ .../Program.cs | 7 + .../StellaOps.ExportCenter.WebService.csproj | 1 + 7 files changed, 869 insertions(+), 2 deletions(-) create mode 100644 src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportEndpoints.cs create mode 100644 src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportGenerator.cs create mode 100644 src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportServiceCollectionExtensions.cs create mode 100644 src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/IExceptionReportGenerator.cs diff --git a/docs/implplan/SPRINT_3900_0002_0002_ui_audit_export.md b/docs/implplan/SPRINT_3900_0002_0002_ui_audit_export.md index 3991a04ee..c4075d02e 100644 --- a/docs/implplan/SPRINT_3900_0002_0002_ui_audit_export.md +++ b/docs/implplan/SPRINT_3900_0002_0002_ui_audit_export.md @@ -271,8 +271,8 @@ End-to-end tests for exception management flow. | 3 | T3 | TODO | None | UI Team | Exception Approval Queue | | 4 | T4 | TODO | None | UI Team | Exception Inline Creation | | 5 | T5 | TODO | None | UI Team | Exception Badge Integration | -| 6 | T6 | TODO | None | Export Team | Audit Pack Export — Exception Report | -| 7 | T7 | TODO | T6 | Export Team | Export Center Integration | +| 6 | T6 | DONE | None | Export Team | Audit Pack Export — Exception Report | +| 7 | T7 | DONE | T6 | Export Team | Export Center Integration | | 8 | T8 | TODO | T1-T5 | UI Team | UI Unit 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 | |------------|--------|-------| | 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 | --- diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportEndpoints.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportEndpoints.cs new file mode 100644 index 000000000..2575c5e3f --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportEndpoints.cs @@ -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; + +/// +/// Minimal API endpoints for exception report generation and retrieval. +/// +public static class ExceptionReportEndpoints +{ + /// + /// Maps exception report endpoints to the application. + /// + 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 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(dto.Filter.Status), + Type = ParseEnum(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 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 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 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(string? value) where TEnum : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return Enum.TryParse(value, ignoreCase: true, out var result) ? result : null; + } +} + +/// +/// DTO for creating an exception report. +/// +public sealed record CreateExceptionReportDto +{ + /// Output format (json, ndjson, pdf). + public string? Format { get; init; } + + /// Report title. + public string? Title { get; init; } + + /// Include audit history per exception. + public bool? IncludeHistory { get; init; } + + /// Include application records. + public bool? IncludeApplications { get; init; } + + /// Filter criteria. + public ExceptionReportFilterDto? Filter { get; init; } +} + +/// +/// Filter criteria for exception report. +/// +public sealed record ExceptionReportFilterDto +{ + /// Filter by status. + public string? Status { get; init; } + + /// Filter by type. + public string? Type { get; init; } + + /// Filter by vulnerability ID. + public string? VulnerabilityId { get; init; } + + /// Filter by PURL pattern. + public string? PurlPattern { get; init; } + + /// Filter by environment. + public string? Environment { get; init; } + + /// Filter by owner. + public string? OwnerId { get; init; } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportGenerator.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportGenerator.cs new file mode 100644 index 000000000..ae49f3f35 --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportGenerator.cs @@ -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; + +/// +/// Generates audit-compliant exception reports with async job tracking. +/// +public sealed class ExceptionReportGenerator : IExceptionReportGenerator +{ + private readonly IExceptionRepository _exceptionRepository; + private readonly IExceptionApplicationRepository _applicationRepository; + private readonly ConcurrentDictionary _jobs = new(); + private readonly ILogger _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 logger) + { + _exceptionRepository = exceptionRepository; + _applicationRepository = applicationRepository; + _logger = logger; + } + + public async Task 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 GetReportStatusAsync( + string jobId, + CancellationToken cancellationToken = default) + { + if (_jobs.TryGetValue(jobId, out var job)) + { + return Task.FromResult(ToStatus(job)); + } + + return Task.FromResult(null); + } + + public Task> 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>(jobs); + } + + public Task GetReportContentAsync( + string jobId, + CancellationToken cancellationToken = default) + { + if (!_jobs.TryGetValue(jobId, out var job) || job.Status != "completed" || job.Content is null) + { + return Task.FromResult(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(new ExceptionReportContent + { + Content = job.Content, + ContentType = contentType, + FileName = $"exception-report-{job.JobId}.{extension}", + ContentHash = job.ContentHash + }); + } + + public async Task 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(); + 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 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 ByStatus { get; init; } = new(); + public Dictionary ByType { get; init; } = new(); + public Dictionary ByReason { get; init; } = new(); +} + +internal sealed record ExceptionReportEntry +{ + public required ExceptionReportScope Exception { get; init; } + public List? History { get; init; } + public List? 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 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 EvidenceRefs { get; init; } = new(); + public List 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 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; } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportServiceCollectionExtensions.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportServiceCollectionExtensions.cs new file mode 100644 index 000000000..38b2121c3 --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/ExceptionReportServiceCollectionExtensions.cs @@ -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; + +/// +/// Service collection extensions for exception report services. +/// +public static class ExceptionReportServiceCollectionExtensions +{ + /// + /// Adds exception report generation services. + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddExceptionReportServices(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/IExceptionReportGenerator.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/IExceptionReportGenerator.cs new file mode 100644 index 000000000..9551e8ba4 --- /dev/null +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/ExceptionReport/IExceptionReportGenerator.cs @@ -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; + +/// +/// Generates audit-compliant exception reports with async job tracking. +/// +public interface IExceptionReportGenerator +{ + /// + /// Creates a new exception report generation job. + /// + /// Report generation request. + /// Cancellation token. + /// Job ID and status for tracking. + Task CreateReportAsync( + ExceptionReportRequest request, + CancellationToken cancellationToken = default); + + /// + /// Gets the status of a report generation job. + /// + /// The job ID. + /// Cancellation token. + /// Job status or null if not found. + Task GetReportStatusAsync( + string jobId, + CancellationToken cancellationToken = default); + + /// + /// Lists all report jobs for a tenant. + /// + /// Tenant ID filter. + /// Maximum results. + /// Cancellation token. + /// List of report jobs. + Task> ListReportsAsync( + Guid tenantId, + int limit = 50, + CancellationToken cancellationToken = default); + + /// + /// Gets the report content as a byte array. + /// + /// The job ID. + /// Cancellation token. + /// Report content and metadata, or null if not ready/found. + Task GetReportContentAsync( + string jobId, + CancellationToken cancellationToken = default); + + /// + /// Streams report content for large reports. + /// + /// The job ID. + /// Stream to write to. + /// Cancellation token. + /// True if streamed successfully, false if not ready/found. + Task StreamReportAsync( + string jobId, + Stream outputStream, + CancellationToken cancellationToken = default); +} + +/// +/// Request to generate an exception report. +/// +public sealed record ExceptionReportRequest +{ + /// Tenant ID for multi-tenant isolation. + public required Guid TenantId { get; init; } + + /// User requesting the report. + public required string RequesterId { get; init; } + + /// Output format (json, ndjson, pdf). + public string Format { get; init; } = "json"; + + /// Filter criteria for exceptions to include. + public ExceptionFilter? Filter { get; init; } + + /// Include full audit history per exception. + public bool IncludeHistory { get; init; } = false; + + /// Include application records (where exceptions were applied). + public bool IncludeApplications { get; init; } = false; + + /// Report title for metadata. + public string? Title { get; init; } +} + +/// +/// Response when creating a report job. +/// +public sealed record ExceptionReportJobResponse +{ + /// Unique job identifier. + public required string JobId { get; init; } + + /// Current status. + public required string Status { get; init; } + + /// When the job was created. + public required DateTimeOffset CreatedAt { get; init; } + + /// URL to check status. + public string? StatusUrl { get; init; } + + /// URL to download when complete. + public string? DownloadUrl { get; init; } +} + +/// +/// Status of a report generation job. +/// +public sealed record ExceptionReportStatus +{ + /// Unique job identifier. + public required string JobId { get; init; } + + /// Tenant that owns this job. + public required Guid TenantId { get; init; } + + /// User who requested the report. + public required string RequesterId { get; init; } + + /// Current status (pending, running, completed, failed). + public required string Status { get; init; } + + /// Progress percentage (0-100). + public int Progress { get; init; } + + /// When the job was created. + public required DateTimeOffset CreatedAt { get; init; } + + /// When the job started processing. + public DateTimeOffset? StartedAt { get; init; } + + /// When the job completed. + public DateTimeOffset? CompletedAt { get; init; } + + /// Number of exceptions included. + public int? ExceptionCount { get; init; } + + /// Output format. + public required string Format { get; init; } + + /// Error message if failed. + public string? ErrorMessage { get; init; } + + /// File size in bytes when complete. + public long? FileSizeBytes { get; init; } +} + +/// +/// Report content with metadata. +/// +public sealed record ExceptionReportContent +{ + /// Report content bytes. + public required byte[] Content { get; init; } + + /// MIME type. + public required string ContentType { get; init; } + + /// Suggested filename. + public required string FileName { get; init; } + + /// Content-addressed hash of the report. + public string? ContentHash { get; init; } +} diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/Program.cs b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/Program.cs index a484dae4a..46fe5daf3 100644 --- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/Program.cs +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/Program.cs @@ -13,6 +13,7 @@ using StellaOps.ExportCenter.WebService.Incident; using StellaOps.ExportCenter.WebService.RiskBundle; using StellaOps.ExportCenter.WebService.SimulationExport; using StellaOps.ExportCenter.WebService.AuditBundle; +using StellaOps.ExportCenter.WebService.ExceptionReport; var builder = WebApplication.CreateBuilder(args); @@ -76,6 +77,9 @@ builder.Services.AddSimulationExport(); // Audit bundle job handler builder.Services.AddAuditBundleJobHandler(); +// Exception report services +builder.Services.AddExceptionReportServices(); + // Export API services (profiles, runs, artifacts) builder.Services.AddExportApiServices(options => { @@ -119,6 +123,9 @@ app.MapSimulationExportEndpoints(); // Audit bundle endpoints app.MapAuditBundleEndpoints(); +// Exception report endpoints +app.MapExceptionReportEndpoints(); + // Export API endpoints (profiles, runs, artifacts, SSE) app.MapExportApiEndpoints(); diff --git a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/StellaOps.ExportCenter.WebService.csproj b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/StellaOps.ExportCenter.WebService.csproj index cb7178f53..fbc9e4db8 100644 --- a/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/StellaOps.ExportCenter.WebService.csproj +++ b/src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/StellaOps.ExportCenter.WebService.csproj @@ -21,5 +21,6 @@ +