Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions backend/admission/apiserviceexportrequest_validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*
Copyright 2025 The Kube Bind 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 admission

import (
"context"
"fmt"
"net/http"
"strings"

apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/klog/v2"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"

"github.com/kube-bind/kube-bind/backend/kubernetes/resources"
kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
"github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2/helpers"
)

// APIServiceExportRequestValidator validates APIServiceExportRequest objects.
type APIServiceExportRequestValidator struct {
decoder admission.Decoder
manager mcmanager.Manager
informerScope kubebindv1alpha2.InformerScope
clusterScopedIsolation kubebindv1alpha2.Isolation
schemaSource string
}

// NewAPIServiceExportRequestValidator creates a new validator for APIServiceExportRequest.
func NewAPIServiceExportRequestValidator(
manager mcmanager.Manager,
decoder admission.Decoder,
scope kubebindv1alpha2.InformerScope,
isolation kubebindv1alpha2.Isolation,
schemaSource string,
) *APIServiceExportRequestValidator {
return &APIServiceExportRequestValidator{
decoder: decoder,
manager: manager,
informerScope: scope,
clusterScopedIsolation: isolation,
schemaSource: schemaSource,
}
}

func (v *APIServiceExportRequestValidator) Handle(ctx context.Context, req admission.Request) admission.Response {
logger := log.FromContext(ctx)
ctx = klog.NewContext(ctx, logger)

obj := &kubebindv1alpha2.APIServiceExportRequest{}
if err := v.decoder.Decode(req, obj); err != nil {
logger.Error(err, "Admission webhook: failed to decode APIServiceExportRequest")
return admission.Errored(http.StatusBadRequest, err)
}

logger.Info("Admission webhook: decoded request", "resources", len(obj.Spec.Resources), "informerScope", v.informerScope)

clusterName := ""
cl, err := v.manager.GetCluster(ctx, clusterName)
if err != nil {
clusterName = "default"
cl, err = v.manager.GetCluster(ctx, clusterName)
if err != nil {
logger.Info("Admission webhook: failed to get cluster for validation", "error", err)
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("failed to get cluster: %w", err))
}
}
client := cl.GetClient()

if err := v.validateAPIServiceExportRequest(ctx, client, obj); err != nil {
return admission.Denied(err.Error())
}

logger.Info("Admission webhook: validation allowed")
return admission.Allowed("")
}

func (v *APIServiceExportRequestValidator) validateAPIServiceExportRequest(ctx context.Context, cl client.Client, req *kubebindv1alpha2.APIServiceExportRequest) error {
logger := klog.FromContext(ctx)
logger.Info("Admission webhook: validating APIServiceExportRequest", "resources", len(req.Spec.Resources), "permissionClaims", len(req.Spec.PermissionClaims))

exportedSchemas, err := v.getExportedSchemas(ctx, cl)
if err != nil {
return err
}

if len(exportedSchemas) == 0 {
return fmt.Errorf("no exported schemas found")
}

first := apiextensionsv1.ResourceScope("")
for _, res := range req.Spec.Resources {
boundSchema, ok := exportedSchemas[res.ResourceGroupName()]
if !ok {
return fmt.Errorf("schema %s not found", res.ResourceGroupName())
}

if boundSchema.Spec.Scope == apiextensionsv1.ClusterScoped && v.informerScope != kubebindv1alpha2.ClusterScope {
return fmt.Errorf("resource %s/%s has scope %q which is incompatible with backend informer scope %q", res.Group, res.Resource, boundSchema.Spec.Scope, v.informerScope)
}

if first == apiextensionsv1.ResourceScope("") {
first = boundSchema.Spec.Scope
continue
}
if boundSchema.Spec.Scope != first {
return fmt.Errorf("different scopes found for claimed resources: %v", boundSchema.Name)
}
}

for _, claim := range req.Spec.PermissionClaims {
if !isClaimableAPI(claim) {
return fmt.Errorf("resource %s is not a valid claimable API", claim.GroupResource.String())
}
}

seenGroupResources := make(map[string]bool)
for _, claim := range req.Spec.PermissionClaims {
key := claim.Group + "/" + claim.Resource
if seenGroupResources[key] {
return fmt.Errorf("duplicate permission claim found for group/resource %s", claim.GroupResource.String())
}
seenGroupResources[key] = true
}

return nil
}

func (v *APIServiceExportRequestValidator) getExportedSchemas(ctx context.Context, cl client.Client) (kubebindv1alpha2.ExportedSchemas, error) {
parts := strings.SplitN(v.schemaSource, ".", 3)
if len(parts) != 3 {
return nil, fmt.Errorf("malformed schema source: %q", v.schemaSource)
}

gvk := schema.GroupVersionKind{
Kind: parts[0],
Version: parts[1],
Group: parts[2],
}

// Ensure we have the List kind
listGVK := gvk
if !strings.HasSuffix(listGVK.Kind, "List") {
listGVK.Kind += "List"
}

list := &unstructured.UnstructuredList{}
list.SetGroupVersionKind(listGVK)

labelSelector := labels.Set{
resources.ExportedCRDsLabel: "true",
}

listOpts := []client.ListOption{}
listOpts = append(listOpts, client.MatchingLabelsSelector{Selector: labelSelector.AsSelector()})

if err := cl.List(ctx, list, listOpts...); err != nil {
return nil, err
}

boundSchemas := make(kubebindv1alpha2.ExportedSchemas, len(list.Items))
for _, item := range list.Items {
boundSchema, err := helpers.UnstructuredToBoundSchema(item)
if err != nil {
return nil, err
}
boundSchemas[boundSchema.ResourceGroupName()] = boundSchema
}

return boundSchemas, nil
}

func isClaimableAPI(claim kubebindv1alpha2.PermissionClaim) bool {
for _, api := range kubebindv1alpha2.ClaimableAPIs {
if claim.Group == api.GroupVersionResource.Group && claim.Resource == api.Names.Plural {
return true
}
}
return false
}
40 changes: 39 additions & 1 deletion backend/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,26 @@ import (
apisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1"
apisv1alpha2 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha2"
"github.com/kcp-dev/multicluster-provider/apiexport"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/klog/v2"
"k8s.io/utils/ptr"
ctrl "sigs.k8s.io/controller-runtime"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
ctrlconfig "sigs.k8s.io/controller-runtime/pkg/config"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook"
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
"sigs.k8s.io/multicluster-runtime/pkg/multicluster"

kuberesources "github.com/kube-bind/kube-bind/backend/kubernetes/resources"
"github.com/kube-bind/kube-bind/backend/options"
webhookpkg "github.com/kube-bind/kube-bind/backend/webhook"
kubebindv1alpha1 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha1"
kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
)
Expand Down Expand Up @@ -82,6 +88,9 @@ func NewConfig(options *options.CompletedOptions) (*Config, error) {
if err := apiextensionsv1.AddToScheme(scheme); err != nil {
return nil, fmt.Errorf("error adding apiextensions scheme: %w", err)
}
if err := admissionregistrationv1.AddToScheme(scheme); err != nil {
return nil, fmt.Errorf("error adding admissionregistration scheme: %w", err)
}
if err := kubebindv1alpha1.AddToScheme(scheme); err != nil {
return nil, fmt.Errorf("error adding kubebind scheme: %w", err)
}
Expand Down Expand Up @@ -118,14 +127,43 @@ func NewConfig(options *options.CompletedOptions) (*Config, error) {
config.Provider = nil
}

// Try to generate certificates using cert-manager
ctx := context.Background()
logger := klog.FromContext(ctx)

kubeClient, err := kubernetes.NewForConfig(config.ClientConfig)
if err == nil {
if crClient, err := ctrlclient.New(config.ClientConfig, ctrlclient.Options{Scheme: scheme}); err == nil {
if err := webhookpkg.EnsureWebhookCertificates(ctx, config.ClientConfig, kubeClient, crClient, scheme); err != nil {
logger.V(2).Info("Could not generate certificates via cert-manager", "error", err)
}
}

hasCertManager, err := webhookpkg.CheckCertManagerInstalled(ctx, config.ClientConfig)
if err == nil && hasCertManager {
webhookpkg.StartWebhookCertificateWatcher(ctx, kubeClient)
logger.V(1).Info("Started webhook certificate watcher for automatic rotation")
}
} else {
logger.V(1).Info("Failed to create kubeClient for webhook certificates", "error", err)
}

webhookServer := webhook.NewServer(webhook.Options{
Port: options.WebhookPort,
CertDir: webhookpkg.WebhookCertDirectory,
})

logger.V(1).Info("Webhook server enabled with certificates", "certDir", webhookpkg.WebhookCertDirectory)

opts := ctrl.Options{
Controller: ctrlconfig.Controller{
SkipNameValidation: ptr.To(config.Options.ExtraOptions.TestingSkipNameValidation),
},
Metrics: metricsserver.Options{
BindAddress: "0",
},
Scheme: scheme,
Scheme: scheme,
WebhookServer: webhookServer,
}

manager, err := mcmanager.New(config.ClientConfig, config.Provider, opts)
Expand Down
Loading
Loading