This commit is contained in:
master
2025-10-24 09:15:37 +03:00
parent 70d7fb529e
commit d8253ec3af
163 changed files with 14269 additions and 452 deletions

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View 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);
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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**

View File

@@ -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) =>
{

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Zastava.Webhook.Tests")]

View File

@@ -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>

View File

@@ -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`.