Files

356 lines
15 KiB
C#

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<EvidenceSnapshotService>();
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<StellaRouterOptionsBase>();
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<EvidenceSnapshotResponseDto>(StatusCodes.Status201Created)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(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<EvidenceBundleResponseDto>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.Produces<ErrorResponse>(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<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.Produces<ErrorResponse>(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<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.Produces<ErrorResponse>(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<EvidenceVerifyResponseDto>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(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<EvidenceHoldResponseDto>(StatusCodes.Status201Created)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(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<string, string[]>
{
["message"] = new[] { message }
});
// Make Program class accessible for integration testing
namespace StellaOps.EvidenceLocker.WebService
{
public partial class Program { }
}