356 lines
15 KiB
C#
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 { }
|
|
}
|