Files
2026-02-23 23:44:50 +02:00

842 lines
36 KiB
C#

using Microsoft.Extensions.FileProviders;
using StellaOps.PacksRegistry.Core.Contracts;
using StellaOps.PacksRegistry.Core.Models;
using StellaOps.PacksRegistry.Core.Services;
using StellaOps.PacksRegistry.Infrastructure.FileSystem;
using StellaOps.PacksRegistry.Infrastructure.Verification;
using StellaOps.PacksRegistry.WebService;
using StellaOps.PacksRegistry.WebService.Contracts;
using StellaOps.PacksRegistry.WebService.Options;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Router.AspNet;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
builder.Services.AddOpenApi();
var dataDir = builder.Configuration.GetValue<string>("PacksRegistry:DataDir") ?? Path.Combine("data", "packs");
builder.Services.AddSingleton<IPackRepository>(_ => new FilePackRepository(dataDir));
builder.Services.AddSingleton<IParityRepository>(_ => new FileParityRepository(dataDir));
builder.Services.AddSingleton<ILifecycleRepository>(_ => new FileLifecycleRepository(dataDir));
builder.Services.AddSingleton<IAuditRepository>(_ => new FileAuditRepository(dataDir));
builder.Services.AddSingleton<IAttestationRepository>(_ => new FileAttestationRepository(dataDir));
builder.Services.AddSingleton<IMirrorRepository>(_ => new FileMirrorRepository(dataDir));
var verificationSection = builder.Configuration.GetSection("PacksRegistry:Verification");
builder.Services.Configure<VerificationOptions>(verificationSection);
var publicKeyPem = verificationSection.GetValue<string>("PublicKeyPem");
if (!string.IsNullOrWhiteSpace(publicKeyPem))
{
builder.Services.AddSingleton<IPackSignatureVerifier>(_ => new RsaSignatureVerifier(publicKeyPem));
}
else
{
builder.Services.AddSingleton<IPackSignatureVerifier, SimpleSignatureVerifier>();
}
var authOptions = builder.Configuration.GetSection("PacksRegistry:Auth").Get<AuthOptions>() ?? new AuthOptions();
builder.Services.AddSingleton(authOptions);
var policyOptions = builder.Configuration.GetSection("PacksRegistry:Policy").Get<PackPolicyOptions>() ?? new PackPolicyOptions();
builder.Services.AddSingleton(policyOptions);
builder.Services.AddSingleton<PackService>();
builder.Services.AddSingleton<ParityService>();
builder.Services.AddSingleton<LifecycleService>();
builder.Services.AddSingleton<AttestationService>();
builder.Services.AddSingleton<MirrorService>();
builder.Services.AddSingleton<ComplianceService>();
builder.Services.AddSingleton<ExportService>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddHealthChecks();
builder.Services.AddStellaOpsTenantServices();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "packsregistry",
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.TryAddStellaOpsLocalBinding("packsregistry");
var app = builder.Build();
app.LogStellaOpsLocalHostname("packsregistry");
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseStellaOpsCors();
app.UseStellaOpsTenantMiddleware();
app.MapHealthChecks("/healthz");
app.TryUseStellaRouter(routerEnabled);
// Serve static OpenAPI stubs for packs APIs (until unified spec is generated)
var openApiDir = Path.Combine(app.Environment.ContentRootPath, "OpenApi");
if (Directory.Exists(openApiDir))
{
var provider = new PhysicalFileProvider(openApiDir);
app.MapGet("/openapi/packs.json", () =>
{
var file = provider.GetFileInfo("packs.openapi.json");
return file.Exists
? Results.File(file.CreateReadStream(), "application/json")
: Results.NotFound();
})
.ExcludeFromDescription();
app.MapGet("/openapi/pack-manifest.json", () =>
{
var file = provider.GetFileInfo("pack-manifest.openapi.json");
return file.Exists
? Results.File(file.CreateReadStream(), "application/json")
: Results.NotFound();
})
.ExcludeFromDescription();
}
app.MapPost("/api/v1/packs", async (PackUploadRequest request, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var tenant = !string.IsNullOrWhiteSpace(request.TenantId) ? request.TenantId : tenantHeader;
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header or tenantId is required." });
}
if (!IsTenantAllowed(tenant, auth, out var tenantResult))
{
return tenantResult;
}
if (request.Content == null || request.Content.Length == 0)
{
return Results.BadRequest(new { error = "content_missing", message = "Content (base64) is required." });
}
try
{
var contentBytes = Convert.FromBase64String(request.Content);
byte[]? provenanceBytes = null;
if (!string.IsNullOrWhiteSpace(request.ProvenanceContent))
{
provenanceBytes = Convert.FromBase64String(request.ProvenanceContent);
}
var record = await service.UploadAsync(
name: request.Name ?? string.Empty,
version: request.Version ?? string.Empty,
tenantId: tenant,
content: contentBytes,
signature: request.Signature,
provenanceUri: request.ProvenanceUri,
provenanceContent: provenanceBytes,
metadata: request.Metadata,
cancellationToken: cancellationToken);
return Results.Created($"/api/v1/packs/{record.PackId}", PackResponse.From(record));
}
catch (FormatException)
{
return Results.BadRequest(new { error = "content_base64_invalid", message = "Content must be valid base64." });
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "upload_failed", message = ex.Message });
}
})
.WithName("UploadPack")
.WithDescription("Uploads a new policy pack as base64-encoded content with optional signature and provenance attachment. Returns 201 Created with the registered pack record and assigned pack ID. Requires the X-StellaOps-Tenant header or a tenantId body field.")
.Produces<PackResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.RequireTenant();
app.MapGet("/api/v1/packs", async (string? tenant, bool? includeDeprecated, PackService service, LifecycleService lifecycleService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader;
if (auth.AllowedTenants is { Length: > 0 } && string.IsNullOrWhiteSpace(effectiveTenant))
{
return Results.BadRequest(new { error = "tenant_missing", message = "tenant query parameter or X-StellaOps-Tenant header is required when tenant allowlists are configured." });
}
if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult))
{
return tenantResult;
}
var packs = await service.ListAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
if (includeDeprecated is not true)
{
var lifecycle = await lifecycleService.ListAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
var deprecatedPackIds = lifecycle
.Where(static l => string.Equals(l.State, "deprecated", StringComparison.OrdinalIgnoreCase))
.Select(static l => l.PackId)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
packs = packs.Where(p => !deprecatedPackIds.Contains(p.PackId)).ToList();
}
return Results.Ok(packs.Select(PackResponse.From));
})
.WithName("ListPacks")
.WithDescription("Returns the list of policy packs for the specified tenant, optionally excluding deprecated packs. When tenant allowlists are configured, a tenant query parameter or X-StellaOps-Tenant header is required.")
.Produces<IEnumerable<PackResponse>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.RequireTenant();
app.MapGet("/api/v1/packs/{packId}", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
return Results.Ok(PackResponse.From(record));
})
.WithName("GetPack")
.WithDescription("Returns the metadata record for the specified pack ID including tenant, digest, provenance URI, and creation timestamp. Returns 403 if the caller's tenant allowlist does not include the pack's tenant. Returns 404 if the pack ID is not found.")
.Produces<PackResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapGet("/api/v1/packs/{packId}/content", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
var content = await service.GetContentAsync(packId, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return Results.NotFound();
}
context.Response.Headers["X-Content-Digest"] = record.Digest;
return Results.File(content, "application/octet-stream", fileDownloadName: packId + ".bin");
})
.WithName("GetPackContent")
.WithDescription("Downloads the binary content of the specified pack as an octet-stream. The response includes an X-Content-Digest header with the stored digest for integrity verification. Returns 403 if the tenant does not match. Returns 404 if the pack or its content is not found.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapGet("/api/v1/packs/{packId}/provenance", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
var content = await service.GetProvenanceAsync(packId, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return Results.NotFound();
}
if (!string.IsNullOrWhiteSpace(record.ProvenanceDigest))
{
context.Response.Headers["X-Provenance-Digest"] = record.ProvenanceDigest;
}
return Results.File(content, "application/json", fileDownloadName: packId + "-provenance.json");
})
.WithName("GetPackProvenance")
.WithDescription("Downloads the provenance document attached to the specified pack as a JSON file. The response includes an X-Provenance-Digest header when a digest is stored. Returns 404 if the pack or its provenance attachment is not found.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapGet("/api/v1/packs/{packId}/manifest", async (string packId, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await service.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
var content = await service.GetContentAsync(packId, cancellationToken).ConfigureAwait(false);
var provenance = await service.GetProvenanceAsync(packId, cancellationToken).ConfigureAwait(false);
var manifest = new PackManifestResponse(
record.PackId,
record.TenantId,
record.Digest,
content?.LongLength ?? 0,
record.ProvenanceDigest,
provenance?.LongLength,
record.CreatedAtUtc,
record.Metadata);
return Results.Ok(manifest);
})
.WithName("GetPackManifest")
.WithDescription("Returns a structured manifest for the specified pack including pack ID, tenant, content digest and size, provenance digest and size, creation timestamp, and attached metadata. Returns 403 if the tenant does not match. Returns 404 if the pack is not found.")
.Produces<PackManifestResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapPost("/api/v1/packs/{packId}/signature", async (string packId, RotateSignatureRequest request, PackService service, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantHeader))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult))
{
return tenantResult;
}
if (string.IsNullOrWhiteSpace(request.Signature))
{
return Results.BadRequest(new { error = "signature_missing", message = "signature is required." });
}
IPackSignatureVerifier? overrideVerifier = null;
if (!string.IsNullOrWhiteSpace(request.PublicKeyPem))
{
overrideVerifier = new RsaSignatureVerifier(request.PublicKeyPem!);
}
try
{
var updated = await service.RotateSignatureAsync(packId, tenantHeader, request.Signature!, overrideVerifier, cancellationToken).ConfigureAwait(false);
return Results.Ok(PackResponse.From(updated));
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "signature_rotation_failed", message = ex.Message });
}
})
.WithName("RotatePackSignature")
.WithDescription("Replaces the stored signature on a pack with a new signature, optionally using a caller-supplied public key PEM for verification instead of the server default. Returns the updated pack record on success. Returns 400 if the new signature is invalid or rotation fails.")
.Produces<PackResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapPost("/api/v1/packs/{packId}/attestations", async (string packId, AttestationUploadRequest request, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantHeader))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult))
{
return tenantResult;
}
if (string.IsNullOrWhiteSpace(request.Type) || string.IsNullOrWhiteSpace(request.Content))
{
return Results.BadRequest(new { error = "attestation_missing", message = "type and content are required." });
}
try
{
var bytes = Convert.FromBase64String(request.Content);
var record = await attestationService.UploadAsync(packId, tenantHeader, request.Type!, bytes, request.Notes, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/packs/{packId}/attestations/{record.Type}", AttestationResponse.From(record));
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "attestation_failed", message = ex.Message });
}
})
.WithName("UploadPackAttestation")
.WithDescription("Attaches a typed attestation document to a pack as base64-encoded content. The type field identifies the attestation kind (e.g., sbom, scan-result). Returns 201 Created with the stored attestation record. Returns 400 if type or content is missing or the content is not valid base64.")
.Produces<AttestationResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapGet("/api/v1/packs/{packId}/attestations", async (string packId, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var records = await attestationService.ListAsync(packId, cancellationToken).ConfigureAwait(false);
if (records.Count == 0)
{
return Results.NotFound();
}
if (!string.IsNullOrWhiteSpace(tenantHeader) && !records.All(r => string.Equals(r.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return Results.Forbid();
}
return Results.Ok(records.Select(AttestationResponse.From));
})
.WithName("ListPackAttestations")
.WithDescription("Returns all attestation records stored for the specified pack. Returns 404 if no attestations exist for the pack. Returns 403 if the X-StellaOps-Tenant header does not match the tenant of the stored attestations.")
.Produces<IEnumerable<AttestationResponse>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapGet("/api/v1/packs/{packId}/attestations/{type}", async (string packId, string type, AttestationService attestationService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await attestationService.GetAsync(packId, type, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase))
{
return Results.Forbid();
}
var content = await attestationService.GetContentAsync(packId, type, cancellationToken).ConfigureAwait(false);
if (content is null)
{
return Results.NotFound();
}
context.Response.Headers["X-Attestation-Digest"] = record.Digest;
return Results.File(content, "application/octet-stream", fileDownloadName: $"{packId}-{type}-attestation.bin");
})
.WithName("GetPackAttestationContent")
.WithDescription("Downloads the binary content of a specific attestation type for the specified pack. The response includes an X-Attestation-Digest header for integrity verification. Returns 403 if the tenant does not match. Returns 404 if the pack or the named attestation type is not found.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapGet("/api/v1/packs/{packId}/parity", async (string packId, ParityService parityService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var parity = await parityService.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (parity is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(parity.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(parity.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
return Results.Ok(ParityResponse.From(parity));
})
.WithName("GetPackParity")
.WithDescription("Returns the parity status record for the specified pack, indicating whether the pack content is consistent across mirror sites. Returns 403 if the tenant does not match. Returns 404 if no parity record exists for the pack.")
.Produces<ParityResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapGet("/api/v1/packs/{packId}/lifecycle", async (string packId, LifecycleService lifecycleService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var record = await lifecycleService.GetAsync(packId, cancellationToken).ConfigureAwait(false);
if (record is null)
{
return Results.NotFound();
}
if (!IsTenantAllowed(record.TenantId, auth, out var tenantResult) ||
(!string.IsNullOrWhiteSpace(tenantHeader) && !string.Equals(record.TenantId, tenantHeader, StringComparison.OrdinalIgnoreCase)))
{
return tenantResult ?? Results.Forbid();
}
return Results.Ok(LifecycleResponse.From(record));
})
.WithName("GetPackLifecycle")
.WithDescription("Returns the current lifecycle state record for the specified pack including state name, transition timestamp, and any associated notes. Returns 403 if the tenant does not match. Returns 404 if no lifecycle record exists for the pack.")
.Produces<LifecycleResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapPost("/api/v1/packs/{packId}/lifecycle", async (string packId, LifecycleRequest request, LifecycleService lifecycleService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var tenant = !string.IsNullOrWhiteSpace(tenantHeader) ? tenantHeader : null;
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
if (!IsTenantAllowed(tenant, auth, out var tenantResult))
{
return tenantResult;
}
if (string.IsNullOrWhiteSpace(request.State))
{
return Results.BadRequest(new { error = "state_missing", message = "state is required." });
}
try
{
var record = await lifecycleService.SetStateAsync(packId, tenant, request.State!, request.Notes, cancellationToken).ConfigureAwait(false);
return Results.Ok(LifecycleResponse.From(record));
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "lifecycle_failed", message = ex.Message });
}
})
.WithName("SetPackLifecycleState")
.WithDescription("Transitions the specified pack to a new lifecycle state (e.g., active, deprecated, archived) with optional notes. Returns the updated lifecycle record. Returns 400 if the state value is missing or the transition is invalid.")
.Produces<LifecycleResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapPost("/api/v1/packs/{packId}/parity", async (string packId, ParityRequest request, ParityService parityService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var tenant = !string.IsNullOrWhiteSpace(tenantHeader) ? tenantHeader : null;
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
if (!IsTenantAllowed(tenant, auth, out var tenantResult))
{
return tenantResult;
}
if (string.IsNullOrWhiteSpace(request.Status))
{
return Results.BadRequest(new { error = "status_missing", message = "status is required." });
}
try
{
var record = await parityService.SetStatusAsync(packId, tenant, request.Status!, request.Notes, cancellationToken).ConfigureAwait(false);
return Results.Ok(ParityResponse.From(record));
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "parity_failed", message = ex.Message });
}
})
.WithName("SetPackParityStatus")
.WithDescription("Records the parity check result for the specified pack, marking it as verified, mismatch, or unknown with optional notes. Returns the updated parity record. Returns 400 if the status value is missing or the parity update fails.")
.Produces<ParityResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapPost("/api/v1/export/offline-seed", async (OfflineSeedRequest request, ExportService exportService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var tenant = !string.IsNullOrWhiteSpace(request.TenantId) ? request.TenantId : tenantHeader;
if (auth.AllowedTenants is { Length: > 0 } && string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_missing", message = "tenantId or X-StellaOps-Tenant header is required when tenant allowlists are configured." });
}
if (!string.IsNullOrWhiteSpace(tenant) && !IsTenantAllowed(tenant, auth, out var tenantResult))
{
return tenantResult;
}
var archive = await exportService.ExportOfflineSeedAsync(tenant, request.IncludeContent, request.IncludeProvenance, cancellationToken).ConfigureAwait(false);
return Results.File(archive, "application/zip", fileDownloadName: "packs-offline-seed.zip");
})
.WithName("ExportOfflineSeed")
.WithDescription("Generates a ZIP archive containing all packs for the specified tenant, optionally including binary content and provenance documents, suitable for seeding an air-gapped PacksRegistry instance. When tenant allowlists are configured, a tenant ID is required.")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.RequireTenant();
app.MapPost("/api/v1/mirrors", async (MirrorRequest request, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantHeader))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
if (!IsTenantAllowed(tenantHeader, auth, out var tenantResult))
{
return tenantResult;
}
if (string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.Upstream))
{
return Results.BadRequest(new { error = "mirror_missing", message = "id and upstream are required." });
}
var record = await mirrorService.UpsertAsync(request.Id!, tenantHeader, new Uri(request.Upstream!), request.Enabled, request.Notes, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/mirrors/{record.Id}", MirrorResponse.From(record));
})
.WithName("UpsertMirror")
.WithDescription("Creates or updates a mirror registration for the specified tenant, associating a mirror ID with an upstream URL and enabled state. Returns 201 Created with the stored mirror record. Returns 400 if required fields are missing.")
.Produces<MirrorResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.RequireTenant();
app.MapGet("/api/v1/mirrors", async (string? tenant, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader;
if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult))
{
return tenantResult;
}
var mirrors = await mirrorService.ListAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
return Results.Ok(mirrors.Select(MirrorResponse.From));
})
.WithName("ListMirrors")
.WithDescription("Returns all mirror registrations for the specified tenant, or all mirrors if no tenant filter is applied. Returns 403 if the caller's tenant allowlist excludes the requested tenant.")
.Produces<IEnumerable<MirrorResponse>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.RequireTenant();
app.MapPost("/api/v1/mirrors/{id}/sync", async (string id, MirrorSyncRequest request, MirrorService mirrorService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantHeader))
{
return Results.BadRequest(new { error = "tenant_missing", message = "X-StellaOps-Tenant header is required." });
}
var updated = await mirrorService.MarkSyncAsync(id, tenantHeader, request.Status ?? "unknown", request.Notes, cancellationToken).ConfigureAwait(false);
if (updated is null)
{
return Results.NotFound();
}
return Results.Ok(MirrorResponse.From(updated));
})
.WithName("MarkMirrorSync")
.WithDescription("Records the outcome of a mirror synchronization attempt for the specified mirror ID, updating its sync status and optional notes. Returns the updated mirror record. Returns 404 if the mirror ID is not found.")
.Produces<MirrorResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.RequireTenant();
app.MapGet("/api/v1/compliance/summary", async (string? tenant, ComplianceService complianceService, HttpContext context, AuthOptions auth, CancellationToken cancellationToken) =>
{
if (!IsAuthorized(context, auth, out var unauthorizedResult))
{
return unauthorizedResult;
}
var tenantHeader = context.Request.Headers["X-StellaOps-Tenant"].ToString();
var effectiveTenant = !string.IsNullOrWhiteSpace(tenant) ? tenant : tenantHeader;
if (!string.IsNullOrWhiteSpace(effectiveTenant) && !IsTenantAllowed(effectiveTenant, auth, out var tenantResult))
{
return tenantResult;
}
var summary = await complianceService.SummarizeAsync(effectiveTenant, cancellationToken).ConfigureAwait(false);
return Results.Ok(summary);
})
.WithName("GetPacksComplianceSummary")
.WithDescription("Returns a compliance summary for the specified tenant's pack collection including signed pack count, unsigned count, packs with attestations, deprecated packs, and mirror sync status breakdown. Returns 403 if the tenant is not allowed.")
.Produces<ComplianceSummary>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.RequireTenant();
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerEnabled);
app.Run();
static bool IsAuthorized(HttpContext context, AuthOptions auth, out IResult result)
{
result = Results.Empty;
if (string.IsNullOrWhiteSpace(auth.ApiKey))
{
return true; // auth disabled
}
var provided = context.Request.Headers["X-API-Key"].ToString();
if (string.Equals(provided, auth.ApiKey, StringComparison.Ordinal))
{
return true;
}
result = Results.Unauthorized();
return false;
}
static bool IsTenantAllowed(string tenant, AuthOptions auth, out IResult? result)
{
result = null;
if (auth.AllowedTenants is { Length: > 0 } && !auth.AllowedTenants.Any(t => string.Equals(t, tenant, StringComparison.OrdinalIgnoreCase)))
{
result = Results.Forbid();
return false;
}
return true;
}
// Expose Program class for WebApplicationFactory in tests
public partial class Program;