This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Webhook.Backend;
|
||||
using StellaOps.Zastava.Webhook.Admission;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Tests.Admission;
|
||||
|
||||
public sealed class AdmissionResponseBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_AllowsWhenAllDecisionsPass()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{
|
||||
"metadata": { "namespace": "payments" },
|
||||
"spec": {
|
||||
"containers": [ { "name": "api", "image": "ghcr.io/example/api:1.0" } ]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var pod = document.RootElement;
|
||||
var spec = pod.GetProperty("spec");
|
||||
|
||||
var context = new AdmissionRequestContext(
|
||||
ApiVersion: "admission.k8s.io/v1",
|
||||
Kind: "AdmissionReview",
|
||||
Uid: "abc",
|
||||
Namespace: "payments",
|
||||
Labels: new Dictionary<string, string>(),
|
||||
Containers: new[] { new AdmissionContainerReference("api", "ghcr.io/example/api:1.0") },
|
||||
PodObject: pod,
|
||||
PodSpec: spec);
|
||||
|
||||
var evaluation = new RuntimeAdmissionEvaluation
|
||||
{
|
||||
Decisions = new[]
|
||||
{
|
||||
new RuntimeAdmissionDecision
|
||||
{
|
||||
OriginalImage = "ghcr.io/example/api:1.0",
|
||||
ResolvedDigest = "ghcr.io/example/api@sha256:deadbeef",
|
||||
Verdict = PolicyVerdict.Pass,
|
||||
Allowed = true,
|
||||
Policy = new RuntimePolicyImageResult
|
||||
{
|
||||
PolicyVerdict = PolicyVerdict.Pass,
|
||||
HasSbom = true,
|
||||
Signed = true
|
||||
},
|
||||
Reasons = Array.Empty<string>(),
|
||||
FromCache = false,
|
||||
ResolutionFailed = false
|
||||
}
|
||||
},
|
||||
BackendFailed = false,
|
||||
FailOpenApplied = false,
|
||||
FailureReason = null,
|
||||
TtlSeconds = 300
|
||||
};
|
||||
|
||||
var builder = new AdmissionResponseBuilder();
|
||||
var (envelope, response) = builder.Build(context, evaluation);
|
||||
|
||||
Assert.Equal("admission.k8s.io/v1", response.ApiVersion);
|
||||
Assert.True(response.Response.Allowed);
|
||||
Assert.Null(response.Response.Status);
|
||||
Assert.NotNull(response.Response.AuditAnnotations);
|
||||
Assert.True(envelope.Decision.Images.First().HasSbomReferrers);
|
||||
Assert.StartsWith("sha256-", envelope.Decision.PodSpecDigest, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DeniedIncludesStatusAndWarnings()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{
|
||||
"metadata": { "namespace": "ops" },
|
||||
"spec": {
|
||||
"containers": [ { "name": "app", "image": "ghcr.io/example/app:latest" } ]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var pod = document.RootElement;
|
||||
var spec = pod.GetProperty("spec");
|
||||
|
||||
var context = new AdmissionRequestContext(
|
||||
"admission.k8s.io/v1",
|
||||
"AdmissionReview",
|
||||
"uid-123",
|
||||
"ops",
|
||||
new Dictionary<string, string>(),
|
||||
new[] { new AdmissionContainerReference("app", "ghcr.io/example/app:latest") },
|
||||
pod,
|
||||
spec);
|
||||
|
||||
var evaluation = new RuntimeAdmissionEvaluation
|
||||
{
|
||||
Decisions = new[]
|
||||
{
|
||||
new RuntimeAdmissionDecision
|
||||
{
|
||||
OriginalImage = "ghcr.io/example/app:latest",
|
||||
ResolvedDigest = null,
|
||||
Verdict = PolicyVerdict.Fail,
|
||||
Allowed = false,
|
||||
Policy = null,
|
||||
Reasons = new[] { "policy.fail" },
|
||||
FromCache = false,
|
||||
ResolutionFailed = true
|
||||
}
|
||||
},
|
||||
BackendFailed = true,
|
||||
FailOpenApplied = false,
|
||||
FailureReason = "backend.unavailable",
|
||||
TtlSeconds = 60
|
||||
};
|
||||
|
||||
var builder = new AdmissionResponseBuilder();
|
||||
var (_, response) = builder.Build(context, evaluation);
|
||||
|
||||
Assert.False(response.Response.Allowed);
|
||||
Assert.NotNull(response.Response.Status);
|
||||
Assert.Equal(403, response.Response.Status!.Code);
|
||||
Assert.NotNull(response.Response.AuditAnnotations);
|
||||
Assert.Contains("zastava.stellaops/admission", response.Response.AuditAnnotations!.Keys);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Zastava.Webhook.Admission;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Tests.Admission;
|
||||
|
||||
public sealed class AdmissionReviewParserTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValidRequestExtractsContainers()
|
||||
{
|
||||
var dto = Deserialize("""
|
||||
{
|
||||
"apiVersion": "admission.k8s.io/v1",
|
||||
"kind": "AdmissionReview",
|
||||
"request": {
|
||||
"uid": "abc-123",
|
||||
"object": {
|
||||
"metadata": {
|
||||
"namespace": "payments",
|
||||
"labels": { "app": "demo" }
|
||||
},
|
||||
"spec": {
|
||||
"containers": [
|
||||
{ "name": "api", "image": "ghcr.io/example/api:1.2.3" }
|
||||
],
|
||||
"initContainers": [
|
||||
{ "name": "init", "image": "ghcr.io/example/init:1.0" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var parser = new AdmissionReviewParser();
|
||||
var context = parser.Parse(dto);
|
||||
|
||||
Assert.Equal("admission.k8s.io/v1", context.ApiVersion);
|
||||
Assert.Equal("AdmissionReview", context.Kind);
|
||||
Assert.Equal("abc-123", context.Uid);
|
||||
Assert.Equal("payments", context.Namespace);
|
||||
Assert.Equal("demo", context.Labels["app"]);
|
||||
Assert.Equal(2, context.Containers.Count);
|
||||
Assert.Contains(context.Containers, c => c.Name == "api" && c.Image == "ghcr.io/example/api:1.2.3");
|
||||
Assert.Contains(context.Containers, c => c.Name == "init" && c.Image == "ghcr.io/example/init:1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UsesRequestNamespaceWhenAvailable()
|
||||
{
|
||||
var dto = Deserialize("""
|
||||
{
|
||||
"apiVersion": "admission.k8s.io/v1",
|
||||
"kind": "AdmissionReview",
|
||||
"request": {
|
||||
"uid": "uid-456",
|
||||
"namespace": "critical",
|
||||
"object": {
|
||||
"metadata": {
|
||||
"labels": { }
|
||||
},
|
||||
"spec": {
|
||||
"containers": [ { "name": "app", "image": "ghcr.io/example/app:latest" } ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var parser = new AdmissionReviewParser();
|
||||
var context = parser.Parse(dto);
|
||||
Assert.Equal("critical", context.Namespace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ThrowsWhenNoContainers()
|
||||
{
|
||||
var dto = Deserialize("""
|
||||
{
|
||||
"apiVersion": "admission.k8s.io/v1",
|
||||
"kind": "AdmissionReview",
|
||||
"request": {
|
||||
"uid": "uid-789",
|
||||
"object": {
|
||||
"metadata": { "namespace": "ops" },
|
||||
"spec": { }
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var parser = new AdmissionReviewParser();
|
||||
var ex = Assert.Throws<AdmissionReviewParseException>(() => parser.Parse(dto));
|
||||
Assert.Equal("admission.review.containers", ex.Code);
|
||||
}
|
||||
|
||||
private static AdmissionReviewRequestDto Deserialize(string json)
|
||||
=> JsonSerializer.Deserialize<AdmissionReviewRequestDto>(json, SerializerOptions)!;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Webhook.Admission;
|
||||
using StellaOps.Zastava.Webhook.Backend;
|
||||
using StellaOps.Zastava.Webhook.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Zastava.Webhook.Tests.Admission;
|
||||
|
||||
public sealed class RuntimeAdmissionPolicyServiceTests
|
||||
{
|
||||
private const string SampleDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_UsesCacheOnSubsequentCalls()
|
||||
{
|
||||
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 10, 24, 12, 0, 0, TimeSpan.Zero));
|
||||
var policyClient = new StubRuntimePolicyClient(new RuntimePolicyResponse
|
||||
{
|
||||
TtlSeconds = 600,
|
||||
Results = new Dictionary<string, RuntimePolicyImageResult>
|
||||
{
|
||||
[SampleDigest] = new RuntimePolicyImageResult
|
||||
{
|
||||
PolicyVerdict = PolicyVerdict.Pass,
|
||||
Signed = true,
|
||||
HasSbom = true,
|
||||
Reasons = Array.Empty<string>()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var runtimeMetrics = new StubRuntimeMetrics();
|
||||
var optionsMonitor = new StaticOptionsMonitor<ZastavaWebhookOptions>(new ZastavaWebhookOptions());
|
||||
var cache = new RuntimePolicyCache(Options.Create(optionsMonitor.CurrentValue), timeProvider, NullLogger<RuntimePolicyCache>.Instance);
|
||||
var resolver = new ImageDigestResolver();
|
||||
|
||||
var service = new RuntimeAdmissionPolicyService(
|
||||
policyClient,
|
||||
resolver,
|
||||
cache,
|
||||
optionsMonitor,
|
||||
runtimeMetrics,
|
||||
timeProvider,
|
||||
NullLogger<RuntimeAdmissionPolicyService>.Instance);
|
||||
|
||||
var request = new RuntimeAdmissionRequest(
|
||||
Namespace: "payments",
|
||||
Labels: new Dictionary<string, string>(),
|
||||
Images: new[] { $"ghcr.io/example/api@{SampleDigest}" });
|
||||
|
||||
var first = await service.EvaluateAsync(request, CancellationToken.None);
|
||||
Assert.Single(first.Decisions);
|
||||
Assert.False(first.BackendFailed);
|
||||
Assert.Equal(600, first.TtlSeconds);
|
||||
Assert.Equal(1, policyClient.CallCount);
|
||||
|
||||
var second = await service.EvaluateAsync(request, CancellationToken.None);
|
||||
Assert.Single(second.Decisions);
|
||||
Assert.Equal(1, policyClient.CallCount); // no additional backend call
|
||||
Assert.True(second.Decisions[0].FromCache);
|
||||
Assert.Equal(300, second.TtlSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_FailOpenWhenBackendUnavailable()
|
||||
{
|
||||
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 10, 24, 12, 0, 0, TimeSpan.Zero));
|
||||
var policyClient = new StubRuntimePolicyClient(new RuntimePolicyException("backend", System.Net.HttpStatusCode.BadGateway));
|
||||
var options = new ZastavaWebhookOptions
|
||||
{
|
||||
Admission = new ZastavaWebhookAdmissionOptions
|
||||
{
|
||||
FailOpenByDefault = false,
|
||||
FailOpenNamespaces = new HashSet<string>(StringComparer.Ordinal) { "payments" }
|
||||
}
|
||||
};
|
||||
var optionsMonitor = new StaticOptionsMonitor<ZastavaWebhookOptions>(options);
|
||||
var cache = new RuntimePolicyCache(Options.Create(optionsMonitor.CurrentValue), timeProvider, NullLogger<RuntimePolicyCache>.Instance);
|
||||
var service = new RuntimeAdmissionPolicyService(
|
||||
policyClient,
|
||||
new ImageDigestResolver(),
|
||||
cache,
|
||||
optionsMonitor,
|
||||
new StubRuntimeMetrics(),
|
||||
timeProvider,
|
||||
NullLogger<RuntimeAdmissionPolicyService>.Instance);
|
||||
|
||||
var request = new RuntimeAdmissionRequest(
|
||||
"payments",
|
||||
new Dictionary<string, string>(),
|
||||
new[] { $"ghcr.io/example/api@{SampleDigest}" });
|
||||
|
||||
var evaluation = await service.EvaluateAsync(request, CancellationToken.None);
|
||||
Assert.True(evaluation.BackendFailed);
|
||||
Assert.True(evaluation.FailOpenApplied);
|
||||
Assert.Equal(300, evaluation.TtlSeconds);
|
||||
var decision = Assert.Single(evaluation.Decisions);
|
||||
Assert.True(decision.Allowed);
|
||||
Assert.Contains("zastava.fail_open.backend_unavailable", decision.Reasons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_FailClosedWhenNamespaceConfigured()
|
||||
{
|
||||
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 10, 24, 12, 0, 0, TimeSpan.Zero));
|
||||
var policyClient = new StubRuntimePolicyClient(new RuntimePolicyException("backend", System.Net.HttpStatusCode.BadGateway));
|
||||
var options = new ZastavaWebhookOptions
|
||||
{
|
||||
Admission = new ZastavaWebhookAdmissionOptions
|
||||
{
|
||||
FailOpenByDefault = true,
|
||||
FailClosedNamespaces = new HashSet<string>(StringComparer.Ordinal) { "critical" }
|
||||
}
|
||||
};
|
||||
var optionsMonitor = new StaticOptionsMonitor<ZastavaWebhookOptions>(options);
|
||||
var cache = new RuntimePolicyCache(Options.Create(optionsMonitor.CurrentValue), timeProvider, NullLogger<RuntimePolicyCache>.Instance);
|
||||
|
||||
var service = new RuntimeAdmissionPolicyService(
|
||||
policyClient,
|
||||
new ImageDigestResolver(),
|
||||
cache,
|
||||
optionsMonitor,
|
||||
new StubRuntimeMetrics(),
|
||||
timeProvider,
|
||||
NullLogger<RuntimeAdmissionPolicyService>.Instance);
|
||||
|
||||
var request = new RuntimeAdmissionRequest(
|
||||
"critical",
|
||||
new Dictionary<string, string>(),
|
||||
new[] { $"ghcr.io/example/api@{SampleDigest}" });
|
||||
|
||||
var evaluation = await service.EvaluateAsync(request, CancellationToken.None);
|
||||
Assert.True(evaluation.BackendFailed);
|
||||
Assert.False(evaluation.FailOpenApplied);
|
||||
Assert.Equal(300, evaluation.TtlSeconds);
|
||||
var decision = Assert.Single(evaluation.Decisions);
|
||||
Assert.False(decision.Allowed);
|
||||
Assert.Contains("zastava.backend.unavailable", decision.Reasons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ResolutionFailureProducesDeny()
|
||||
{
|
||||
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 10, 24, 12, 0, 0, TimeSpan.Zero));
|
||||
var policyClient = new StubRuntimePolicyClient(new RuntimePolicyResponse { TtlSeconds = 300 });
|
||||
var optionsMonitor = new StaticOptionsMonitor<ZastavaWebhookOptions>(new ZastavaWebhookOptions());
|
||||
var cache = new RuntimePolicyCache(Options.Create(optionsMonitor.CurrentValue), timeProvider, NullLogger<RuntimePolicyCache>.Instance);
|
||||
|
||||
var service = new RuntimeAdmissionPolicyService(
|
||||
policyClient,
|
||||
new ImageDigestResolver(),
|
||||
cache,
|
||||
optionsMonitor,
|
||||
new StubRuntimeMetrics(),
|
||||
timeProvider,
|
||||
NullLogger<RuntimeAdmissionPolicyService>.Instance);
|
||||
|
||||
var request = new RuntimeAdmissionRequest(
|
||||
Namespace: "payments",
|
||||
Labels: new Dictionary<string, string>(),
|
||||
Images: new[] { "ghcr.io/example/api:latest" });
|
||||
|
||||
var evaluation = await service.EvaluateAsync(request, CancellationToken.None);
|
||||
Assert.Equal(300, evaluation.TtlSeconds);
|
||||
var decision = Assert.Single(evaluation.Decisions);
|
||||
Assert.False(decision.Allowed);
|
||||
Assert.True(decision.ResolutionFailed);
|
||||
Assert.Contains("image.reference.tag_unresolved", decision.Reasons);
|
||||
}
|
||||
|
||||
private sealed class StubRuntimePolicyClient : IRuntimePolicyClient
|
||||
{
|
||||
private readonly RuntimePolicyResponse? response;
|
||||
private readonly Exception? exception;
|
||||
|
||||
public StubRuntimePolicyClient(RuntimePolicyResponse response)
|
||||
{
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
public StubRuntimePolicyClient(Exception exception)
|
||||
{
|
||||
this.exception = exception;
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<RuntimePolicyResponse> EvaluateAsync(RuntimePolicyRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
if (exception is not null)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
|
||||
return Task.FromResult(response ?? new RuntimePolicyResponse());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubRuntimeMetrics : IZastavaRuntimeMetrics
|
||||
{
|
||||
public StubRuntimeMetrics()
|
||||
{
|
||||
Meter = new Meter("Test.Zastava.Webhook");
|
||||
RuntimeEvents = Meter.CreateCounter<long>("test.runtime.events");
|
||||
AdmissionDecisions = Meter.CreateCounter<long>("test.admission.decisions");
|
||||
BackendLatencyMs = Meter.CreateHistogram<double>("test.backend.latency");
|
||||
DefaultTags = Array.Empty<KeyValuePair<string, object?>>();
|
||||
}
|
||||
|
||||
public Meter Meter { get; }
|
||||
|
||||
public Counter<long> RuntimeEvents { get; }
|
||||
|
||||
public Counter<long> AdmissionDecisions { get; }
|
||||
|
||||
public Histogram<double> BackendLatencyMs { get; }
|
||||
|
||||
public IReadOnlyList<KeyValuePair<string, object?>> DefaultTags { get; }
|
||||
|
||||
public void Dispose() => Meter.Dispose();
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public StaticOptionsMonitor(T currentValue)
|
||||
{
|
||||
CurrentValue = currentValue;
|
||||
}
|
||||
|
||||
public T CurrentValue { get; }
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable OnChange(Action<T, string?> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset now;
|
||||
|
||||
public TestTimeProvider(DateTimeOffset initial)
|
||||
{
|
||||
now = initial;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
|
||||
public void Advance(TimeSpan delta) => now = now.Add(delta);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user