eb3d35c8b5
This commit implements a reconciler for the new `ProxyGroupPolicy` custom resource. When created, all `ProxyGroupPolicy` resources within the same namespace are merged into two `ValidatingAdmissionPolicy` resources, one for egress and one for ingress. These policies use CEL expressions to limit the usage of the "tailscale.com/proxy-group" annotation on `Service` and `Ingress` resources on create & update. Included here is also a new e2e test that ensures that resources that violate the policy return an error on creation, and that once the policy is changed to allow them they can be created. Closes: https://github.com/tailscale/corp/issues/36830 Signed-off-by: David Bond <davidsbond93@gmail.com>
218 lines
6.6 KiB
Go
218 lines
6.6 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package proxygrouppolicy_test
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
admr "k8s.io/api/admissionregistration/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
|
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
|
"tailscale.com/k8s-operator/reconciler/proxygrouppolicy"
|
|
)
|
|
|
|
func TestReconciler_Reconcile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tt := []struct {
|
|
Name string
|
|
Request reconcile.Request
|
|
ExpectedPolicyCount int
|
|
ExistingResources []client.Object
|
|
ExpectsError bool
|
|
}{
|
|
{
|
|
Name: "single policy, denies all",
|
|
ExpectedPolicyCount: 2,
|
|
Request: reconcile.Request{
|
|
NamespacedName: types.NamespacedName{
|
|
Name: "deny-all",
|
|
Namespace: metav1.NamespaceDefault,
|
|
},
|
|
},
|
|
ExistingResources: []client.Object{
|
|
&tsapi.ProxyGroupPolicy{
|
|
TypeMeta: metav1.TypeMeta{},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "deny-all",
|
|
Namespace: metav1.NamespaceDefault,
|
|
},
|
|
Spec: tsapi.ProxyGroupPolicySpec{
|
|
Ingress: []string{},
|
|
Egress: []string{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "multiple policies merged",
|
|
ExpectedPolicyCount: 2,
|
|
Request: reconcile.Request{
|
|
NamespacedName: types.NamespacedName{
|
|
Name: "deny-all",
|
|
Namespace: metav1.NamespaceDefault,
|
|
},
|
|
},
|
|
ExistingResources: []client.Object{
|
|
&tsapi.ProxyGroupPolicy{
|
|
TypeMeta: metav1.TypeMeta{},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "deny-all",
|
|
Namespace: metav1.NamespaceDefault,
|
|
},
|
|
Spec: tsapi.ProxyGroupPolicySpec{
|
|
Ingress: []string{},
|
|
Egress: []string{},
|
|
},
|
|
},
|
|
&tsapi.ProxyGroupPolicy{
|
|
TypeMeta: metav1.TypeMeta{},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "allow-one",
|
|
Namespace: metav1.NamespaceDefault,
|
|
},
|
|
Spec: tsapi.ProxyGroupPolicySpec{
|
|
Ingress: []string{
|
|
"test-ingress",
|
|
},
|
|
Egress: []string{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "no policies, no child resources",
|
|
ExpectedPolicyCount: 0,
|
|
Request: reconcile.Request{
|
|
NamespacedName: types.NamespacedName{
|
|
Name: "deny-all",
|
|
Namespace: metav1.NamespaceDefault,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tt {
|
|
t.Run(tc.Name, func(t *testing.T) {
|
|
bldr := fake.NewClientBuilder().WithScheme(tsapi.GlobalScheme)
|
|
bldr = bldr.WithObjects(tc.ExistingResources...)
|
|
|
|
fc := bldr.Build()
|
|
opts := proxygrouppolicy.ReconcilerOptions{
|
|
Client: fc,
|
|
}
|
|
|
|
reconciler := proxygrouppolicy.NewReconciler(opts)
|
|
_, err := reconciler.Reconcile(t.Context(), tc.Request)
|
|
if tc.ExpectsError && err == nil {
|
|
t.Fatalf("expected error, got none")
|
|
}
|
|
|
|
if !tc.ExpectsError && err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
var policies admr.ValidatingAdmissionPolicyList
|
|
if err = fc.List(t.Context(), &policies); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(policies.Items) != tc.ExpectedPolicyCount {
|
|
t.Fatalf("expected %d ValidatingAdmissionPolicy resources, got %d", tc.ExpectedPolicyCount, len(policies.Items))
|
|
}
|
|
|
|
var bindings admr.ValidatingAdmissionPolicyBindingList
|
|
if err = fc.List(t.Context(), &bindings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(bindings.Items) != tc.ExpectedPolicyCount {
|
|
t.Fatalf("expected %d ValidatingAdmissionPolicyBinding resources, got %d", tc.ExpectedPolicyCount, len(bindings.Items))
|
|
}
|
|
|
|
for _, binding := range bindings.Items {
|
|
actual, ok := binding.Spec.MatchResources.NamespaceSelector.MatchLabels["kubernetes.io/metadata.name"]
|
|
if !ok || actual != metav1.NamespaceDefault {
|
|
t.Fatalf("expected binding to be for default namespace, got %v", actual)
|
|
}
|
|
|
|
if !slices.Contains(binding.Spec.ValidationActions, admr.Deny) {
|
|
t.Fatalf("expected binding to be deny, got %v", binding.Spec.ValidationActions)
|
|
}
|
|
}
|
|
|
|
for _, policy := range policies.Items {
|
|
// Each ValidatingAdmissionPolicy must be set to fail (rejecting resources).
|
|
if policy.Spec.FailurePolicy == nil || *policy.Spec.FailurePolicy != admr.Fail {
|
|
t.Fatalf("expected fail policy, got %v", *policy.Spec.FailurePolicy)
|
|
}
|
|
|
|
// Each ValidatingAdmissionPolicy must have a matching ValidatingAdmissionPolicyBinding
|
|
bound := slices.ContainsFunc(bindings.Items, func(obj admr.ValidatingAdmissionPolicyBinding) bool {
|
|
return obj.Spec.PolicyName == policy.Name
|
|
})
|
|
if !bound {
|
|
t.Fatalf("expected policy %s to be bound, but wasn't", policy.Name)
|
|
}
|
|
|
|
// Each ValidatingAdmissionPolicy must be set to evaluate on creation and update of resources.
|
|
for _, rule := range policy.Spec.MatchConstraints.ResourceRules {
|
|
if !slices.Contains(rule.Operations, admr.Update) {
|
|
t.Fatal("expected ingress rule to act on update, but doesn't")
|
|
}
|
|
|
|
if !slices.Contains(rule.Operations, admr.Create) {
|
|
t.Fatal("expected ingress rule to act on create, but doesn't")
|
|
}
|
|
}
|
|
|
|
// Egress policies should only act on Service resources.
|
|
if strings.Contains(policy.Name, "egress") {
|
|
if len(policy.Spec.MatchConstraints.ResourceRules) != 1 {
|
|
t.Fatalf("expected exactly one matching resource, got %d", len(policy.Spec.MatchConstraints.ResourceRules))
|
|
}
|
|
|
|
rule := policy.Spec.MatchConstraints.ResourceRules[0]
|
|
|
|
if !slices.Contains(rule.Resources, "services") {
|
|
t.Fatal("expected egress rule to act on services, but doesn't")
|
|
}
|
|
|
|
if len(policy.Spec.Validations) != 1 {
|
|
t.Fatalf("expected exactly one validation, got %d", len(policy.Spec.Validations))
|
|
}
|
|
}
|
|
|
|
// Ingress policies should act on both Ingress and Service resources.
|
|
if strings.Contains(policy.Name, "ingress") {
|
|
if len(policy.Spec.MatchConstraints.ResourceRules) != 2 {
|
|
t.Fatalf("expected exactly two matching resources, got %d", len(policy.Spec.MatchConstraints.ResourceRules))
|
|
}
|
|
|
|
ingressRule := policy.Spec.MatchConstraints.ResourceRules[0]
|
|
if !slices.Contains(ingressRule.Resources, "ingresses") {
|
|
t.Fatal("expected ingress rule to act on ingresses, but doesn't")
|
|
}
|
|
|
|
serviceRule := policy.Spec.MatchConstraints.ResourceRules[1]
|
|
if !slices.Contains(serviceRule.Resources, "services") {
|
|
t.Fatal("expected ingress rule to act on services, but doesn't")
|
|
}
|
|
|
|
if len(policy.Spec.Validations) != 2 {
|
|
t.Fatalf("expected exactly two validations, got %d", len(policy.Spec.Validations))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|