Skip to content

Commit 4ce46f7

Browse files
committed
add admission webhook valisation for service export request
Signed-off-by: olalekan odukoya <odukoyaonline@gmail.com>
1 parent 21be815 commit 4ce46f7

8 files changed

Lines changed: 1229 additions & 111 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
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+
obj := &kubebindv1alpha2.APIServiceExportRequest{}
71+
if err := v.decoder.Decode(req, obj); err != nil {
72+
logger.Error(err, "Admission webhook: failed to decode APIServiceExportRequest")
73+
return admission.Errored(http.StatusBadRequest, err)
74+
}
75+
76+
logger.Info("Admission webhook: decoded request", "resources", len(obj.Spec.Resources), "informerScope", v.informerScope)
77+
78+
clusterName := ""
79+
cl, err := v.manager.GetCluster(ctx, clusterName)
80+
if err != nil {
81+
clusterName = "default"
82+
cl, err = v.manager.GetCluster(ctx, clusterName)
83+
if err != nil {
84+
logger.Info("Admission webhook: failed to get cluster for validation", "error", err)
85+
return admission.Errored(http.StatusInternalServerError, fmt.Errorf("failed to get cluster: %w", err))
86+
}
87+
}
88+
client := cl.GetClient()
89+
90+
if err := v.validateAPIServiceExportRequest(ctx, client, obj); err != nil {
91+
return admission.Denied(err.Error())
92+
}
93+
94+
logger.Info("Admission webhook: validation allowed")
95+
return admission.Allowed("")
96+
}
97+
98+
func (v *APIServiceExportRequestValidator) validateAPIServiceExportRequest(ctx context.Context, cl client.Client, req *kubebindv1alpha2.APIServiceExportRequest) error {
99+
logger := klog.FromContext(ctx)
100+
logger.Info("Admission webhook: validating APIServiceExportRequest", "resources", len(req.Spec.Resources), "permissionClaims", len(req.Spec.PermissionClaims))
101+
102+
exportedSchemas, err := v.getExportedSchemas(ctx, cl)
103+
if err != nil {
104+
return err
105+
}
106+
107+
if len(exportedSchemas) == 0 {
108+
return fmt.Errorf("no exported schemas found")
109+
}
110+
111+
first := apiextensionsv1.ResourceScope("")
112+
for _, res := range req.Spec.Resources {
113+
boundSchema, ok := exportedSchemas[res.ResourceGroupName()]
114+
if !ok {
115+
return fmt.Errorf("schema %s not found", res.ResourceGroupName())
116+
}
117+
118+
if boundSchema.Spec.Scope == apiextensionsv1.ClusterScoped && v.informerScope != kubebindv1alpha2.ClusterScope {
119+
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)
120+
}
121+
122+
if first == apiextensionsv1.ResourceScope("") {
123+
first = boundSchema.Spec.Scope
124+
continue
125+
}
126+
if boundSchema.Spec.Scope != first {
127+
return fmt.Errorf("different scopes found for claimed resources: %v", boundSchema.Name)
128+
}
129+
}
130+
131+
for _, claim := range req.Spec.PermissionClaims {
132+
if !isClaimableAPI(claim) {
133+
return fmt.Errorf("resource %s is not a valid claimable API", claim.GroupResource.String())
134+
}
135+
}
136+
137+
seenGroupResources := make(map[string]bool)
138+
for _, claim := range req.Spec.PermissionClaims {
139+
key := claim.Group + "/" + claim.Resource
140+
if seenGroupResources[key] {
141+
return fmt.Errorf("duplicate permission claim found for group/resource %s", claim.GroupResource.String())
142+
}
143+
seenGroupResources[key] = true
144+
}
145+
146+
return nil
147+
}
148+
149+
func (v *APIServiceExportRequestValidator) getExportedSchemas(ctx context.Context, cl client.Client) (kubebindv1alpha2.ExportedSchemas, error) {
150+
parts := strings.SplitN(v.schemaSource, ".", 3)
151+
if len(parts) != 3 {
152+
return nil, fmt.Errorf("malformed schema source: %q", v.schemaSource)
153+
}
154+
155+
gvk := schema.GroupVersionKind{
156+
Kind: parts[0],
157+
Version: parts[1],
158+
Group: parts[2],
159+
}
160+
161+
// Ensure we have the List kind
162+
listGVK := gvk
163+
if !strings.HasSuffix(listGVK.Kind, "List") {
164+
listGVK.Kind += "List"
165+
}
166+
167+
list := &unstructured.UnstructuredList{}
168+
list.SetGroupVersionKind(listGVK)
169+
170+
labelSelector := labels.Set{
171+
resources.ExportedCRDsLabel: "true",
172+
}
173+
174+
listOpts := []client.ListOption{}
175+
listOpts = append(listOpts, client.MatchingLabelsSelector{Selector: labelSelector.AsSelector()})
176+
177+
if err := cl.List(ctx, list, listOpts...); err != nil {
178+
return nil, err
179+
}
180+
181+
boundSchemas := make(kubebindv1alpha2.ExportedSchemas, len(list.Items))
182+
for _, item := range list.Items {
183+
boundSchema, err := helpers.UnstructuredToBoundSchema(item)
184+
if err != nil {
185+
return nil, err
186+
}
187+
boundSchemas[boundSchema.ResourceGroupName()] = boundSchema
188+
}
189+
190+
return boundSchemas, nil
191+
}
192+
193+
func isClaimableAPI(claim kubebindv1alpha2.PermissionClaim) bool {
194+
for _, api := range kubebindv1alpha2.ClaimableAPIs {
195+
if claim.Group == api.GroupVersionResource.Group && claim.Resource == api.Names.Plural {
196+
return true
197+
}
198+
}
199+
return false
200+
}

backend/config.go

Lines changed: 40 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,44 @@ 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+
if crClient, err := ctrlclient.New(config.ClientConfig, ctrlclient.Options{Scheme: scheme}); err == nil {
137+
if err := webhookpkg.EnsureWebhookCertificates(ctx, config.ClientConfig, kubeClient, crClient, scheme); err != nil {
138+
logger.V(2).Info("Could not generate certificates via cert-manager", "error", err)
139+
}
140+
}
141+
142+
hasCertManager, err := webhookpkg.CheckCertManagerInstalled(ctx, config.ClientConfig)
143+
if err == nil && hasCertManager {
144+
webhookpkg.StartWebhookCertificateWatcher(ctx, kubeClient)
145+
logger.V(1).Info("Started webhook certificate watcher for automatic rotation")
146+
}
147+
148+
} else {
149+
logger.V(1).Info("Failed to create kubeClient for webhook certificates", "error", err)
150+
}
151+
152+
webhookServer := webhook.NewServer(webhook.Options{
153+
Port: options.WebhookPort,
154+
CertDir: webhookpkg.WebhookCertDirectory,
155+
})
156+
157+
logger.V(1).Info("Webhook server enabled with certificates", "certDir", webhookpkg.WebhookCertDirectory)
158+
121159
opts := ctrl.Options{
122160
Controller: ctrlconfig.Controller{
123161
SkipNameValidation: ptr.To(config.Options.ExtraOptions.TestingSkipNameValidation),
124162
},
125163
Metrics: metricsserver.Options{
126164
BindAddress: "0",
127165
},
128-
Scheme: scheme,
166+
Scheme: scheme,
167+
WebhookServer: webhookServer,
129168
}
130169

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

0 commit comments

Comments
 (0)