Resolve Concelier/Excititor merge conflicts
This commit is contained in:
405
src/StellaOps.Attestor/StellaOps.Attestor.WebService/Program.cs
Normal file
405
src/StellaOps.Attestor/StellaOps.Attestor.WebService/Program.cs
Normal file
@@ -0,0 +1,405 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading.RateLimiting;
|
||||
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;
|
||||
using Serilog.Context;
|
||||
|
||||
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);
|
||||
|
||||
var clientCertificateAuthorities = LoadClientCertificateAuthorities(attestorOptions.Security.Mtls.CaBundle);
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton(attestorOptions);
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.OnRejected = static (context, _) =>
|
||||
{
|
||||
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
|
||||
return ValueTask.CompletedTask;
|
||||
};
|
||||
|
||||
options.AddPolicy("attestor-submissions", httpContext =>
|
||||
{
|
||||
var identity = httpContext.Connection.ClientCertificate?.Thumbprint
|
||||
?? httpContext.User.FindFirst("sub")?.Value
|
||||
?? httpContext.User.FindFirst("client_id")?.Value
|
||||
?? httpContext.Connection.RemoteIpAddress?.ToString()
|
||||
?? "anonymous";
|
||||
|
||||
var quota = attestorOptions.Quotas.PerCaller;
|
||||
var tokensPerPeriod = Math.Max(1, quota.Qps);
|
||||
var tokenLimit = Math.Max(tokensPerPeriod, quota.Burst);
|
||||
var queueLimit = Math.Max(quota.Burst, tokensPerPeriod);
|
||||
|
||||
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
|
||||
{
|
||||
TokenLimit = tokenLimit,
|
||||
TokensPerPeriod = tokensPerPeriod,
|
||||
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
|
||||
QueueLimit = queueLimit,
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
AutoReplenishment = true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
https.SslProtocols = SslProtocols.Tls13 | SslProtocols.Tls12;
|
||||
|
||||
https.ClientCertificateValidation = (certificate, _, _) =>
|
||||
{
|
||||
if (!attestorOptions.Security.Mtls.RequireClientCertificate)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (certificate is null)
|
||||
{
|
||||
Log.Warning("Client certificate missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (clientCertificateAuthorities.Count > 0)
|
||||
{
|
||||
using var chain = new X509Chain
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
TrustMode = X509ChainTrustMode.CustomRootTrust
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var authority in clientCertificateAuthorities)
|
||||
{
|
||||
chain.ChainPolicy.CustomTrustStore.Add(authority);
|
||||
}
|
||||
|
||||
if (!chain.Build(certificate))
|
||||
{
|
||||
Log.Warning("Client certificate chain validation failed for {Subject}", certificate.Subject);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (attestorOptions.Security.Mtls.AllowedThumbprints.Count > 0 &&
|
||||
!attestorOptions.Security.Mtls.AllowedThumbprints.Contains(certificate.Thumbprint ?? string.Empty, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warning("Client certificate thumbprint {Thumbprint} rejected", certificate.Thumbprint);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attestorOptions.Security.Mtls.AllowedSubjects.Count > 0 &&
|
||||
!attestorOptions.Security.Mtls.AllowedSubjects.Contains(certificate.Subject, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warning("Client certificate subject {Subject} rejected", certificate.Subject);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
correlationId = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
context.Response.Headers["X-Correlation-Id"] = correlationId;
|
||||
|
||||
using (LogContext.PushProperty("CorrelationId", correlationId))
|
||||
{
|
||||
await next().ConfigureAwait(false);
|
||||
}
|
||||
});
|
||||
|
||||
app.UseExceptionHandler(static handler =>
|
||||
{
|
||||
handler.Run(async context =>
|
||||
{
|
||||
var result = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
|
||||
await result.ExecuteAsync(context);
|
||||
});
|
||||
});
|
||||
|
||||
app.UseRateLimiter();
|
||||
|
||||
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")
|
||||
.RequireRateLimiting("attestor-submissions");
|
||||
|
||||
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,
|
||||
backend = entry.Log.Backend,
|
||||
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,
|
||||
mirror = entry.Mirror is null ? null : new
|
||||
{
|
||||
backend = entry.Mirror.Backend,
|
||||
uuid = entry.Mirror.Uuid,
|
||||
index = entry.Mirror.Index,
|
||||
logURL = entry.Mirror.Url,
|
||||
status = entry.Mirror.Status,
|
||||
proof = entry.Mirror.Proof is null ? null : new
|
||||
{
|
||||
checkpoint = entry.Mirror.Proof.Checkpoint is null ? null : new
|
||||
{
|
||||
origin = entry.Mirror.Proof.Checkpoint.Origin,
|
||||
size = entry.Mirror.Proof.Checkpoint.Size,
|
||||
rootHash = entry.Mirror.Proof.Checkpoint.RootHash,
|
||||
timestamp = entry.Mirror.Proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
inclusion = entry.Mirror.Proof.Inclusion is null ? null : new
|
||||
{
|
||||
leafHash = entry.Mirror.Proof.Inclusion.LeafHash,
|
||||
path = entry.Mirror.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
error = entry.Mirror.Error
|
||||
},
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
static List<X509Certificate2> LoadClientCertificateAuthorities(string? path)
|
||||
{
|
||||
var certificates = new List<X509Certificate2>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return certificates;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Log.Warning("Client CA bundle '{Path}' not found", path);
|
||||
return certificates;
|
||||
}
|
||||
|
||||
var collection = new X509Certificate2Collection();
|
||||
collection.ImportFromPemFile(path);
|
||||
|
||||
certificates.AddRange(collection.Cast<X509Certificate2>());
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or CryptographicException)
|
||||
{
|
||||
Log.Warning(ex, "Failed to load client CA bundle from {Path}", path);
|
||||
}
|
||||
|
||||
return certificates;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user