diff --git a/pkg/apis/policies/v1/admissionpolicy_webhook_test.go b/pkg/apis/policies/v1/admissionpolicy_webhook_test.go new file mode 100644 index 00000000..33f747c0 --- /dev/null +++ b/pkg/apis/policies/v1/admissionpolicy_webhook_test.go @@ -0,0 +1,54 @@ +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kubewarden/kubewarden-controller/internal/pkg/constants" +) + +func TestAdmissionPolicyDefault(t *testing.T) { + policy := admissionPolicyFactory(nil, "", "protect") + policy.Default() + + require.Equal(t, constants.DefaultPolicyServer, policy.GetPolicyServer()) + require.Contains(t, policy.GetFinalizers(), constants.KubewardenFinalizer) +} + +func TestAdmissionPolicyValidateCreate(t *testing.T) { + policy := admissionPolicyFactory(nil, "", "protect") + warnings, err := policy.ValidateCreate() + require.NoError(t, err) + require.Empty(t, warnings) +} + +func TestAdmissionPolicyValidateUpdate(t *testing.T) { + oldPolicy := admissionPolicyFactory(nil, "", "protect") + newPolicy := admissionPolicyFactory(nil, "", "protect") + warnings, err := newPolicy.ValidateUpdate(oldPolicy) + require.NoError(t, err) + require.Empty(t, warnings) +} + +func TestAdmissionPolicyValidateUpdateWithInvalidOldPolicy(t *testing.T) { + oldPolicy := clusterAdmissionPolicyFactory(nil, "", "protect") + newPolicy := admissionPolicyFactory(nil, "", "protect") + warnings, err := newPolicy.ValidateUpdate(oldPolicy) + require.Empty(t, warnings) + require.ErrorContains(t, err, "object is not of type AdmissionPolicy") +} diff --git a/pkg/apis/policies/v1/clusteradmissionpolicy_webhook.go b/pkg/apis/policies/v1/clusteradmissionpolicy_webhook.go index 864725f4..d6a3a040 100644 --- a/pkg/apis/policies/v1/clusteradmissionpolicy_webhook.go +++ b/pkg/apis/policies/v1/clusteradmissionpolicy_webhook.go @@ -19,11 +19,7 @@ package v1 import ( "fmt" - admissionregistrationv1 "k8s.io/api/admissionregistration/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/webhook" @@ -88,111 +84,6 @@ func (r *ClusterAdmissionPolicy) ValidateUpdate(old runtime.Object) (admission.W return nil, validatePolicyUpdate(oldPolicy, r) } -// Validates the spec.Rules field for non-empty, webhook-valid rules -func validateRulesField(policy Policy) error { - errs := field.ErrorList{} - rulesField := field.NewPath("spec", "rules") - - if len(policy.GetRules()) == 0 { - errs = append(errs, field.Required(rulesField, "a value must be specified")) - - return prepareInvalidAPIError(policy, errs) - } - - for _, rule := range policy.GetRules() { - switch { - case len(rule.Operations) == 0: - opField := rulesField.Child("operations") - errs = append(errs, field.Required(opField, "a value must be specified")) - case len(rule.Rule.APIVersions) == 0 || len(rule.Rule.Resources) == 0: - errs = append(errs, field.Required(rulesField, "apiVersions and resources must have specified values")) - default: - if err := checkOperationsArrayForEmptyString(rule.Operations, rulesField); err != nil { - errs = append(errs, err) - } - - if err := checkRulesArrayForEmptyString(rule.Rule.APIVersions, "rule.apiVersions", rulesField); err != nil { - errs = append(errs, err) - } - - if err := checkRulesArrayForEmptyString(rule.Rule.Resources, "rule.resources", rulesField); err != nil { - errs = append(errs, err) - } - } - } - - if len(errs) != 0 { - return prepareInvalidAPIError(policy, errs) - } - - return nil -} - -// checkOperationsArrayForEmptyString checks if any of the values in the operations array is the empty string and returns -// an error if this is true. -func checkOperationsArrayForEmptyString(operationsArray []admissionregistrationv1.OperationType, rulesField *field.Path) *field.Error { - for _, operation := range operationsArray { - if operation == "" { - return field.Invalid(rulesField.Child("operations"), "", "field value cannot contain the empty string") - } - } - - return nil -} - -// checkRulesArrayForEmptyString checks if any of the values specified is the empty string and returns an error if this -// is true. -func checkRulesArrayForEmptyString(rulesArray []string, fieldName string, parentField *field.Path) *field.Error { - for _, apiVersion := range rulesArray { - if apiVersion == "" { - apiVersionField := parentField.Child(fieldName) - - return field.Invalid(apiVersionField, "", fmt.Sprintf("%s value cannot contain the empty string", fieldName)) - } - } - - return nil -} - -// prepareInvalidAPIError is a shorthand for generating an invalid apierrors.StatusError with data from a policy -func prepareInvalidAPIError(policy Policy, errorList field.ErrorList) *apierrors.StatusError { - return apierrors.NewInvalid( - policy.GetObjectKind().GroupVersionKind().GroupKind(), - policy.GetName(), - errorList, - ) -} - -func validatePolicyUpdate(oldPolicy, newPolicy Policy) error { - if err := validateRulesField(newPolicy); err != nil { - return err - } - - if newPolicy.GetPolicyServer() != oldPolicy.GetPolicyServer() { - var errs field.ErrorList - p := field.NewPath("spec") - pp := p.Child("policyServer") - errs = append(errs, field.Forbidden(pp, "the field is immutable")) - - return apierrors.NewInvalid( - schema.GroupKind{Group: GroupVersion.Group, Kind: "ClusterAdmissionPolicy"}, - newPolicy.GetName(), errs) - } - - if newPolicy.GetPolicyMode() == "monitor" && oldPolicy.GetPolicyMode() == "protect" { - var errs field.ErrorList - p := field.NewPath("spec") - pp := p.Child("mode") - errs = append(errs, field.Forbidden(pp, "field cannot transition from protect to monitor. Recreate instead.")) - - return apierrors.NewInvalid( - schema.GroupKind{Group: GroupVersion.Group, Kind: "ClusterAdmissionPolicy"}, - newPolicy.GetName(), errs) - } - - return nil -} - // ValidateDelete implements webhook.Validator so a webhook will be registered for the type func (r *ClusterAdmissionPolicy) ValidateDelete() (admission.Warnings, error) { clusteradmissionpolicylog.Info("validate delete", "name", r.Name) diff --git a/pkg/apis/policies/v1/clusteradmissionpolicy_webhook_test.go b/pkg/apis/policies/v1/clusteradmissionpolicy_webhook_test.go index 77a662d6..bd8c7075 100644 --- a/pkg/apis/policies/v1/clusteradmissionpolicy_webhook_test.go +++ b/pkg/apis/policies/v1/clusteradmissionpolicy_webhook_test.go @@ -19,211 +19,36 @@ import ( "github.com/stretchr/testify/require" - admissionregistrationv1 "k8s.io/api/admissionregistration/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" + "github.com/kubewarden/kubewarden-controller/internal/pkg/constants" ) -func buildClusterAdmissionPolicy(customRules []admissionregistrationv1.RuleWithOperations, policyServer string, policyMode PolicyMode) *ClusterAdmissionPolicy { - rules := customRules +func TestClusterAdmissionPolicyDefault(t *testing.T) { + policy := clusterAdmissionPolicyFactory(nil, "", "protect") + policy.Default() - if rules == nil { - rules = append(rules, admissionregistrationv1.RuleWithOperations{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{"*"}, - Resources: []string{"*/*"}, - }, - }) - } - - return &ClusterAdmissionPolicy{ - ObjectMeta: metav1.ObjectMeta{ - Name: "testing-policy", - Namespace: "default", - }, - Spec: ClusterAdmissionPolicySpec{ - PolicySpec: PolicySpec{ - PolicyServer: policyServer, - Settings: runtime.RawExtension{ - Raw: []byte("{}"), - }, - Rules: rules, - Mode: policyMode, - }, - }, - } + require.Equal(t, constants.DefaultPolicyServer, policy.GetPolicyServer()) + require.Contains(t, policy.GetFinalizers(), constants.KubewardenFinalizer) } -func TestValidateRulesField(t *testing.T) { - tests := []struct { - name string - policy Policy - expectedErrorMessage string // use empty string when no error is expected - }{ - {"with no operations and API groups and resources", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{}, "default", "protect"), "spec.rules: Required value: a value must be specified"}, - {"with empty objects", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{}}, "default", "protect"), "spec.rules.operations: Required value: a value must be specified"}, - {"with no operations", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{"*"}, - Resources: []string{"*/*"}, - }}}, "default", "protect"), "spec.rules.operations: Required value: a value must be specified"}, - {"with null operations", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: nil, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{"*"}, - Resources: []string{"*/*"}, - }}}, "default", "protect"), "spec.rules.operations: Required value: a value must be specified"}, - {"with empty operations string", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{""}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{"*"}, - Resources: []string{"*/*"}, - }}}, "default", "protect"), "spec.rules.operations: Invalid value: \"\": field value cannot contain the empty string"}, - - {"with no apiVersion", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{}, - Resources: []string{"*/*"}, - }}}, "default", "protect"), "spec.rules: Required value: apiVersions and resources must have specified values"}, - {"with no resources", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{"*"}, - Resources: []string{}, - }}}, "default", "protect"), "spec.rules: Required value: apiVersions and resources must have specified values"}, - {"with empty apiVersion string", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{""}, - Resources: []string{"*/*"}, - }}}, "default", "protect"), "spec.rules.rule.apiVersions: Invalid value: \"\": rule.apiVersions value cannot contain the empty string"}, - {"with some of the apiVersion are empty string", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{""}, - Resources: []string{"*/*"}, - }}}, "default", "protect"), "spec.rules.rule.apiVersions: Invalid value: \"\": rule.apiVersions value cannot contain the empty string"}, - {"with empty resources string", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{"*"}, - Resources: []string{""}, - }}}, "default", "protect"), "spec.rules.rule.resources: Invalid value: \"\": rule.resources value cannot contain the empty string"}, - {"with some of the resources are string", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{""}, - APIVersions: []string{"v1"}, - Resources: []string{"", "pods"}, - }}}, "default", "protect"), "spec.rules.rule.resources: Invalid value: \"\": rule.resources value cannot contain the empty string"}, - {"with all operations and API groups and resources", buildClusterAdmissionPolicy(nil, "default", "protect"), ""}, - {"with valid APIVersion and resources. But with empty APIGroup", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{""}, - APIVersions: []string{"v1"}, - Resources: []string{"pods"}, - }}}, "default", "protect"), ""}, - {"with valid APIVersion, Resources and APIGroup", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"apps"}, - APIVersions: []string{"v1"}, - Resources: []string{"deployments"}, - }}}, "default", "protect"), ""}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := validateRulesField(test.policy) - if test.expectedErrorMessage != "" { - require.ErrorContains(t, err, test.expectedErrorMessage) - return - } - require.NoError(t, err) - }) - } +func TestClusterAdmissionPolicyValidateCreate(t *testing.T) { + policy := clusterAdmissionPolicyFactory(nil, "", "protect") + warnings, err := policy.ValidateCreate() + require.NoError(t, err) + require.Empty(t, warnings) } -func TestValidatePolicyUpdate(t *testing.T) { - tests := []struct { - name string - oldPolicy Policy - newPolicy Policy - expectedErrorMessage string // use empty string when no error is expected - }{ - {"update policy server", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"apps"}, - APIVersions: []string{"v1"}, - Resources: []string{"deployments"}, - }}}, "old-policy-server", "monitor"), buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"apps"}, - APIVersions: []string{"v1"}, - Resources: []string{"deployments"}, - }}}, "new-policy-server", "monitor"), "spec.policyServer: Forbidden: the field is immutable"}, - {"change from protect to monitor", buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"apps"}, - APIVersions: []string{"v1"}, - Resources: []string{"deployments"}, - }}}, "default", "protect"), buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"apps"}, - APIVersions: []string{"v1"}, - Resources: []string{"deployments"}, - }}}, "default", "monitor"), "spec.mode: Forbidden: field cannot transition from protect to monitor. Recreate instead."}, - {"adding more rules", - buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{{ - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"apps"}, - APIVersions: []string{"v1"}, - Resources: []string{"deployments"}, - }}}, "default", "protect"), - buildClusterAdmissionPolicy([]admissionregistrationv1.RuleWithOperations{ - { - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{"apps"}, - APIVersions: []string{"v1"}, - Resources: []string{"deployments"}, - }}, - { - Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, - Rule: admissionregistrationv1.Rule{ - APIGroups: []string{""}, - APIVersions: []string{"v1"}, - Resources: []string{"pods"}, - }}, - }, "default", "protect"), ""}, - } +func TestClusterAdmissionPolicyValidateUpdate(t *testing.T) { + oldPolicy := clusterAdmissionPolicyFactory(nil, "", "protect") + newPolicy := clusterAdmissionPolicyFactory(nil, "", "protect") + warnings, err := newPolicy.ValidateUpdate(oldPolicy) + require.NoError(t, err) + require.Empty(t, warnings) +} - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := validatePolicyUpdate(test.oldPolicy, test.newPolicy) - if test.expectedErrorMessage != "" { - require.ErrorContains(t, err, test.expectedErrorMessage) - return - } - require.NoError(t, err) - }) - } +func TestClusterAdmissionPolicyValidateUpdateWithInvalidOldPolicy(t *testing.T) { + oldPolicy := admissionPolicyFactory(nil, "", "protect") + newPolicy := clusterAdmissionPolicyFactory(nil, "", "protect") + warnings, err := newPolicy.ValidateUpdate(oldPolicy) + require.Empty(t, warnings) + require.ErrorContains(t, err, "object is not of type ClusterAdmissionPolicy") } diff --git a/pkg/apis/policies/v1/policy_test_utils.go b/pkg/apis/policies/v1/policy_test_utils.go new file mode 100644 index 00000000..4d2f66c6 --- /dev/null +++ b/pkg/apis/policies/v1/policy_test_utils.go @@ -0,0 +1,85 @@ +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func admissionPolicyFactory(customRules []admissionregistrationv1.RuleWithOperations, policyServer string, policyMode PolicyMode) *AdmissionPolicy { + rules := customRules + + if rules == nil { + rules = append(rules, admissionregistrationv1.RuleWithOperations{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{"*"}, + Resources: []string{"*/*"}, + }, + }) + } + + return &AdmissionPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testing-policy", + Namespace: "default", + }, + Spec: AdmissionPolicySpec{ + PolicySpec: PolicySpec{ + PolicyServer: policyServer, + Settings: runtime.RawExtension{ + Raw: []byte("{}"), + }, + Rules: rules, + Mode: policyMode, + }, + }, + } +} + +func clusterAdmissionPolicyFactory(customRules []admissionregistrationv1.RuleWithOperations, policyServer string, policyMode PolicyMode) *ClusterAdmissionPolicy { + rules := customRules + + if rules == nil { + rules = append(rules, admissionregistrationv1.RuleWithOperations{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{"*"}, + Resources: []string{"*/*"}, + }, + }) + } + + return &ClusterAdmissionPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testing-policy", + Namespace: "default", + }, + Spec: ClusterAdmissionPolicySpec{ + PolicySpec: PolicySpec{ + PolicyServer: policyServer, + Settings: runtime.RawExtension{ + Raw: []byte("{}"), + }, + Rules: rules, + Mode: policyMode, + }, + }, + } +} diff --git a/pkg/apis/policies/v1/policy_validation.go b/pkg/apis/policies/v1/policy_validation.go new file mode 100644 index 00000000..d5e39786 --- /dev/null +++ b/pkg/apis/policies/v1/policy_validation.go @@ -0,0 +1,133 @@ +/* + + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + "fmt" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +// Validates the spec.Rules field for non-empty, webhook-valid rules +func validateRulesField(policy Policy) error { + errs := field.ErrorList{} + rulesField := field.NewPath("spec", "rules") + + if len(policy.GetRules()) == 0 { + errs = append(errs, field.Required(rulesField, "a value must be specified")) + + return prepareInvalidAPIError(policy, errs) + } + + for _, rule := range policy.GetRules() { + switch { + case len(rule.Operations) == 0: + opField := rulesField.Child("operations") + errs = append(errs, field.Required(opField, "a value must be specified")) + case len(rule.Rule.APIVersions) == 0 || len(rule.Rule.Resources) == 0: + errs = append(errs, field.Required(rulesField, "apiVersions and resources must have specified values")) + default: + if err := checkOperationsArrayForEmptyString(rule.Operations, rulesField); err != nil { + errs = append(errs, err) + } + + if err := checkRulesArrayForEmptyString(rule.Rule.APIVersions, "rule.apiVersions", rulesField); err != nil { + errs = append(errs, err) + } + + if err := checkRulesArrayForEmptyString(rule.Rule.Resources, "rule.resources", rulesField); err != nil { + errs = append(errs, err) + } + } + } + + if len(errs) != 0 { + return prepareInvalidAPIError(policy, errs) + } + + return nil +} + +// checkOperationsArrayForEmptyString checks if any of the values in the operations array is the empty string and returns +// an error if this is true. +func checkOperationsArrayForEmptyString(operationsArray []admissionregistrationv1.OperationType, rulesField *field.Path) *field.Error { + for _, operation := range operationsArray { + if operation == "" { + return field.Invalid(rulesField.Child("operations"), "", "field value cannot contain the empty string") + } + } + + return nil +} + +// checkRulesArrayForEmptyString checks if any of the values specified is the empty string and returns an error if this +// is true. +func checkRulesArrayForEmptyString(rulesArray []string, fieldName string, parentField *field.Path) *field.Error { + for _, apiVersion := range rulesArray { + if apiVersion == "" { + apiVersionField := parentField.Child(fieldName) + + return field.Invalid(apiVersionField, "", fmt.Sprintf("%s value cannot contain the empty string", fieldName)) + } + } + + return nil +} + +// prepareInvalidAPIError is a shorthand for generating an invalid apierrors.StatusError with data from a policy +func prepareInvalidAPIError(policy Policy, errorList field.ErrorList) *apierrors.StatusError { + return apierrors.NewInvalid( + policy.GetObjectKind().GroupVersionKind().GroupKind(), + policy.GetName(), + errorList, + ) +} + +func validatePolicyUpdate(oldPolicy, newPolicy Policy) error { + if err := validateRulesField(newPolicy); err != nil { + return err + } + + if newPolicy.GetPolicyServer() != oldPolicy.GetPolicyServer() { + var errs field.ErrorList + p := field.NewPath("spec") + pp := p.Child("policyServer") + errs = append(errs, field.Forbidden(pp, "the field is immutable")) + + return apierrors.NewInvalid( + schema.GroupKind{Group: GroupVersion.Group, Kind: "ClusterAdmissionPolicy"}, + newPolicy.GetName(), errs) + } + + if newPolicy.GetPolicyMode() == "monitor" && oldPolicy.GetPolicyMode() == "protect" { + var errs field.ErrorList + p := field.NewPath("spec") + pp := p.Child("mode") + errs = append(errs, field.Forbidden(pp, "field cannot transition from protect to monitor. Recreate instead.")) + + return apierrors.NewInvalid( + schema.GroupKind{Group: GroupVersion.Group, Kind: "ClusterAdmissionPolicy"}, + newPolicy.GetName(), errs) + } + + return nil +} diff --git a/pkg/apis/policies/v1/policy_validation_test.go b/pkg/apis/policies/v1/policy_validation_test.go new file mode 100644 index 00000000..5b45afe6 --- /dev/null +++ b/pkg/apis/policies/v1/policy_validation_test.go @@ -0,0 +1,195 @@ +/* +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" +) + +func TestValidateRulesField(t *testing.T) { + tests := []struct { + name string + policy Policy + expectedErrorMessage string // use empty string when no error is expected + }{ + {"with no operations and API groups and resources", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{}, "default", "protect"), "spec.rules: Required value: a value must be specified"}, + {"with empty objects", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{}}, "default", "protect"), "spec.rules.operations: Required value: a value must be specified"}, + {"with no operations", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{"*"}, + Resources: []string{"*/*"}, + }}}, "default", "protect"), "spec.rules.operations: Required value: a value must be specified"}, + {"with null operations", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: nil, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{"*"}, + Resources: []string{"*/*"}, + }}}, "default", "protect"), "spec.rules.operations: Required value: a value must be specified"}, + {"with empty operations string", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{""}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{"*"}, + Resources: []string{"*/*"}, + }}}, "default", "protect"), "spec.rules.operations: Invalid value: \"\": field value cannot contain the empty string"}, + + {"with no apiVersion", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{}, + Resources: []string{"*/*"}, + }}}, "default", "protect"), "spec.rules: Required value: apiVersions and resources must have specified values"}, + {"with no resources", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{"*"}, + Resources: []string{}, + }}}, "default", "protect"), "spec.rules: Required value: apiVersions and resources must have specified values"}, + {"with empty apiVersion string", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{""}, + Resources: []string{"*/*"}, + }}}, "default", "protect"), "spec.rules.rule.apiVersions: Invalid value: \"\": rule.apiVersions value cannot contain the empty string"}, + {"with some of the apiVersion are empty string", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{""}, + Resources: []string{"*/*"}, + }}}, "default", "protect"), "spec.rules.rule.apiVersions: Invalid value: \"\": rule.apiVersions value cannot contain the empty string"}, + {"with empty resources string", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{"*"}, + Resources: []string{""}, + }}}, "default", "protect"), "spec.rules.rule.resources: Invalid value: \"\": rule.resources value cannot contain the empty string"}, + {"with some of the resources are string", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{"v1"}, + Resources: []string{"", "pods"}, + }}}, "default", "protect"), "spec.rules.rule.resources: Invalid value: \"\": rule.resources value cannot contain the empty string"}, + {"with all operations and API groups and resources", clusterAdmissionPolicyFactory(nil, "default", "protect"), ""}, + {"with valid APIVersion and resources. But with empty APIGroup", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{"v1"}, + Resources: []string{"pods"}, + }}}, "default", "protect"), ""}, + {"with valid APIVersion, Resources and APIGroup", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"apps"}, + APIVersions: []string{"v1"}, + Resources: []string{"deployments"}, + }}}, "default", "protect"), ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateRulesField(test.policy) + if test.expectedErrorMessage != "" { + require.ErrorContains(t, err, test.expectedErrorMessage) + return + } + require.NoError(t, err) + }) + } +} + +func TestValidatePolicyUpdate(t *testing.T) { + tests := []struct { + name string + oldPolicy Policy + newPolicy Policy + expectedErrorMessage string // use empty string when no error is expected + }{ + {"update policy server", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"apps"}, + APIVersions: []string{"v1"}, + Resources: []string{"deployments"}, + }}}, "old-policy-server", "monitor"), clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"apps"}, + APIVersions: []string{"v1"}, + Resources: []string{"deployments"}, + }}}, "new-policy-server", "monitor"), "spec.policyServer: Forbidden: the field is immutable"}, + {"change from protect to monitor", clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"apps"}, + APIVersions: []string{"v1"}, + Resources: []string{"deployments"}, + }}}, "default", "protect"), clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"apps"}, + APIVersions: []string{"v1"}, + Resources: []string{"deployments"}, + }}}, "default", "monitor"), "spec.mode: Forbidden: field cannot transition from protect to monitor. Recreate instead."}, + {"adding more rules", + clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"apps"}, + APIVersions: []string{"v1"}, + Resources: []string{"deployments"}, + }}}, "default", "protect"), + clusterAdmissionPolicyFactory([]admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"apps"}, + APIVersions: []string{"v1"}, + Resources: []string{"deployments"}, + }}, + { + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.OperationAll}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{"v1"}, + Resources: []string{"pods"}, + }}, + }, "default", "protect"), ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validatePolicyUpdate(test.oldPolicy, test.newPolicy) + if test.expectedErrorMessage != "" { + require.ErrorContains(t, err, test.expectedErrorMessage) + return + } + require.NoError(t, err) + }) + } +}