diff --git a/pkg/kubeapiserver/options/BUILD b/pkg/kubeapiserver/options/BUILD index 0ea52a3fb74..bd0c53bf575 100644 --- a/pkg/kubeapiserver/options/BUILD +++ b/pkg/kubeapiserver/options/BUILD @@ -26,6 +26,9 @@ go_library( "//plugin/pkg/admission/admit:go_default_library", "//plugin/pkg/admission/alwayspullimages:go_default_library", "//plugin/pkg/admission/antiaffinity:go_default_library", + "//plugin/pkg/admission/certificates/approval:go_default_library", + "//plugin/pkg/admission/certificates/signing:go_default_library", + "//plugin/pkg/admission/certificates/subjectrestriction:go_default_library", "//plugin/pkg/admission/defaulttolerationseconds:go_default_library", "//plugin/pkg/admission/deny:go_default_library", "//plugin/pkg/admission/eventratelimit:go_default_library", diff --git a/pkg/kubeapiserver/options/plugins.go b/pkg/kubeapiserver/options/plugins.go index e08408c9f6a..045bd3fc74c 100644 --- a/pkg/kubeapiserver/options/plugins.go +++ b/pkg/kubeapiserver/options/plugins.go @@ -24,6 +24,9 @@ import ( "k8s.io/kubernetes/plugin/pkg/admission/admit" "k8s.io/kubernetes/plugin/pkg/admission/alwayspullimages" "k8s.io/kubernetes/plugin/pkg/admission/antiaffinity" + certapproval "k8s.io/kubernetes/plugin/pkg/admission/certificates/approval" + certsigning "k8s.io/kubernetes/plugin/pkg/admission/certificates/signing" + certsubjectrestriction "k8s.io/kubernetes/plugin/pkg/admission/certificates/subjectrestriction" "k8s.io/kubernetes/plugin/pkg/admission/defaulttolerationseconds" "k8s.io/kubernetes/plugin/pkg/admission/deny" "k8s.io/kubernetes/plugin/pkg/admission/eventratelimit" @@ -87,6 +90,9 @@ var AllOrderedPlugins = []string{ gc.PluginName, // OwnerReferencesPermissionEnforcement resize.PluginName, // PersistentVolumeClaimResize runtimeclass.PluginName, // RuntimeClass + certapproval.PluginName, // CertificateApproval + certsigning.PluginName, // CertificateSigning + certsubjectrestriction.PluginName, // CertificateSubjectRestriction // new admission plugins should generally be inserted above here // webhook, resourcequota, and deny plugins must go at the end @@ -128,6 +134,9 @@ func RegisterAllAdmissionPlugins(plugins *admission.Plugins) { setdefault.Register(plugins) resize.Register(plugins) storageobjectinuseprotection.Register(plugins) + certapproval.Register(plugins) + certsigning.Register(plugins) + certsubjectrestriction.Register(plugins) } // DefaultOffAdmissionPlugins get admission plugins off by default for kube-apiserver. @@ -146,6 +155,9 @@ func DefaultOffAdmissionPlugins() sets.String { podpriority.PluginName, //PodPriority nodetaint.PluginName, //TaintNodesByCondition runtimeclass.PluginName, //RuntimeClass, gates internally on the feature + certapproval.PluginName, // CertificateApproval + certsigning.PluginName, // CertificateSigning + certsubjectrestriction.PluginName, // CertificateSubjectRestriction ) return sets.NewString(AllOrderedPlugins...).Difference(defaultOnPlugins) diff --git a/plugin/BUILD b/plugin/BUILD index 23dd6fa3047..b64ad30a317 100644 --- a/plugin/BUILD +++ b/plugin/BUILD @@ -14,6 +14,7 @@ filegroup( "//plugin/pkg/admission/admit:all-srcs", "//plugin/pkg/admission/alwayspullimages:all-srcs", "//plugin/pkg/admission/antiaffinity:all-srcs", + "//plugin/pkg/admission/certificates:all-srcs", "//plugin/pkg/admission/defaulttolerationseconds:all-srcs", "//plugin/pkg/admission/deny:all-srcs", "//plugin/pkg/admission/eventratelimit:all-srcs", diff --git a/plugin/pkg/admission/certificates/BUILD b/plugin/pkg/admission/certificates/BUILD new file mode 100644 index 00000000000..1f0e16a427f --- /dev/null +++ b/plugin/pkg/admission/certificates/BUILD @@ -0,0 +1,32 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +package(default_visibility = ["//visibility:public"]) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [ + ":package-srcs", + "//plugin/pkg/admission/certificates/approval:all-srcs", + "//plugin/pkg/admission/certificates/signing:all-srcs", + "//plugin/pkg/admission/certificates/subjectrestriction:all-srcs", + ], + tags = ["automanaged"], +) + +go_library( + name = "go_default_library", + srcs = ["util.go"], + importpath = "k8s.io/kubernetes/plugin/pkg/admission/certificates", + deps = [ + "//staging/src/k8s.io/apiserver/pkg/authentication/user:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/authorization/authorizer:go_default_library", + "//vendor/k8s.io/klog:go_default_library", + ], +) diff --git a/plugin/pkg/admission/certificates/OWNERS b/plugin/pkg/admission/certificates/OWNERS new file mode 100644 index 00000000000..9013cc311a0 --- /dev/null +++ b/plugin/pkg/admission/certificates/OWNERS @@ -0,0 +1,8 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +approvers: +- sig-auth-certificates-approvers +reviewers: +- sig-auth-certificates-approvers +labels: +- sig/auth diff --git a/plugin/pkg/admission/certificates/approval/BUILD b/plugin/pkg/admission/certificates/approval/BUILD new file mode 100644 index 00000000000..b9ee5de335f --- /dev/null +++ b/plugin/pkg/admission/certificates/approval/BUILD @@ -0,0 +1,44 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["admission.go"], + importpath = "k8s.io/kubernetes/plugin/pkg/admission/certificates/approval", + visibility = ["//visibility:public"], + deps = [ + "//pkg/apis/certificates:go_default_library", + "//plugin/pkg/admission/certificates:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/admission:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/admission/initializer:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/authorization/authorizer:go_default_library", + "//vendor/k8s.io/klog:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["admission_test.go"], + embed = [":go_default_library"], + deps = [ + "//pkg/apis/certificates:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/runtime/schema:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/admission:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/authentication/user:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/authorization/authorizer: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/plugin/pkg/admission/certificates/approval/admission.go b/plugin/pkg/admission/certificates/approval/admission.go new file mode 100644 index 00000000000..65214427092 --- /dev/null +++ b/plugin/pkg/admission/certificates/approval/admission.go @@ -0,0 +1,99 @@ +/* +Copyright 2020 The Kubernetes 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" + "io" + + "k8s.io/klog" + + "k8s.io/apiserver/pkg/admission" + genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer" + "k8s.io/apiserver/pkg/authorization/authorizer" + + api "k8s.io/kubernetes/pkg/apis/certificates" + "k8s.io/kubernetes/plugin/pkg/admission/certificates" +) + +// PluginName is a string with the name of the plugin +const PluginName = "CertificateApproval" + +// Register registers a plugin +func Register(plugins *admission.Plugins) { + plugins.Register(PluginName, func(config io.Reader) (admission.Interface, error) { + return NewPlugin(), nil + }) +} + +// Plugin holds state for and implements the admission plugin. +type Plugin struct { + *admission.Handler + authz authorizer.Authorizer +} + +// SetAuthorizer sets the authorizer. +func (p *Plugin) SetAuthorizer(authz authorizer.Authorizer) { + p.authz = authz +} + +// ValidateInitialization ensures an authorizer is set. +func (p *Plugin) ValidateInitialization() error { + if p.authz == nil { + return fmt.Errorf("%s requires an authorizer", PluginName) + } + return nil +} + +var _ admission.ValidationInterface = &Plugin{} +var _ genericadmissioninit.WantsAuthorizer = &Plugin{} + +// NewPlugin creates a new CSR approval admission plugin +func NewPlugin() *Plugin { + return &Plugin{ + Handler: admission.NewHandler(admission.Update), + } +} + +var csrGroupResource = api.Resource("certificatesigningrequests") + +// Validate verifies that the requesting user has permission to approve +// CertificateSigningRequests for the specified signerName. +func (p *Plugin) Validate(ctx context.Context, a admission.Attributes, _ admission.ObjectInterfaces) error { + // Ignore all calls to anything other than 'certificatesigningrequests/approval'. + // Ignore all operations other than UPDATE. + if a.GetSubresource() != "approval" || + a.GetResource().GroupResource() != csrGroupResource { + return nil + } + + // We check permissions against the *old* version of the resource, in case + // a user is attempting to update the SignerName when calling the approval + // endpoint (which is an invalid/not allowed operation) + csr, ok := a.GetOldObject().(*api.CertificateSigningRequest) + if !ok { + return admission.NewForbidden(a, fmt.Errorf("expected type CertificateSigningRequest, got: %T", a.GetOldObject())) + } + + if !certificates.IsAuthorizedForSignerName(ctx, p.authz, a.GetUserInfo(), "approve", csr.Spec.SignerName) { + klog.V(4).Infof("user not permitted to approve CertificateSigningRequest %q with signerName %q", csr.Name, csr.Spec.SignerName) + return admission.NewForbidden(a, fmt.Errorf("user not permitted to approve requests with signerName %q", csr.Spec.SignerName)) + } + + return nil +} diff --git a/plugin/pkg/admission/certificates/approval/admission_test.go b/plugin/pkg/admission/certificates/approval/admission_test.go new file mode 100644 index 00000000000..f17ee7cc5cb --- /dev/null +++ b/plugin/pkg/admission/certificates/approval/admission_test.go @@ -0,0 +1,206 @@ +/* +Copyright 2020 The Kubernetes 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" + "errors" + "fmt" + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" + + certificatesapi "k8s.io/kubernetes/pkg/apis/certificates" +) + +func TestPlugin_Validate(t *testing.T) { + tests := map[string]struct { + attributes admission.Attributes + allowedName string + allowed bool + authzErr error + }{ + "wrong type": { + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "approval", + oldObj: &certificatesapi.CertificateSigningRequestList{}, + operation: admission.Update, + }, + allowed: false, + }, + "reject requests if looking up permissions fails": { + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "approval", + oldObj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }}, + operation: admission.Update, + }, + authzErr: errors.New("forced error"), + allowed: false, + }, + "should allow request if user is authorized for specific signerName": { + allowedName: "abc.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "approval", + oldObj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }}, + operation: admission.Update, + }, + allowed: true, + }, + "should allow request if user is authorized with wildcard": { + allowedName: "abc.com/*", + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "approval", + oldObj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }}, + operation: admission.Update, + }, + allowed: true, + }, + "should deny request if user does not have permission for this signerName": { + allowedName: "notabc.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "approval", + oldObj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }}, + operation: admission.Update, + }, + allowed: false, + }, + "should deny request if user attempts to update signerName to a new value they *do* have permission to approve for": { + allowedName: "allowed.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "approval", + oldObj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "notallowed.com/xyz", + }}, + obj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "allowed.com/xyz", + }}, + operation: admission.Update, + }, + allowed: false, + }, + } + + for n, test := range tests { + t.Run(n, func(t *testing.T) { + p := Plugin{ + authz: fakeAuthorizer{ + t: t, + verb: "approve", + allowedName: test.allowedName, + decision: authorizer.DecisionAllow, + err: test.authzErr, + }, + } + err := p.Validate(context.Background(), test.attributes, nil) + if err == nil && !test.allowed { + t.Errorf("Expected authorization policy to reject CSR but it was allowed") + } + if err != nil && test.allowed { + t.Errorf("Expected authorization policy to accept CSR but it was rejected: %v", 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() != "certificates.k8s.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 +} + +type testAttributes struct { + resource schema.GroupResource + subresource string + operation admission.Operation + obj, oldObj runtime.Object + name string + + admission.Attributes // nil panic if any other methods called +} + +func (t *testAttributes) GetResource() schema.GroupVersionResource { + return t.resource.WithVersion("ignored") +} + +func (t *testAttributes) GetSubresource() string { + return t.subresource +} + +func (t *testAttributes) GetObject() runtime.Object { + return t.obj +} + +func (t *testAttributes) GetOldObject() runtime.Object { + return t.oldObj +} + +func (t *testAttributes) GetName() string { + return t.name +} + +func (t *testAttributes) GetOperation() admission.Operation { + return t.operation +} + +func (t *testAttributes) GetUserInfo() user.Info { + return &user.DefaultInfo{Name: "ignored"} +} diff --git a/plugin/pkg/admission/certificates/signing/BUILD b/plugin/pkg/admission/certificates/signing/BUILD new file mode 100644 index 00000000000..9579c8ae801 --- /dev/null +++ b/plugin/pkg/admission/certificates/signing/BUILD @@ -0,0 +1,44 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["admission.go"], + importpath = "k8s.io/kubernetes/plugin/pkg/admission/certificates/signing", + visibility = ["//visibility:public"], + deps = [ + "//pkg/apis/certificates:go_default_library", + "//plugin/pkg/admission/certificates:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/admission:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/admission/initializer:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/authorization/authorizer:go_default_library", + "//vendor/k8s.io/klog:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["admission_test.go"], + embed = [":go_default_library"], + deps = [ + "//pkg/apis/certificates:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/runtime/schema:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/admission:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/authentication/user:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/authorization/authorizer: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/plugin/pkg/admission/certificates/signing/admission.go b/plugin/pkg/admission/certificates/signing/admission.go new file mode 100644 index 00000000000..1b26c439d9e --- /dev/null +++ b/plugin/pkg/admission/certificates/signing/admission.go @@ -0,0 +1,106 @@ +/* +Copyright 2020 The Kubernetes 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 signing + +import ( + "context" + "fmt" + "io" + "reflect" + + "k8s.io/klog" + + "k8s.io/apiserver/pkg/admission" + genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer" + "k8s.io/apiserver/pkg/authorization/authorizer" + + api "k8s.io/kubernetes/pkg/apis/certificates" + "k8s.io/kubernetes/plugin/pkg/admission/certificates" +) + +// PluginName is a string with the name of the plugin +const PluginName = "CertificateSigning" + +// Register registers a plugin +func Register(plugins *admission.Plugins) { + plugins.Register(PluginName, func(config io.Reader) (admission.Interface, error) { + return NewPlugin(), nil + }) +} + +// Plugin holds state for and implements the admission plugin. +type Plugin struct { + *admission.Handler + authz authorizer.Authorizer +} + +// SetAuthorizer sets the authorizer. +func (p *Plugin) SetAuthorizer(authz authorizer.Authorizer) { + p.authz = authz +} + +// ValidateInitialization ensures an authorizer is set. +func (p *Plugin) ValidateInitialization() error { + if p.authz == nil { + return fmt.Errorf("%s requires an authorizer", PluginName) + } + return nil +} + +var _ admission.ValidationInterface = &Plugin{} +var _ genericadmissioninit.WantsAuthorizer = &Plugin{} + +// NewPlugin creates a new CSR approval admission plugin +func NewPlugin() *Plugin { + return &Plugin{ + Handler: admission.NewHandler(admission.Update), + } +} + +var csrGroupResource = api.Resource("certificatesigningrequests") + +// Validate verifies that the requesting user has permission to approve +// CertificateSigningRequests for the specified signerName. +func (p *Plugin) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error { + // Ignore all calls to anything other than 'certificatesigningrequests/approval'. + // Ignore all operations other than UPDATE. + if a.GetSubresource() != "status" || + a.GetResource().GroupResource() != csrGroupResource { + return nil + } + + oldCSR, ok := a.GetOldObject().(*api.CertificateSigningRequest) + if !ok { + return admission.NewForbidden(a, fmt.Errorf("expected type CertificateSigningRequest, got: %T", a.GetOldObject())) + } + csr, ok := a.GetObject().(*api.CertificateSigningRequest) + if !ok { + return admission.NewForbidden(a, fmt.Errorf("expected type CertificateSigningRequest, got: %T", a.GetObject())) + } + + // only run if the status.certificate field has been changed + if reflect.DeepEqual(oldCSR.Status.Certificate, csr.Status.Certificate) { + return nil + } + + if !certificates.IsAuthorizedForSignerName(ctx, p.authz, a.GetUserInfo(), "sign", oldCSR.Spec.SignerName) { + klog.V(4).Infof("user not permitted to sign CertificateSigningRequest %q with signerName %q", oldCSR.Name, oldCSR.Spec.SignerName) + return admission.NewForbidden(a, fmt.Errorf("user not permitted to sign requests with signerName %q", oldCSR.Spec.SignerName)) + } + + return nil +} diff --git a/plugin/pkg/admission/certificates/signing/admission_test.go b/plugin/pkg/admission/certificates/signing/admission_test.go new file mode 100644 index 00000000000..724c3e4dad3 --- /dev/null +++ b/plugin/pkg/admission/certificates/signing/admission_test.go @@ -0,0 +1,260 @@ +/* +Copyright 2020 The Kubernetes 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 signing + +import ( + "context" + "errors" + "fmt" + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" + + certificatesapi "k8s.io/kubernetes/pkg/apis/certificates" +) + +func TestPlugin_Validate(t *testing.T) { + tests := map[string]struct { + attributes admission.Attributes + allowedName string + allowed bool + authzErr error + }{ + "wrong type": { + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "status", + oldObj: &certificatesapi.CertificateSigningRequestList{}, + obj: &certificatesapi.CertificateSigningRequestList{}, + operation: admission.Update, + }, + allowed: false, + }, + "allowed if the 'certificate' field has not changed": { + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "status", + oldObj: &certificatesapi.CertificateSigningRequest{Status: certificatesapi.CertificateSigningRequestStatus{ + Certificate: []byte("data"), + }}, + obj: &certificatesapi.CertificateSigningRequest{Status: certificatesapi.CertificateSigningRequestStatus{ + Certificate: []byte("data"), + }}, + operation: admission.Update, + }, + allowed: true, + authzErr: errors.New("faked error"), + }, + "deny request if authz lookup fails": { + allowedName: "abc.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "status", + oldObj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }}, + obj: &certificatesapi.CertificateSigningRequest{ + Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }, + Status: certificatesapi.CertificateSigningRequestStatus{ + Certificate: []byte("data"), + }, + }, + operation: admission.Update, + }, + authzErr: errors.New("test"), + allowed: false, + }, + "allow request if user is authorized for specific signerName": { + allowedName: "abc.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "status", + oldObj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }}, + obj: &certificatesapi.CertificateSigningRequest{ + Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }, + Status: certificatesapi.CertificateSigningRequestStatus{ + Certificate: []byte("data"), + }, + }, + operation: admission.Update, + }, + allowed: true, + }, + "allow request if user is authorized with wildcard": { + allowedName: "abc.com/*", + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "status", + oldObj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }}, + obj: &certificatesapi.CertificateSigningRequest{ + Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }, + Status: certificatesapi.CertificateSigningRequestStatus{ + Certificate: []byte("data"), + }, + }, + operation: admission.Update, + }, + allowed: true, + }, + "should deny request if user does not have permission for this signerName": { + allowedName: "notabc.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "status", + oldObj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }}, + obj: &certificatesapi.CertificateSigningRequest{ + Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "abc.com/xyz", + }, + Status: certificatesapi.CertificateSigningRequestStatus{ + Certificate: []byte("data"), + }, + }, + operation: admission.Update, + }, + allowed: false, + }, + "should deny request if user attempts to update signerName to a new value they *do* have permission to sign for": { + allowedName: "allowed.com/xyz", + attributes: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "status", + oldObj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "notallowed.com/xyz", + }}, + obj: &certificatesapi.CertificateSigningRequest{ + Spec: certificatesapi.CertificateSigningRequestSpec{ + SignerName: "allowed.com/xyz", + }, + Status: certificatesapi.CertificateSigningRequestStatus{ + Certificate: []byte("data"), + }, + }, + operation: admission.Update, + }, + allowed: false, + }, + } + + for n, test := range tests { + t.Run(n, func(t *testing.T) { + p := Plugin{ + authz: fakeAuthorizer{ + t: t, + verb: "sign", + allowedName: test.allowedName, + decision: authorizer.DecisionAllow, + err: test.authzErr, + }, + } + err := p.Validate(context.Background(), test.attributes, nil) + if err == nil && !test.allowed { + t.Errorf("Expected authorization policy to reject CSR but it was allowed") + } + if err != nil && test.allowed { + t.Errorf("Expected authorization policy to accept CSR but it was rejected: %v", 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() != "certificates.k8s.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 +} + +type testAttributes struct { + resource schema.GroupResource + subresource string + operation admission.Operation + oldObj, obj runtime.Object + name string + + admission.Attributes // nil panic if any other methods called +} + +func (t *testAttributes) GetResource() schema.GroupVersionResource { + return t.resource.WithVersion("ignored") +} + +func (t *testAttributes) GetSubresource() string { + return t.subresource +} + +func (t *testAttributes) GetOldObject() runtime.Object { + return t.oldObj +} + +func (t *testAttributes) GetObject() runtime.Object { + return t.obj +} + +func (t *testAttributes) GetName() string { + return t.name +} + +func (t *testAttributes) GetOperation() admission.Operation { + return t.operation +} + +func (t *testAttributes) GetUserInfo() user.Info { + return &user.DefaultInfo{Name: "ignored"} +} diff --git a/plugin/pkg/admission/certificates/subjectrestriction/BUILD b/plugin/pkg/admission/certificates/subjectrestriction/BUILD new file mode 100644 index 00000000000..f73da8a6e02 --- /dev/null +++ b/plugin/pkg/admission/certificates/subjectrestriction/BUILD @@ -0,0 +1,41 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["admission.go"], + importpath = "k8s.io/kubernetes/plugin/pkg/admission/certificates/subjectrestriction", + visibility = ["//visibility:public"], + deps = [ + "//pkg/apis/certificates:go_default_library", + "//staging/src/k8s.io/api/certificates/v1beta1:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/admission:go_default_library", + "//vendor/k8s.io/klog:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["admission_test.go"], + embed = [":go_default_library"], + deps = [ + "//pkg/apis/certificates:go_default_library", + "//staging/src/k8s.io/api/certificates/v1beta1:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/runtime/schema:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/admission: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/plugin/pkg/admission/certificates/subjectrestriction/admission.go b/plugin/pkg/admission/certificates/subjectrestriction/admission.go new file mode 100644 index 00000000000..e1efff256bb --- /dev/null +++ b/plugin/pkg/admission/certificates/subjectrestriction/admission.go @@ -0,0 +1,93 @@ +/* +Copyright 2020 The Kubernetes 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 subjectrestriction + +import ( + "context" + "fmt" + "io" + + certificatesv1beta1 "k8s.io/api/certificates/v1beta1" + "k8s.io/apiserver/pkg/admission" + "k8s.io/klog" + certificatesapi "k8s.io/kubernetes/pkg/apis/certificates" +) + +// PluginName is a string with the name of the plugin +const PluginName = "CertificateSubjectRestriction" + +// Register registers the plugin +func Register(plugins *admission.Plugins) { + plugins.Register(PluginName, func(config io.Reader) (admission.Interface, error) { + return NewPlugin(), nil + }) +} + +// Plugin holds state for and implements the admission plugin. +type Plugin struct { + *admission.Handler +} + +// ValidateInitialization always returns nil. +func (p *Plugin) ValidateInitialization() error { + return nil +} + +var _ admission.ValidationInterface = &Plugin{} + +// NewPlugin constructs a new instance of the CertificateSubjectRestrictions admission interface. +func NewPlugin() *Plugin { + return &Plugin{ + Handler: admission.NewHandler(admission.Create), + } +} + +var csrGroupResource = certificatesapi.Resource("certificatesigningrequests") + +// Validate ensures that if the signerName on a CSR is set to +// `kubernetes.io/kube-apiserver-client`, that its organization (group) +// attribute is not set to `system:masters`. +func (p *Plugin) Validate(_ context.Context, a admission.Attributes, _ admission.ObjectInterfaces) error { + if a.GetResource().GroupResource() != csrGroupResource || a.GetSubresource() != "" { + return nil + } + + csr, ok := a.GetObject().(*certificatesapi.CertificateSigningRequest) + if !ok { + return admission.NewForbidden(a, fmt.Errorf("expected type CertificateSigningRequest, got: %T", a.GetObject())) + } + + if csr.Spec.SignerName != certificatesv1beta1.KubeAPIServerClientSignerName { + return nil + } + + csrParsed, err := certificatesapi.ParseCSR(csr) + if err != nil { + return admission.NewForbidden(a, fmt.Errorf("failed to parse CSR: %v", err)) + } + + for _, group := range csrParsed.Subject.Organization { + if group == "system:masters" { + klog.V(4).Infof("CSR %s rejected by admission plugin %s for attempting to use signer %s with system:masters group", + csr.Name, PluginName, certificatesv1beta1.KubeAPIServerClientSignerName) + return admission.NewForbidden(a, fmt.Errorf("use of %s signer with system:masters group is not allowed", + certificatesv1beta1.KubeAPIServerClientSignerName)) + } + } + + return nil +} diff --git a/plugin/pkg/admission/certificates/subjectrestriction/admission_test.go b/plugin/pkg/admission/certificates/subjectrestriction/admission_test.go new file mode 100644 index 00000000000..083f6ea99ad --- /dev/null +++ b/plugin/pkg/admission/certificates/subjectrestriction/admission_test.go @@ -0,0 +1,189 @@ +/* +Copyright 2020 The Kubernetes 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 subjectrestriction + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "testing" + + certificatesv1beta1 "k8s.io/api/certificates/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" + certificatesapi "k8s.io/kubernetes/pkg/apis/certificates" +) + +func TestPlugin_Validate(t *testing.T) { + tests := []struct { + name string + a admission.Attributes + wantErr string + }{ + { + name: "ignored resource", + a: &testAttributes{ + resource: schema.GroupResource{ + Group: "foo", + Resource: "bar", + }, + }, + wantErr: "", + }, + { + name: "ignored subresource", + a: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + subresource: "approve", + }, + wantErr: "", + }, + { + name: "wrong type", + a: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + obj: &certificatesapi.CertificateSigningRequestList{}, + name: "panda", + }, + wantErr: `certificatesigningrequests.certificates.k8s.io "panda" is forbidden: expected type CertificateSigningRequest, got: *certificates.CertificateSigningRequestList`, + }, + { + name: "some other signer", + a: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + obj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + Request: pemWithGroup("system:masters"), + SignerName: certificatesv1beta1.KubeAPIServerClientKubeletSignerName, + }}, + }, + wantErr: "", + }, + { + name: "invalid request", + a: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + obj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + Request: []byte("this is not a CSR"), + SignerName: certificatesv1beta1.KubeAPIServerClientSignerName, + }}, + name: "bear", + }, + wantErr: `certificatesigningrequests.certificates.k8s.io "bear" is forbidden: failed to parse CSR: PEM block type must be CERTIFICATE REQUEST`, + }, + { + name: "some other group", + a: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + obj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + Request: pemWithGroup("system:admin"), + SignerName: certificatesv1beta1.KubeAPIServerClientSignerName, + }}, + }, + wantErr: "", + }, + { + name: "request for system:masters", + a: &testAttributes{ + resource: certificatesapi.Resource("certificatesigningrequests"), + obj: &certificatesapi.CertificateSigningRequest{Spec: certificatesapi.CertificateSigningRequestSpec{ + Request: pemWithGroup("system:masters"), + SignerName: certificatesv1beta1.KubeAPIServerClientSignerName, + }}, + name: "pooh", + }, + wantErr: `certificatesigningrequests.certificates.k8s.io "pooh" is forbidden: use of kubernetes.io/kube-apiserver-client signer with system:masters group is not allowed`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{} + if err := p.Validate(context.TODO(), tt.a, nil); errStr(err) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +type testAttributes struct { + resource schema.GroupResource + subresource string + obj runtime.Object + name string + + admission.Attributes // nil panic if any other methods called +} + +func (t *testAttributes) GetResource() schema.GroupVersionResource { + return t.resource.WithVersion("ignored") +} + +func (t *testAttributes) GetSubresource() string { + return t.subresource +} + +func (t *testAttributes) GetObject() runtime.Object { + return t.obj +} + +func (t *testAttributes) GetName() string { + return t.name +} + +func errStr(err error) string { + if err == nil { + return "" + } + es := err.Error() + if len(es) == 0 { + panic("invalid empty error") + } + return es +} + +func pemWithGroup(group string) []byte { + template := &x509.CertificateRequest{ + Subject: pkix.Name{ + Organization: []string{group}, + }, + } + + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + panic(err) + } + + csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key) + if err != nil { + panic(err) + } + + csrPemBlock := &pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: csrDER, + } + + p := pem.EncodeToMemory(csrPemBlock) + if p == nil { + panic("invalid pem block") + } + + return p +} diff --git a/plugin/pkg/admission/certificates/util.go b/plugin/pkg/admission/certificates/util.go new file mode 100644 index 00000000000..ad58bad49dc --- /dev/null +++ b/plugin/pkg/admission/certificates/util.go @@ -0,0 +1,75 @@ +/* +Copyright 2020 The Kubernetes 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 certificates + +import ( + "context" + "strings" + + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/klog" +) + +// IsAuthorizedForSignerName returns true if 'info' is authorized to perform the given +// 'verb' on the synthetic 'signers' resource with the given signerName. +// If the user does not have permission to perform the 'verb' on the given signerName, +// it will also perform an authorization check against {domain portion}/*, for example +// `kubernetes.io/*`. This allows an entity to be granted permission to 'verb' on all +// signerNames with a given 'domain portion'. +func IsAuthorizedForSignerName(ctx context.Context, authz authorizer.Authorizer, info user.Info, verb, signerName string) bool { + // First check if the user has explicit permission to 'verb' for the given signerName. + attr := buildAttributes(info, verb, signerName) + decision, reason, err := authz.Authorize(ctx, attr) + switch { + case err != nil: + klog.V(3).Infof("cannot authorize %q %q for policy: %v,%v", verb, attr.GetName(), reason, err) + case decision == authorizer.DecisionAllow: + return true + } + + // If not, check if the user has wildcard permissions to 'verb' for the domain portion of the signerName, e.g. + // 'kubernetes.io/*'. + attr = buildWildcardAttributes(info, verb, signerName) + decision, reason, err = authz.Authorize(ctx, attr) + switch { + case err != nil: + klog.V(3).Infof("cannot authorize %q %q for policy: %v,%v", verb, attr.GetName(), reason, err) + 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: "certificates.k8s.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+"/*") +} diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/BUILD b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/BUILD index 51c72997834..97b595f1ab7 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/BUILD +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/BUILD @@ -17,6 +17,7 @@ go_library( deps = [ "//pkg/apis/rbac/v1:go_default_library", "//pkg/features:go_default_library", + "//staging/src/k8s.io/api/certificates/v1beta1:go_default_library", "//staging/src/k8s.io/api/rbac/v1:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/api/meta:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/controller_policy.go b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/controller_policy.go index 8ae39744b6e..8dc55a6f85b 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/controller_policy.go +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/controller_policy.go @@ -21,6 +21,7 @@ import ( "k8s.io/klog" + capi "k8s.io/api/certificates/v1beta1" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" utilfeature "k8s.io/apiserver/pkg/util/feature" @@ -337,6 +338,13 @@ func buildControllerRoles() ([]rbacv1.ClusterRole, []rbacv1.ClusterRoleBinding) Rules: []rbacv1.PolicyRule{ rbacv1helpers.NewRule("get", "list", "watch", "delete").Groups(certificatesGroup).Resources("certificatesigningrequests").RuleOrDie(), rbacv1helpers.NewRule("update").Groups(certificatesGroup).Resources("certificatesigningrequests/status", "certificatesigningrequests/approval").RuleOrDie(), + rbacv1helpers.NewRule("approve").Groups(certificatesGroup).Resources("signers").Names(capi.KubeAPIServerClientKubeletSignerName).RuleOrDie(), + rbacv1helpers.NewRule("sign").Groups(certificatesGroup).Resources("signers").Names( + capi.LegacyUnknownSignerName, + capi.KubeAPIServerClientSignerName, + capi.KubeAPIServerClientKubeletSignerName, + capi.KubeletServingSignerName, + ).RuleOrDie(), rbacv1helpers.NewRule("create").Groups(authorizationGroup).Resources("subjectaccessreviews").RuleOrDie(), eventsRule(), }, diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go index 04705d87293..8ebca128a86 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go @@ -17,6 +17,7 @@ limitations under the License. package bootstrappolicy import ( + capi "k8s.io/api/certificates/v1beta1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -459,6 +460,30 @@ func ClusterRoles() []rbacv1.ClusterRole { rbacv1helpers.NewRule(ReadUpdate...).Groups(legacyGroup).Resources("persistentvolumeclaims").RuleOrDie(), }, }, + { + ObjectMeta: metav1.ObjectMeta{Name: "system:certificates.k8s.io:legacy-unknown-approver"}, + Rules: []rbacv1.PolicyRule{ + rbacv1helpers.NewRule("approve").Groups(certificatesGroup).Resources("signers").Names(capi.LegacyUnknownSignerName).RuleOrDie(), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "system:certificates.k8s.io:kubelet-serving-approver"}, + Rules: []rbacv1.PolicyRule{ + rbacv1helpers.NewRule("approve").Groups(certificatesGroup).Resources("signers").Names(capi.KubeletServingSignerName).RuleOrDie(), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "system:certificates.k8s.io:kube-apiserver-client-approver"}, + Rules: []rbacv1.PolicyRule{ + rbacv1helpers.NewRule("approve").Groups(certificatesGroup).Resources("signers").Names(capi.KubeAPIServerClientSignerName).RuleOrDie(), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "system:certificates.k8s.io:kube-apiserver-client-kubelet-approver"}, + Rules: []rbacv1.PolicyRule{ + rbacv1helpers.NewRule("approve").Groups(certificatesGroup).Resources("signers").Names(capi.KubeAPIServerClientKubeletSignerName).RuleOrDie(), + }, + }, } if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) { diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles.yaml b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles.yaml index ad6468a53d2..b517b51eeb0 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles.yaml +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/cluster-roles.yaml @@ -419,6 +419,78 @@ items: - certificatesigningrequests/selfnodeclient verbs: - create +- apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + annotations: + rbac.authorization.kubernetes.io/autoupdate: "true" + creationTimestamp: null + labels: + kubernetes.io/bootstrapping: rbac-defaults + name: system:certificates.k8s.io:kube-apiserver-client-approver + rules: + - apiGroups: + - certificates.k8s.io + resourceNames: + - kubernetes.io/kube-apiserver-client + resources: + - signers + verbs: + - approve +- apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + annotations: + rbac.authorization.kubernetes.io/autoupdate: "true" + creationTimestamp: null + labels: + kubernetes.io/bootstrapping: rbac-defaults + name: system:certificates.k8s.io:kube-apiserver-client-kubelet-approver + rules: + - apiGroups: + - certificates.k8s.io + resourceNames: + - kubernetes.io/kube-apiserver-client-kubelet + resources: + - signers + verbs: + - approve +- apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + annotations: + rbac.authorization.kubernetes.io/autoupdate: "true" + creationTimestamp: null + labels: + kubernetes.io/bootstrapping: rbac-defaults + name: system:certificates.k8s.io:kubelet-serving-approver + rules: + - apiGroups: + - certificates.k8s.io + resourceNames: + - kubernetes.io/kubelet-serving + resources: + - signers + verbs: + - approve +- apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + annotations: + rbac.authorization.kubernetes.io/autoupdate: "true" + creationTimestamp: null + labels: + kubernetes.io/bootstrapping: rbac-defaults + name: system:certificates.k8s.io:legacy-unknown-approver + rules: + - apiGroups: + - certificates.k8s.io + resourceNames: + - kubernetes.io/legacy-unknown + resources: + - signers + verbs: + - approve - apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/controller-roles.yaml b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/controller-roles.yaml index c1bfa8d991f..89808d315ab 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/controller-roles.yaml +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/testdata/controller-roles.yaml @@ -101,6 +101,25 @@ items: - certificatesigningrequests/status verbs: - update + - apiGroups: + - certificates.k8s.io + resourceNames: + - kubernetes.io/kube-apiserver-client-kubelet + resources: + - signers + verbs: + - approve + - apiGroups: + - certificates.k8s.io + resourceNames: + - kubernetes.io/kube-apiserver-client + - kubernetes.io/kube-apiserver-client-kubelet + - kubernetes.io/kubelet-serving + - kubernetes.io/legacy-unknown + resources: + - signers + verbs: + - sign - apiGroups: - authorization.k8s.io resources: diff --git a/test/integration/certificates/BUILD b/test/integration/certificates/BUILD index d9eb36ac965..3af1dccc9db 100644 --- a/test/integration/certificates/BUILD +++ b/test/integration/certificates/BUILD @@ -3,16 +3,25 @@ load("@io_bazel_rules_go//go:def.bzl", "go_test") go_test( name = "go_default_test", srcs = [ + "admission_approval_test.go", + "admission_sign_test.go", + "admission_subjectrestriction_test.go", + "admission_test.go", "defaulting_test.go", "field_selector_test.go", "main_test.go", ], tags = ["integration"], deps = [ + "//cmd/kube-apiserver/app/testing:go_default_library", + "//staging/src/k8s.io/api/authorization/v1:go_default_library", "//staging/src/k8s.io/api/certificates/v1beta1:go_default_library", + "//staging/src/k8s.io/api/rbac/v1:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/runtime/schema:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/util/wait:go_default_library", "//staging/src/k8s.io/client-go/kubernetes:go_default_library", + "//staging/src/k8s.io/client-go/kubernetes/typed/authorization/v1:go_default_library", "//staging/src/k8s.io/client-go/kubernetes/typed/certificates/v1beta1:go_default_library", "//staging/src/k8s.io/client-go/rest:go_default_library", "//test/integration/framework:go_default_library", diff --git a/test/integration/certificates/admission_approval_test.go b/test/integration/certificates/admission_approval_test.go new file mode 100644 index 00000000000..eb30adf9412 --- /dev/null +++ b/test/integration/certificates/admission_approval_test.go @@ -0,0 +1,147 @@ +/* +Copyright 2020 The Kubernetes 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 certificates + +import ( + "context" + "testing" + + certv1beta1 "k8s.io/api/certificates/v1beta1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + clientset "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" + + kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" + "k8s.io/kubernetes/test/integration/framework" +) + +// Verifies that the CSR approval admission plugin correctly enforces that a +// user has permission to approve CSRs for the named signer +func TestCSRSignerNameApprovalPlugin(t *testing.T) { + tests := map[string]struct { + allowedSignerName string + signerName string + error string + }{ + "should admit when a user has permission for the exact signerName": { + allowedSignerName: "example.com/something", + signerName: "example.com/something", + }, + "should admit when a user has permission for the wildcard-suffixed signerName": { + allowedSignerName: "example.com/*", + signerName: "example.com/something", + }, + "should deny if a user does not have permission for the given signerName": { + allowedSignerName: "example.com/not-something", + signerName: "example.com/something", + error: `certificatesigningrequests.certificates.k8s.io "csr" is forbidden: user not permitted to approve requests with signerName "example.com/something"`, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // Run an apiserver with the default configuration options. + s := kubeapiservertesting.StartTestServerOrDie(t, kubeapiservertesting.NewDefaultTestServerOptions(), []string{"--authorization-mode=RBAC"}, framework.SharedEtcd()) + defer s.TearDownFn() + client := clientset.NewForConfigOrDie(s.ClientConfig) + + // Grant 'test-user' permission to approve CertificateSigningRequests with the specified signerName. + const username = "test-user" + grantUserPermissionToApproveFor(t, client, username, test.allowedSignerName) + // Create a CSR to attempt to approve. + csr := createTestingCSR(t, client.CertificatesV1beta1().CertificateSigningRequests(), "csr", test.signerName, "") + + // Create a second client, impersonating the 'test-user' for us to test with. + testuserConfig := restclient.CopyConfig(s.ClientConfig) + testuserConfig.Impersonate = restclient.ImpersonationConfig{UserName: username} + testuserClient := clientset.NewForConfigOrDie(testuserConfig) + + // Attempt to update the Approved condition. + csr.Status.Conditions = append(csr.Status.Conditions, certv1beta1.CertificateSigningRequestCondition{ + Type: certv1beta1.CertificateApproved, + Reason: "AutoApproved", + Message: "Approved during integration test", + }) + _, err := testuserClient.CertificatesV1beta1().CertificateSigningRequests().UpdateApproval(csr) + if err != nil && test.error != err.Error() { + t.Errorf("expected error %q but got: %v", test.error, err) + } + if err == nil && test.error != "" { + t.Errorf("expected to get an error %q but got none", test.error) + } + }) + } +} + +func grantUserPermissionToApproveFor(t *testing.T, client clientset.Interface, username string, signerNames ...string) { + resourceName := "signername-" + username + cr := buildApprovalClusterRoleForSigners(resourceName, signerNames...) + crb := buildClusterRoleBindingForUser(resourceName, username, cr.Name) + if _, err := client.RbacV1().ClusterRoles().Create(context.TODO(), cr, metav1.CreateOptions{}); err != nil { + t.Fatalf("unable to create test fixture RBAC rules: %v", err) + } + if _, err := client.RbacV1().ClusterRoleBindings().Create(context.TODO(), crb, metav1.CreateOptions{}); err != nil { + t.Fatalf("unable to create test fixture RBAC rules: %v", err) + } + approveRule := cr.Rules[0] + updateRule := cr.Rules[1] + waitForNamedAuthorizationUpdate(t, client.AuthorizationV1(), username, "", approveRule.Verbs[0], approveRule.ResourceNames[0], schema.GroupResource{Group: approveRule.APIGroups[0], Resource: approveRule.Resources[0]}, true) + waitForNamedAuthorizationUpdate(t, client.AuthorizationV1(), username, "", updateRule.Verbs[0], "", schema.GroupResource{Group: updateRule.APIGroups[0], Resource: updateRule.Resources[0]}, true) +} + +func buildApprovalClusterRoleForSigners(name string, signerNames ...string) *rbacv1.ClusterRole { + return &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Rules: []rbacv1.PolicyRule{ + // must have permission to 'approve' the 'certificatesigners' named + // 'signerName' to approve CSRs with the given signerName. + { + Verbs: []string{"approve"}, + APIGroups: []string{certv1beta1.SchemeGroupVersion.Group}, + Resources: []string{"signers"}, + ResourceNames: signerNames, + }, + { + Verbs: []string{"update"}, + APIGroups: []string{certv1beta1.SchemeGroupVersion.Group}, + Resources: []string{"certificatesigningrequests/approval"}, + }, + }, + } +} + +func buildClusterRoleBindingForUser(name, username, clusterRoleName string) *rbacv1.ClusterRoleBinding { + return &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Subjects: []rbacv1.Subject{ + { + Kind: rbacv1.UserKind, + Name: username, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "ClusterRole", + Name: clusterRoleName, + }, + } +} diff --git a/test/integration/certificates/admission_sign_test.go b/test/integration/certificates/admission_sign_test.go new file mode 100644 index 00000000000..514114a0536 --- /dev/null +++ b/test/integration/certificates/admission_sign_test.go @@ -0,0 +1,124 @@ +/* +Copyright 2020 The Kubernetes 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 certificates + +import ( + "context" + "testing" + + certv1beta1 "k8s.io/api/certificates/v1beta1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + clientset "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" + + kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" + "k8s.io/kubernetes/test/integration/framework" +) + +// Verifies that the CSR approval admission plugin correctly enforces that a +// user has permission to sign CSRs for the named signer +func TestCSRSignerNameSigningPlugin(t *testing.T) { + tests := map[string]struct { + allowedSignerName string + signerName string + error string + }{ + "should admit when a user has permission for the exact signerName": { + allowedSignerName: "example.com/something", + signerName: "example.com/something", + }, + "should admit when a user has permission for the wildcard-suffixed signerName": { + allowedSignerName: "example.com/*", + signerName: "example.com/something", + }, + "should deny if a user does not have permission for the given signerName": { + allowedSignerName: "example.com/not-something", + signerName: "example.com/something", + error: `certificatesigningrequests.certificates.k8s.io "csr" is forbidden: user not permitted to sign requests with signerName "example.com/something"`, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // Run an apiserver with the default configuration options. + s := kubeapiservertesting.StartTestServerOrDie(t, kubeapiservertesting.NewDefaultTestServerOptions(), []string{"--authorization-mode=RBAC"}, framework.SharedEtcd()) + defer s.TearDownFn() + client := clientset.NewForConfigOrDie(s.ClientConfig) + + // Grant 'test-user' permission to sign CertificateSigningRequests with the specified signerName. + const username = "test-user" + grantUserPermissionToSignFor(t, client, username, test.allowedSignerName) + // Create a CSR to attempt to sign. + csr := createTestingCSR(t, client.CertificatesV1beta1().CertificateSigningRequests(), "csr", test.signerName, "") + + // Create a second client, impersonating the 'test-user' for us to test with. + testuserConfig := restclient.CopyConfig(s.ClientConfig) + testuserConfig.Impersonate = restclient.ImpersonationConfig{UserName: username} + testuserClient := clientset.NewForConfigOrDie(testuserConfig) + + // Attempt to 'sign' the certificate. + csr.Status.Certificate = []byte("dummy data") + _, err := testuserClient.CertificatesV1beta1().CertificateSigningRequests().UpdateStatus(context.TODO(), csr, metav1.UpdateOptions{}) + if err != nil && test.error != err.Error() { + t.Errorf("expected error %q but got: %v", test.error, err) + } + if err == nil && test.error != "" { + t.Errorf("expected to get an error %q but got none", test.error) + } + }) + } +} + +func grantUserPermissionToSignFor(t *testing.T, client clientset.Interface, username string, signerNames ...string) { + resourceName := "signername-" + username + cr := buildSigningClusterRoleForSigners(resourceName, signerNames...) + crb := buildClusterRoleBindingForUser(resourceName, username, cr.Name) + if _, err := client.RbacV1().ClusterRoles().Create(context.TODO(), cr, metav1.CreateOptions{}); err != nil { + t.Fatalf("failed to create test fixtures: %v", err) + } + if _, err := client.RbacV1().ClusterRoleBindings().Create(context.TODO(), crb, metav1.CreateOptions{}); err != nil { + t.Fatalf("failed to create test fixtures: %v", err) + } + signRule := cr.Rules[0] + statusRule := cr.Rules[1] + waitForNamedAuthorizationUpdate(t, client.AuthorizationV1(), username, "", signRule.Verbs[0], signRule.ResourceNames[0], schema.GroupResource{Group: signRule.APIGroups[0], Resource: signRule.Resources[0]}, true) + waitForNamedAuthorizationUpdate(t, client.AuthorizationV1(), username, "", statusRule.Verbs[0], "", schema.GroupResource{Group: statusRule.APIGroups[0], Resource: statusRule.Resources[0]}, true) +} + +func buildSigningClusterRoleForSigners(name string, signerNames ...string) *rbacv1.ClusterRole { + return &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Rules: []rbacv1.PolicyRule{ + // must have permission to 'approve' the 'certificatesigners' named + // 'signerName' to approve CSRs with the given signerName. + { + Verbs: []string{"sign"}, + APIGroups: []string{certv1beta1.SchemeGroupVersion.Group}, + Resources: []string{"signers"}, + ResourceNames: signerNames, + }, + { + Verbs: []string{"update"}, + APIGroups: []string{certv1beta1.SchemeGroupVersion.Group}, + Resources: []string{"certificatesigningrequests/status"}, + }, + }, + } +} diff --git a/test/integration/certificates/admission_subjectrestriction_test.go b/test/integration/certificates/admission_subjectrestriction_test.go new file mode 100644 index 00000000000..9231693cfcc --- /dev/null +++ b/test/integration/certificates/admission_subjectrestriction_test.go @@ -0,0 +1,70 @@ +/* +Copyright 2020 The Kubernetes 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 certificates + +import ( + "context" + "testing" + + certv1beta1 "k8s.io/api/certificates/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientset "k8s.io/client-go/kubernetes" + + kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" + "k8s.io/kubernetes/test/integration/framework" +) + +// Verifies that the CertificateSubjectRestriction admission controller works as expected. +func TestCertificateSubjectRestrictionPlugin(t *testing.T) { + tests := map[string]struct { + signerName string + group string + error string + }{ + "should reject a request if signerName is kube-apiserver-client and group is system:masters": { + signerName: certv1beta1.KubeAPIServerClientSignerName, + group: "system:masters", + error: `certificatesigningrequests.certificates.k8s.io "csr" is forbidden: use of kubernetes.io/kube-apiserver-client signer with system:masters group is not allowed`, + }, + "should admit a request if signerName is NOT kube-apiserver-client and org is system:masters": { + signerName: certv1beta1.LegacyUnknownSignerName, + group: "system:masters", + }, + "should admit a request if signerName is kube-apiserver-client and group is NOT system:masters": { + signerName: certv1beta1.KubeAPIServerClientSignerName, + group: "system:notmasters", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // Run an apiserver with the default configuration options. + s := kubeapiservertesting.StartTestServerOrDie(t, kubeapiservertesting.NewDefaultTestServerOptions(), []string{""}, framework.SharedEtcd()) + defer s.TearDownFn() + client := clientset.NewForConfigOrDie(s.ClientConfig) + + // Attempt to create the CSR resource. + csr := buildTestingCSR("csr", test.signerName, test.group) + _, err := client.CertificatesV1beta1().CertificateSigningRequests().Create(context.TODO(), csr, metav1.CreateOptions{}) + if err != nil && test.error != err.Error() { + t.Errorf("expected error %q but got: %v", test.error, err) + } + if err == nil && test.error != "" { + t.Errorf("expected to get an error %q but got none", test.error) + } + }) + } +} diff --git a/test/integration/certificates/admission_test.go b/test/integration/certificates/admission_test.go new file mode 100644 index 00000000000..34989a16ddf --- /dev/null +++ b/test/integration/certificates/admission_test.go @@ -0,0 +1,59 @@ +/* +Copyright 2020 The Kubernetes 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 certificates + +import ( + "context" + "testing" + "time" + + authorizationv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + v1authorization "k8s.io/client-go/kubernetes/typed/authorization/v1" +) + +// waitForNamedAuthorizationUpdate checks if the given user can perform the named verb and action on the named resource. +// Copied from k8s.io/kubernetes/test/e2e/framework/auth. +func waitForNamedAuthorizationUpdate(t *testing.T, c v1authorization.SubjectAccessReviewsGetter, user, namespace, verb, resourceName string, resource schema.GroupResource, allowed bool) { + review := &authorizationv1.SubjectAccessReview{ + Spec: authorizationv1.SubjectAccessReviewSpec{ + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Group: resource.Group, + Verb: verb, + Resource: resource.Resource, + Namespace: namespace, + Name: resourceName, + }, + User: user, + }, + } + + if err := wait.Poll(time.Millisecond*100, time.Second*5, func() (bool, error) { + response, err := c.SubjectAccessReviews().Create(context.TODO(), review, metav1.CreateOptions{}) + if err != nil { + return false, err + } + if response.Status.Allowed != allowed { + return false, nil + } + return true, nil + }); err != nil { + t.Fatal(err) + } +} diff --git a/test/integration/certificates/defaulting_test.go b/test/integration/certificates/defaulting_test.go index 4e448f0d01c..9376703c494 100644 --- a/test/integration/certificates/defaulting_test.go +++ b/test/integration/certificates/defaulting_test.go @@ -43,14 +43,14 @@ func TestCSRSignerNameDefaulting(t *testing.T) { }{ "defaults to legacy-unknown if not recognised": { csr: capi.CertificateSigningRequestSpec{ - Request: testCSRPEM, + Request: pemWithGroup(""), Usages: []capi.KeyUsage{capi.UsageKeyEncipherment, capi.UsageDigitalSignature}, }, expectedSignerName: capi.LegacyUnknownSignerName, }, "does not default signerName if an explicit value is provided": { csr: capi.CertificateSigningRequestSpec{ - Request: testCSRPEM, + Request: pemWithGroup(""), Usages: []capi.KeyUsage{capi.UsageKeyEncipherment, capi.UsageDigitalSignature}, SignerName: strPtr("example.com/my-custom-signer"), }, diff --git a/test/integration/certificates/field_selector_test.go b/test/integration/certificates/field_selector_test.go index 36b7e78e177..9f1b4eb5854 100644 --- a/test/integration/certificates/field_selector_test.go +++ b/test/integration/certificates/field_selector_test.go @@ -18,6 +18,11 @@ package certificates import ( "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "testing" capi "k8s.io/api/certificates/v1beta1" @@ -37,11 +42,11 @@ func TestCSRSignerNameFieldSelector(t *testing.T) { client := clientset.NewForConfigOrDie(&restclient.Config{Host: s.URL, ContentConfig: restclient.ContentConfig{GroupVersion: &schema.GroupVersion{Group: "", Version: "v1"}}}) csrClient := client.CertificatesV1beta1().CertificateSigningRequests() - csr1 := createTestingCSR(t, csrClient, "csr-1", "example.com/signer-name-1") - csr2 := createTestingCSR(t, csrClient, "csr-2", "example.com/signer-name-2") + csr1 := createTestingCSR(t, csrClient, "csr-1", "example.com/signer-name-1", "") + csr2 := createTestingCSR(t, csrClient, "csr-2", "example.com/signer-name-2", "") // csr3 has the same signerName as csr2 so we can ensure multiple items are returned when running a filtered // LIST call. - csr3 := createTestingCSR(t, csrClient, "csr-3", "example.com/signer-name-2") + csr3 := createTestingCSR(t, csrClient, "csr-3", "example.com/signer-name-2", "") signerOneList, err := client.CertificatesV1beta1().CertificateSigningRequests().List(context.TODO(), metav1.ListOptions{FieldSelector: "spec.signerName=example.com/signer-name-1"}) if err != nil { @@ -68,45 +73,55 @@ func TestCSRSignerNameFieldSelector(t *testing.T) { } } -func createTestingCSR(t *testing.T, certClient certclientset.CertificateSigningRequestInterface, name, signerName string) *capi.CertificateSigningRequest { - csr, err := certClient.Create(context.TODO(), buildTestingCSR(name, signerName), metav1.CreateOptions{}) +func createTestingCSR(t *testing.T, certClient certclientset.CertificateSigningRequestInterface, name, signerName, groupName string) *capi.CertificateSigningRequest { + csr, err := certClient.Create(context.TODO(), buildTestingCSR(name, signerName, groupName), metav1.CreateOptions{}) if err != nil { t.Fatalf("failed to create testing CSR: %v", err) } return csr } -func buildTestingCSR(name, signerName string) *capi.CertificateSigningRequest { +func buildTestingCSR(name, signerName, groupName string) *capi.CertificateSigningRequest { return &capi.CertificateSigningRequest{ ObjectMeta: metav1.ObjectMeta{ Name: name, }, Spec: capi.CertificateSigningRequestSpec{ SignerName: &signerName, - Request: testCSRPEM, + Request: pemWithGroup(groupName), }, } } -var ( - // The contents of this CSR do not matter, and it is only used to allow the - // CSR resource submitted during integration tests to pass through - // validation. - testCSRPEM = []byte(`-----BEGIN CERTIFICATE REQUEST----- -MIICrzCCAZcCAQAwajENMAsGA1UECAwESE9OSzENMAsGA1UEBwwESE9OSzENMAsG -A1UECgwESE9OSzENMAsGA1UECwwESE9OSzENMAsGA1UEAwwESE9OSzEdMBsGCSqG -SIb3DQEJARYOaG9ua0Bob25rLmhvbmswggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw -ggEKAoIBAQDhw6t5C0Vtzzl4jQVMM9S2epAKOyKZCXRYC50sG8UFectfSALJHPUY -rv3LNfUTSkqg+EJO+5an1PeQS+GK94DUiJ2cUR2hBiTfXenyDAm2fGSDIqLQ/YcZ -fprwlqMu3YfpMH1KyyNORoOgWgsyWP0rBIRoWEFcFNaBu7BazaJHQIYNpcyRkHJC -610It4MV5dUqNFAfYqmxqlkMa4lR0U4f8cCA3J+lajNOMz/GkPotBINU+xX4bVob -Q+ghAatgiZnEvC6pe0LqG788SHaIu7hArSK8ZG7+HcqCwISFLJiA8+A6HE24PhQC -69pGqHePAFO4a09c5/MTPfBfohYkEGX7AgMBAAGgADANBgkqhkiG9w0BAQsFAAOC -AQEAwg/7CWhWZICusSKEeIHJE+rgeSySAgL0S05KJKtwjHK1zf2B8Az4F2pe0aCe -r+mqNyFutmaLOXmNH7H1BJuw0wXeEg8wlT3nknRTJ4EWYf4G0H1dOICk/tB4Mgl1 -qgmMcP37QQRCMit5VY9BOKfXo+AHCH9rwmX91mXwzyejY/wO6Y3R6Y+GvMKA259F -zRt2J8VJkeeXOE/H93putfT1KcmayTwO0gTzPFd7ZZzLSVMnpirxCUujkduxy8DK -dDcZdaTZofztqa5ej1gzptxU6fBfVvl3Wevc30yDH5Dum0aiohJbijncgIR6SQx5 -6nuYWH340f/Ivm5b1gyEqb12ag== ------END CERTIFICATE REQUEST-----`) -) +func pemWithGroup(group string) []byte { + template := &x509.CertificateRequest{ + Subject: pkix.Name{ + Organization: []string{group}, + }, + } + return pemWithTemplate(template) +} + +func pemWithTemplate(template *x509.CertificateRequest) []byte { + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + panic(err) + } + + csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key) + if err != nil { + panic(err) + } + + csrPemBlock := &pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: csrDER, + } + + p := pem.EncodeToMemory(csrPemBlock) + if p == nil { + panic("invalid pem block") + } + + return p +}