up
This commit is contained in:
@@ -1,93 +1,51 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Authority;
|
||||
|
||||
public interface IAuthorityTokenProvider
|
||||
{
|
||||
ValueTask<AuthorityToken> GetTokenAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record AuthorityToken(string Value, DateTimeOffset? ExpiresAtUtc);
|
||||
|
||||
public sealed class StaticAuthorityTokenProvider : IAuthorityTokenProvider
|
||||
{
|
||||
private readonly ZastavaWebhookAuthorityOptions _options;
|
||||
private readonly ILogger<StaticAuthorityTokenProvider> _logger;
|
||||
private AuthorityToken? _cachedToken;
|
||||
|
||||
public StaticAuthorityTokenProvider(
|
||||
IOptionsMonitor<ZastavaWebhookOptions> options,
|
||||
ILogger<StaticAuthorityTokenProvider> logger)
|
||||
{
|
||||
_options = options.CurrentValue.Authority;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityToken> GetTokenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_cachedToken is { } token)
|
||||
{
|
||||
return ValueTask.FromResult(token);
|
||||
}
|
||||
|
||||
var value = !string.IsNullOrWhiteSpace(_options.StaticTokenValue)
|
||||
? _options.StaticTokenValue
|
||||
: LoadTokenFromFile(_options.StaticTokenPath);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException("No Authority token configured. Provide either 'StaticTokenValue' or 'StaticTokenPath'.");
|
||||
}
|
||||
|
||||
token = new AuthorityToken(value.Trim(), ExpiresAtUtc: null);
|
||||
_cachedToken = token;
|
||||
_logger.LogInformation("Loaded static Authority token (length {Length}).", token.Value.Length);
|
||||
return ValueTask.FromResult(token);
|
||||
}
|
||||
|
||||
private string LoadTokenFromFile(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new InvalidOperationException("Authority static token path not set.");
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException("Authority static token file not found.", path);
|
||||
}
|
||||
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AuthorityTokenHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IAuthorityTokenProvider _tokenProvider;
|
||||
private readonly ILogger<AuthorityTokenHealthCheck> _logger;
|
||||
|
||||
public AuthorityTokenHealthCheck(IAuthorityTokenProvider tokenProvider, ILogger<AuthorityTokenHealthCheck> logger)
|
||||
{
|
||||
_tokenProvider = tokenProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = await _tokenProvider.GetTokenAsync(cancellationToken);
|
||||
return HealthCheckResult.Healthy("Authority token acquired.", data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = token.ExpiresAtUtc?.ToString("O") ?? "static"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to obtain Authority token.");
|
||||
return HealthCheckResult.Unhealthy("Failed to obtain Authority token.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Authority;
|
||||
|
||||
public sealed class AuthorityTokenHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IZastavaAuthorityTokenProvider authorityTokenProvider;
|
||||
private readonly IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions;
|
||||
private readonly ILogger<AuthorityTokenHealthCheck> logger;
|
||||
|
||||
public AuthorityTokenHealthCheck(
|
||||
IZastavaAuthorityTokenProvider authorityTokenProvider,
|
||||
IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions,
|
||||
ILogger<AuthorityTokenHealthCheck> logger)
|
||||
{
|
||||
this.authorityTokenProvider = authorityTokenProvider ?? throw new ArgumentNullException(nameof(authorityTokenProvider));
|
||||
this.runtimeOptions = runtimeOptions ?? throw new ArgumentNullException(nameof(runtimeOptions));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var runtime = runtimeOptions.CurrentValue;
|
||||
var authority = runtime.Authority;
|
||||
var audience = authority.Audience.FirstOrDefault() ?? "scanner";
|
||||
var token = await authorityTokenProvider.GetAsync(audience, authority.Scopes ?? Array.Empty<string>(), cancellationToken);
|
||||
|
||||
return HealthCheckResult.Healthy(
|
||||
"Authority token acquired.",
|
||||
data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = token.ExpiresAtUtc?.ToString("O") ?? "static",
|
||||
["tokenType"] = token.TokenType
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to obtain Authority token via runtime core.");
|
||||
return HealthCheckResult.Unhealthy("Failed to obtain Authority token via runtime core.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public interface IRuntimePolicyClient
|
||||
{
|
||||
Task<RuntimePolicyResponse> EvaluateAsync(RuntimePolicyRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
115
src/StellaOps.Zastava.Webhook/Backend/RuntimePolicyClient.cs
Normal file
115
src/StellaOps.Zastava.Webhook/Backend/RuntimePolicyClient.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
internal sealed class RuntimePolicyClient : IRuntimePolicyClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
static RuntimePolicyClient()
|
||||
{
|
||||
SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
|
||||
}
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly IZastavaAuthorityTokenProvider authorityTokenProvider;
|
||||
private readonly IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions;
|
||||
private readonly IOptionsMonitor<ZastavaWebhookOptions> webhookOptions;
|
||||
private readonly IZastavaRuntimeMetrics runtimeMetrics;
|
||||
private readonly ILogger<RuntimePolicyClient> logger;
|
||||
|
||||
public RuntimePolicyClient(
|
||||
HttpClient httpClient,
|
||||
IZastavaAuthorityTokenProvider authorityTokenProvider,
|
||||
IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions,
|
||||
IOptionsMonitor<ZastavaWebhookOptions> webhookOptions,
|
||||
IZastavaRuntimeMetrics runtimeMetrics,
|
||||
ILogger<RuntimePolicyClient> logger)
|
||||
{
|
||||
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
this.authorityTokenProvider = authorityTokenProvider ?? throw new ArgumentNullException(nameof(authorityTokenProvider));
|
||||
this.runtimeOptions = runtimeOptions ?? throw new ArgumentNullException(nameof(runtimeOptions));
|
||||
this.webhookOptions = webhookOptions ?? throw new ArgumentNullException(nameof(webhookOptions));
|
||||
this.runtimeMetrics = runtimeMetrics ?? throw new ArgumentNullException(nameof(runtimeMetrics));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RuntimePolicyResponse> EvaluateAsync(RuntimePolicyRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var runtime = runtimeOptions.CurrentValue;
|
||||
var authority = runtime.Authority;
|
||||
var audience = authority.Audience.FirstOrDefault() ?? "scanner";
|
||||
var token = await authorityTokenProvider.GetAsync(audience, authority.Scopes ?? Array.Empty<string>(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var backend = webhookOptions.CurrentValue.Backend;
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, backend.PolicyPath)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(request, SerializerOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
httpRequest.Headers.Authorization = CreateAuthorizationHeader(token);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogWarning("Runtime policy call returned {StatusCode}: {Payload}", (int)response.StatusCode, payload);
|
||||
throw new RuntimePolicyException($"Runtime policy call failed with status {(int)response.StatusCode}", response.StatusCode);
|
||||
}
|
||||
|
||||
var result = JsonSerializer.Deserialize<RuntimePolicyResponse>(payload, SerializerOptions);
|
||||
if (result is null)
|
||||
{
|
||||
throw new RuntimePolicyException("Runtime policy response payload was empty or invalid.", response.StatusCode);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
RecordLatency(stopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
private AuthenticationHeaderValue CreateAuthorizationHeader(ZastavaOperationalToken token)
|
||||
{
|
||||
var scheme = string.Equals(token.TokenType, "dpop", StringComparison.OrdinalIgnoreCase) ? "DPoP" : token.TokenType;
|
||||
return new AuthenticationHeaderValue(scheme, token.AccessToken);
|
||||
}
|
||||
|
||||
private void RecordLatency(double elapsedMs)
|
||||
{
|
||||
var tags = runtimeMetrics.DefaultTags
|
||||
.Concat(new[] { new KeyValuePair<string, object?>("endpoint", "policy") })
|
||||
.ToArray();
|
||||
runtimeMetrics.BackendLatencyMs.Record(elapsedMs, tags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public sealed class RuntimePolicyException : Exception
|
||||
{
|
||||
public RuntimePolicyException(string message, HttpStatusCode statusCode)
|
||||
: base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public RuntimePolicyException(string message, HttpStatusCode statusCode, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public sealed record RuntimePolicyRequest
|
||||
{
|
||||
[JsonPropertyName("namespace")]
|
||||
public required string Namespace { get; init; }
|
||||
|
||||
[JsonPropertyName("labels")]
|
||||
public IReadOnlyDictionary<string, string>? Labels { get; init; }
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public required IReadOnlyList<string> Images { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Backend;
|
||||
|
||||
public sealed record RuntimePolicyResponse
|
||||
{
|
||||
[JsonPropertyName("ttlSeconds")]
|
||||
public int TtlSeconds { get; init; }
|
||||
|
||||
[JsonPropertyName("results")]
|
||||
public IReadOnlyDictionary<string, RuntimePolicyImageResult> Results { get; init; } = new Dictionary<string, RuntimePolicyImageResult>();
|
||||
}
|
||||
|
||||
public sealed record RuntimePolicyImageResult
|
||||
{
|
||||
[JsonPropertyName("signed")]
|
||||
public bool Signed { get; init; }
|
||||
|
||||
[JsonPropertyName("hasSbom")]
|
||||
public bool HasSbom { get; init; }
|
||||
|
||||
[JsonPropertyName("policyVerdict")]
|
||||
public PolicyVerdict PolicyVerdict { get; init; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
|
||||
|
||||
[JsonPropertyName("rekor")]
|
||||
public AdmissionRekorEvidence? Rekor { get; init; }
|
||||
}
|
||||
@@ -82,17 +82,22 @@ public sealed class SecretFileCertificateSource : IWebhookCertificateSource
|
||||
|
||||
internal static class X509Certificate2Extensions
|
||||
{
|
||||
public static X509Certificate2 WithExportablePrivateKey(this X509Certificate2 certificate)
|
||||
{
|
||||
// Ensure the private key is exportable for Kestrel; CreateFromPemFile returns a temporary key material otherwise.
|
||||
using var rsa = certificate.GetRSAPrivateKey();
|
||||
if (rsa is null)
|
||||
{
|
||||
return certificate;
|
||||
}
|
||||
|
||||
var certificateWithKey = certificate.CopyWithPrivateKey(rsa);
|
||||
certificate.Dispose();
|
||||
return certificateWithKey;
|
||||
}
|
||||
}
|
||||
public static X509Certificate2 WithExportablePrivateKey(this X509Certificate2 certificate)
|
||||
{
|
||||
// Ensure the private key is exportable for Kestrel; CreateFromPemFile returns a temporary key material otherwise.
|
||||
if (certificate.HasPrivateKey)
|
||||
{
|
||||
return certificate;
|
||||
}
|
||||
|
||||
using var rsa = certificate.GetRSAPrivateKey();
|
||||
if (rsa is null)
|
||||
{
|
||||
return certificate;
|
||||
}
|
||||
|
||||
var certificateWithKey = certificate.CopyWithPrivateKey(rsa);
|
||||
certificate.Dispose();
|
||||
return certificateWithKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ public sealed class ZastavaWebhookOptions
|
||||
[Required]
|
||||
public ZastavaWebhookTlsOptions Tls { get; init; } = new();
|
||||
|
||||
[Required]
|
||||
public ZastavaWebhookAuthorityOptions Authority { get; init; } = new();
|
||||
|
||||
[Required]
|
||||
public ZastavaWebhookAdmissionOptions Admission { get; init; } = new();
|
||||
[Required]
|
||||
public ZastavaWebhookAuthorityOptions Authority { get; init; } = new();
|
||||
|
||||
[Required]
|
||||
public ZastavaWebhookAdmissionOptions Admission { get; init; } = new();
|
||||
|
||||
[Required]
|
||||
public ZastavaWebhookBackendOptions Backend { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookAdmissionOptions
|
||||
@@ -114,11 +117,11 @@ public sealed class ZastavaWebhookTlsCsrOptions
|
||||
public string PersistPath { get; init; } = "/var/run/zastava-webhook/certs";
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookAuthorityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Authority issuer URL for token acquisition.
|
||||
/// </summary>
|
||||
public sealed class ZastavaWebhookAuthorityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Authority issuer URL for token acquisition.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public Uri Issuer { get; init; } = new("https://authority.internal");
|
||||
|
||||
@@ -142,5 +145,31 @@ public sealed class ZastavaWebhookAuthorityOptions
|
||||
/// Interval for refreshing cached tokens before expiry.
|
||||
/// </summary>
|
||||
[Range(typeof(double), "1", "3600")]
|
||||
public double RefreshSkewSeconds { get; init; } = TimeSpan.FromMinutes(5).TotalSeconds;
|
||||
}
|
||||
public double RefreshSkewSeconds { get; init; } = TimeSpan.FromMinutes(5).TotalSeconds;
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookBackendOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base address for Scanner WebService policy requests.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Uri BaseAddress { get; init; } = new("https://scanner.internal");
|
||||
|
||||
/// <summary>
|
||||
/// Relative path for runtime policy endpoint.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string PolicyPath { get; init; } = "/api/v1/scanner/policy/runtime";
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in seconds for backend calls (default 5 s).
|
||||
/// </summary>
|
||||
[Range(typeof(double), "1", "120")]
|
||||
public double RequestTimeoutSeconds { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Allows HTTP (non-TLS) endpoints when set. Defaults to false for safety.
|
||||
/// </summary>
|
||||
public bool AllowInsecureHttp { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,31 +1,50 @@
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Zastava.Webhook.Authority;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
using StellaOps.Zastava.Webhook.Hosting;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Webhook.Authority;
|
||||
using StellaOps.Zastava.Webhook.Backend;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
using StellaOps.Zastava.Webhook.Hosting;
|
||||
using StellaOps.Zastava.Webhook.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddZastavaWebhook(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ZastavaWebhookOptions>()
|
||||
.Bind(configuration.GetSection(ZastavaWebhookOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWebhookCertificateSource, SecretFileCertificateSource>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWebhookCertificateSource, CsrCertificateSource>());
|
||||
services.TryAddSingleton<IWebhookCertificateProvider, WebhookCertificateProvider>();
|
||||
services.TryAddSingleton<WebhookCertificateHealthCheck>();
|
||||
|
||||
services.TryAddSingleton<IAuthorityTokenProvider, StaticAuthorityTokenProvider>();
|
||||
services.TryAddSingleton<AuthorityTokenHealthCheck>();
|
||||
services.AddHostedService<StartupValidationHostedService>();
|
||||
|
||||
services.AddHealthChecks()
|
||||
.AddCheck<WebhookCertificateHealthCheck>("webhook_tls")
|
||||
services.AddZastavaRuntimeCore(configuration, "webhook");
|
||||
|
||||
services.AddOptions<ZastavaWebhookOptions>()
|
||||
.Bind(configuration.GetSection(ZastavaWebhookOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWebhookCertificateSource, SecretFileCertificateSource>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IWebhookCertificateSource, CsrCertificateSource>());
|
||||
services.TryAddSingleton<IWebhookCertificateProvider, WebhookCertificateProvider>();
|
||||
services.TryAddSingleton<WebhookCertificateHealthCheck>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<ZastavaRuntimeOptions>, WebhookRuntimeOptionsPostConfigure>());
|
||||
|
||||
services.AddHttpClient<IRuntimePolicyClient, RuntimePolicyClient>((provider, client) =>
|
||||
{
|
||||
var backend = provider.GetRequiredService<IOptions<ZastavaWebhookOptions>>().Value.Backend;
|
||||
if (!backend.AllowInsecureHttp && backend.BaseAddress.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("HTTP backend URLs are disabled unless AllowInsecureHttp is true.");
|
||||
}
|
||||
|
||||
client.BaseAddress = backend.BaseAddress;
|
||||
client.Timeout = TimeSpan.FromSeconds(backend.RequestTimeoutSeconds);
|
||||
});
|
||||
|
||||
services.TryAddSingleton<AuthorityTokenHealthCheck>();
|
||||
services.AddHostedService<StartupValidationHostedService>();
|
||||
|
||||
services.AddHealthChecks()
|
||||
.AddCheck<WebhookCertificateHealthCheck>("webhook_tls")
|
||||
.AddCheck<AuthorityTokenHealthCheck>("authority_token");
|
||||
|
||||
return services;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures legacy webhook authority options propagate to runtime options when not explicitly configured.
|
||||
/// </summary>
|
||||
internal sealed class WebhookRuntimeOptionsPostConfigure : IPostConfigureOptions<ZastavaRuntimeOptions>
|
||||
{
|
||||
private readonly IOptionsMonitor<ZastavaWebhookOptions> webhookOptions;
|
||||
|
||||
public WebhookRuntimeOptionsPostConfigure(IOptionsMonitor<ZastavaWebhookOptions> webhookOptions)
|
||||
{
|
||||
this.webhookOptions = webhookOptions ?? throw new ArgumentNullException(nameof(webhookOptions));
|
||||
}
|
||||
|
||||
public void PostConfigure(string? name, ZastavaRuntimeOptions runtimeOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtimeOptions);
|
||||
|
||||
var snapshot = webhookOptions.Get(name ?? Options.DefaultName);
|
||||
var source = snapshot.Authority;
|
||||
if (source is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeOptions.Authority ??= new ZastavaAuthorityOptions();
|
||||
var authority = runtimeOptions.Authority;
|
||||
|
||||
if (ShouldCopyStaticTokenValue(authority.StaticTokenValue, source.StaticTokenValue))
|
||||
{
|
||||
authority.StaticTokenValue = source.StaticTokenValue;
|
||||
}
|
||||
|
||||
if (ShouldCopyStaticTokenValue(authority.StaticTokenPath, source.StaticTokenPath))
|
||||
{
|
||||
authority.StaticTokenPath = source.StaticTokenPath;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(source.StaticTokenValue) || !string.IsNullOrWhiteSpace(source.StaticTokenPath))
|
||||
{
|
||||
authority.AllowStaticTokenFallback = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldCopyStaticTokenValue(string? current, string? source)
|
||||
=> string.IsNullOrWhiteSpace(current) && !string.IsNullOrWhiteSpace(source);
|
||||
}
|
||||
@@ -1,31 +1,39 @@
|
||||
using StellaOps.Zastava.Webhook.Authority;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Hosting;
|
||||
|
||||
public sealed class StartupValidationHostedService : IHostedService
|
||||
{
|
||||
private readonly IWebhookCertificateProvider _certificateProvider;
|
||||
private readonly IAuthorityTokenProvider _authorityTokenProvider;
|
||||
private readonly ILogger<StartupValidationHostedService> _logger;
|
||||
|
||||
public StartupValidationHostedService(
|
||||
IWebhookCertificateProvider certificateProvider,
|
||||
IAuthorityTokenProvider authorityTokenProvider,
|
||||
ILogger<StartupValidationHostedService> logger)
|
||||
{
|
||||
_certificateProvider = certificateProvider;
|
||||
_authorityTokenProvider = authorityTokenProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Running webhook startup validation.");
|
||||
_certificateProvider.GetCertificate();
|
||||
await _authorityTokenProvider.GetTokenAsync(cancellationToken);
|
||||
_logger.LogInformation("Webhook startup validation complete.");
|
||||
}
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Hosting;
|
||||
|
||||
public sealed class StartupValidationHostedService : IHostedService
|
||||
{
|
||||
private readonly IWebhookCertificateProvider _certificateProvider;
|
||||
private readonly IZastavaAuthorityTokenProvider _authorityTokenProvider;
|
||||
private readonly IOptionsMonitor<ZastavaRuntimeOptions> _runtimeOptions;
|
||||
private readonly ILogger<StartupValidationHostedService> _logger;
|
||||
|
||||
public StartupValidationHostedService(
|
||||
IWebhookCertificateProvider certificateProvider,
|
||||
IZastavaAuthorityTokenProvider authorityTokenProvider,
|
||||
IOptionsMonitor<ZastavaRuntimeOptions> runtimeOptions,
|
||||
ILogger<StartupValidationHostedService> logger)
|
||||
{
|
||||
_certificateProvider = certificateProvider;
|
||||
_authorityTokenProvider = authorityTokenProvider;
|
||||
_runtimeOptions = runtimeOptions;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Running webhook startup validation.");
|
||||
_certificateProvider.GetCertificate();
|
||||
var authority = _runtimeOptions.CurrentValue.Authority;
|
||||
var audience = authority.Audience.FirstOrDefault() ?? "scanner";
|
||||
await _authorityTokenProvider.GetAsync(audience, authority.Scopes, cancellationToken);
|
||||
_logger.LogInformation("Webhook startup validation complete.");
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
2. CSR workflow: generate CSR + private key, submit to Kubernetes Certificates API when `admission.tls.autoApprove` enabled; persist signed cert/key to mounted emptyDir for reuse across replicas.
|
||||
- Validate cert/key pair on boot; abort start-up if invalid to preserve deterministic behavior.
|
||||
- Configure Kestrel for mutual TLS off (API Server already provides client auth) but enforce minimum TLS 1.3, strong cipher suite list, HTTP/2 disabled (K8s uses HTTP/1.1).
|
||||
3. **Authority auth**
|
||||
- Bootstrap Authority client via shared DI extension (`AuthorityClientBuilder` once exposed); until then, placeholder `IAuthorityTokenSource` reading static OpTok from secret for smoke testing.
|
||||
3. **Authority auth**
|
||||
- Bootstrap Authority client via shared runtime core (`AddZastavaRuntimeCore` + `IZastavaAuthorityTokenProvider`) so webhook reuses multitenant OpTok caching and guardrails.
|
||||
- Implement DPoP proof generator bound to webhook host keypair (prefer Ed25519) with configurable rotation period (default 24h, triggered at restart).
|
||||
- Add background health check verifying token freshness and surfacing metrics (`zastava.authority_token_renew_failures_total`).
|
||||
4. **Hosting concerns**
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Security.Authentication;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Zastava.Webhook.Authority;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
using System.Security.Authentication;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Zastava.Webhook.Authority;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -18,11 +18,11 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
.WriteTo.Console();
|
||||
});
|
||||
|
||||
builder.Services.AddRouting();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddZastavaWebhook(builder.Configuration);
|
||||
builder.Services.AddRouting();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddZastavaWebhook(builder.Configuration);
|
||||
|
||||
builder.WebHost.ConfigureKestrel((context, options) =>
|
||||
{
|
||||
|
||||
3
src/StellaOps.Zastava.Webhook/Properties/AssemblyInfo.cs
Normal file
3
src/StellaOps.Zastava.Webhook/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Zastava.Webhook.Tests")]
|
||||
@@ -8,9 +8,12 @@
|
||||
<RootNamespace>StellaOps.Zastava.Webhook</RootNamespace>
|
||||
<NoWarn>$(NoWarn);CA2254</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0-preview.6.24328.4" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0-preview.6.24328.4" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Zastava.Core\StellaOps.Zastava.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ZASTAVA-WEBHOOK-12-101 | DOING | Zastava Webhook Guild | — | Admission controller host with TLS bootstrap and Authority auth. | Webhook host boots with deterministic TLS bootstrap, enforces Authority-issued credentials, e2e smoke proves admission callback lifecycle, structured logs + metrics emit on each decision. |
|
||||
| ZASTAVA-WEBHOOK-12-102 | DOING | Zastava Webhook Guild | — | Query Scanner `/policy/runtime`, resolve digests, enforce verdicts. | Scanner client resolves image digests + policy verdicts, unit tests cover allow/deny, integration harness rejects/admits workloads per policy with deterministic payloads. |
|
||||
| ZASTAVA-WEBHOOK-12-103 | DOING | Zastava Webhook Guild | — | Caching, fail-open/closed toggles, metrics/logging for admission decisions. | Configurable cache TTL + seeds survive restart, fail-open/closed toggles verified via tests, metrics/logging exported per decision path, docs note operational knobs. |
|
||||
| ZASTAVA-WEBHOOK-12-101 | DONE (2025-10-24) | Zastava Webhook Guild | — | Admission controller host with TLS bootstrap and Authority auth. | Webhook host boots with deterministic TLS bootstrap, enforces Authority-issued credentials, e2e smoke proves admission callback lifecycle, structured logs + metrics emit on each decision. |
|
||||
| ZASTAVA-WEBHOOK-12-102 | DOING | Zastava Webhook Guild | — | Query Scanner `/policy/runtime`, resolve digests, enforce verdicts. | Scanner client resolves image digests + policy verdicts, unit tests cover allow/deny, integration harness rejects/admits workloads per policy with deterministic payloads. |
|
||||
| ZASTAVA-WEBHOOK-12-103 | DOING | Zastava Webhook Guild | — | Caching, fail-open/closed toggles, metrics/logging for admission decisions. | Configurable cache TTL + seeds survive restart, fail-open/closed toggles verified via tests, metrics/logging exported per decision path, docs note operational knobs. |
|
||||
| ZASTAVA-WEBHOOK-12-104 | TODO | Zastava Webhook Guild | ZASTAVA-WEBHOOK-12-102 | Wire `/admission` endpoint to runtime policy client and emit allow/deny envelopes. | Admission handler resolves pods to digests, invokes policy client, returns canonical `AdmissionDecisionEnvelope` with deterministic logging and metrics. |
|
||||
|
||||
> Status update · 2025-10-19: Confirmed no prerequisites for ZASTAVA-WEBHOOK-12-101/102/103; tasks moved to DOING for kickoff. Implementation plan covering TLS bootstrap, backend contract, caching/metrics recorded in `IMPLEMENTATION_PLAN.md`.
|
||||
|
||||
Reference in New Issue
Block a user