mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-09-12 12:48:51 +00:00
ClusterTrustBundles: Define types
This commit is the main API piece of KEP-3257 (ClusterTrustBundles). This commit: * Adds the certificates.k8s.io/v1alpha1 API group * Adds the ClusterTrustBundle type. * Registers the new type in kube-apiserver. * Implements the type-specfic validation specified for ClusterTrustBundles: - spec.pemTrustAnchors must always be non-empty. - spec.signerName must be either empty or a valid signer name. - Changing spec.signerName is disallowed. * Implements the "attest" admission check to restrict actions on ClusterTrustBundles that include a signer name. Because it wasn't specified in the KEP, I chose to make attempts to update the signer name be validation errors, rather than silently ignored. I have tested this out by launching these changes in kind and manipulating ClusterTrustBundle objects in the resulting cluster using kubectl.
This commit is contained in:
133
plugin/pkg/admission/certificates/ctbattest/admission.go
Normal file
133
plugin/pkg/admission/certificates/ctbattest/admission.go
Normal file
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
Copyright 2022 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 ctbattest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"k8s.io/apiserver/pkg/admission"
|
||||
genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/component-base/featuregate"
|
||||
"k8s.io/klog/v2"
|
||||
api "k8s.io/kubernetes/pkg/apis/certificates"
|
||||
"k8s.io/kubernetes/pkg/features"
|
||||
"k8s.io/kubernetes/plugin/pkg/admission/certificates"
|
||||
)
|
||||
|
||||
const PluginName = "ClusterTrustBundleAttest"
|
||||
|
||||
func Register(plugins *admission.Plugins) {
|
||||
plugins.Register(PluginName, func(config io.Reader) (admission.Interface, error) {
|
||||
return NewPlugin(), nil
|
||||
})
|
||||
}
|
||||
|
||||
// Plugin is the ClusterTrustBundle attest plugin.
|
||||
//
|
||||
// In order to create or update a ClusterTrustBundle that sets signerName,
|
||||
// you must have the following permission: group=certificates.k8s.io
|
||||
// resource=signers resourceName=<the signer name> verb=attest.
|
||||
type Plugin struct {
|
||||
*admission.Handler
|
||||
authz authorizer.Authorizer
|
||||
|
||||
inspectedFeatureGates bool
|
||||
enabled bool
|
||||
}
|
||||
|
||||
var _ admission.ValidationInterface = &Plugin{}
|
||||
var _ admission.InitializationValidator = &Plugin{}
|
||||
var _ genericadmissioninit.WantsAuthorizer = &Plugin{}
|
||||
var _ genericadmissioninit.WantsFeatures = &Plugin{}
|
||||
|
||||
func NewPlugin() *Plugin {
|
||||
return &Plugin{
|
||||
Handler: admission.NewHandler(admission.Create, admission.Update),
|
||||
}
|
||||
}
|
||||
|
||||
// SetAuthorizer sets the plugin's authorizer.
|
||||
func (p *Plugin) SetAuthorizer(authz authorizer.Authorizer) {
|
||||
p.authz = authz
|
||||
}
|
||||
|
||||
// InspectFeatureGates implements WantsFeatures.
|
||||
func (p *Plugin) InspectFeatureGates(featureGates featuregate.FeatureGate) {
|
||||
p.enabled = featureGates.Enabled(features.ClusterTrustBundle)
|
||||
p.inspectedFeatureGates = true
|
||||
}
|
||||
|
||||
// ValidateInitialization checks that the plugin was initialized correctly.
|
||||
func (p *Plugin) ValidateInitialization() error {
|
||||
if p.authz == nil {
|
||||
return fmt.Errorf("%s requires an authorizer", PluginName)
|
||||
}
|
||||
if !p.inspectedFeatureGates {
|
||||
return fmt.Errorf("%s did not see feature gates", PluginName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var clusterTrustBundleGroupResource = api.Resource("clustertrustbundles")
|
||||
|
||||
func (p *Plugin) Validate(ctx context.Context, a admission.Attributes, _ admission.ObjectInterfaces) error {
|
||||
if !p.enabled {
|
||||
return nil
|
||||
}
|
||||
if a.GetResource().GroupResource() != clusterTrustBundleGroupResource {
|
||||
return nil
|
||||
}
|
||||
|
||||
newBundle, ok := a.GetObject().(*api.ClusterTrustBundle)
|
||||
if !ok {
|
||||
return admission.NewForbidden(a, fmt.Errorf("expected type ClusterTrustBundle, got: %T", a.GetOldObject()))
|
||||
}
|
||||
|
||||
// Unlike CSRs, it's OK to validate against the *new* object, because
|
||||
// updates to signer name will be rejected during validation. For defense
|
||||
// in depth, reject attempts to change signer at this layer as well.
|
||||
//
|
||||
// We want to use the new object because we also need to perform the signer
|
||||
// name permission check on *create*.
|
||||
|
||||
if a.GetOperation() == admission.Update {
|
||||
oldBundle, ok := a.GetOldObject().(*api.ClusterTrustBundle)
|
||||
if !ok {
|
||||
return admission.NewForbidden(a, fmt.Errorf("expected type ClusterTrustBundle, got: %T", a.GetOldObject()))
|
||||
}
|
||||
|
||||
if oldBundle.Spec.SignerName != newBundle.Spec.SignerName {
|
||||
return admission.NewForbidden(a, fmt.Errorf("changing signerName is forbidden"))
|
||||
}
|
||||
}
|
||||
|
||||
// If signer name isn't specified, we don't need to perform the
|
||||
// attest check.
|
||||
if newBundle.Spec.SignerName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !certificates.IsAuthorizedForSignerName(ctx, p.authz, a.GetUserInfo(), "attest", newBundle.Spec.SignerName) {
|
||||
klog.V(4).Infof("user not permitted to attest ClusterTrustBundle %q with signerName %q", newBundle.Name, newBundle.Spec.SignerName)
|
||||
return admission.NewForbidden(a, fmt.Errorf("user not permitted to attest for signerName %q", newBundle.Spec.SignerName))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
306
plugin/pkg/admission/certificates/ctbattest/admission_test.go
Normal file
306
plugin/pkg/admission/certificates/ctbattest/admission_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
Copyright 2022 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 ctbattest
|
||||
|
||||
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"
|
||||
"k8s.io/apiserver/pkg/util/feature"
|
||||
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
||||
certificatesapi "k8s.io/kubernetes/pkg/apis/certificates"
|
||||
"k8s.io/kubernetes/pkg/features"
|
||||
)
|
||||
|
||||
func TestPluginValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
description string
|
||||
clusterTrustBundleFeatureEnabled bool
|
||||
attributes admission.Attributes
|
||||
allowedName string
|
||||
allowed bool
|
||||
authzErr error
|
||||
}{
|
||||
{
|
||||
description: "wrong type on create",
|
||||
clusterTrustBundleFeatureEnabled: true,
|
||||
attributes: &testAttributes{
|
||||
resource: certificatesapi.Resource("clustertrustbundles"),
|
||||
obj: &certificatesapi.ClusterTrustBundleList{},
|
||||
operation: admission.Create,
|
||||
},
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
description: "wrong type on update",
|
||||
clusterTrustBundleFeatureEnabled: true,
|
||||
attributes: &testAttributes{
|
||||
resource: certificatesapi.Resource("clustertrustbundles"),
|
||||
obj: &certificatesapi.ClusterTrustBundleList{},
|
||||
operation: admission.Update,
|
||||
},
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
description: "reject requests if looking up permissions fails",
|
||||
clusterTrustBundleFeatureEnabled: true,
|
||||
attributes: &testAttributes{
|
||||
resource: certificatesapi.Resource("clustertrustbundles"),
|
||||
obj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{
|
||||
SignerName: "abc.com/xyz",
|
||||
},
|
||||
},
|
||||
operation: admission.Update,
|
||||
},
|
||||
authzErr: errors.New("forced error"),
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
description: "should allow create if no signer name is specified",
|
||||
clusterTrustBundleFeatureEnabled: true,
|
||||
allowedName: "abc.com/xyz",
|
||||
attributes: &testAttributes{
|
||||
resource: certificatesapi.Resource("clustertrustbundles"),
|
||||
obj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{},
|
||||
},
|
||||
operation: admission.Create,
|
||||
},
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
description: "should allow update if no signer name is specified",
|
||||
clusterTrustBundleFeatureEnabled: true,
|
||||
allowedName: "abc.com/xyz",
|
||||
attributes: &testAttributes{
|
||||
resource: certificatesapi.Resource("clustertrustbundles"),
|
||||
oldObj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{},
|
||||
},
|
||||
obj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{},
|
||||
},
|
||||
operation: admission.Update,
|
||||
},
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
description: "should allow create if user is authorized for specific signerName",
|
||||
clusterTrustBundleFeatureEnabled: true,
|
||||
allowedName: "abc.com/xyz",
|
||||
attributes: &testAttributes{
|
||||
resource: certificatesapi.Resource("clustertrustbundles"),
|
||||
obj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{
|
||||
SignerName: "abc.com/xyz",
|
||||
},
|
||||
},
|
||||
operation: admission.Create,
|
||||
},
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
description: "should allow update if user is authorized for specific signerName",
|
||||
clusterTrustBundleFeatureEnabled: true,
|
||||
allowedName: "abc.com/xyz",
|
||||
attributes: &testAttributes{
|
||||
resource: certificatesapi.Resource("clustertrustbundles"),
|
||||
oldObj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{
|
||||
SignerName: "abc.com/xyz",
|
||||
},
|
||||
},
|
||||
obj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{
|
||||
SignerName: "abc.com/xyz",
|
||||
},
|
||||
},
|
||||
operation: admission.Update,
|
||||
},
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
description: "should allow create if user is authorized with wildcard",
|
||||
clusterTrustBundleFeatureEnabled: true,
|
||||
allowedName: "abc.com/*",
|
||||
attributes: &testAttributes{
|
||||
resource: certificatesapi.Resource("clustertrustbundles"),
|
||||
obj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{
|
||||
SignerName: "abc.com/xyz",
|
||||
},
|
||||
},
|
||||
operation: admission.Create,
|
||||
},
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
description: "should allow update if user is authorized with wildcard",
|
||||
clusterTrustBundleFeatureEnabled: true,
|
||||
allowedName: "abc.com/*",
|
||||
attributes: &testAttributes{
|
||||
resource: certificatesapi.Resource("clustertrustbundles"),
|
||||
oldObj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{
|
||||
SignerName: "abc.com/xyz",
|
||||
},
|
||||
},
|
||||
obj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{
|
||||
SignerName: "abc.com/xyz",
|
||||
},
|
||||
},
|
||||
operation: admission.Update,
|
||||
},
|
||||
allowed: true,
|
||||
},
|
||||
{
|
||||
description: "should deny create if user does not have permission for this signerName",
|
||||
clusterTrustBundleFeatureEnabled: true,
|
||||
allowedName: "notabc.com/xyz",
|
||||
attributes: &testAttributes{
|
||||
resource: certificatesapi.Resource("clustertrustbundles"),
|
||||
obj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{
|
||||
SignerName: "abc.com/xyz",
|
||||
},
|
||||
},
|
||||
operation: admission.Create,
|
||||
},
|
||||
allowed: false,
|
||||
},
|
||||
{
|
||||
description: "should deny update if user does not have permission for this signerName",
|
||||
clusterTrustBundleFeatureEnabled: true,
|
||||
allowedName: "notabc.com/xyz",
|
||||
attributes: &testAttributes{
|
||||
resource: certificatesapi.Resource("clustertrustbundles"),
|
||||
obj: &certificatesapi.ClusterTrustBundle{
|
||||
Spec: certificatesapi.ClusterTrustBundleSpec{
|
||||
SignerName: "abc.com/xyz",
|
||||
},
|
||||
},
|
||||
operation: admission.Update,
|
||||
},
|
||||
allowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
p := Plugin{
|
||||
authz: fakeAuthorizer{
|
||||
t: t,
|
||||
verb: "attest",
|
||||
allowedName: tc.allowedName,
|
||||
decision: authorizer.DecisionAllow,
|
||||
err: tc.authzErr,
|
||||
},
|
||||
}
|
||||
|
||||
defer featuregatetesting.SetFeatureGateDuringTest(t, feature.DefaultFeatureGate, features.ClusterTrustBundle, tc.clusterTrustBundleFeatureEnabled)()
|
||||
p.InspectFeatureGates(feature.DefaultFeatureGate)
|
||||
|
||||
err := p.Validate(context.Background(), tc.attributes, nil)
|
||||
if err == nil && !tc.allowed {
|
||||
t.Errorf("Expected authorization policy to reject ClusterTrustBundle but it was allowed")
|
||||
}
|
||||
if err != nil && tc.allowed {
|
||||
t.Errorf("Expected authorization policy to accept ClusterTrustBundle 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"}
|
||||
}
|
Reference in New Issue
Block a user