Skip to content

Commit 3a2cbbf

Browse files
committed
add admission webhook valisation for service export request
Signed-off-by: olalekan odukoya <odukoyaonline@gmail.com>
1 parent 640871f commit 3a2cbbf

8 files changed

Lines changed: 1077 additions & 111 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*
2+
Copyright 2025 The Kube Bind Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package admission
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"net/http"
23+
"strings"
24+
25+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
26+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
27+
"k8s.io/apimachinery/pkg/labels"
28+
"k8s.io/apimachinery/pkg/runtime/schema"
29+
"k8s.io/klog/v2"
30+
"sigs.k8s.io/controller-runtime/pkg/client"
31+
"sigs.k8s.io/controller-runtime/pkg/log"
32+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
33+
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
34+
35+
"github.com/kube-bind/kube-bind/backend/kubernetes/resources"
36+
kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
37+
"github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2/helpers"
38+
)
39+
40+
// APIServiceExportRequestValidator validates APIServiceExportRequest objects.
41+
type APIServiceExportRequestValidator struct {
42+
decoder admission.Decoder
43+
manager mcmanager.Manager
44+
informerScope kubebindv1alpha2.InformerScope
45+
clusterScopedIsolation kubebindv1alpha2.Isolation
46+
schemaSource string
47+
}
48+
49+
// NewAPIServiceExportRequestValidator creates a new validator for APIServiceExportRequest.
50+
func NewAPIServiceExportRequestValidator(
51+
manager mcmanager.Manager,
52+
decoder admission.Decoder,
53+
scope kubebindv1alpha2.InformerScope,
54+
isolation kubebindv1alpha2.Isolation,
55+
schemaSource string,
56+
) *APIServiceExportRequestValidator {
57+
return &APIServiceExportRequestValidator{
58+
decoder: decoder,
59+
manager: manager,
60+
informerScope: scope,
61+
clusterScopedIsolation: isolation,
62+
schemaSource: schemaSource,
63+
}
64+
}
65+
66+
func (v *APIServiceExportRequestValidator) Handle(ctx context.Context, req admission.Request) admission.Response {
67+
logger := log.FromContext(ctx)
68+
ctx = klog.NewContext(ctx, logger)
69+
70+
apiVersion := fmt.Sprintf("%s/%s", req.Kind.Group, req.Kind.Version)
71+
logger.Info("Admission webhook: validating APIServiceExportRequest", "operation", req.Operation, "apiVersion", apiVersion)
72+
73+
obj := &kubebindv1alpha2.APIServiceExportRequest{}
74+
if err := v.decoder.Decode(req, obj); err != nil {
75+
logger.Error(err, "Admission webhook: failed to decode APIServiceExportRequest")
76+
return admission.Errored(http.StatusBadRequest, err)
77+
}
78+
79+
logger.Info("Admission webhook: decoded request", "resources", len(obj.Spec.Resources), "informerScope", v.informerScope)
80+
81+
clusterName := ""
82+
cl, err := v.manager.GetCluster(ctx, clusterName)
83+
if err != nil {
84+
clusterName = "default"
85+
cl, err = v.manager.GetCluster(ctx, clusterName)
86+
if err != nil {
87+
logger.Info("Admission webhook: failed to get cluster for validation", "error", err)
88+
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("failed to get cluster: %w", err))
89+
}
90+
}
91+
client := cl.GetClient()
92+
93+
if err := v.validateAPIServiceExportRequest(ctx, client, obj); err != nil {
94+
return admission.Denied(err.Error())
95+
}
96+
97+
logger.Info("Admission webhook: validation allowed")
98+
return admission.Allowed("")
99+
}
100+
101+
func (v *APIServiceExportRequestValidator) validateAPIServiceExportRequest(ctx context.Context, cl client.Client, req *kubebindv1alpha2.APIServiceExportRequest) error {
102+
logger := klog.FromContext(ctx)
103+
logger.Info("Admission webhook: validating APIServiceExportRequest", "resources", len(req.Spec.Resources), "permissionClaims", len(req.Spec.PermissionClaims))
104+
105+
exportedSchemas, err := v.getExportedSchemas(ctx, cl)
106+
if err != nil {
107+
return err
108+
}
109+
110+
if len(exportedSchemas) == 0 {
111+
return fmt.Errorf("no exported schemas found")
112+
}
113+
114+
first := apiextensionsv1.ResourceScope("")
115+
for _, res := range req.Spec.Resources {
116+
boundSchema, ok := exportedSchemas[res.ResourceGroupName()]
117+
if !ok {
118+
return fmt.Errorf("schema %s not found", res.ResourceGroupName())
119+
}
120+
121+
if boundSchema.Spec.Scope == apiextensionsv1.ClusterScoped && v.informerScope != kubebindv1alpha2.ClusterScope {
122+
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)
123+
}
124+
125+
if first == apiextensionsv1.ResourceScope("") {
126+
first = boundSchema.Spec.Scope
127+
continue
128+
}
129+
if boundSchema.Spec.Scope != first {
130+
return fmt.Errorf("different scopes found for claimed resources: %v", boundSchema.Name)
131+
}
132+
}
133+
134+
for _, claim := range req.Spec.PermissionClaims {
135+
if !isClaimableAPI(claim) {
136+
return fmt.Errorf("resource %s is not a valid claimable API", claim.GroupResource.String())
137+
}
138+
}
139+
140+
seenGroupResources := make(map[string]bool)
141+
for _, claim := range req.Spec.PermissionClaims {
142+
key := claim.Group + "/" + claim.Resource
143+
if seenGroupResources[key] {
144+
return fmt.Errorf("duplicate permission claim found for group/resource %s", claim.GroupResource.String())
145+
}
146+
seenGroupResources[key] = true
147+
}
148+
149+
return nil
150+
}
151+
152+
func (v *APIServiceExportRequestValidator) getExportedSchemas(ctx context.Context, cl client.Client) (kubebindv1alpha2.ExportedSchemas, error) {
153+
parts := strings.SplitN(v.schemaSource, ".", 3)
154+
if len(parts) != 3 {
155+
return nil, fmt.Errorf("malformed schema source: %q", v.schemaSource)
156+
}
157+
158+
gvk := schema.GroupVersionKind{
159+
Kind: parts[0],
160+
Version: parts[1],
161+
Group: parts[2],
162+
}
163+
164+
// Ensure we have the List kind
165+
listGVK := gvk
166+
if !strings.HasSuffix(listGVK.Kind, "List") {
167+
listGVK.Kind += "List"
168+
}
169+
170+
list := &unstructured.UnstructuredList{}
171+
list.SetGroupVersionKind(listGVK)
172+
173+
labelSelector := labels.Set{
174+
resources.ExportedCRDsLabel: "true",
175+
}
176+
177+
listOpts := []client.ListOption{}
178+
listOpts = append(listOpts, client.MatchingLabelsSelector{Selector: labelSelector.AsSelector()})
179+
180+
if err := cl.List(ctx, list, listOpts...); err != nil {
181+
return nil, err
182+
}
183+
184+
boundSchemas := make(kubebindv1alpha2.ExportedSchemas, len(list.Items))
185+
for _, item := range list.Items {
186+
boundSchema, err := helpers.UnstructuredToBoundSchema(item)
187+
if err != nil {
188+
return nil, err
189+
}
190+
boundSchemas[boundSchema.ResourceGroupName()] = boundSchema
191+
}
192+
193+
return boundSchemas, nil
194+
}
195+
196+
func isClaimableAPI(claim kubebindv1alpha2.PermissionClaim) bool {
197+
for _, api := range kubebindv1alpha2.ClaimableAPIs {
198+
if claim.Group == api.GroupVersionResource.Group && claim.Resource == api.Names.Plural {
199+
return true
200+
}
201+
}
202+
return false
203+
}

backend/config.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,26 @@ import (
2525
apisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1"
2626
apisv1alpha2 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha2"
2727
"github.com/kcp-dev/multicluster-provider/apiexport"
28+
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
2829
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2930
"k8s.io/apimachinery/pkg/runtime"
31+
"k8s.io/client-go/kubernetes"
3032
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
3133
"k8s.io/client-go/rest"
3234
"k8s.io/client-go/tools/clientcmd"
35+
"k8s.io/klog/v2"
3336
"k8s.io/utils/ptr"
3437
ctrl "sigs.k8s.io/controller-runtime"
38+
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
3539
ctrlconfig "sigs.k8s.io/controller-runtime/pkg/config"
3640
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
41+
"sigs.k8s.io/controller-runtime/pkg/webhook"
3742
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
3843
"sigs.k8s.io/multicluster-runtime/pkg/multicluster"
3944

4045
kuberesources "github.com/kube-bind/kube-bind/backend/kubernetes/resources"
4146
"github.com/kube-bind/kube-bind/backend/options"
47+
webhookpkg "github.com/kube-bind/kube-bind/backend/webhook"
4248
kubebindv1alpha1 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha1"
4349
kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
4450
)
@@ -82,6 +88,9 @@ func NewConfig(options *options.CompletedOptions) (*Config, error) {
8288
if err := apiextensionsv1.AddToScheme(scheme); err != nil {
8389
return nil, fmt.Errorf("error adding apiextensions scheme: %w", err)
8490
}
91+
if err := admissionregistrationv1.AddToScheme(scheme); err != nil {
92+
return nil, fmt.Errorf("error adding admissionregistration scheme: %w", err)
93+
}
8594
if err := kubebindv1alpha1.AddToScheme(scheme); err != nil {
8695
return nil, fmt.Errorf("error adding kubebind scheme: %w", err)
8796
}
@@ -118,14 +127,36 @@ func NewConfig(options *options.CompletedOptions) (*Config, error) {
118127
config.Provider = nil
119128
}
120129

130+
// Try to generate certificates using cert-manager
131+
ctx := context.Background()
132+
logger := klog.FromContext(ctx)
133+
134+
kubeClient, err := kubernetes.NewForConfig(config.ClientConfig)
135+
if err == nil {
136+
crClient, err := ctrlclient.New(config.ClientConfig, ctrlclient.Options{Scheme: scheme})
137+
if err == nil {
138+
if err := webhookpkg.EnsureWebhookCertificates(ctx, config.ClientConfig, kubeClient, crClient, scheme); err != nil {
139+
logger.V(2).Info("Could not generate certificates via cert-manager", "error", err)
140+
}
141+
}
142+
}
143+
144+
webhookServer := webhook.NewServer(webhook.Options{
145+
Port: options.WebhookPort,
146+
CertDir: webhookpkg.WebhookCertDirectory,
147+
})
148+
149+
logger.V(1).Info("Webhook server enabled with certificates", "certDir", webhookpkg.WebhookCertDirectory)
150+
121151
opts := ctrl.Options{
122152
Controller: ctrlconfig.Controller{
123153
SkipNameValidation: ptr.To(config.Options.ExtraOptions.TestingSkipNameValidation),
124154
},
125155
Metrics: metricsserver.Options{
126156
BindAddress: "0",
127157
},
128-
Scheme: scheme,
158+
Scheme: scheme,
159+
WebhookServer: webhookServer,
129160
}
130161

131162
manager, err := mcmanager.New(config.ClientConfig, config.Provider, opts)

0 commit comments

Comments
 (0)