feat(exception-report): Implement exception report generation and endpoints

This commit is contained in:
StellaOps Bot
2025-12-22 08:00:44 +02:00
parent 292a6e94e8
commit 9a1572e11e
7 changed files with 869 additions and 2 deletions

View File

@@ -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 |
--- ---

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

@@ -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>