work
This commit is contained in:
@@ -1,41 +1,770 @@
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
var summaries = new[]
|
||||
{
|
||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
||||
};
|
||||
|
||||
app.MapGet("/weatherforecast", () =>
|
||||
{
|
||||
var forecast = Enumerable.Range(1, 5).Select(index =>
|
||||
new WeatherForecast
|
||||
(
|
||||
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
||||
Random.Shared.Next(-20, 55),
|
||||
summaries[Random.Shared.Next(summaries.Length)]
|
||||
))
|
||||
.ToArray();
|
||||
return forecast;
|
||||
})
|
||||
.WithName("GetWeatherForecast");
|
||||
|
||||
app.Run();
|
||||
|
||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
||||
{
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.PacksRegistry.Core.Contracts;
|
||||
using StellaOps.PacksRegistry.Core.Models;
|
||||
using StellaOps.PacksRegistry.Core.Services;
|
||||
using StellaOps.PacksRegistry.Infrastructure.FileSystem;
|
||||
using StellaOps.PacksRegistry.Infrastructure.InMemory;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Verification;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Mongo;
|
||||
using StellaOps.PacksRegistry.Infrastructure.Options;
|
||||
using StellaOps.PacksRegistry.WebService;
|
||||
using StellaOps.PacksRegistry.WebService.Contracts;
|
||||
using StellaOps.PacksRegistry.WebService.Options;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using MongoDB.Driver;
|
||||
|
||||
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");
|
||||
var mongoOptions = builder.Configuration.GetSection("PacksRegistry:Mongo").Get<MongoOptions>() ?? new MongoOptions();
|
||||
mongoOptions.ConnectionString ??= builder.Configuration.GetConnectionString("packs-registry");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(mongoOptions.ConnectionString))
|
||||
{
|
||||
builder.Services.AddSingleton(mongoOptions);
|
||||
builder.Services.AddSingleton<IMongoClient>(_ => new MongoClient(mongoOptions.ConnectionString));
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IMongoClient>().GetDatabase(mongoOptions.Database));
|
||||
builder.Services.AddSingleton<IPackRepository, MongoPackRepository>();
|
||||
builder.Services.AddSingleton<IParityRepository, MongoParityRepository>();
|
||||
builder.Services.AddSingleton<ILifecycleRepository, MongoLifecycleRepository>();
|
||||
builder.Services.AddSingleton<IAuditRepository, MongoAuditRepository>();
|
||||
builder.Services.AddSingleton<IAttestationRepository, MongoAttestationRepository>();
|
||||
builder.Services.AddSingleton<IMirrorRepository, MongoMirrorRepository>();
|
||||
builder.Services.AddHostedService<PacksMongoInitializer>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IPackRepository>(_ => new FilePackRepository(dataDir ?? "data/packs"));
|
||||
builder.Services.AddSingleton<IParityRepository>(_ => new FileParityRepository(dataDir ?? "data/packs"));
|
||||
builder.Services.AddSingleton<ILifecycleRepository>(_ => new FileLifecycleRepository(dataDir ?? "data/packs"));
|
||||
builder.Services.AddSingleton<IAuditRepository>(_ => new FileAuditRepository(dataDir ?? "data/packs"));
|
||||
builder.Services.AddSingleton<IAttestationRepository>(_ => new FileAttestationRepository(dataDir ?? "data/packs"));
|
||||
builder.Services.AddSingleton<IMirrorRepository>(_ => new FileMirrorRepository(dataDir ?? "data/packs"));
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
// 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 });
|
||||
}
|
||||
})
|
||||
.Produces<PackResponse>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
app.MapGet("/api/v1/packs", async (string? tenant, 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 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);
|
||||
return Results.Ok(packs.Select(PackResponse.From));
|
||||
})
|
||||
.Produces<IEnumerable<PackResponse>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
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));
|
||||
})
|
||||
.Produces<PackResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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");
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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");
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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);
|
||||
})
|
||||
.Produces<PackManifestResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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 });
|
||||
}
|
||||
})
|
||||
.Produces<PackResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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 });
|
||||
}
|
||||
})
|
||||
.Produces<AttestationResponse>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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));
|
||||
})
|
||||
.Produces<IEnumerable<AttestationResponse>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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");
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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));
|
||||
})
|
||||
.Produces<ParityResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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));
|
||||
})
|
||||
.Produces<LifecycleResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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 });
|
||||
}
|
||||
})
|
||||
.Produces<LifecycleResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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 });
|
||||
}
|
||||
})
|
||||
.Produces<ParityResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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");
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
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));
|
||||
})
|
||||
.Produces<MirrorResponse>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
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));
|
||||
})
|
||||
.Produces<IEnumerable<MirrorResponse>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
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));
|
||||
})
|
||||
.Produces<MirrorResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
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);
|
||||
})
|
||||
.Produces<ComplianceSummary>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
Reference in New Issue
Block a user