Skip to content

Commit

Permalink
Admission for APIExportEndpointSlice, check for bind authorization ag…
Browse files Browse the repository at this point in the history
…ainst the referenced APIExport.

Signed-off-by: Frederic Giloux <fgiloux@redhat.com>
  • Loading branch information
fgiloux committed Jan 6, 2023
1 parent ca46574 commit db84695
Show file tree
Hide file tree
Showing 5 changed files with 695 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/*
Copyright 2023 The KCP 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 apiexportendpointslice

import (
"context"
"errors"
"fmt"
"io"
"reflect"

kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes"
"github.com/kcp-dev/logicalcluster/v3"

apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/client-go/tools/cache"
"k8s.io/klog/v2"

kcpinitializers "github.com/kcp-dev/kcp/pkg/admission/initializers"
apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1"
"github.com/kcp-dev/kcp/pkg/authorization/delegated"
kcpinformers "github.com/kcp-dev/kcp/pkg/client/informers/externalversions"
apisv1alpha1listers "github.com/kcp-dev/kcp/pkg/client/listers/apis/v1alpha1"
"github.com/kcp-dev/kcp/pkg/indexers"
)

const (
PluginName = "apis.kcp.io/APIExportEndpointSlice"
)

func Register(plugins *admission.Plugins) {
plugins.Register(PluginName,
func(_ io.Reader) (admission.Interface, error) {
p := &apiExportEndpointSliceAdmission{
Handler: admission.NewHandler(admission.Create, admission.Update),
createAuthorizer: delegated.NewDelegatedAuthorizer,
}
p.getAPIExport = func(path logicalcluster.Path, name string) (*apisv1alpha1.APIExport, error) {
return indexers.ByPathAndName[*apisv1alpha1.APIExport](apisv1alpha1.Resource("apiexports"), p.apiExportIndexer, path, name)
}

return p, nil
})
}

type apiExportEndpointSliceAdmission struct {
*admission.Handler

getAPIExport func(path logicalcluster.Path, name string) (*apisv1alpha1.APIExport, error)

apiExportLister apisv1alpha1listers.APIExportClusterLister
apiExportIndexer cache.Indexer

deepSARClient kcpkubernetesclientset.ClusterInterface
createAuthorizer delegated.DelegatedAuthorizerFactory
}

// Ensure that the required admission interfaces are implemented.
var (
_ = admission.ValidationInterface(&apiExportEndpointSliceAdmission{})
_ = admission.MutationInterface(&apiExportEndpointSliceAdmission{})
_ = admission.InitializationValidator(&apiExportEndpointSliceAdmission{})
_ = kcpinitializers.WantsDeepSARClient(&apiExportEndpointSliceAdmission{})
_ = kcpinitializers.WantsKcpInformers(&apiExportEndpointSliceAdmission{})
)

func (o *apiExportEndpointSliceAdmission) Admit(ctx context.Context, a admission.Attributes, _ admission.ObjectInterfaces) error {
if a.GetResource().GroupResource() != apisv1alpha1.Resource("apiexportendpointslices") {
return nil
}

u, ok := a.GetObject().(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("unexpected type %T", a.GetObject())
}

slice := &apisv1alpha1.APIExportEndpointSlice{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, slice); err != nil {
return fmt.Errorf("failed to convert unstructured to APIExportEndpointSlice: %w", err)
}

var oldSlice *apisv1alpha1.APIExportEndpointSlice
if a.GetOperation() == admission.Update {
u, ok := a.GetOldObject().(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("unexpected type %T", a.GetObject())
}

oldSlice = &apisv1alpha1.APIExportEndpointSlice{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, oldSlice); err != nil {
return fmt.Errorf("failed to convert unstructured to APIExportEndpointSlice: %w", err)
}
}

switch {
case a.GetOperation() == admission.Create:
// unified forbidden error that does not leak workspace existence
// only considering "create" as the APIExport reference is immutable
forbidden := admission.NewForbidden(a, fmt.Errorf("unable to create APIExportEndpointSlice: no permission to bind to export %s",
logicalcluster.NewPath(slice.Spec.APIExport.Path).Join(slice.Spec.APIExport.Name).String()))

// get cluster name of export
if slice.Spec.APIExport.Path != "" {
path := logicalcluster.NewPath(slice.Spec.APIExport.Path)
_, err := o.getAPIExport(path, slice.Spec.APIExport.Name)
if err != nil {
return forbidden
}
}
}

return nil
}

// Validate validates the creation of APIExportEndpointSlice resources. It also performs a SubjectAccessReview
// making sure the user is allowed to use the 'bind' verb with the referenced APIExport.
func (o *apiExportEndpointSliceAdmission) Validate(ctx context.Context, a admission.Attributes, _ admission.ObjectInterfaces) error {
clusterName, err := genericapirequest.ClusterNameFrom(ctx)
if err != nil {
return apierrors.NewInternalError(err)
}

if a.GetResource().GroupResource() != apisv1alpha1.Resource("apiexportendpointslices") {
return nil
}

u, ok := a.GetObject().(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("unexpected type %T", a.GetObject())
}

slice := &apisv1alpha1.APIExportEndpointSlice{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, slice); err != nil {
return fmt.Errorf("failed to convert unstructured to APIExportEndpointSlice: %w", err)
}

// Object validation
var errs field.ErrorList
var oldSlice *apisv1alpha1.APIExportEndpointSlice
switch a.GetOperation() {
case admission.Create:
errs = ValidateAPIExportEndpointSlice(slice)
if len(errs) > 0 {
return admission.NewForbidden(a, fmt.Errorf("%v", errs))
}
case admission.Update:
u, ok = a.GetOldObject().(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("unexpected type %T", a.GetOldObject())
}
oldSlice = &apisv1alpha1.APIExportEndpointSlice{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, oldSlice); err != nil {
return fmt.Errorf("failed to convert unstructured to APIExportEndpointSlice: %w", err)
}
}

switch {
case a.GetOperation() == admission.Create,
// this may be redundant as APIExportEndpointSlice.Spec.APIExport is immutable
a.GetOperation() == admission.Update && !reflect.DeepEqual(slice.Spec.APIExport, oldSlice.Spec.APIExport):
// unified forbidden error that does not leak workspace existence
// only considering "create" as the APIExport reference is immutable
forbidden := admission.NewForbidden(a, fmt.Errorf("unable to create APIExportEndpointSlice: no permission to bind to export %s",
logicalcluster.NewPath(slice.Spec.APIExport.Path).Join(slice.Spec.APIExport.Name).String()))

// get cluster name of export
var exportClusterName logicalcluster.Name
if slice.Spec.APIExport.Path == "" {
exportClusterName = clusterName
} else {
path := logicalcluster.NewPath(slice.Spec.APIExport.Path)
export, err := o.getAPIExport(path, slice.Spec.APIExport.Name)
if err != nil {
return forbidden
}
exportClusterName = logicalcluster.From(export)
}

// Access check
if err := o.checkAPIExportAccess(ctx, a.GetUserInfo(), exportClusterName, slice.Spec.APIExport.Name); err != nil {
return forbidden
}
}

return nil
}

func (o *apiExportEndpointSliceAdmission) checkAPIExportAccess(ctx context.Context, user user.Info, apiExportClusterName logicalcluster.Name, apiExportName string) error {
logger := klog.FromContext(ctx)
authz, err := o.createAuthorizer(apiExportClusterName, o.deepSARClient)
if err != nil {
// Logging a more specific error for the operator
logger.Error(err, "error creating authorizer from delegating authorizer config")
// Returning a less specific error to the end user
return errors.New("unable to authorize request")
}

bindAttr := authorizer.AttributesRecord{
User: user,
Verb: "bind",
APIGroup: apisv1alpha1.SchemeGroupVersion.Group,
APIVersion: apisv1alpha1.SchemeGroupVersion.Version,
Resource: "apiexports",
Name: apiExportName,
ResourceRequest: true,
}

if decision, _, err := authz.Authorize(ctx, bindAttr); err != nil {
return fmt.Errorf("unable to determine access to apiexports: %w", err)
} else if decision != authorizer.DecisionAllow {
return fmt.Errorf("no permission to bind to export %q", apiExportName)
}

return nil
}

// ValidateInitialization ensures the required injected fields are set.
func (o *apiExportEndpointSliceAdmission) ValidateInitialization() error {
if o.deepSARClient == nil {
return fmt.Errorf(PluginName + " plugin needs a Kubernetes ClusterInterface")
}
if o.apiExportLister == nil {
return fmt.Errorf(PluginName + " plugin needs an APIExport lister")
}
return nil
}

// SetDeepSARClient is an admission plugin initializer function that injects a client capable of deep SAR requests into
// this admission plugin.
func (o *apiExportEndpointSliceAdmission) SetDeepSARClient(client kcpkubernetesclientset.ClusterInterface) {
o.deepSARClient = client
}

func (o *apiExportEndpointSliceAdmission) SetKcpInformers(informers kcpinformers.SharedInformerFactory) {
apiExportsReady := informers.Apis().V1alpha1().APIExports().Informer().HasSynced
o.SetReadyFunc(func() bool {
return apiExportsReady()
})
o.apiExportLister = informers.Apis().V1alpha1().APIExports().Lister()
o.apiExportIndexer = informers.Apis().V1alpha1().APIExports().Informer().GetIndexer()

indexers.AddIfNotPresentOrDie(informers.Tenancy().V1alpha1().WorkspaceTypes().Informer().GetIndexer(), cache.Indexers{
indexers.ByLogicalClusterPathAndName: indexers.IndexByLogicalClusterPathAndName,
})
}
Loading

0 comments on commit db84695

Please sign in to comment.