Restructure solution layout by module
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Admission;
|
||||
|
||||
internal static class AdmissionEndpoint
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static async Task<IResult> HandleAsync(
|
||||
HttpContext httpContext,
|
||||
AdmissionReviewParser parser,
|
||||
AdmissionResponseBuilder responseBuilder,
|
||||
IRuntimeAdmissionPolicyService policyService,
|
||||
IZastavaLogScopeBuilder logScopeBuilder,
|
||||
ILogger<AdmissionEndpointMarker> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AdmissionReviewRequestDto? dto;
|
||||
try
|
||||
{
|
||||
dto = await httpContext.Request.ReadFromJsonAsync<AdmissionReviewRequestDto>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to deserialize AdmissionReview payload.");
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Invalid AdmissionReview",
|
||||
detail: "Request body was not a valid AdmissionReview document.",
|
||||
type: "https://stellaops.org/problems/admission.review.invalid-json");
|
||||
}
|
||||
|
||||
AdmissionRequestContext context;
|
||||
try
|
||||
{
|
||||
context = parser.Parse(dto!);
|
||||
}
|
||||
catch (AdmissionReviewParseException ex)
|
||||
{
|
||||
logger.LogWarning("AdmissionReview parse failure ({Code}): {Message}", ex.Code, ex.Message);
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Invalid AdmissionReview",
|
||||
detail: ex.Message,
|
||||
type: $"https://stellaops.org/problems/{ex.Code}");
|
||||
}
|
||||
|
||||
using var scope = logger.BeginScope(logScopeBuilder.BuildScope(
|
||||
correlationId: context.Uid,
|
||||
node: null,
|
||||
workload: context.Namespace,
|
||||
eventId: context.Uid,
|
||||
additional: new Dictionary<string, string>
|
||||
{
|
||||
["namespace"] = context.Namespace,
|
||||
["containerCount"] = context.Containers.Count.ToString(CultureInfo.InvariantCulture)
|
||||
}));
|
||||
|
||||
var request = new RuntimeAdmissionRequest(
|
||||
context.Namespace,
|
||||
context.Labels,
|
||||
context.Containers.Select(static c => c.Image).ToArray());
|
||||
|
||||
RuntimeAdmissionEvaluation evaluation;
|
||||
try
|
||||
{
|
||||
evaluation = await policyService.EvaluateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogError(ex, "Admission evaluation failed unexpectedly.");
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Admission evaluation failed",
|
||||
detail: "An unexpected error occurred while evaluating admission policy.",
|
||||
type: "https://stellaops.org/problems/admission.evaluation.failed");
|
||||
}
|
||||
|
||||
var (envelope, response) = responseBuilder.Build(context, evaluation);
|
||||
var allowed = evaluation.Decisions.All(static d => d.Allowed);
|
||||
|
||||
logger.LogInformation("Admission decision computed (allowed={Allowed}, containers={Count}, failOpen={FailOpen}).",
|
||||
allowed,
|
||||
context.Containers.Count,
|
||||
evaluation.FailOpenApplied);
|
||||
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
return Results.Json(response, SerializerOptions);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AdmissionEndpointMarker
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Admission;
|
||||
|
||||
internal sealed record AdmissionRequestContext(
|
||||
string ApiVersion,
|
||||
string Kind,
|
||||
string Uid,
|
||||
string Namespace,
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
IReadOnlyList<AdmissionContainerReference> Containers,
|
||||
JsonElement PodObject,
|
||||
JsonElement PodSpec);
|
||||
|
||||
internal sealed record AdmissionContainerReference(string Name, string Image);
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Buffers;
|
||||
using System.Linq;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Core.Hashing;
|
||||
using StellaOps.Zastava.Core.Serialization;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Admission;
|
||||
|
||||
internal sealed class AdmissionResponseBuilder
|
||||
{
|
||||
public (AdmissionDecisionEnvelope Envelope, AdmissionReviewResponseDto Response) Build(
|
||||
AdmissionRequestContext context,
|
||||
RuntimeAdmissionEvaluation evaluation)
|
||||
{
|
||||
var decision = BuildDecision(context, evaluation);
|
||||
var envelope = AdmissionDecisionEnvelope.Create(decision, ZastavaContractVersions.AdmissionDecision);
|
||||
var auditAnnotations = CreateAuditAnnotations(envelope, evaluation);
|
||||
|
||||
var warnings = BuildWarnings(evaluation);
|
||||
var allowed = evaluation.Decisions.All(static d => d.Allowed);
|
||||
var status = allowed
|
||||
? null
|
||||
: new AdmissionReviewStatus
|
||||
{
|
||||
Code = 403,
|
||||
Message = BuildFailureMessage(evaluation)
|
||||
};
|
||||
|
||||
var response = new AdmissionReviewResponseDto
|
||||
{
|
||||
ApiVersion = context.ApiVersion,
|
||||
Kind = context.Kind,
|
||||
Response = new AdmissionReviewResponsePayload
|
||||
{
|
||||
Uid = context.Uid,
|
||||
Allowed = allowed,
|
||||
Status = status,
|
||||
Warnings = warnings,
|
||||
AuditAnnotations = auditAnnotations
|
||||
}
|
||||
};
|
||||
|
||||
return (envelope, response);
|
||||
}
|
||||
|
||||
private static AdmissionDecision BuildDecision(AdmissionRequestContext context, RuntimeAdmissionEvaluation evaluation)
|
||||
{
|
||||
var images = new List<AdmissionImageVerdict>(evaluation.Decisions.Count);
|
||||
for (var i = 0; i < evaluation.Decisions.Count; i++)
|
||||
{
|
||||
var decision = evaluation.Decisions[i];
|
||||
var container = context.Containers[Math.Min(i, context.Containers.Count - 1)];
|
||||
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["image"] = decision.OriginalImage
|
||||
};
|
||||
|
||||
if (!string.Equals(container.Image, container.Name, StringComparison.Ordinal))
|
||||
{
|
||||
metadata["container"] = container.Name;
|
||||
}
|
||||
|
||||
if (decision.FromCache)
|
||||
{
|
||||
metadata["cache"] = "hit";
|
||||
}
|
||||
|
||||
var resolved = decision.ResolvedDigest ?? decision.OriginalImage;
|
||||
|
||||
images.Add(new AdmissionImageVerdict
|
||||
{
|
||||
Name = container.Name,
|
||||
Resolved = resolved,
|
||||
Signed = decision.Policy?.Signed ?? false,
|
||||
HasSbomReferrers = decision.Policy?.HasSbom ?? false,
|
||||
PolicyVerdict = decision.Verdict,
|
||||
Reasons = decision.Reasons,
|
||||
Rekor = decision.Policy?.Rekor,
|
||||
Metadata = metadata
|
||||
});
|
||||
}
|
||||
|
||||
return new AdmissionDecision
|
||||
{
|
||||
AdmissionId = context.Uid,
|
||||
Namespace = context.Namespace,
|
||||
PodSpecDigest = ComputePodSpecDigest(context.PodSpec),
|
||||
Images = images,
|
||||
Decision = evaluation.Decisions.All(static d => d.Allowed)
|
||||
? AdmissionDecisionOutcome.Allow
|
||||
: AdmissionDecisionOutcome.Deny,
|
||||
TtlSeconds = Math.Max(0, evaluation.TtlSeconds),
|
||||
Annotations = BuildAnnotations(evaluation)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string>? BuildAnnotations(RuntimeAdmissionEvaluation evaluation)
|
||||
{
|
||||
if (!evaluation.BackendFailed && !evaluation.FailOpenApplied && evaluation.FailureReason is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var annotations = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (evaluation.BackendFailed)
|
||||
{
|
||||
annotations["zastava.backend.failed"] = "true";
|
||||
}
|
||||
|
||||
if (evaluation.FailOpenApplied)
|
||||
{
|
||||
annotations["zastava.failOpen"] = "true";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(evaluation.FailureReason))
|
||||
{
|
||||
annotations["zastava.failureReason"] = evaluation.FailureReason!;
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> CreateAuditAnnotations(AdmissionDecisionEnvelope envelope, RuntimeAdmissionEvaluation evaluation)
|
||||
{
|
||||
var annotations = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["zastava.stellaops/admission"] = ZastavaCanonicalJsonSerializer.Serialize(envelope)
|
||||
};
|
||||
|
||||
if (evaluation.FailOpenApplied)
|
||||
{
|
||||
annotations["zastava.stellaops/failOpen"] = "true";
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? BuildWarnings(RuntimeAdmissionEvaluation evaluation)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (evaluation.FailOpenApplied)
|
||||
{
|
||||
warnings.Add("zastava.fail_open.applied");
|
||||
}
|
||||
|
||||
foreach (var decision in evaluation.Decisions)
|
||||
{
|
||||
if (decision.Verdict == PolicyVerdict.Warn)
|
||||
{
|
||||
warnings.Add($"policy.warn:{decision.OriginalImage}");
|
||||
}
|
||||
}
|
||||
|
||||
return warnings.Count == 0 ? null : warnings;
|
||||
}
|
||||
|
||||
private static string BuildFailureMessage(RuntimeAdmissionEvaluation evaluation)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(evaluation.FailureReason))
|
||||
{
|
||||
return evaluation.FailureReason!;
|
||||
}
|
||||
|
||||
var denied = evaluation.Decisions
|
||||
.Where(static d => !d.Allowed)
|
||||
.SelectMany(static d => d.Reasons)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return denied.Length > 0
|
||||
? string.Join(", ", denied)
|
||||
: "admission.denied";
|
||||
}
|
||||
|
||||
private static string ComputePodSpecDigest(JsonElement podSpec)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false
|
||||
}))
|
||||
{
|
||||
WriteCanonical(podSpec, writer);
|
||||
}
|
||||
|
||||
return ZastavaHashing.ComputeMultihash(buffer.WrittenSpan);
|
||||
}
|
||||
|
||||
private static void WriteCanonical(JsonElement element, Utf8JsonWriter writer)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(static p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonical(property.Value, writer);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(item, writer);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
default:
|
||||
element.WriteTo(writer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Admission;
|
||||
|
||||
internal sealed record AdmissionReviewRequestDto
|
||||
{
|
||||
[JsonPropertyName("apiVersion")]
|
||||
public string? ApiVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string? Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("request")]
|
||||
public AdmissionReviewRequestPayload? Request { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record AdmissionReviewRequestPayload
|
||||
{
|
||||
[JsonPropertyName("uid")]
|
||||
public string? Uid { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public AdmissionReviewGroupVersionKind? Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("namespace")]
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("object")]
|
||||
public JsonElement Object { get; init; }
|
||||
|
||||
[JsonPropertyName("dryRun")]
|
||||
public bool? DryRun { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record AdmissionReviewGroupVersionKind
|
||||
{
|
||||
[JsonPropertyName("group")]
|
||||
public string? Group { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string? Kind { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record AdmissionReviewResponseDto
|
||||
{
|
||||
[JsonPropertyName("apiVersion")]
|
||||
public string ApiVersion { get; init; } = "admission.k8s.io/v1";
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = "AdmissionReview";
|
||||
|
||||
[JsonPropertyName("response")]
|
||||
public required AdmissionReviewResponsePayload Response { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record AdmissionReviewResponsePayload
|
||||
{
|
||||
[JsonPropertyName("uid")]
|
||||
public required string Uid { get; init; }
|
||||
|
||||
[JsonPropertyName("allowed")]
|
||||
public required bool Allowed { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public AdmissionReviewStatus? Status { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
|
||||
[JsonPropertyName("auditAnnotations")]
|
||||
public IReadOnlyDictionary<string, string>? AuditAnnotations { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record AdmissionReviewStatus
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public int? Code { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Admission;
|
||||
|
||||
internal sealed class AdmissionReviewParser
|
||||
{
|
||||
public AdmissionRequestContext Parse(AdmissionReviewRequestDto dto)
|
||||
{
|
||||
if (dto is null)
|
||||
{
|
||||
throw new AdmissionReviewParseException("admission.review.invalid", "AdmissionReview payload was empty.");
|
||||
}
|
||||
|
||||
if (!string.Equals(dto.Kind, "AdmissionReview", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new AdmissionReviewParseException("admission.review.kind", "AdmissionReview.kind must equal 'AdmissionReview'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.ApiVersion))
|
||||
{
|
||||
throw new AdmissionReviewParseException("admission.review.apiVersion", "AdmissionReview.apiVersion is required.");
|
||||
}
|
||||
|
||||
var payload = dto.Request ?? throw new AdmissionReviewParseException("admission.review.request", "AdmissionReview.request is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.Uid))
|
||||
{
|
||||
throw new AdmissionReviewParseException("admission.review.uid", "AdmissionReview.request.uid is required.");
|
||||
}
|
||||
|
||||
if (payload.Object.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
throw new AdmissionReviewParseException("admission.review.object", "AdmissionReview.request.object must be a JSON object.");
|
||||
}
|
||||
|
||||
var podObject = payload.Object;
|
||||
if (!podObject.TryGetProperty("spec", out var podSpec) || podSpec.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
throw new AdmissionReviewParseException("admission.review.podSpec", "AdmissionReview.request.object.spec is required.");
|
||||
}
|
||||
|
||||
var podNamespace = payload.Namespace
|
||||
?? TryGetProperty(podObject, "metadata", "namespace")
|
||||
?? throw new AdmissionReviewParseException("admission.review.namespace", "Namespace could not be determined for the pod.");
|
||||
|
||||
var labels = ReadLabels(podObject);
|
||||
var containers = ReadContainers(podSpec);
|
||||
if (containers.Count == 0)
|
||||
{
|
||||
throw new AdmissionReviewParseException("admission.review.containers", "No containers were found in the pod spec.");
|
||||
}
|
||||
|
||||
return new AdmissionRequestContext(
|
||||
ApiVersion: dto.ApiVersion!,
|
||||
Kind: dto.Kind!,
|
||||
Uid: payload.Uid!,
|
||||
Namespace: podNamespace,
|
||||
Labels: labels,
|
||||
Containers: containers,
|
||||
PodObject: podObject,
|
||||
PodSpec: podSpec);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ReadLabels(JsonElement podObject)
|
||||
{
|
||||
if (!podObject.TryGetProperty("metadata", out var metadata) || metadata.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
if (!metadata.TryGetProperty("labels", out var labelsElement) || labelsElement.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var labels = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var property in labelsElement.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind is JsonValueKind.String)
|
||||
{
|
||||
labels[property.Name] = property.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdmissionContainerReference> ReadContainers(JsonElement podSpec)
|
||||
{
|
||||
var containers = new List<AdmissionContainerReference>();
|
||||
CollectContainers(podSpec, "containers", containers);
|
||||
CollectContainers(podSpec, "initContainers", containers);
|
||||
CollectContainers(podSpec, "ephemeralContainers", containers);
|
||||
return containers;
|
||||
}
|
||||
|
||||
private static void CollectContainers(JsonElement spec, string propertyName, ICollection<AdmissionContainerReference> sink)
|
||||
{
|
||||
if (!spec.TryGetProperty(propertyName, out var array) || array.ValueKind is not JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var element in array.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var image = TryGetProperty(element, "image");
|
||||
if (string.IsNullOrWhiteSpace(image))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = TryGetProperty(element, "name") ?? image;
|
||||
sink.Add(new AdmissionContainerReference(name, image));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetProperty(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return element.TryGetProperty(propertyName, out var property) && property.ValueKind is JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? TryGetProperty(JsonElement element, string firstProperty, string nestedProperty)
|
||||
{
|
||||
if (!element.TryGetProperty(firstProperty, out var nested) || nested.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return TryGetProperty(nested, nestedProperty);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AdmissionReviewParseException : Exception
|
||||
{
|
||||
public AdmissionReviewParseException(string code, string message)
|
||||
: base(message)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public string Code { get; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Admission;
|
||||
|
||||
internal interface IImageDigestResolver
|
||||
{
|
||||
Task<ImageResolutionResult> ResolveAsync(string imageReference, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class ImageDigestResolver : IImageDigestResolver
|
||||
{
|
||||
private static readonly Regex DigestPattern = new(@"(?<algorithm>[a-z0-9_+.-]+):(?<digest>[a-f0-9]{32,})", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
|
||||
public Task<ImageResolutionResult> ResolveAsync(string imageReference, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageReference))
|
||||
{
|
||||
return Task.FromResult(ImageResolutionResult.CreateFailure(imageReference, "image.reference.empty"));
|
||||
}
|
||||
|
||||
if (imageReference.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
var digest = imageReference[(imageReference.IndexOf('@') + 1)..];
|
||||
if (DigestPattern.IsMatch(digest))
|
||||
{
|
||||
return Task.FromResult(ImageResolutionResult.CreateSuccess(imageReference, digest));
|
||||
}
|
||||
|
||||
return Task.FromResult(ImageResolutionResult.CreateFailure(imageReference, "image.reference.invalid_digest"));
|
||||
}
|
||||
|
||||
if (DigestPattern.IsMatch(imageReference))
|
||||
{
|
||||
return Task.FromResult(ImageResolutionResult.CreateSuccess(imageReference, imageReference));
|
||||
}
|
||||
|
||||
return Task.FromResult(ImageResolutionResult.CreateFailure(imageReference, "image.reference.tag_unresolved"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ImageResolutionResult(
|
||||
string Original,
|
||||
string? ResolvedDigest,
|
||||
bool Success,
|
||||
string? FailureReason)
|
||||
{
|
||||
public static ImageResolutionResult CreateSuccess(string original, string digest)
|
||||
=> new(original, digest, true, null);
|
||||
|
||||
public static ImageResolutionResult CreateFailure(string original, string reason)
|
||||
=> new(original, null, false, reason);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Webhook.Backend;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Admission;
|
||||
|
||||
internal interface IRuntimeAdmissionPolicyService
|
||||
{
|
||||
Task<RuntimeAdmissionEvaluation> EvaluateAsync(RuntimeAdmissionRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class RuntimeAdmissionPolicyService : IRuntimeAdmissionPolicyService
|
||||
{
|
||||
private readonly IRuntimePolicyClient policyClient;
|
||||
private readonly IImageDigestResolver digestResolver;
|
||||
private readonly RuntimePolicyCache cache;
|
||||
private readonly IOptionsMonitor<ZastavaWebhookOptions> options;
|
||||
private readonly IZastavaRuntimeMetrics runtimeMetrics;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<RuntimeAdmissionPolicyService> logger;
|
||||
|
||||
public RuntimeAdmissionPolicyService(
|
||||
IRuntimePolicyClient policyClient,
|
||||
IImageDigestResolver digestResolver,
|
||||
RuntimePolicyCache cache,
|
||||
IOptionsMonitor<ZastavaWebhookOptions> options,
|
||||
IZastavaRuntimeMetrics runtimeMetrics,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RuntimeAdmissionPolicyService> logger)
|
||||
{
|
||||
this.policyClient = policyClient ?? throw new ArgumentNullException(nameof(policyClient));
|
||||
this.digestResolver = digestResolver ?? throw new ArgumentNullException(nameof(digestResolver));
|
||||
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
this.options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
this.runtimeMetrics = runtimeMetrics ?? throw new ArgumentNullException(nameof(runtimeMetrics));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RuntimeAdmissionEvaluation> EvaluateAsync(RuntimeAdmissionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.Images.Count == 0)
|
||||
{
|
||||
return RuntimeAdmissionEvaluation.Empty();
|
||||
}
|
||||
|
||||
var admissionOptions = options.CurrentValue.Admission;
|
||||
|
||||
var resolutionResults = new List<ImageResolutionResult>(request.Images.Count);
|
||||
foreach (var image in request.Images)
|
||||
{
|
||||
var resolution = await digestResolver.ResolveAsync(image, cancellationToken).ConfigureAwait(false);
|
||||
resolutionResults.Add(resolution);
|
||||
}
|
||||
|
||||
var resolved = resolutionResults.Where(static r => r.Success && r.ResolvedDigest is not null)
|
||||
.GroupBy(r => r.ResolvedDigest!, StringComparer.Ordinal)
|
||||
.Select(group => new ResolvedDigest(group.Key, group.ToArray()))
|
||||
.ToArray();
|
||||
|
||||
var combinedResults = new Dictionary<string, RuntimePolicyImageResult>(StringComparer.Ordinal);
|
||||
var backendMisses = new List<string>();
|
||||
var fromCache = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var digest in resolved)
|
||||
{
|
||||
if (cache.TryGet(digest.Digest, out var cached))
|
||||
{
|
||||
combinedResults[digest.Digest] = cached;
|
||||
fromCache.Add(digest.Digest);
|
||||
}
|
||||
else
|
||||
{
|
||||
backendMisses.Add(digest.Digest);
|
||||
}
|
||||
}
|
||||
|
||||
RuntimePolicyResponse? backendResponse = null;
|
||||
bool backendFailed = false;
|
||||
var ttlSeconds = 300;
|
||||
if (backendMisses.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
backendResponse = await policyClient.EvaluateAsync(new RuntimePolicyRequest
|
||||
{
|
||||
Namespace = request.Namespace ?? string.Empty,
|
||||
Labels = request.Labels,
|
||||
Images = backendMisses
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var expiry = CalculateExpiry(backendResponse);
|
||||
ttlSeconds = Math.Max(1, (int)Math.Ceiling((expiry - now).TotalSeconds));
|
||||
foreach (var pair in backendResponse.Results)
|
||||
{
|
||||
combinedResults[pair.Key] = pair.Value;
|
||||
cache.Set(pair.Key, pair.Value, expiry);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
backendFailed = true;
|
||||
logger.LogWarning(ex, "Runtime policy backend call failed for namespace {Namespace}.", request.Namespace ?? "<none>");
|
||||
}
|
||||
}
|
||||
|
||||
var failOpenApplied = false;
|
||||
var decisions = new List<RuntimeAdmissionDecision>(request.Images.Count);
|
||||
var effectiveTtl = backendResponse?.TtlSeconds is > 0 ? backendResponse.TtlSeconds : ttlSeconds;
|
||||
|
||||
if (backendFailed && backendMisses.Count > 0)
|
||||
{
|
||||
failOpenApplied = ShouldFailOpen(admissionOptions, request.Namespace);
|
||||
foreach (var resolution in resolutionResults)
|
||||
{
|
||||
if (resolution.Success && resolution.ResolvedDigest is not null)
|
||||
{
|
||||
var allowed = failOpenApplied;
|
||||
var reasons = failOpenApplied
|
||||
? new[] { "zastava.fail_open.backend_unavailable" }
|
||||
: new[] { "zastava.backend.unavailable" };
|
||||
|
||||
RecordDecisionMetrics(allowed, true, failOpenApplied, RuntimeEventKind.ContainerStart);
|
||||
decisions.Add(new RuntimeAdmissionDecision
|
||||
{
|
||||
OriginalImage = resolution.Original,
|
||||
ResolvedDigest = resolution.ResolvedDigest,
|
||||
Verdict = allowed ? PolicyVerdict.Warn : PolicyVerdict.Error,
|
||||
Allowed = allowed,
|
||||
Policy = null,
|
||||
Reasons = reasons,
|
||||
FromCache = false,
|
||||
ResolutionFailed = false
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
decisions.Add(CreateResolutionFailureDecision(resolution));
|
||||
}
|
||||
}
|
||||
|
||||
return new RuntimeAdmissionEvaluation
|
||||
{
|
||||
Decisions = decisions,
|
||||
BackendFailed = true,
|
||||
FailOpenApplied = failOpenApplied,
|
||||
FailureReason = failOpenApplied ? null : "backend.unavailable",
|
||||
TtlSeconds = effectiveTtl
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var resolution in resolutionResults)
|
||||
{
|
||||
if (!resolution.Success || resolution.ResolvedDigest is null)
|
||||
{
|
||||
var failureDecision = CreateResolutionFailureDecision(resolution);
|
||||
RecordDecisionMetrics(failureDecision.Allowed, false, false, RuntimeEventKind.ContainerStart);
|
||||
decisions.Add(failureDecision);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!combinedResults.TryGetValue(resolution.ResolvedDigest, out var policyResult))
|
||||
{
|
||||
var synthetic = new RuntimeAdmissionDecision
|
||||
{
|
||||
OriginalImage = resolution.Original,
|
||||
ResolvedDigest = resolution.ResolvedDigest,
|
||||
Verdict = PolicyVerdict.Error,
|
||||
Allowed = false,
|
||||
Policy = null,
|
||||
Reasons = new[] { "zastava.policy.result.missing" },
|
||||
FromCache = false,
|
||||
ResolutionFailed = false
|
||||
};
|
||||
RecordDecisionMetrics(false, false, false, RuntimeEventKind.ContainerStart);
|
||||
decisions.Add(synthetic);
|
||||
continue;
|
||||
}
|
||||
|
||||
var allowed = policyResult.PolicyVerdict is PolicyVerdict.Pass or PolicyVerdict.Warn;
|
||||
var cached = fromCache.Contains(resolution.ResolvedDigest);
|
||||
var reasons = policyResult.Reasons.Count > 0 ? policyResult.Reasons : Array.Empty<string>();
|
||||
RecordDecisionMetrics(allowed, cached, false, RuntimeEventKind.ContainerStart);
|
||||
decisions.Add(new RuntimeAdmissionDecision
|
||||
{
|
||||
OriginalImage = resolution.Original,
|
||||
ResolvedDigest = resolution.ResolvedDigest,
|
||||
Verdict = policyResult.PolicyVerdict,
|
||||
Allowed = allowed,
|
||||
Policy = policyResult,
|
||||
Reasons = reasons,
|
||||
FromCache = cached,
|
||||
ResolutionFailed = false
|
||||
});
|
||||
}
|
||||
|
||||
return new RuntimeAdmissionEvaluation
|
||||
{
|
||||
Decisions = decisions,
|
||||
BackendFailed = backendFailed,
|
||||
FailOpenApplied = failOpenApplied,
|
||||
FailureReason = null,
|
||||
TtlSeconds = effectiveTtl
|
||||
};
|
||||
}
|
||||
|
||||
private static RuntimeAdmissionDecision CreateResolutionFailureDecision(ImageResolutionResult resolution)
|
||||
=> new RuntimeAdmissionDecision
|
||||
{
|
||||
OriginalImage = resolution.Original,
|
||||
ResolvedDigest = null,
|
||||
Verdict = PolicyVerdict.Fail,
|
||||
Allowed = false,
|
||||
Policy = null,
|
||||
Reasons = new[] { resolution.FailureReason ?? "image.resolution.failed" },
|
||||
FromCache = false,
|
||||
ResolutionFailed = true
|
||||
};
|
||||
|
||||
private void RecordDecisionMetrics(bool allowed, bool fromCache, bool failOpen, RuntimeEventKind eventKind)
|
||||
{
|
||||
var tags = runtimeMetrics.DefaultTags
|
||||
.Concat(new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("decision", allowed ? "allow" : "deny"),
|
||||
new KeyValuePair<string, object?>("source", fromCache ? "cache" : "backend"),
|
||||
new KeyValuePair<string, object?>("fail_open", failOpen ? "true" : "false"),
|
||||
new KeyValuePair<string, object?>("event", eventKind.ToString())
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
runtimeMetrics.AdmissionDecisions.Add(1, tags);
|
||||
}
|
||||
|
||||
private bool ShouldFailOpen(ZastavaWebhookAdmissionOptions admission, string? @namespace)
|
||||
{
|
||||
if (@namespace is null)
|
||||
{
|
||||
return admission.FailOpenByDefault;
|
||||
}
|
||||
|
||||
if (admission.FailClosedNamespaces.Contains(@namespace))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (admission.FailOpenNamespaces.Contains(@namespace))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return admission.FailOpenByDefault;
|
||||
}
|
||||
|
||||
private DateTimeOffset CalculateExpiry(RuntimePolicyResponse response)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var ttlSeconds = Math.Max(1, response.TtlSeconds);
|
||||
var intended = now.AddSeconds(ttlSeconds);
|
||||
if (response.ExpiresAtUtc != default)
|
||||
{
|
||||
return response.ExpiresAtUtc < intended ? response.ExpiresAtUtc : intended;
|
||||
}
|
||||
|
||||
return intended;
|
||||
}
|
||||
|
||||
private sealed record ResolvedDigest(string Digest, IReadOnlyList<ImageResolutionResult> Entries);
|
||||
}
|
||||
|
||||
internal sealed record RuntimeAdmissionRequest(
|
||||
string? Namespace,
|
||||
IReadOnlyDictionary<string, string> Labels,
|
||||
IReadOnlyList<string> Images);
|
||||
|
||||
internal sealed record RuntimeAdmissionDecision
|
||||
{
|
||||
public required string OriginalImage { get; init; }
|
||||
public string? ResolvedDigest { get; init; }
|
||||
public PolicyVerdict Verdict { get; init; }
|
||||
public bool Allowed { get; init; }
|
||||
public RuntimePolicyImageResult? Policy { get; init; }
|
||||
public IReadOnlyList<string> Reasons { get; init; } = Array.Empty<string>();
|
||||
public bool FromCache { get; init; }
|
||||
public bool ResolutionFailed { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record RuntimeAdmissionEvaluation
|
||||
{
|
||||
public required IReadOnlyList<RuntimeAdmissionDecision> Decisions { get; init; }
|
||||
public bool BackendFailed { get; init; }
|
||||
public bool FailOpenApplied { get; init; }
|
||||
public string? FailureReason { get; init; }
|
||||
public int TtlSeconds { get; init; }
|
||||
|
||||
public static RuntimeAdmissionEvaluation Empty()
|
||||
=> new()
|
||||
{
|
||||
Decisions = Array.Empty<RuntimeAdmissionDecision>(),
|
||||
BackendFailed = false,
|
||||
FailOpenApplied = false,
|
||||
FailureReason = null,
|
||||
TtlSeconds = 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Webhook.Backend;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Admission;
|
||||
|
||||
internal sealed class RuntimePolicyCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> entries = new(StringComparer.Ordinal);
|
||||
private readonly ILogger<RuntimePolicyCache> logger;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public RuntimePolicyCache(IOptions<ZastavaWebhookOptions> options, TimeProvider timeProvider, ILogger<RuntimePolicyCache> logger)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var admission = options.Value.Admission;
|
||||
if (!string.IsNullOrWhiteSpace(admission.CacheSeedPath) && File.Exists(admission.CacheSeedPath))
|
||||
{
|
||||
TryLoadSeed(admission.CacheSeedPath!);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGet(string digest, out RuntimePolicyImageResult result)
|
||||
{
|
||||
if (entries.TryGetValue(digest, out var entry))
|
||||
{
|
||||
if (timeProvider.GetUtcNow() <= entry.ExpiresAtUtc)
|
||||
{
|
||||
result = entry.Result;
|
||||
return true;
|
||||
}
|
||||
|
||||
entries.TryRemove(digest, out _);
|
||||
}
|
||||
|
||||
result = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Set(string digest, RuntimePolicyImageResult result, DateTimeOffset expiresAtUtc)
|
||||
{
|
||||
entries[digest] = new CacheEntry(result, expiresAtUtc);
|
||||
}
|
||||
|
||||
private void TryLoadSeed(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = File.ReadAllText(path);
|
||||
var seed = JsonSerializer.Deserialize<RuntimePolicyResponse>(payload, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
if (seed?.Results is null || seed.Results.Count == 0)
|
||||
{
|
||||
logger.LogDebug("Runtime policy cache seed file {Path} empty or invalid.", path);
|
||||
return;
|
||||
}
|
||||
|
||||
var ttlSeconds = Math.Max(1, seed.TtlSeconds);
|
||||
var expires = timeProvider.GetUtcNow().AddSeconds(ttlSeconds);
|
||||
foreach (var pair in seed.Results)
|
||||
{
|
||||
Set(pair.Key, pair.Value, expires);
|
||||
}
|
||||
|
||||
logger.LogInformation("Loaded {Count} runtime policy cache seed entries from {Path}.", seed.Results.Count, path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to load runtime policy cache seed from {Path}.", path);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(RuntimePolicyImageResult Result, DateTimeOffset ExpiresAtUtc);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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);
|
||||
}
|
||||
@@ -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,39 @@
|
||||
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("expiresAtUtc")]
|
||||
public DateTimeOffset ExpiresAtUtc { get; init; }
|
||||
|
||||
[JsonPropertyName("policyRevision")]
|
||||
public string? PolicyRevision { 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; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder implementation for CSR-based certificate provisioning.
|
||||
/// </summary>
|
||||
public sealed class CsrCertificateSource : IWebhookCertificateSource
|
||||
{
|
||||
private readonly ILogger<CsrCertificateSource> _logger;
|
||||
|
||||
public CsrCertificateSource(ILogger<CsrCertificateSource> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool CanHandle(ZastavaWebhookTlsMode mode) => mode == ZastavaWebhookTlsMode.CertificateSigningRequest;
|
||||
|
||||
public X509Certificate2 LoadCertificate(ZastavaWebhookTlsOptions options)
|
||||
{
|
||||
_logger.LogError("CSR certificate mode is not implemented yet. Configuration requested CSR mode.");
|
||||
throw new NotSupportedException("CSR certificate provisioning is not implemented (tracked by ZASTAVA-WEBHOOK-12-101).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
public interface IWebhookCertificateProvider
|
||||
{
|
||||
X509Certificate2 GetCertificate();
|
||||
}
|
||||
|
||||
public sealed class WebhookCertificateProvider : IWebhookCertificateProvider
|
||||
{
|
||||
private readonly ILogger<WebhookCertificateProvider> _logger;
|
||||
private readonly ZastavaWebhookTlsOptions _options;
|
||||
private readonly Lazy<X509Certificate2> _certificate;
|
||||
private readonly IWebhookCertificateSource _certificateSource;
|
||||
|
||||
public WebhookCertificateProvider(
|
||||
IOptions<ZastavaWebhookOptions> options,
|
||||
IEnumerable<IWebhookCertificateSource> certificateSources,
|
||||
ILogger<WebhookCertificateProvider> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_options = options.Value.Tls;
|
||||
_certificateSource = certificateSources.FirstOrDefault(source => source.CanHandle(_options.Mode))
|
||||
?? throw new InvalidOperationException($"No certificate source registered for mode {_options.Mode}.");
|
||||
|
||||
_certificate = new Lazy<X509Certificate2>(LoadCertificate, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
}
|
||||
|
||||
public X509Certificate2 GetCertificate() => _certificate.Value;
|
||||
|
||||
private X509Certificate2 LoadCertificate()
|
||||
{
|
||||
_logger.LogInformation("Loading webhook TLS certificate using {Mode} mode.", _options.Mode);
|
||||
var certificate = _certificateSource.LoadCertificate(_options);
|
||||
_logger.LogInformation("Loaded webhook TLS certificate with subject {Subject} and thumbprint {Thumbprint}.",
|
||||
certificate.Subject, certificate.Thumbprint);
|
||||
return certificate;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IWebhookCertificateSource
|
||||
{
|
||||
bool CanHandle(ZastavaWebhookTlsMode mode);
|
||||
|
||||
X509Certificate2 LoadCertificate(ZastavaWebhookTlsOptions options);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
public sealed class SecretFileCertificateSource : IWebhookCertificateSource
|
||||
{
|
||||
private readonly ILogger<SecretFileCertificateSource> _logger;
|
||||
|
||||
public SecretFileCertificateSource(ILogger<SecretFileCertificateSource> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool CanHandle(ZastavaWebhookTlsMode mode) => mode == ZastavaWebhookTlsMode.Secret;
|
||||
|
||||
public X509Certificate2 LoadCertificate(ZastavaWebhookTlsOptions options)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.PfxPath))
|
||||
{
|
||||
return LoadFromPfx(options.PfxPath, options.PfxPassword);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.CertificatePath) || string.IsNullOrWhiteSpace(options.PrivateKeyPath))
|
||||
{
|
||||
throw new InvalidOperationException("TLS mode 'Secret' requires either a PFX bundle or both PEM certificate and private key paths.");
|
||||
}
|
||||
|
||||
if (!File.Exists(options.CertificatePath))
|
||||
{
|
||||
throw new FileNotFoundException("Webhook certificate file not found.", options.CertificatePath);
|
||||
}
|
||||
|
||||
if (!File.Exists(options.PrivateKeyPath))
|
||||
{
|
||||
throw new FileNotFoundException("Webhook certificate private key file not found.", options.PrivateKeyPath);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var certificate = X509Certificate2.CreateFromPemFile(options.CertificatePath, options.PrivateKeyPath)
|
||||
.WithExportablePrivateKey();
|
||||
|
||||
_logger.LogDebug("Loaded certificate {Subject} from PEM secret files.", certificate.Subject);
|
||||
return certificate;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load webhook certificate from PEM files {CertPath} / {KeyPath}.",
|
||||
options.CertificatePath, options.PrivateKeyPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private X509Certificate2 LoadFromPfx(string pfxPath, string? password)
|
||||
{
|
||||
if (!File.Exists(pfxPath))
|
||||
{
|
||||
throw new FileNotFoundException("Webhook certificate PFX bundle not found.", pfxPath);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var storageFlags = X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.EphemeralKeySet;
|
||||
var certificate = X509CertificateLoader.LoadPkcs12FromFile(pfxPath, password, storageFlags);
|
||||
_logger.LogDebug("Loaded certificate {Subject} from PFX bundle.", certificate.Subject);
|
||||
return certificate;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load webhook certificate from PFX bundle {PfxPath}.", pfxPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Certificates;
|
||||
|
||||
public sealed class WebhookCertificateHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IWebhookCertificateProvider _certificateProvider;
|
||||
private readonly ILogger<WebhookCertificateHealthCheck> _logger;
|
||||
private readonly TimeSpan _expiryThreshold = TimeSpan.FromDays(7);
|
||||
|
||||
public WebhookCertificateHealthCheck(
|
||||
IWebhookCertificateProvider certificateProvider,
|
||||
ILogger<WebhookCertificateHealthCheck> logger)
|
||||
{
|
||||
_certificateProvider = certificateProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var certificate = _certificateProvider.GetCertificate();
|
||||
var expires = certificate.NotAfter.ToUniversalTime();
|
||||
var remaining = expires - DateTimeOffset.UtcNow;
|
||||
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy("Webhook certificate expired.", data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = expires.ToString("O")
|
||||
}));
|
||||
}
|
||||
|
||||
if (remaining <= _expiryThreshold)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Degraded("Webhook certificate nearing expiry.", data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = expires.ToString("O"),
|
||||
["daysRemaining"] = remaining.TotalDays
|
||||
}));
|
||||
}
|
||||
|
||||
return Task.FromResult(HealthCheckResult.Healthy("Webhook certificate valid.", data: new Dictionary<string, object>
|
||||
{
|
||||
["expiresAtUtc"] = expires.ToString("O"),
|
||||
["daysRemaining"] = remaining.TotalDays
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load webhook certificate.");
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy("Failed to load webhook certificate.", ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
public sealed class ZastavaWebhookOptions
|
||||
{
|
||||
public const string SectionName = "zastava:webhook";
|
||||
|
||||
[Required]
|
||||
public ZastavaWebhookTlsOptions Tls { 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Namespaces that default to fail-open when backend calls fail.
|
||||
/// </summary>
|
||||
public HashSet<string> FailOpenNamespaces { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Namespaces that must fail-closed even if the global default is fail-open.
|
||||
/// </summary>
|
||||
public HashSet<string> FailClosedNamespaces { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Global fail-open toggle. When true, namespaces not in <see cref="FailClosedNamespaces"/> will allow requests on backend failures.
|
||||
/// </summary>
|
||||
public bool FailOpenByDefault { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables tag resolution to immutable digests when set.
|
||||
/// </summary>
|
||||
public bool ResolveTags { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional cache seed path for pre-computed runtime verdicts.
|
||||
/// </summary>
|
||||
public string? CacheSeedPath { get; init; }
|
||||
}
|
||||
|
||||
public enum ZastavaWebhookTlsMode
|
||||
{
|
||||
Secret = 0,
|
||||
CertificateSigningRequest = 1
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookTlsOptions
|
||||
{
|
||||
[Required]
|
||||
public ZastavaWebhookTlsMode Mode { get; init; } = ZastavaWebhookTlsMode.Secret;
|
||||
|
||||
/// <summary>
|
||||
/// PEM certificate path when using <see cref="ZastavaWebhookTlsMode.Secret"/>.
|
||||
/// </summary>
|
||||
public string? CertificatePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PEM private key path when using <see cref="ZastavaWebhookTlsMode.Secret"/>.
|
||||
/// </summary>
|
||||
public string? PrivateKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional PFX bundle path; takes precedence over PEM values when provided.
|
||||
/// </summary>
|
||||
public string? PfxPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional password for the PFX bundle.
|
||||
/// </summary>
|
||||
public string? PfxPassword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional CA bundle path to present to Kubernetes when configuring webhook registration.
|
||||
/// </summary>
|
||||
public string? CaBundlePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CSR related settings when <see cref="Mode"/> equals <see cref="ZastavaWebhookTlsMode.CertificateSigningRequest"/>.
|
||||
/// </summary>
|
||||
public ZastavaWebhookTlsCsrOptions Csr { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookTlsCsrOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Kubernetes namespace that owns the <c>CertificateSigningRequest</c> object.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string Namespace { get; init; } = "stellaops";
|
||||
|
||||
/// <summary>
|
||||
/// CSR object name; defaults to <c>zastava-webhook</c>.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
[MaxLength(253)]
|
||||
public string Name { get; init; } = "zastava-webhook";
|
||||
|
||||
/// <summary>
|
||||
/// DNS names placed in the CSR <c>subjectAltName</c>.
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public string[] DnsNames { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Where the signed certificate is persisted after approval (mounted emptyDir).
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public string PersistPath { get; init; } = "/var/run/zastava-webhook/certs";
|
||||
}
|
||||
|
||||
public sealed class ZastavaWebhookAuthorityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Authority issuer URL for token acquisition.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
public Uri Issuer { get; init; } = new("https://authority.internal");
|
||||
|
||||
/// <summary>
|
||||
/// Audience that tokens must target.
|
||||
/// </summary>
|
||||
[MinLength(1)]
|
||||
public string[] Audience { get; init; } = new[] { "scanner", "zastava" };
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to static OpTok for bootstrap environments.
|
||||
/// </summary>
|
||||
public string? StaticTokenPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional literal token value (test only). Takes precedence over <see cref="StaticTokenPath"/>.
|
||||
/// </summary>
|
||||
public string? StaticTokenValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Interval for refreshing cached tokens before expiry.
|
||||
/// </summary>
|
||||
[Range(typeof(double), "1", "3600")]
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Webhook.Admission;
|
||||
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.AddZastavaRuntimeCore(configuration, "webhook");
|
||||
|
||||
services.AddOptions<ZastavaWebhookOptions>()
|
||||
.Bind(configuration.GetSection(ZastavaWebhookOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
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.TryAddSingleton<AdmissionReviewParser>();
|
||||
services.TryAddSingleton<AdmissionResponseBuilder>();
|
||||
services.TryAddSingleton<RuntimePolicyCache>();
|
||||
services.TryAddSingleton<IImageDigestResolver, ImageDigestResolver>();
|
||||
services.TryAddSingleton<IRuntimeAdmissionPolicyService, RuntimeAdmissionPolicyService>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
}
|
||||
105
src/Zastava/StellaOps.Zastava.Webhook/IMPLEMENTATION_PLAN.md
Normal file
105
src/Zastava/StellaOps.Zastava.Webhook/IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Zastava Webhook · Wave 0 Implementation Notes
|
||||
|
||||
> Authored 2025-10-19 by Zastava Webhook Guild.
|
||||
|
||||
## ZASTAVA-WEBHOOK-12-101 — Admission Controller Host (TLS bootstrap + Authority auth)
|
||||
|
||||
**Objectives**
|
||||
- Provide a deterministic, restart-safe .NET 10 host that exposes a Kubernetes ValidatingAdmissionWebhook endpoint.
|
||||
- Load serving certificates at start-up only (per restart-time plug-in rule) and surface reload guidance via documentation rather than hot-reload.
|
||||
- Authenticate outbound calls to Authority/Scanner using OpTok + DPoP as defined in `docs/ARCHITECTURE_ZASTAVA.md`.
|
||||
|
||||
**Plan**
|
||||
1. **Project scaffolding**
|
||||
- Create `StellaOps.Zastava.Webhook` project with minimal API pipeline (`Program.cs`, `Startup` equivalent via extension methods).
|
||||
- Reference shared helpers once `ZASTAVA-CORE-12-201/202` land; temporarily stub interfaces behind `IZastavaAdmissionRequest`/`IZastavaAdmissionResult`.
|
||||
2. **TLS bootstrap**
|
||||
- Support two certificate sources:
|
||||
1. Mounted secret path (`/var/run/secrets/zastava-webhook/tls.{crt,key}`) with optional CA bundle.
|
||||
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 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**
|
||||
- Configure structured logging with correlation id from AdmissionReview UID.
|
||||
- Expose `/healthz` (reads cert expiry, Authority token status) and `/metrics` (Prometheus).
|
||||
- Add readiness gate that requires initial TLS and Authority bootstrap to succeed.
|
||||
|
||||
**Deliverables**
|
||||
- Compilable host project with integration tests covering TLS load (mounted files + CSR mock) and Authority token acquisition.
|
||||
- Documentation snippet for deploy charts describing secret/CSR wiring.
|
||||
|
||||
**Open Questions**
|
||||
- Need confirmation from Core guild on DTO naming (`AdmissionReviewEnvelope`, `AdmissionDecision`) to avoid rework.
|
||||
- Determine whether CSR auto-approval is acceptable for air-gapped clusters without Kubernetes cert-manager; may require fallback manual cert import path.
|
||||
|
||||
## ZASTAVA-WEBHOOK-12-102 — Backend policy query & digest resolution
|
||||
|
||||
**Objectives**
|
||||
- Resolve all images within AdmissionReview to immutable digests before policy evaluation.
|
||||
- Call Scanner WebService `/api/v1/scanner/policy/runtime` with namespace/labels/images payload, enforce verdicts with deterministic error messaging.
|
||||
|
||||
**Plan**
|
||||
1. **Image resolution**
|
||||
- Implement resolver service with pluggable strategies:
|
||||
- Use existing digest if present.
|
||||
- Resolve tags via registry HEAD (respecting `admission.resolveTags` flag); fallback to Observer-provided digest once core DTOs available.
|
||||
- Cache per-registry auth to minimise latency; adhere to allow/deny lists from configuration.
|
||||
2. **Scanner client**
|
||||
- Define typed request/response models mirroring `docs/ARCHITECTURE_ZASTAVA.md` structure (`ttlSeconds`, `results[digest] -> { signed, hasSbom, policyVerdict, reasons, rekor }`).
|
||||
- Implement retry policy (3 attempts, exponential backoff) and map HTTP errors to webhook fail-open/closed depending on namespace configuration.
|
||||
- Instrument latency (`zastava.backend_latency_seconds`) and failure counts.
|
||||
3. **Verdict enforcement**
|
||||
- Evaluate per-image results: if any `policyVerdict != pass` (or `warn` when `enforceWarnings=false`), deny with aggregated reasons.
|
||||
- Attach `ttlSeconds` to admission response annotations for auditing.
|
||||
- Record structured logs with namespace, pod, image digest, decision, reasons, backend latency.
|
||||
4. **Contract coordination**
|
||||
- Schedule joint review with Scanner WebService guild once SCANNER-RUNTIME-12-302 schema stabilises; track in TASKS sub-items.
|
||||
- Provide sample payload fixtures for CLI team (`CLI-RUNTIME-13-005`) to validate table output; ensure field names stay aligned.
|
||||
|
||||
**Deliverables**
|
||||
- Registry resolver unit tests (tag->digest) with deterministic fixtures.
|
||||
- HTTP client integration tests using Scanner stub returning varied verdict combinations.
|
||||
- Documentation update summarising contract and failure handling.
|
||||
|
||||
**Open Questions**
|
||||
- Confirm expected policy verdict enumeration (`pass|warn|fail|error`?) and textual reason codes.
|
||||
- Need TTL behaviour: should webhook reduce TTL when backend returns > configured max?
|
||||
|
||||
## ZASTAVA-WEBHOOK-12-103 — Caching, fail-open/closed toggles, metrics/logging
|
||||
|
||||
**Objectives**
|
||||
- Provide deterministic caching layer respecting backend TTL while ensuring eviction on policy mutation.
|
||||
- Allow namespace-scoped fail-open behaviour with explicit metrics and alerts.
|
||||
- Surface actionable metrics/logging aligned with Architecture doc.
|
||||
|
||||
**Plan**
|
||||
1. **Cache design**
|
||||
- In-memory LRU keyed by image digest; value carries verdict payload + expiry timestamp.
|
||||
- Support optional persistent seed (read-only) to prime hot digests for offline clusters (config: `admission.cache.seedPath`).
|
||||
- On startup, load seed file and emit metric `zastava.cache_seed_entries_total`.
|
||||
- Evict entries on TTL or when `policyRevision` annotation in AdmissionReview changes (requires hook from Core DTO).
|
||||
2. **Fail-open/closed toggles**
|
||||
- Configuration: global default + namespace overrides through `admission.failOpenNamespaces`, `admission.failClosedNamespaces`.
|
||||
- Decision matrix:
|
||||
- Backend success + verdict PASS → allow.
|
||||
- Backend success + non-pass → deny unless namespace override says warn allowed.
|
||||
- Backend failure → allow if namespace fail-open, deny otherwise; annotate response with `zastava.ops/fail-open=true`.
|
||||
- Implement policy change event hook (future) to clear cache if observer signals revocation.
|
||||
3. **Metrics & logging**
|
||||
- Counters: `zastava.admission_requests_total{decision}`, `zastava.cache_hits_total{result=hit|miss}`, `zastava.fail_open_total`, `zastava.backend_failures_total{stage}`.
|
||||
- Histograms: `zastava.admission_latency_seconds` (overall), `zastava.resolve_latency_seconds`.
|
||||
- Logs: structured JSON with `decision`, `namespace`, `pod`, `imageDigest`, `reasons`, `cacheStatus`, `failMode`.
|
||||
- Optionally emit OpenTelemetry span for admission path with attributes capturing backend latency + cache path.
|
||||
4. **Testing & ops hooks**
|
||||
- Unit tests for cache TTL, namespace override logic, fail-open metric increments.
|
||||
- Integration test simulating backend outage ensuring fail-open/closed behaviour matches config.
|
||||
- Document runbook snippet describing interpreting metrics and toggling namespaces.
|
||||
|
||||
**Open Questions**
|
||||
- Confirm whether cache entries should include `policyRevision` to detect backend policy updates; requires coordination with Policy guild.
|
||||
- Need guidance on maximum cache size (default suggestions: 5k entries per replica?) to avoid memory blow-up.
|
||||
|
||||
68
src/Zastava/StellaOps.Zastava.Webhook/Program.cs
Normal file
68
src/Zastava/StellaOps.Zastava.Webhook/Program.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Security.Authentication;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Zastava.Webhook.Admission;
|
||||
using StellaOps.Zastava.Webhook.Authority;
|
||||
using StellaOps.Zastava.Webhook.Certificates;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
{
|
||||
loggerConfiguration
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console();
|
||||
});
|
||||
|
||||
builder.Services.AddRouting();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddZastavaWebhook(builder.Configuration);
|
||||
|
||||
builder.WebHost.ConfigureKestrel((context, options) =>
|
||||
{
|
||||
options.AddServerHeader = false;
|
||||
options.Limits.MinRequestBodyDataRate = null; // Admission payloads are small; relax defaults for determinism.
|
||||
|
||||
options.ConfigureHttpsDefaults(httpsOptions =>
|
||||
{
|
||||
var certificateProvider = options.ApplicationServices?.GetRequiredService<IWebhookCertificateProvider>()
|
||||
?? throw new InvalidOperationException("Webhook certificate provider unavailable.");
|
||||
|
||||
httpsOptions.SslProtocols = SslProtocols.Tls13;
|
||||
httpsOptions.ClientCertificateMode = Microsoft.AspNetCore.Server.Kestrel.Https.ClientCertificateMode.NoCertificate;
|
||||
httpsOptions.CheckCertificateRevocation = false; // Kubernetes API server terminates client auth; revocation handled upstream.
|
||||
httpsOptions.ServerCertificate = certificateProvider.GetCertificate();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseRouting();
|
||||
|
||||
app.UseStatusCodePages();
|
||||
|
||||
// Health endpoints.
|
||||
app.MapHealthChecks("/healthz/ready", new HealthCheckOptions
|
||||
{
|
||||
AllowCachingResponses = false
|
||||
});
|
||||
app.MapHealthChecks("/healthz/live", new HealthCheckOptions
|
||||
{
|
||||
AllowCachingResponses = false,
|
||||
Predicate = _ => false
|
||||
});
|
||||
|
||||
app.MapPost("/admission", AdmissionEndpoint.HandleAsync)
|
||||
.WithName("AdmissionReview");
|
||||
|
||||
app.MapGet("/", () => Results.Ok(new { status = "ok", service = "zastava-webhook" }));
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Zastava.Webhook.Tests")]
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Zastava.Webhook</RootNamespace>
|
||||
<NoWarn>$(NoWarn);CA2254</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Zastava.Core/StellaOps.Zastava.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
10
src/Zastava/StellaOps.Zastava.Webhook/TASKS.md
Normal file
10
src/Zastava/StellaOps.Zastava.Webhook/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Zastava Webhook Task Board
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| 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 | DONE (2025-10-24) | 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 | DONE (2025-10-24) | 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 | DONE (2025-10-24) | 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