using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using StellaOps.Auth.Abstractions; using StellaOps.Auth.ServerIntegration; using StellaOps.EvidenceLocker.Api; using StellaOps.EvidenceLocker.Core.Domain; using StellaOps.EvidenceLocker.Core.Storage; using StellaOps.EvidenceLocker.Infrastructure.DependencyInjection; using StellaOps.EvidenceLocker.Infrastructure.Services; using StellaOps.EvidenceLocker.WebService.Audit; using StellaOps.EvidenceLocker.WebService.Contracts; using StellaOps.EvidenceLocker.WebService.Security; using StellaOps.Router.AspNet; var builder = WebApplication.CreateBuilder(args); builder.Services.AddEvidenceLockerInfrastructure(builder.Configuration); builder.Services.AddScoped(); builder.Services.AddHealthChecks(); builder.Services.AddStellaOpsResourceServerAuthentication( builder.Configuration, configure: options => options.RequiredScopes.Clear()); builder.Services.AddAuthorization(options => { options.AddObservabilityResourcePolicies(); options.DefaultPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .AddRequirements(new StellaOpsScopeRequirement(new[] { StellaOpsScopes.EvidenceRead })) .Build(); options.FallbackPolicy = options.DefaultPolicy; }); builder.Services.AddOpenApi(); // Stella Router integration var routerOptions = builder.Configuration.GetSection("EvidenceLocker:Router").Get(); builder.Services.TryAddStellaRouter( serviceName: "evidencelocker", version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0", routerOptions: routerOptions); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.TryUseStellaRouter(routerOptions); app.MapHealthChecks("/health/ready"); app.MapPost("/evidence/snapshot", async (HttpContext context, ClaimsPrincipal user, EvidenceSnapshotRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) => { var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName); if (!TenantResolution.TryResolveTenant(user, out var tenantId)) { EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/snapshot"); return ForbidTenant(); } try { var result = await service.CreateSnapshotAsync(tenantId, request.ToDomain(), cancellationToken); var materialCount = request.Materials.Count; var totalSize = request.Materials.Sum(material => material.SizeBytes); EvidenceAuditLogger.LogSnapshotCreated(logger, user, tenantId, request.Kind, result.BundleId, materialCount, totalSize); var dto = new EvidenceSnapshotResponseDto( result.BundleId, result.RootHash, result.Signature.ToDto()); return Results.Created($"/evidence/{result.BundleId}", dto); } catch (InvalidOperationException ex) { EvidenceAuditLogger.LogSnapshotRejected(logger, user, tenantId, request.Kind, ex.Message); return ValidationProblem(ex.Message); } }) .RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceHold) .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden) .WithName("CreateEvidenceSnapshot") .WithTags("Evidence") .WithSummary("Create a new evidence snapshot for the tenant."); app.MapGet("/evidence/{bundleId:guid}", async (HttpContext context, ClaimsPrincipal user, Guid bundleId, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) => { var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName); if (!TenantResolution.TryResolveTenant(user, out var tenantId)) { EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}"); return ForbidTenant(); } var details = await service.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken); if (details is null) { EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId); return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found.")); } EvidenceAuditLogger.LogBundleRetrieved(logger, user, tenantId, details.Bundle); var dto = new EvidenceBundleResponseDto( details.Bundle.Id.Value, details.Bundle.Kind, details.Bundle.Status, details.Bundle.RootHash, details.Bundle.StorageKey, details.Bundle.CreatedAt, details.Bundle.UpdatedAt, details.Bundle.Description, details.Bundle.SealedAt, details.Bundle.ExpiresAt, details.Signature.ToDto()); return Results.Ok(dto); }) .RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .WithName("GetEvidenceBundle") .WithTags("Evidence"); app.MapGet("/evidence/{bundleId:guid}/download", async (HttpContext context, ClaimsPrincipal user, Guid bundleId, EvidenceSnapshotService snapshotService, EvidenceBundlePackagingService packagingService, IEvidenceObjectStore objectStore, ILoggerFactory loggerFactory, CancellationToken cancellationToken) => { var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName); if (!TenantResolution.TryResolveTenant(user, out var tenantId)) { EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}/download"); return ForbidTenant(); } var bundle = await snapshotService.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken); if (bundle is null) { EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId); return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found.")); } try { var package = await packagingService.EnsurePackageAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken); EvidenceAuditLogger.LogBundleDownload(logger, user, tenantId, bundleId, package.StorageKey, package.Created); var packageStream = await objectStore.OpenReadAsync(package.StorageKey, cancellationToken).ConfigureAwait(false); return Results.File( packageStream, contentType: "application/gzip", fileDownloadName: $"evidence-bundle-{bundleId:D}.tgz"); } catch (InvalidOperationException ex) { EvidenceAuditLogger.LogBundleDownloadFailure(logger, user, tenantId, bundleId, ex.Message); return ValidationProblem(ex.Message); } }) .RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .WithName("DownloadEvidenceBundle") .WithTags("Evidence"); app.MapGet("/evidence/{bundleId:guid}/portable", async (HttpContext context, ClaimsPrincipal user, Guid bundleId, EvidenceSnapshotService snapshotService, EvidencePortableBundleService portableService, IEvidenceObjectStore objectStore, ILoggerFactory loggerFactory, CancellationToken cancellationToken) => { var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName); if (!TenantResolution.TryResolveTenant(user, out var tenantId)) { EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}/portable"); return ForbidTenant(); } var bundle = await snapshotService.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken); if (bundle is null) { EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId); return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found.")); } try { var package = await portableService.EnsurePortablePackageAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken); EvidenceAuditLogger.LogPortableDownload(logger, user, tenantId, bundleId, package.StorageKey, package.Created); var packageStream = await objectStore.OpenReadAsync(package.StorageKey, cancellationToken).ConfigureAwait(false); return Results.File( packageStream, contentType: "application/gzip", fileDownloadName: $"portable-evidence-bundle-{bundleId:D}.tgz"); } catch (InvalidOperationException ex) { EvidenceAuditLogger.LogPortableDownloadFailure(logger, user, tenantId, bundleId, ex.Message); return ValidationProblem(ex.Message); } }) .RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .WithName("DownloadPortableEvidenceBundle") .WithTags("Evidence") .WithSummary("Download a sealed, portable evidence bundle for sealed or air-gapped distribution."); app.MapPost("/evidence/verify", async (HttpContext context, ClaimsPrincipal user, EvidenceVerifyRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) => { var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName); if (!TenantResolution.TryResolveTenant(user, out var tenantId)) { EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/verify"); return ForbidTenant(); } var trusted = await service.VerifyAsync(tenantId, EvidenceBundleId.FromGuid(request.BundleId), request.RootHash, cancellationToken); EvidenceAuditLogger.LogVerificationResult(logger, user, tenantId, request.BundleId, request.RootHash, trusted); return Results.Ok(new EvidenceVerifyResponseDto(trusted)); }) .RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status403Forbidden) .WithName("VerifyEvidenceBundle") .WithTags("Evidence"); app.MapPost("/evidence/hold/{caseId}", async (HttpContext context, ClaimsPrincipal user, string caseId, EvidenceHoldRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) => { var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName); if (string.IsNullOrWhiteSpace(caseId)) { return ValidationProblem("Case identifier is required."); } if (!TenantResolution.TryResolveTenant(user, out var tenantId)) { EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/hold/{caseId}"); return ForbidTenant(); } try { var hold = await service.CreateHoldAsync(tenantId, caseId, request.ToDomain(), cancellationToken); EvidenceAuditLogger.LogHoldCreated(logger, user, tenantId, hold); var dto = new EvidenceHoldResponseDto( hold.Id.Value, hold.BundleId?.Value, hold.CaseId, hold.Reason, hold.CreatedAt, hold.ExpiresAt, hold.ReleasedAt, hold.Notes); return Results.Created($"/evidence/hold/{hold.Id.Value}", dto); } catch (InvalidOperationException ex) { if (ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase) && request.BundleId is Guid referencedBundle) { EvidenceAuditLogger.LogHoldBundleMissing(logger, user, tenantId, caseId, referencedBundle); } else if (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)) { EvidenceAuditLogger.LogHoldConflict(logger, user, tenantId, caseId); } else { EvidenceAuditLogger.LogHoldValidationFailure(logger, user, tenantId, caseId, ex.Message); } return ValidationProblem(ex.Message); } catch (ArgumentException ex) { EvidenceAuditLogger.LogHoldValidationFailure(logger, user, tenantId, caseId, ex.Message); return ValidationProblem(ex.Message); } }) .RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceCreate) .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden) .WithName("CreateEvidenceHold") .WithTags("Evidence") .WithSummary("Create a legal hold for the specified case identifier."); // Verdict attestation endpoints app.MapVerdictEndpoints(); // Refresh Router endpoint cache app.TryRefreshStellaRouterEndpoints(routerOptions); app.Run(); static IResult ForbidTenant() => Results.Forbid(); static IResult ValidationProblem(string message) => Results.ValidationProblem(new Dictionary { ["message"] = new[] { message } }); // Make Program class accessible for integration testing namespace StellaOps.EvidenceLocker.WebService { public partial class Program { } }