- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint. - Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately. - Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly. - Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
235 lines
7.6 KiB
C#
235 lines
7.6 KiB
C#
using System.Collections.Generic;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using Serilog;
|
|
using Serilog.Events;
|
|
using StellaOps.Attestor.Core.Options;
|
|
using StellaOps.Attestor.Core.Submission;
|
|
using StellaOps.Attestor.Infrastructure;
|
|
using StellaOps.Configuration;
|
|
using StellaOps.Auth.ServerIntegration;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using OpenTelemetry.Metrics;
|
|
using StellaOps.Attestor.Core.Observability;
|
|
using StellaOps.Attestor.Core.Verification;
|
|
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
|
|
|
const string ConfigurationSection = "attestor";
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Configuration.AddStellaOpsDefaults(options =>
|
|
{
|
|
options.BasePath = builder.Environment.ContentRootPath;
|
|
options.EnvironmentPrefix = "ATTESTOR_";
|
|
options.BindingSection = ConfigurationSection;
|
|
});
|
|
|
|
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
|
{
|
|
loggerConfiguration
|
|
.MinimumLevel.Information()
|
|
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
|
.Enrich.FromLogContext()
|
|
.WriteTo.Console();
|
|
});
|
|
|
|
var attestorOptions = builder.Configuration.BindOptions<AttestorOptions>(ConfigurationSection);
|
|
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
builder.Services.AddSingleton(attestorOptions);
|
|
|
|
builder.Services.AddOptions<AttestorOptions>()
|
|
.Bind(builder.Configuration.GetSection(ConfigurationSection))
|
|
.ValidateOnStart();
|
|
|
|
builder.Services.AddProblemDetails();
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddAttestorInfrastructure();
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddHealthChecks()
|
|
.AddCheck("self", () => HealthCheckResult.Healthy());
|
|
|
|
builder.Services.AddOpenTelemetry()
|
|
.WithMetrics(metricsBuilder =>
|
|
{
|
|
metricsBuilder.AddMeter(AttestorMetrics.MeterName);
|
|
metricsBuilder.AddAspNetCoreInstrumentation();
|
|
metricsBuilder.AddRuntimeInstrumentation();
|
|
});
|
|
|
|
if (attestorOptions.Security.Authority is { Issuer: not null } authority)
|
|
{
|
|
builder.Services.AddStellaOpsResourceServerAuthentication(
|
|
builder.Configuration,
|
|
configurationSection: null,
|
|
configure: resourceOptions =>
|
|
{
|
|
resourceOptions.Authority = authority.Issuer!;
|
|
resourceOptions.RequireHttpsMetadata = authority.RequireHttpsMetadata;
|
|
if (!string.IsNullOrWhiteSpace(authority.JwksUrl))
|
|
{
|
|
resourceOptions.MetadataAddress = authority.JwksUrl;
|
|
}
|
|
|
|
foreach (var audience in authority.Audiences)
|
|
{
|
|
resourceOptions.Audiences.Add(audience);
|
|
}
|
|
|
|
foreach (var scope in authority.RequiredScopes)
|
|
{
|
|
resourceOptions.RequiredScopes.Add(scope);
|
|
}
|
|
});
|
|
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
options.AddPolicy("attestor:write", policy =>
|
|
{
|
|
policy.RequireAuthenticatedUser();
|
|
policy.RequireClaim("scope", authority.RequiredScopes);
|
|
});
|
|
});
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddAuthorization();
|
|
}
|
|
|
|
builder.WebHost.ConfigureKestrel(kestrel =>
|
|
{
|
|
kestrel.ConfigureHttpsDefaults(https =>
|
|
{
|
|
if (attestorOptions.Security.Mtls.RequireClientCertificate)
|
|
{
|
|
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
|
|
}
|
|
});
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseSerilogRequestLogging();
|
|
|
|
app.UseExceptionHandler(static handler =>
|
|
{
|
|
handler.Run(async context =>
|
|
{
|
|
var result = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
|
|
await result.ExecuteAsync(context);
|
|
});
|
|
});
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapHealthChecks("/health/ready");
|
|
app.MapHealthChecks("/health/live");
|
|
|
|
app.MapPost("/api/v1/rekor/entries", async (AttestorSubmissionRequest request, HttpContext httpContext, IAttestorSubmissionService submissionService, CancellationToken cancellationToken) =>
|
|
{
|
|
var certificate = httpContext.Connection.ClientCertificate;
|
|
if (certificate is null)
|
|
{
|
|
return Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Client certificate required");
|
|
}
|
|
|
|
var user = httpContext.User;
|
|
if (user?.Identity is not { IsAuthenticated: true })
|
|
{
|
|
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Authentication required");
|
|
}
|
|
|
|
var submissionContext = BuildSubmissionContext(user, certificate);
|
|
|
|
try
|
|
{
|
|
var result = await submissionService.SubmitAsync(request, submissionContext, cancellationToken).ConfigureAwait(false);
|
|
return Results.Ok(result);
|
|
}
|
|
catch (AttestorValidationException validationEx)
|
|
{
|
|
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: validationEx.Message, extensions: new Dictionary<string, object?>
|
|
{
|
|
["code"] = validationEx.Code
|
|
});
|
|
}
|
|
})
|
|
.RequireAuthorization("attestor:write");
|
|
|
|
app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
|
|
{
|
|
var entry = await verificationService.GetEntryAsync(uuid, refresh is true, cancellationToken).ConfigureAwait(false);
|
|
if (entry is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
return Results.Ok(new
|
|
{
|
|
uuid = entry.RekorUuid,
|
|
index = entry.Index,
|
|
proof = entry.Proof is null ? null : new
|
|
{
|
|
checkpoint = entry.Proof.Checkpoint is null ? null : new
|
|
{
|
|
origin = entry.Proof.Checkpoint.Origin,
|
|
size = entry.Proof.Checkpoint.Size,
|
|
rootHash = entry.Proof.Checkpoint.RootHash,
|
|
timestamp = entry.Proof.Checkpoint.Timestamp?.ToString("O")
|
|
},
|
|
inclusion = entry.Proof.Inclusion is null ? null : new
|
|
{
|
|
leafHash = entry.Proof.Inclusion.LeafHash,
|
|
path = entry.Proof.Inclusion.Path
|
|
}
|
|
},
|
|
logURL = entry.Log.Url,
|
|
status = entry.Status,
|
|
artifact = new
|
|
{
|
|
sha256 = entry.Artifact.Sha256,
|
|
kind = entry.Artifact.Kind,
|
|
imageDigest = entry.Artifact.ImageDigest,
|
|
subjectUri = entry.Artifact.SubjectUri
|
|
}
|
|
});
|
|
}).RequireAuthorization("attestor:write");
|
|
|
|
app.MapPost("/api/v1/rekor/verify", async (AttestorVerificationRequest request, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
|
|
{
|
|
try
|
|
{
|
|
var result = await verificationService.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
|
|
return Results.Ok(result);
|
|
}
|
|
catch (AttestorVerificationException ex)
|
|
{
|
|
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, extensions: new Dictionary<string, object?>
|
|
{
|
|
["code"] = ex.Code
|
|
});
|
|
}
|
|
}).RequireAuthorization("attestor:write");
|
|
|
|
app.Run();
|
|
|
|
static SubmissionContext BuildSubmissionContext(ClaimsPrincipal user, X509Certificate2 certificate)
|
|
{
|
|
var subject = user.FindFirst("sub")?.Value ?? certificate.Subject;
|
|
var audience = user.FindFirst("aud")?.Value ?? string.Empty;
|
|
var clientId = user.FindFirst("client_id")?.Value;
|
|
var tenant = user.FindFirst("tenant")?.Value;
|
|
|
|
return new SubmissionContext
|
|
{
|
|
CallerSubject = subject,
|
|
CallerAudience = audience,
|
|
CallerClientId = clientId,
|
|
CallerTenant = tenant,
|
|
ClientCertificate = certificate,
|
|
MtlsThumbprint = certificate.Thumbprint
|
|
};
|
|
}
|