diff --git a/internal/BUILD.bazel b/internal/BUILD.bazel index 56cb69f57..6b6a23287 100644 --- a/internal/BUILD.bazel +++ b/internal/BUILD.bazel @@ -17,6 +17,7 @@ filegroup( "//internal/apis/meta:all-srcs", "//internal/controller/feature:all-srcs", "//internal/ingress:all-srcs", + "//internal/plugin:all-srcs", "//internal/test/paths:all-srcs", "//internal/vault:all-srcs", "//internal/webhook:all-srcs", diff --git a/internal/plugin/BUILD.bazel b/internal/plugin/BUILD.bazel new file mode 100644 index 000000000..21cbf425a --- /dev/null +++ b/internal/plugin/BUILD.bazel @@ -0,0 +1,36 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["plugins.go"], + importpath = "github.com/jetstack/cert-manager/internal/plugin", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/plugin/admission/apideprecation:go_default_library", + "//internal/plugin/admission/certificaterequest/approval:go_default_library", + "//internal/plugin/admission/certificaterequest/identity:go_default_library", + "//internal/plugin/admission/resourcevalidation:go_default_library", + "//pkg/webhook/admission:go_default_library", + "@io_k8s_apimachinery//pkg/util/sets:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [ + ":package-srcs", + "//internal/plugin/admission/apideprecation:all-srcs", + "//internal/plugin/admission/certificaterequest/approval:all-srcs", + "//internal/plugin/admission/certificaterequest/identity:all-srcs", + "//internal/plugin/admission/resourcevalidation:all-srcs", + ], + tags = ["automanaged"], + visibility = ["//visibility:public"], +) diff --git a/internal/plugin/admission/apideprecation/BUILD.bazel b/internal/plugin/admission/apideprecation/BUILD.bazel new file mode 100644 index 000000000..b743c1a52 --- /dev/null +++ b/internal/plugin/admission/apideprecation/BUILD.bazel @@ -0,0 +1,39 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["apideprecation.go"], + importpath = "github.com/jetstack/cert-manager/internal/plugin/admission/apideprecation", + visibility = ["//:__subpackages__"], + deps = [ + "//pkg/apis/acme:go_default_library", + "//pkg/apis/certmanager:go_default_library", + "//pkg/webhook/admission:go_default_library", + "@io_k8s_api//admission/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], + visibility = ["//visibility:public"], +) + +go_test( + name = "go_default_test", + srcs = ["apideprecation_test.go"], + embed = [":go_default_library"], + deps = [ + "@io_k8s_api//admission/v1:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + ], +) diff --git a/internal/plugin/admission/apideprecation/apideprecation.go b/internal/plugin/admission/apideprecation/apideprecation.go new file mode 100644 index 000000000..23c9838e4 --- /dev/null +++ b/internal/plugin/admission/apideprecation/apideprecation.go @@ -0,0 +1,64 @@ +/* +Copyright 2021 The cert-manager Authors. + +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 apideprecation + +import ( + "context" + "fmt" + + admissionv1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/jetstack/cert-manager/pkg/apis/acme" + "github.com/jetstack/cert-manager/pkg/apis/certmanager" + "github.com/jetstack/cert-manager/pkg/webhook/admission" +) + +const PluginName = "APIDeprecation" + +type apiDeprecation struct{} + +// Register registers a plugin +func Register(plugins *admission.Plugins) { + plugins.Register(PluginName, func() (admission.Interface, error) { + return NewPlugin(), nil + }) +} + +var _ admission.ValidationInterface = &apiDeprecation{} + +func (p apiDeprecation) Handles(_ admissionv1.Operation) bool { + return true +} + +func (p apiDeprecation) Validate(ctx context.Context, request admissionv1.AdmissionRequest, oldObj, obj runtime.Object) (warnings []string, err error) { + // Only generate warning messages for cert-manager.io and acme.cert-manager.io APIs + if request.RequestResource.Group != certmanager.GroupName && + request.RequestResource.Group != acme.GroupName { + return nil, nil + } + + // All non-v1 API resources in cert-manager.io and acme.cert-manager.io are now deprecated + if request.RequestResource.Version == "v1" { + return nil, nil + } + return []string{fmt.Sprintf("%s.%s/%s is deprecated in v1.4+, unavailable in v1.6+; use %s.%s/v1", request.RequestResource.Resource, request.RequestResource.Group, request.RequestResource.Version, request.RequestResource.Resource, request.RequestResource.Group)}, nil +} + +func NewPlugin() admission.Interface { + return new(apiDeprecation) +} diff --git a/internal/plugin/admission/apideprecation/apideprecation_test.go b/internal/plugin/admission/apideprecation/apideprecation_test.go new file mode 100644 index 000000000..427aa3b44 --- /dev/null +++ b/internal/plugin/admission/apideprecation/apideprecation_test.go @@ -0,0 +1,75 @@ +/* +Copyright 2021 The cert-manager Authors. + +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 apideprecation + +import ( + "context" + "reflect" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAPIDeprecation(t *testing.T) { + tests := map[string]struct { + req *admissionv1.AdmissionRequest + warnings []string + }{ + "should print warnings for all non-v1 cert-manager.io types": { + req: &admissionv1.AdmissionRequest{ + RequestResource: &metav1.GroupVersionResource{ + Group: "cert-manager.io", + Version: "something-not-v1", + Resource: "somethings", + }, + }, + warnings: []string{"somethings.cert-manager.io/something-not-v1 is deprecated in v1.4+, unavailable in v1.6+; use somethings.cert-manager.io/v1"}, + }, + "should print warnings for all non-v1 acme.cert-manager.io types": { + req: &admissionv1.AdmissionRequest{ + RequestResource: &metav1.GroupVersionResource{ + Group: "acme.cert-manager.io", + Version: "something-not-v1", + Resource: "somethings", + }, + }, + warnings: []string{"somethings.acme.cert-manager.io/something-not-v1 is deprecated in v1.4+, unavailable in v1.6+; use somethings.acme.cert-manager.io/v1"}, + }, + "should not print warnings for non-v1 types in other groups": { + req: &admissionv1.AdmissionRequest{ + RequestResource: &metav1.GroupVersionResource{ + Group: "some-other-group-name", + Version: "something-not-v1", + Resource: "somethings", + }, + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + p := NewPlugin().(*apiDeprecation) + warnings, err := p.Validate(context.Background(), *test.req, nil, nil) + if err != nil { + t.Errorf("unexpected error") + } + if !reflect.DeepEqual(warnings, test.warnings) { + t.Errorf("unexpected warnings, exp=%q, got=%q", test.warnings, warnings) + } + }) + } +} diff --git a/internal/plugin/admission/certificaterequest/approval/BUILD.bazel b/internal/plugin/admission/certificaterequest/approval/BUILD.bazel new file mode 100644 index 000000000..7a2031f81 --- /dev/null +++ b/internal/plugin/admission/certificaterequest/approval/BUILD.bazel @@ -0,0 +1,53 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["certificaterequest_approval.go"], + importpath = "github.com/jetstack/cert-manager/internal/plugin/admission/certificaterequest/approval", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/apis/certmanager:go_default_library", + "//internal/apis/certmanager/validation/util:go_default_library", + "//pkg/webhook/admission:go_default_library", + "//pkg/webhook/admission/initializer:go_default_library", + "@io_k8s_api//admission/v1:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/schema:go_default_library", + "@io_k8s_apimachinery//pkg/util/validation/field:go_default_library", + "@io_k8s_apiserver//pkg/authentication/user:go_default_library", + "@io_k8s_apiserver//pkg/authorization/authorizer:go_default_library", + "@io_k8s_client_go//discovery:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["certificaterequest_approval_test.go"], + embed = [":go_default_library"], + deps = [ + "//internal/apis/certmanager:go_default_library", + "//internal/apis/meta:go_default_library", + "//test/unit/discovery:go_default_library", + "@io_k8s_api//admission/v1:go_default_library", + "@io_k8s_api//authentication/v1:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/util/validation/field:go_default_library", + "@io_k8s_apiserver//pkg/authorization/authorizer:go_default_library", + "@io_k8s_client_go//discovery:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], + visibility = ["//visibility:public"], +) diff --git a/internal/plugin/admission/certificaterequest/approval/certificaterequest_approval.go b/internal/plugin/admission/certificaterequest/approval/certificaterequest_approval.go new file mode 100644 index 000000000..4908f8fdd --- /dev/null +++ b/internal/plugin/admission/certificaterequest/approval/certificaterequest_approval.go @@ -0,0 +1,284 @@ +/* +Copyright 2021 The cert-manager Authors. + +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 approval + +// CertificateRequestApproval is a plugin that ensures entities that are attempting to +// modify `status.conditions[type="Approved"]` or `status.conditions[type="Denied"]` +// have permission to do so (granted via RBAC). +// Entities will need to be able to `approve` (verb) `signers` (resource type) in +// `cert-manager.io` (group) with the name `./[.]`. +// For example: `issuers.cert-manager.io/my-namespace.my-issuer-name`. +// A wildcard signerName format is also supported: `issuers.cert-manager.io/*`. + +import ( + "context" + "fmt" + "strings" + "sync" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/client-go/discovery" + + "github.com/jetstack/cert-manager/internal/apis/certmanager" + "github.com/jetstack/cert-manager/internal/apis/certmanager/validation/util" + "github.com/jetstack/cert-manager/pkg/webhook/admission" + "github.com/jetstack/cert-manager/pkg/webhook/admission/initializer" +) + +const PluginName = "CertificateRequestApproval" + +type certificateRequestApproval struct { + *admission.Handler + + authorizer authorizer.Authorizer + discovery discovery.DiscoveryInterface + + // resourceCache stores the associated APIResource for a given GroupKind + // to making multiple queries to the API server for every approval. + resourceCache map[schema.GroupKind]metav1.APIResource + mutex sync.RWMutex +} + +var _ admission.ValidationInterface = &certificateRequestApproval{} +var _ initializer.WantsAuthorizer = &certificateRequestApproval{} +var _ initializer.WantsDiscoveryCache = &certificateRequestApproval{} + +func Register(plugins *admission.Plugins) { + plugins.Register(PluginName, func() (admission.Interface, error) { + return NewPlugin(), nil + }) +} + +func NewPlugin() admission.Interface { + return &certificateRequestApproval{ + Handler: admission.NewHandler(admissionv1.Update), + resourceCache: map[schema.GroupKind]metav1.APIResource{}, + } +} + +func (c *certificateRequestApproval) Validate(ctx context.Context, request admissionv1.AdmissionRequest, oldObj, obj runtime.Object) (warnings []string, err error) { + if request.RequestResource.Group != "cert-manager.io" || + request.RequestResource.Resource != "certificaterequests" || + request.RequestSubResource != "status" { + return nil, nil + } + + oldCR, cr := oldObj.(*certmanager.CertificateRequest), obj.(*certmanager.CertificateRequest) + if !approvalConditionsHaveChanged(oldCR, cr) { + return nil, nil + } + + group := cr.Spec.IssuerRef.Group + kind := cr.Spec.IssuerRef.Kind + // TODO: move this defaulting into the Scheme (registered as default functions) so + // these will be set when the CertificateRequest is decoded. + if group == "" { + group = "cert-manager.io" + } + if kind == "" { + kind = "Issuer" + } + apiResource, err := c.apiResourceForGroupKind(schema.GroupKind{Group: group, Kind: kind}) + switch { + case err == errNoResourceExists: + return nil, field.Forbidden(field.NewPath("spec.issuerRef"), + fmt.Sprintf("referenced signer resource does not exist: %v", cr.Spec.IssuerRef)) + case err != nil: + return nil, err + } + + signerName := signerNameForAPIResource(cr.Spec.IssuerRef.Name, cr.Namespace, *apiResource) + if !isAuthorizedForSignerName(ctx, c.authorizer, userInfoForRequest(request), signerName) { + return nil, field.Forbidden(field.NewPath("status.conditions"), + fmt.Sprintf("user %q does not have permissions to set approved/denied conditions for issuer %v", request.UserInfo.Username, cr.Spec.IssuerRef)) + } + + return nil, nil +} + +// approvalConditionsHaveChanged returns true if either the Approved or Denied conditions +// have been added to the CertificateRequest. +func approvalConditionsHaveChanged(oldCR, cr *certmanager.CertificateRequest) bool { + oldCRApproving := util.GetCertificateRequestCondition(oldCR.Status.Conditions, certmanager.CertificateRequestConditionApproved) + newCRApproving := util.GetCertificateRequestCondition(cr.Status.Conditions, certmanager.CertificateRequestConditionApproved) + oldCRDenying := util.GetCertificateRequestCondition(oldCR.Status.Conditions, certmanager.CertificateRequestConditionDenied) + newCRDenying := util.GetCertificateRequestCondition(cr.Status.Conditions, certmanager.CertificateRequestConditionDenied) + return (oldCRApproving == nil && newCRApproving != nil) || (oldCRDenying == nil && newCRDenying != nil) +} + +// apiResourceForGroupKind returns the metav1.APIResource descriptor for a given GroupKind. +// This is required to properly construct the `signerName` used as part of validating +// requests that approve or deny the CertificateRequest. +// namespaced will be true if the resource is namespaced. +// 'resource' may be nil even if err is also nil. +func (c *certificateRequestApproval) apiResourceForGroupKind(groupKind schema.GroupKind) (resource *metav1.APIResource, err error) { + // fast path if resource is in the cache already + if resource := c.readAPIResourceFromCache(groupKind); resource != nil { + return resource, nil + } + + // otherwise, query the apiserver + // TODO: we should enhance caching here to avoid performing discovery queries + // many times if many CertificateRequest resources exist that reference + // a resource that doesn't exist + groups, err := c.discovery.ServerGroups() + if err != nil { + return nil, err + } + + for _, apiGroup := range groups.Groups { + if apiGroup.Name != groupKind.Group { + continue + } + + for _, version := range apiGroup.Versions { + apiResources, err := c.discovery.ServerResourcesForGroupVersion(version.GroupVersion) + if err != nil { + return nil, err + } + + for _, resource := range apiResources.APIResources { + if resource.Kind != groupKind.Kind { + continue + } + + r := resource.DeepCopy() + // the Group field is not always populated in responses, so explicitly set it + r.Group = apiGroup.Name + c.cacheAPIResource(groupKind, *r) + return r, nil + } + } + } + + return nil, errNoResourceExists +} + +func (c *certificateRequestApproval) readAPIResourceFromCache(groupKind schema.GroupKind) *metav1.APIResource { + c.mutex.RLock() + defer c.mutex.RUnlock() + if resource, ok := c.resourceCache[groupKind]; ok { + return &resource + } + return nil +} + +func (c *certificateRequestApproval) cacheAPIResource(groupKind schema.GroupKind, resource metav1.APIResource) { + c.mutex.Lock() + defer c.mutex.Unlock() + c.resourceCache[groupKind] = resource +} + +var errNoResourceExists = fmt.Errorf("no resource registered") + +// signerNameForAPIResource returns the computed signerName for a given API resource +// referenced by a CertificateRequest in a namespace. +func signerNameForAPIResource(name, namespace string, apiResource metav1.APIResource) string { + if apiResource.Namespaced { + return fmt.Sprintf("%s.%s/%s.%s", apiResource.Name, apiResource.Group, namespace, name) + } + return fmt.Sprintf("%s.%s/%s", apiResource.Name, apiResource.Group, name) +} + +// userInfoForRequest constructs a user.Info suitable for using with the authorizer interface +// from an AdmissionRequest. +func userInfoForRequest(req admissionv1.AdmissionRequest) user.Info { + extra := make(map[string][]string) + for k, v := range req.UserInfo.Extra { + extra[k] = v + } + return &user.DefaultInfo{ + Name: req.UserInfo.Username, + UID: req.UserInfo.UID, + Groups: req.UserInfo.Groups, + Extra: extra, + } +} + +// isAuthorizedForSignerName checks whether an entity is authorized to 'approve' certificaterequests +// for a given signerName. +func isAuthorizedForSignerName(ctx context.Context, authz authorizer.Authorizer, info user.Info, signerName string) bool { + verb := "approve" + // First check if the user has explicit permission to 'approve' for the given signerName. + attr := buildAttributes(info, verb, signerName) + decision, _, err := authz.Authorize(ctx, attr) + switch { + case err != nil: + return false + case decision == authorizer.DecisionAllow: + return true + } + + // If not, check if the user has wildcard permissions to 'approve' for the domain portion of the signerName, e.g. + // 'issuers.cert-manager.io/*'. + attr = buildWildcardAttributes(info, verb, signerName) + decision, _, err = authz.Authorize(ctx, attr) + switch { + case err != nil: + return false + case decision == authorizer.DecisionAllow: + return true + } + + return false +} + +func buildAttributes(info user.Info, verb, signerName string) authorizer.Attributes { + return authorizer.AttributesRecord{ + User: info, + Verb: verb, + Name: signerName, + APIGroup: "cert-manager.io", + APIVersion: "*", + Resource: "signers", + ResourceRequest: true, + } +} + +func buildWildcardAttributes(info user.Info, verb, signerName string) authorizer.Attributes { + parts := strings.Split(signerName, "/") + domain := parts[0] + return buildAttributes(info, verb, domain+"/*") +} + +func (c *certificateRequestApproval) SetAuthorizer(a authorizer.Authorizer) { + c.authorizer = a +} + +func (c *certificateRequestApproval) SetDiscoveryCache(discovery discovery.CachedDiscoveryInterface) { + c.discovery = discovery +} + +func (c *certificateRequestApproval) ValidateInitialization() error { + if c.authorizer == nil { + return fmt.Errorf("authorizer not set") + } + if c.discovery == nil { + return fmt.Errorf("discovery client not set") + } + _, _, err := c.discovery.ServerGroupsAndResources() + if err != nil { + return err + } + return nil +} diff --git a/internal/plugin/admission/certificaterequest/approval/certificaterequest_approval_test.go b/internal/plugin/admission/certificaterequest/approval/certificaterequest_approval_test.go new file mode 100644 index 000000000..4ecb686ae --- /dev/null +++ b/internal/plugin/admission/certificaterequest/approval/certificaterequest_approval_test.go @@ -0,0 +1,358 @@ +/* +Copyright 2021 The cert-manager Authors. + +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 approval + +import ( + "context" + "fmt" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + authnv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/client-go/discovery" + + "github.com/jetstack/cert-manager/internal/apis/certmanager" + "github.com/jetstack/cert-manager/internal/apis/meta" + discoveryfake "github.com/jetstack/cert-manager/test/unit/discovery" +) + +var ( + expNoDiscovery = discovery.DiscoveryInterface(nil) +) + +func TestValidate(t *testing.T) { + baseCR := &certmanager.CertificateRequest{ + ObjectMeta: metav1.ObjectMeta{Namespace: "testns"}, + Spec: certmanager.CertificateRequestSpec{ + IssuerRef: meta.ObjectReference{ + Name: "my-issuer", + Kind: "Issuer", + Group: "example.io", + }, + }, + } + + approvedCR := baseCR.DeepCopy() + approvedCR.Status = certmanager.CertificateRequestStatus{ + Conditions: []certmanager.CertificateRequestCondition{ + { + Type: certmanager.CertificateRequestConditionApproved, + Status: meta.ConditionTrue, + Reason: "cert-manager.io", + Message: "", + }, + }, + } + + var alwaysPanicAuthorizer *fakeAuthorizer + tests := map[string]struct { + req *admissionv1.AdmissionRequest + oldCR, newCR *certmanager.CertificateRequest + + authorizer *fakeAuthorizer + discoverclient discovery.DiscoveryInterface + + expErr error + }{ + "if the request is not for CertificateRequest, exit nil": { + req: &admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + RequestResource: &metav1.GroupVersionResource{ + Group: "cert-manager.io", + Resource: "issuers", + }, + RequestSubResource: "status", + }, + authorizer: alwaysPanicAuthorizer, + discoverclient: expNoDiscovery, + expErr: nil, + }, + "if the request is not for cert-manager.io, exit nil": { + req: &admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + RequestResource: &metav1.GroupVersionResource{ + Group: "foo.cert-manager.io", + Resource: "certificaterequests", + }, + RequestSubResource: "status", + }, + authorizer: alwaysPanicAuthorizer, + expErr: nil, + }, + "if the CertificateRequest references a signer that doesn't exist, error": { + req: &admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + RequestResource: &metav1.GroupVersionResource{ + Group: "cert-manager.io", + Resource: "certificaterequests", + }, + RequestSubResource: "status", + }, + oldCR: baseCR, + newCR: approvedCR, + authorizer: alwaysPanicAuthorizer, + discoverclient: discoveryfake.NewDiscovery(). + WithServerGroups(func() (*metav1.APIGroupList, error) { + return &metav1.APIGroupList{}, nil + }), + expErr: field.Forbidden(field.NewPath("spec.issuerRef"), + "referenced signer resource does not exist: {my-issuer Issuer example.io}"), + }, + "if the CertificateRequest references a signer that the approver doesn't have permissions for, error": { + req: &admissionv1.AdmissionRequest{ + UserInfo: authnv1.UserInfo{ + Username: "user-1", + }, + Operation: admissionv1.Update, + RequestResource: &metav1.GroupVersionResource{ + Group: "cert-manager.io", + Resource: "certificaterequests", + }, + RequestSubResource: "status", + }, + oldCR: baseCR, + newCR: approvedCR, + discoverclient: discoveryfake.NewDiscovery(). + WithServerGroups(func() (*metav1.APIGroupList, error) { + return &metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "example.io", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "example.io/a-version", Version: "a-version"}, + }, + }, + }, + }, nil + }). + WithServerResourcesForGroupVersion(func(groupVersion string) (*metav1.APIResourceList, error) { + return &metav1.APIResourceList{ + APIResources: []metav1.APIResource{ + { + Name: "issuers", + Namespaced: true, + Kind: "Issuer", + }, + }, + }, nil + }), + authorizer: &fakeAuthorizer{ + verb: "approve", + allowedName: "issuers.example.io/testns.my-issuer", + decision: authorizer.DecisionNoOpinion, + }, + expErr: field.Forbidden(field.NewPath("status.conditions"), + `user "user-1" does not have permissions to set approved/denied conditions for issuer {my-issuer Issuer example.io}`), + }, + "if the CertificateRequest references a signer that the approver has permissions for, return nil": { + req: &admissionv1.AdmissionRequest{ + UserInfo: authnv1.UserInfo{ + Username: "user-1", + }, + Operation: admissionv1.Update, + RequestResource: &metav1.GroupVersionResource{ + Group: "cert-manager.io", + Resource: "certificaterequests", + }, + RequestSubResource: "status", + }, + oldCR: baseCR, + newCR: approvedCR, + discoverclient: discoveryfake.NewDiscovery(). + WithServerGroups(func() (*metav1.APIGroupList, error) { + return &metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "example.io", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "example.io/a-version", Version: "a-version"}, + }, + }, + }, + }, nil + }). + WithServerResourcesForGroupVersion(func(groupVersion string) (*metav1.APIResourceList, error) { + return &metav1.APIResourceList{ + APIResources: []metav1.APIResource{ + { + Name: "issuers", + Namespaced: true, + Kind: "Issuer", + }, + }, + }, nil + }), + authorizer: &fakeAuthorizer{ + verb: "approve", + allowedName: "issuers.example.io/testns.my-issuer", + decision: authorizer.DecisionAllow, + }, + }, + "if the CertificateRequest references a signer that the approver has permissions for the wildcard of, return nil": { + req: &admissionv1.AdmissionRequest{ + UserInfo: authnv1.UserInfo{ + Username: "user-1", + }, + Operation: admissionv1.Update, + RequestResource: &metav1.GroupVersionResource{ + Group: "cert-manager.io", + Resource: "certificaterequests", + }, + RequestSubResource: "status", + }, + oldCR: baseCR, + newCR: approvedCR, + discoverclient: discoveryfake.NewDiscovery(). + WithServerGroups(func() (*metav1.APIGroupList, error) { + return &metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "example.io", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "example.io/a-version", Version: "a-version"}, + }, + }, + }, + }, nil + }). + WithServerResourcesForGroupVersion(func(groupVersion string) (*metav1.APIResourceList, error) { + return &metav1.APIResourceList{ + APIResources: []metav1.APIResource{ + { + Name: "issuers", + Namespaced: true, + Kind: "Issuer", + }, + }, + }, nil + }), + authorizer: &fakeAuthorizer{ + verb: "approve", + allowedName: "issuers.example.io/*", + decision: authorizer.DecisionAllow, + }, + }, + "should error if the authorizer returns an error": { + req: &admissionv1.AdmissionRequest{ + UserInfo: authnv1.UserInfo{ + Username: "user-1", + }, + Operation: admissionv1.Update, + RequestResource: &metav1.GroupVersionResource{ + Group: "cert-manager.io", + Resource: "certificaterequests", + }, + RequestSubResource: "status", + }, + oldCR: baseCR, + newCR: approvedCR, + discoverclient: discoveryfake.NewDiscovery(). + WithServerGroups(func() (*metav1.APIGroupList, error) { + return &metav1.APIGroupList{ + Groups: []metav1.APIGroup{ + { + Name: "example.io", + Versions: []metav1.GroupVersionForDiscovery{ + {GroupVersion: "example.io/a-version", Version: "a-version"}, + }, + }, + }, + }, nil + }). + WithServerResourcesForGroupVersion(func(groupVersion string) (*metav1.APIResourceList, error) { + return &metav1.APIResourceList{ + APIResources: []metav1.APIResource{ + { + Name: "issuers", + Namespaced: true, + Kind: "Issuer", + }, + }, + }, nil + }), + authorizer: &fakeAuthorizer{ + err: fmt.Errorf("authorizer error"), + }, + expErr: field.Forbidden(field.NewPath("status.conditions"), + `user "user-1" does not have permissions to set approved/denied conditions for issuer {my-issuer Issuer example.io}`), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + a := NewPlugin().(*certificateRequestApproval) + a.discovery = test.discoverclient + if test.authorizer != nil { + test.authorizer.t = t + } + a.authorizer = test.authorizer + + warnings, err := a.Validate(context.TODO(), *test.req, test.oldCR, test.newCR) + if len(warnings) > 0 { + t.Errorf("expected no warnings but got: %v", warnings) + } + compareErrors(t, test.expErr, err) + }) + } +} + +type fakeAuthorizer struct { + t *testing.T + verb string + allowedName string + decision authorizer.Decision + err error +} + +func (f fakeAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + if f.err != nil { + return f.decision, "forced error", f.err + } + if a.GetVerb() != f.verb { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised verb '%s'", a.GetVerb()), nil + } + if a.GetAPIGroup() != "cert-manager.io" { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised groupName '%s'", a.GetAPIGroup()), nil + } + if a.GetAPIVersion() != "*" { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised apiVersion '%s'", a.GetAPIVersion()), nil + } + if a.GetResource() != "signers" { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised resource '%s'", a.GetResource()), nil + } + if a.GetName() != f.allowedName { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised resource name '%s'", a.GetName()), nil + } + if !a.IsResourceRequest() { + return authorizer.DecisionDeny, fmt.Sprintf("unrecognised IsResourceRequest '%t'", a.IsResourceRequest()), nil + } + return f.decision, "", nil +} + +func compareErrors(t *testing.T, exp, act error) { + if exp == nil && act == nil { + return + } + if exp == nil && act != nil || + exp != nil && act == nil || + exp.Error() != act.Error() { + t.Errorf("error not as expected. exp=%v, act=%v", exp, act) + } +} diff --git a/internal/plugin/admission/certificaterequest/identity/BUILD.bazel b/internal/plugin/admission/certificaterequest/identity/BUILD.bazel new file mode 100644 index 000000000..2e927742c --- /dev/null +++ b/internal/plugin/admission/certificaterequest/identity/BUILD.bazel @@ -0,0 +1,44 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["certificaterequest_identity.go"], + importpath = "github.com/jetstack/cert-manager/internal/plugin/admission/certificaterequest/identity", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/apis/certmanager:go_default_library", + "//pkg/util:go_default_library", + "//pkg/webhook/admission:go_default_library", + "@io_k8s_api//admission/v1:go_default_library", + "@io_k8s_api//authentication/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime:go_default_library", + "@io_k8s_apimachinery//pkg/util/validation/field:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["certificaterequest_identity_test.go"], + embed = [":go_default_library"], + deps = [ + "//internal/apis/certmanager:go_default_library", + "@io_k8s_api//admission/v1:go_default_library", + "@io_k8s_api//authentication/v1:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_apimachinery//pkg/util/validation/field:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], + visibility = ["//visibility:public"], +) diff --git a/internal/plugin/admission/certificaterequest/identity/certificaterequest_identity.go b/internal/plugin/admission/certificaterequest/identity/certificaterequest_identity.go new file mode 100644 index 000000000..476827db9 --- /dev/null +++ b/internal/plugin/admission/certificaterequest/identity/certificaterequest_identity.go @@ -0,0 +1,158 @@ +/* +Copyright 2021 The cert-manager Authors. + +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 identity + +import ( + "context" + "fmt" + "reflect" + + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/jetstack/cert-manager/internal/apis/certmanager" + "github.com/jetstack/cert-manager/pkg/util" + "github.com/jetstack/cert-manager/pkg/webhook/admission" +) + +const PluginName = "CertificateRequestIdentity" + +type certificateRequestIdentity struct { + *admission.Handler +} + +// Register registers a plugin +func Register(plugins *admission.Plugins) { + plugins.Register(PluginName, func() (admission.Interface, error) { + return NewPlugin(), nil + }) +} + +var _ admission.ValidationInterface = &certificateRequestIdentity{} +var _ admission.MutationInterface = &certificateRequestIdentity{} + +func NewPlugin() admission.Interface { + return &certificateRequestIdentity{ + Handler: admission.NewHandler(admissionv1.Create, admissionv1.Update), + } +} + +func (p *certificateRequestIdentity) Mutate(ctx context.Context, request admissionv1.AdmissionRequest, obj runtime.Object) error { + // Only run this admission plugin for the certificaterequests/status sub-resource + if request.RequestResource.Group != "cert-manager.io" || + request.RequestResource.Resource != "certificaterequests" || + request.Operation != admissionv1.Create { + return nil + } + + cr := obj.(*certmanager.CertificateRequest) + cr.Spec.UID = request.UserInfo.UID + cr.Spec.Username = request.UserInfo.Username + cr.Spec.Groups = request.UserInfo.Groups + cr.Spec.Extra = make(map[string][]string) + for k, v := range request.UserInfo.Extra { + cr.Spec.Extra[k] = v + } + + return nil +} + +func (p *certificateRequestIdentity) Validate(ctx context.Context, request admissionv1.AdmissionRequest, oldObj, obj runtime.Object) ([]string, error) { + // Only run this admission plugin for CertificateRequest resources + if request.RequestResource.Group != "cert-manager.io" || + request.RequestResource.Resource != "certificaterequests" { + return nil, nil + } + + // Cast the obj to a CertificateRequest + cr, ok := obj.(*certmanager.CertificateRequest) + if !ok { + return nil, fmt.Errorf("internal error: object in admission request is not of type *certmanager.CertificateRequest") + } + + switch request.Operation { + case admissionv1.Create: + return nil, validateCreate(request, cr) + case admissionv1.Update: + oldCR, ok := oldObj.(*certmanager.CertificateRequest) + if !ok { + return nil, fmt.Errorf("internal error: oldObject in admission request is not of type *certmanager.CertificateRequest") + } + return nil, validateUpdate(oldCR, cr) + } + + return nil, fmt.Errorf("internal error: request operation has changed - this should never be possible") +} + +func validateUpdate(oldCR *certmanager.CertificateRequest, cr *certmanager.CertificateRequest) error { + fldPath := field.NewPath("spec") + + var el field.ErrorList + if oldCR.Spec.UID != cr.Spec.UID { + el = append(el, field.Forbidden(fldPath.Child("uid"), "uid identity cannot be changed once set")) + } + if oldCR.Spec.Username != cr.Spec.Username { + el = append(el, field.Forbidden(fldPath.Child("username"), "username identity cannot be changed once set")) + } + if !util.EqualUnsorted(oldCR.Spec.Groups, cr.Spec.Groups) { + el = append(el, field.Forbidden(fldPath.Child("groups"), "groups identity cannot be changed once set")) + } + if !reflect.DeepEqual(oldCR.Spec.Extra, cr.Spec.Extra) { + el = append(el, field.Forbidden(fldPath.Child("extra"), "extra identity cannot be changed once set")) + } + return el.ToAggregate() +} + +func validateCreate(request admissionv1.AdmissionRequest, cr *certmanager.CertificateRequest) error { + fldPath := field.NewPath("spec") + + var el field.ErrorList + if cr.Spec.UID != request.UserInfo.UID { + el = append(el, field.Forbidden(fldPath.Child("uid"), "uid identity must be that of the requester")) + } + if cr.Spec.Username != request.UserInfo.Username { + el = append(el, field.Forbidden(fldPath.Child("username"), "username identity must be that of the requester")) + } + if !util.EqualUnsorted(cr.Spec.Groups, request.UserInfo.Groups) { + el = append(el, field.Forbidden(fldPath.Child("groups"), "groups identity must be that of the requester")) + } + if !extrasMatch(cr.Spec.Extra, request.UserInfo.Extra) { + el = append(el, field.Forbidden(fldPath.Child("extra"), "extra identity must be that of the requester")) + } + return el.ToAggregate() +} + +func extrasMatch(crExtra map[string][]string, reqExtra map[string]authenticationv1.ExtraValue) bool { + if len(crExtra) != len(reqExtra) { + return false + } + + for k, v := range crExtra { + reqv, ok := reqExtra[k] + if !ok { + return false + } + + if !util.EqualUnsorted(v, reqv) { + return false + } + } + + return true +} diff --git a/internal/plugin/admission/certificaterequest/identity/certificaterequest_identity_test.go b/internal/plugin/admission/certificaterequest/identity/certificaterequest_identity_test.go new file mode 100644 index 000000000..5e30f65cd --- /dev/null +++ b/internal/plugin/admission/certificaterequest/identity/certificaterequest_identity_test.go @@ -0,0 +1,467 @@ +/* +Copyright 2021 The cert-manager Authors. + +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 identity + +import ( + "context" + "reflect" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/jetstack/cert-manager/internal/apis/certmanager" +) + +var correctRequestResource = &metav1.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "certificaterequests", +} + +func TestMutate(t *testing.T) { + plugin := NewPlugin().(*certificateRequestIdentity) + cr := &certmanager.CertificateRequest{} + err := plugin.Mutate(context.Background(), admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + RequestResource: &metav1.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "certificaterequests", + }, + UserInfo: authenticationv1.UserInfo{ + Username: "testuser", + UID: "testuid", + Groups: []string{"testgroup"}, + Extra: map[string]authenticationv1.ExtraValue{ + "testkey": []string{"testvalue"}, + }, + }}, cr) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if cr.Spec.Username != "testuser" { + t.Errorf("unexpected username. got: %q, expected %q", cr.Spec.UID, "testuser") + } + if cr.Spec.UID != "testuid" { + t.Errorf("unexpected uid. got: %q, expected %q", cr.Spec.UID, "testuid") + } + if len(cr.Spec.Groups) != 1 || cr.Spec.Groups[0] != "testgroup" { + t.Errorf("unexpected groups. got: %q, expected %q", cr.Spec.Groups, "[testgroup]") + } + if len(cr.Spec.Extra) != 1 || len(cr.Spec.Extra["testkey"]) != 1 || cr.Spec.Extra["testkey"][0] != "testvalue" { + t.Errorf("unexpected uid. got: %q, expected %q", cr.Spec.Extra, "{testkey=testvalue}") + } +} + +func TestMutate_Ignores(t *testing.T) { + plugin := NewPlugin().(*certificateRequestIdentity) + tests := map[string]struct { + op admissionv1.Operation + gvr *metav1.GroupVersionResource + }{ + "ignores if resource is not 'certificaterequests'": { + op: admissionv1.Create, + gvr: &metav1.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "not-certificaterequests", + }, + }, + "ignores if group is not 'cert-manager.io'": { + op: admissionv1.Create, + gvr: &metav1.GroupVersionResource{ + Group: "not-cert-manager.io", + Version: "v1", + Resource: "certificaterequests", + }, + }, + "ignores if operation is not Create": { + op: admissionv1.Update, + gvr: &metav1.GroupVersionResource{ + Group: "cert-manager.io", + Version: "v1", + Resource: "certificaterequests", + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + cr := &certmanager.CertificateRequest{} + err := plugin.Mutate(context.Background(), admissionv1.AdmissionRequest{ + Operation: test.op, + RequestResource: test.gvr, + UserInfo: authenticationv1.UserInfo{ + Username: "testuser", + UID: "testuid", + Groups: []string{"testgroup"}, + Extra: map[string]authenticationv1.ExtraValue{ + "testkey": []string{"testvalue"}, + }, + }}, cr) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if cr.Spec.UID != "" || cr.Spec.Extra != nil || cr.Spec.Username != "" || len(cr.Spec.Groups) != 0 { + t.Errorf("unexpected mutation") + } + }) + } +} + +func TestValidateCreate(t *testing.T) { + fldPath := field.NewPath("spec") + + tests := map[string]struct { + req *admissionv1.AdmissionRequest + cr *certmanager.CertificateRequest + wantE error + wantW []string + }{ + "if identity fields don't match that of requester, should fail": { + req: &admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + RequestResource: correctRequestResource, + UserInfo: authenticationv1.UserInfo{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string]authenticationv1.ExtraValue{ + "1": []string{"abc", "efg"}, + "2": []string{"efg", "abc"}, + }, + }, + }, + cr: &certmanager.CertificateRequest{ + Spec: certmanager.CertificateRequestSpec{ + UID: "efg", + Username: "user-2", + Groups: []string{"group-3", "group-4"}, + Extra: map[string][]string{ + "1": {"123", "456"}, + "2": {"efg", "abc"}, + }, + }, + }, + wantE: field.ErrorList{ + field.Forbidden(fldPath.Child("uid"), "uid identity must be that of the requester"), + field.Forbidden(fldPath.Child("username"), "username identity must be that of the requester"), + field.Forbidden(fldPath.Child("groups"), "groups identity must be that of the requester"), + field.Forbidden(fldPath.Child("extra"), "extra identity must be that of the requester"), + }.ToAggregate(), + }, + "if identity fields match that of requester, should pass": { + req: &admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + RequestResource: correctRequestResource, + UserInfo: authenticationv1.UserInfo{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string]authenticationv1.ExtraValue{ + "1": []string{"abc", "efg"}, + "2": []string{"efg", "abc"}, + }, + }, + }, + cr: &certmanager.CertificateRequest{ + Spec: certmanager.CertificateRequestSpec{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string][]string{ + "1": {"abc", "efg"}, + "2": {"efg", "abc"}, + }, + }, + }, + wantE: nil, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + p := NewPlugin().(*certificateRequestIdentity) + gotW, gotE := p.Validate(context.Background(), *test.req, nil, test.cr) + compareErrors(t, test.wantE, gotE) + if !reflect.DeepEqual(gotW, test.wantW) { + t.Errorf("warnings from ValidateCreate() = %v, want %v", gotW, test.wantW) + } + }) + } +} + +func compareErrors(t *testing.T, exp, act error) { + if exp == nil && act == nil { + return + } + if exp == nil && act != nil || + exp != nil && act == nil || + exp.Error() != act.Error() { + t.Errorf("error not as expected. exp=%v, act=%v", exp, act) + } +} + +func TestValidateUpdate(t *testing.T) { + fldPath := field.NewPath("spec") + + tests := map[string]struct { + req *admissionv1.AdmissionRequest + oldCR, newCR *certmanager.CertificateRequest + wantE error + wantW []string + }{ + "if identity fields don't match that of the old CertificateRequest, should fail": { + req: &admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + RequestResource: correctRequestResource, + UserInfo: authenticationv1.UserInfo{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string]authenticationv1.ExtraValue{ + "1": []string{"abc", "efg"}, + "2": []string{"efg", "abc"}, + }, + }, + }, + oldCR: &certmanager.CertificateRequest{ + Spec: certmanager.CertificateRequestSpec{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string][]string{ + "1": {"abc", "efg"}, + "2": {"efg", "abc"}, + }, + }, + }, + newCR: &certmanager.CertificateRequest{ + Spec: certmanager.CertificateRequestSpec{ + UID: "efg", + Username: "user-2", + Groups: []string{"group-3", "group-4"}, + Extra: map[string][]string{ + "1": {"123", "456"}, + "2": {"efg", "abc"}, + }, + }, + }, + wantE: field.ErrorList{ + field.Forbidden(fldPath.Child("uid"), "uid identity cannot be changed once set"), + field.Forbidden(fldPath.Child("username"), "username identity cannot be changed once set"), + field.Forbidden(fldPath.Child("groups"), "groups identity cannot be changed once set"), + field.Forbidden(fldPath.Child("extra"), "extra identity cannot be changed once set"), + }.ToAggregate(), + }, + "if identity fields match that of requester, should pass": { + req: &admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + RequestResource: correctRequestResource, + UserInfo: authenticationv1.UserInfo{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string]authenticationv1.ExtraValue{ + "1": []string{"abc", "efg"}, + "2": []string{"efg", "abc"}, + }, + }, + }, + oldCR: &certmanager.CertificateRequest{ + Spec: certmanager.CertificateRequestSpec{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string][]string{ + "1": {"abc", "efg"}, + "2": {"efg", "abc"}, + }, + }, + }, + newCR: &certmanager.CertificateRequest{ + Spec: certmanager.CertificateRequestSpec{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string][]string{ + "1": {"abc", "efg"}, + "2": {"efg", "abc"}, + }, + }, + }, + wantE: nil, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + p := NewPlugin().(*certificateRequestIdentity) + gotW, gotE := p.Validate(context.Background(), *test.req, test.oldCR, test.newCR) + compareErrors(t, test.wantE, gotE) + if !reflect.DeepEqual(gotW, test.wantW) { + t.Errorf("warnings from ValidateUpdate() = %v, want %v", gotW, test.wantW) + } + }) + } +} + +func TestMutateCreate(t *testing.T) { + tests := map[string]struct { + req *admissionv1.AdmissionRequest + existingCR, expectedCR *certmanager.CertificateRequest + }{ + "should set the identity of CertificateRequest to that of the requester": { + req: &admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + RequestResource: correctRequestResource, + UserInfo: authenticationv1.UserInfo{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string]authenticationv1.ExtraValue{ + "1": []string{"abc", "efg"}, + "2": []string{"efg", "abc"}, + }, + }, + }, + existingCR: new(certmanager.CertificateRequest), + expectedCR: &certmanager.CertificateRequest{ + Spec: certmanager.CertificateRequestSpec{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string][]string{ + "1": {"abc", "efg"}, + "2": {"efg", "abc"}, + }, + }, + }, + }, + "should overwrite user info fields if already present during a CREATE operation": { + req: &admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + RequestResource: correctRequestResource, + UserInfo: authenticationv1.UserInfo{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string]authenticationv1.ExtraValue{ + "1": []string{"abc", "efg"}, + "2": []string{"efg", "abc"}, + }, + }, + }, + existingCR: &certmanager.CertificateRequest{ + Spec: certmanager.CertificateRequestSpec{ + UID: "1234", + Username: "user-2", + Groups: []string{"group-3", "group-4"}, + Extra: map[string][]string{ + "3": {"abc", "efg"}, + "4": {"efg", "abc"}, + }, + }, + }, + expectedCR: &certmanager.CertificateRequest{ + Spec: certmanager.CertificateRequestSpec{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string][]string{ + "1": {"abc", "efg"}, + "2": {"efg", "abc"}, + }, + }, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + cr := test.existingCR.DeepCopy() + p := NewPlugin().(*certificateRequestIdentity) + if err := p.Mutate(context.Background(), *test.req, cr); err != nil { + t.Errorf("unexpected error: %v", err) + } + if !reflect.DeepEqual(test.expectedCR, cr) { + t.Errorf("MutateCreate() = %v, want %v", cr, test.expectedCR) + } + }) + } +} + +func TestMutateUpdate(t *testing.T) { + tests := map[string]struct { + req *admissionv1.AdmissionRequest + existingCR, expectedCR *certmanager.CertificateRequest + }{ + "should not overwrite user info fields during an Update operation": { + req: &admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + RequestResource: correctRequestResource, + UserInfo: authenticationv1.UserInfo{ + UID: "abc", + Username: "user-1", + Groups: []string{"group-1", "group-2"}, + Extra: map[string]authenticationv1.ExtraValue{ + "1": []string{"abc", "efg"}, + "2": []string{"efg", "abc"}, + }, + }, + }, + existingCR: &certmanager.CertificateRequest{ + Spec: certmanager.CertificateRequestSpec{ + UID: "1234", + Username: "user-2", + Groups: []string{"group-3", "group-4"}, + Extra: map[string][]string{ + "3": {"abc", "efg"}, + "4": {"efg", "abc"}, + }, + }, + }, + expectedCR: &certmanager.CertificateRequest{ + Spec: certmanager.CertificateRequestSpec{ + UID: "1234", + Username: "user-2", + Groups: []string{"group-3", "group-4"}, + Extra: map[string][]string{ + "3": {"abc", "efg"}, + "4": {"efg", "abc"}, + }, + }, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + cr := test.existingCR.DeepCopy() + p := NewPlugin().(*certificateRequestIdentity) + if err := p.Mutate(context.Background(), *test.req, cr); err != nil { + t.Errorf("unexpected error: %v", err) + } + if !reflect.DeepEqual(test.expectedCR, cr) { + t.Errorf("MutateCreate() = %v, want %v", cr, test.expectedCR) + } + }) + } +} diff --git a/internal/plugin/admission/resourcevalidation/BUILD.bazel b/internal/plugin/admission/resourcevalidation/BUILD.bazel new file mode 100644 index 000000000..c75ccb126 --- /dev/null +++ b/internal/plugin/admission/resourcevalidation/BUILD.bazel @@ -0,0 +1,34 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["plugin.go"], + importpath = "github.com/jetstack/cert-manager/internal/plugin/admission/resourcevalidation", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/api/validation:go_default_library", + "//internal/apis/acme/validation:go_default_library", + "//internal/apis/certmanager/validation:go_default_library", + "//pkg/apis/acme/v1:go_default_library", + "//pkg/apis/certmanager/v1:go_default_library", + "//pkg/webhook/admission:go_default_library", + "@io_k8s_api//admission/v1:go_default_library", + "@io_k8s_apimachinery//pkg/runtime:go_default_library", + "@io_k8s_apimachinery//pkg/runtime/schema:go_default_library", + "@io_k8s_apimachinery//pkg/util/validation/field:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], + visibility = ["//visibility:public"], +) diff --git a/internal/plugin/admission/resourcevalidation/plugin.go b/internal/plugin/admission/resourcevalidation/plugin.go new file mode 100644 index 000000000..205cecc8a --- /dev/null +++ b/internal/plugin/admission/resourcevalidation/plugin.go @@ -0,0 +1,112 @@ +/* +Copyright 2021 The cert-manager Authors. + +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 resourcevalidation + +import ( + "context" + "github.com/jetstack/cert-manager/internal/api/validation" + acmevalidation "github.com/jetstack/cert-manager/internal/apis/acme/validation" + acmev1 "github.com/jetstack/cert-manager/pkg/apis/acme/v1" + admission2 "github.com/jetstack/cert-manager/pkg/webhook/admission" + "k8s.io/apimachinery/pkg/util/validation/field" + + admissionv1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + cmvalidation "github.com/jetstack/cert-manager/internal/apis/certmanager/validation" + certmanagerv1 "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1" +) + +const PluginName = "ResourceValidation" + +type resourceValidation struct { + *admission2.Handler +} + +// Register registers a plugin +func Register(plugins *admission2.Plugins) { + plugins.Register(PluginName, func() (admission2.Interface, error) { + return NewPlugin(), nil + }) +} + +var _ admission2.ValidationInterface = &resourceValidation{} + +var certificateGVR = certmanagerv1.SchemeGroupVersion.WithResource("certificates") +var certificateRequestGVR = certmanagerv1.SchemeGroupVersion.WithResource("certificaterequests") +var issuerGVR = certmanagerv1.SchemeGroupVersion.WithResource("issuers") +var clusterIssuerGVR = certmanagerv1.SchemeGroupVersion.WithResource("clusterissuers") +var orderGVR = acmev1.SchemeGroupVersion.WithResource("orders") +var challengeGVR = acmev1.SchemeGroupVersion.WithResource("challenges") + +type validateCreateFunc func(a *admissionv1.AdmissionRequest, obj runtime.Object) (field.ErrorList, validation.WarningList) +type validateUpdateFunc func(a *admissionv1.AdmissionRequest, oldObj, obj runtime.Object) (field.ErrorList, validation.WarningList) + +type validationPair struct { + create validateCreateFunc + update validateUpdateFunc +} + +func newValidationPair(create validateCreateFunc, update validateUpdateFunc) validationPair { + return validationPair{create: create, update: update} +} + +var validationMapping = map[schema.GroupVersionResource]validationPair{ + certificateGVR: newValidationPair(cmvalidation.ValidateCertificate, cmvalidation.ValidateUpdateCertificate), + certificateRequestGVR: newValidationPair(cmvalidation.ValidateCertificateRequest, cmvalidation.ValidateUpdateCertificateRequest), + issuerGVR: newValidationPair(cmvalidation.ValidateIssuer, cmvalidation.ValidateUpdateIssuer), + clusterIssuerGVR: newValidationPair(cmvalidation.ValidateClusterIssuer, cmvalidation.ValidateUpdateClusterIssuer), + orderGVR: newValidationPair(acmevalidation.ValidateOrder, acmevalidation.ValidateOrderUpdate), + challengeGVR: newValidationPair(acmevalidation.ValidateChallenge, acmevalidation.ValidateChallengeUpdate), +} + +func NewPlugin() admission2.Interface { + return &resourceValidation{ + Handler: admission2.NewHandler(admissionv1.Create, admissionv1.Update), + } +} + +func (p resourceValidation) Validate(_ context.Context, request admissionv1.AdmissionRequest, oldObj, obj runtime.Object) ([]string, error) { + requestResource := schema.GroupVersionResource{ + Group: request.RequestResource.Group, + Version: request.RequestResource.Version, + Resource: request.RequestResource.Resource, + } + + pair, ok := validationMapping[requestResource] + if !ok { + return nil, nil + } + + switch request.Operation { + case admissionv1.Create: + if pair.create == nil { + return nil, nil + } + errs, warnings := pair.create(&request, obj) + return warnings, errs.ToAggregate() + case admissionv1.Update: + if pair.update == nil { + return nil, nil + } + errs, warnings := pair.update(&request, oldObj, obj) + return warnings, errs.ToAggregate() + } + + return nil, nil +} diff --git a/internal/plugin/plugins.go b/internal/plugin/plugins.go new file mode 100644 index 000000000..199fdbe29 --- /dev/null +++ b/internal/plugin/plugins.go @@ -0,0 +1,54 @@ +/* +Copyright 2021 The cert-manager Authors. + +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 plugin + +import ( + "github.com/jetstack/cert-manager/internal/plugin/admission/apideprecation" + certificaterequestapproval "github.com/jetstack/cert-manager/internal/plugin/admission/certificaterequest/approval" + certificaterequestidentity "github.com/jetstack/cert-manager/internal/plugin/admission/certificaterequest/identity" + "github.com/jetstack/cert-manager/internal/plugin/admission/resourcevalidation" + "github.com/jetstack/cert-manager/pkg/webhook/admission" + "k8s.io/apimachinery/pkg/util/sets" +) + +var AllOrderedPlugins = []string{ + apideprecation.PluginName, + resourcevalidation.PluginName, + certificaterequestidentity.PluginName, + certificaterequestapproval.PluginName, +} + +func RegisterAllPlugins(plugins *admission.Plugins) { + apideprecation.Register(plugins) + certificaterequestidentity.Register(plugins) + certificaterequestapproval.Register(plugins) + resourcevalidation.Register(plugins) +} + +func DefaultOnAdmissionPlugins() sets.String { + return sets.NewString( + apideprecation.PluginName, + resourcevalidation.PluginName, + certificaterequestidentity.PluginName, + certificaterequestapproval.PluginName, + ) +} + +// DefaultOffAdmissionPlugins gets admission plugins off by default for the webhook. +func DefaultOffAdmissionPlugins() sets.String { + return sets.NewString(AllOrderedPlugins...).Difference(DefaultOnAdmissionPlugins()) +}