This commit is contained in:
master
2025-10-24 19:19:23 +03:00
parent d8253ec3af
commit 625299fa2b
72 changed files with 6070 additions and 151 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,12 @@ 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>();
}

View File

@@ -2,6 +2,7 @@ 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;
@@ -22,12 +23,19 @@ public static class ServiceCollectionExtensions
.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;

View File

@@ -2,6 +2,7 @@ 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;
@@ -59,9 +60,8 @@ app.MapHealthChecks("/healthz/live", new HealthCheckOptions
Predicate = _ => false
});
// Placeholder admission endpoint; will be replaced as tasks 12-102/12-103 land.
app.MapPost("/admission", () => Results.StatusCode(StatusCodes.Status501NotImplemented))
.WithName("AdmissionReview");
app.MapPost("/admission", AdmissionEndpoint.HandleAsync)
.WithName("AdmissionReview");
app.MapGet("/", () => Results.Ok(new { status = "ok", service = "zastava-webhook" }));

View File

@@ -3,8 +3,8 @@
| 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 | 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. |
| 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`.