Skip to content

Commit 70c4885

Browse files
committed
add cross-workspace implementation for ValidatingAdmissionPolicy
Signed-off-by: olalekan odukoya <[email protected]>
1 parent 27e508d commit 70c4885

File tree

3 files changed

+353
-23
lines changed

3 files changed

+353
-23
lines changed

docs/content/concepts/apis/admission-webhooks.md

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
---
2+
description: >
3+
How admission webhooks and validating admission policies work across workspaces in kcp.
4+
---
5+
16
# Admission Webhooks
27

3-
kcp extends the vanilla [admission plugins](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/) for webhooks, and makes them cluster-aware.
8+
kcp extends the vanilla [admission plugins](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/) for webhooks and validating admission policies, and makes them cluster-aware.
49

510
```mermaid
611
flowchart TD
712
subgraph ws1["API Provider Workspace ws1"]
813
export["Widgets APIExport"]
914
schema["Widgets APIResourceSchema<br/>(widgets.v1.example.org)"]
10-
webhook["Mutating/ValidatingWebhookConfiguration<br/>for widgets.v1.example.org<br/><br/>Handle a from ws2 (APIResourceSchema)<br/>Handle b from ws3 (APIResourceSchema)<br/>Handle a from ws1 (CRD)"]
15+
webhook["Mutating/ValidatingWebhookConfiguration<br/>ValidatingAdmissionPolicy<br/>for widgets.v1.example.org<br/><br/>Handle a from ws2 (APIResourceSchema)<br/>Handle b from ws3 (APIResourceSchema)<br/>Handle a from ws1 (CRD)"]
1116
crd["Widgets CustomResourceDefinition<br/>(widgets.v1.example.org)"]
1217
1318
export --> schema
@@ -37,9 +42,39 @@ flowchart TD
3742
class export,schema,webhook,crd,binding1,binding2 resource;
3843
```
3944

40-
When an object is to be mutated or validated, the webhook admission plugin ([`apis.kcp.io/MutatingWebhook`](https://github.com/kcp-dev/kcp/tree/main/pkg/admission/mutatingwebhook) and [`apis.kcp.io/ValidatingWebhook`](https://github.com/kcp-dev/kcp/tree/main/pkg/admission/validatingwebhook) respectively) looks for the owner of the resource schema. Once found, it then dispatches the handling for that object in the owner's workspace. There are two such cases in the diagram above:
45+
When an object is to be mutated or validated, the admission plugins ([`apis.kcp.io/MutatingWebhook`](https://github.com/kcp-dev/kcp/tree/main/pkg/admission/mutatingwebhook), [`apis.kcp.io/ValidatingWebhook`](https://github.com/kcp-dev/kcp/tree/main/pkg/admission/validatingwebhook), and [`ValidatingAdmissionPolicy`](https://github.com/kcp-dev/kcp/tree/main/pkg/admission/validatingadmissionpolicy) respectively) look for the owner of the resource schema. Once found, they then dispatch the handling for that object in the owner's workspace. There are two such cases in the diagram above:
46+
47+
* **Admitting bound resources.** During the request handling, Widget objects inside the consumer workspaces `ws2` and `ws3` are picked up by the respective admission plugin. The plugin sees the resource's schema comes from an APIBinding, and so it sets up an instance of the admission plugin to be working with its APIExport's workspace, in `ws1`. Afterwards, normal admission flow continues: the request is dispatched to all eligible webhook configurations or validating admission policies inside `ws1` and the object in request is mutated or validated.
48+
* **Admitting local resources.** The second case is when the webhook configuration or validating admission policy exists in the same workspace as the object it's handling. The admission plugin sees the resource is not sourced via an `APIBinding`, and so it looks for eligible webhook configurations or policies locally, and dispatches the request accordingly. The same would of course be true if `APIExport` and its `APIBinding` lived in the same workspace: the `APIExport` would resolve to the same cluster.
49+
50+
## ValidatingAdmissionPolicy Support
51+
52+
kcp supports cross-workspace `ValidatingAdmissionPolicy` and `ValidatingAdmissionPolicyBinding` resources, similar to how it supports cross-workspace webhooks. When a resource is created in a consumer workspace that is bound via an `APIBinding`, the `ValidatingAdmissionPolicy` plugin will:
53+
54+
1. Check the `APIBinding` to find the source workspace (`APIExport` workspace)
55+
2. Look for `ValidatingAdmissionPolicy` and `ValidatingAdmissionPolicyBinding` resources in the source workspace
56+
3. Apply those policies to validate the resource in the consumer workspace
57+
58+
This means that policies defined in an `APIExport` workspace will automatically apply to all resources created in consuming workspaces, providing a consistent validation experience across all consumers of an API.
59+
60+
### Example
61+
62+
Consider a scenario where:
63+
- **Provider workspace** (`root:provider`) has:
64+
- An `APIExport` for `cowboys.wildwest.dev`
65+
- A `ValidatingAdmissionPolicy` that rejects cowboys with `intent: "bad"`
66+
- A `ValidatingAdmissionPolicyBinding` that binds the policy
67+
68+
- **Consumer workspace** (`root:consumer`) has:
69+
- An `APIBinding` that binds to the provider's `APIExport`
70+
- A user trying to create a cowboy with `intent: "bad"`
71+
72+
When the user creates the cowboy in the consumer workspace, the `ValidatingAdmissionPolicy` plugin will:
73+
1. Detect that the cowboy resource comes from an `APIBinding`
74+
2. Look up the source workspace (provider workspace)
75+
3. Find and apply the policy from the provider workspace
76+
4. Reject the cowboy creation because it violates the policy
4177

42-
* **Admitting bound resources.** During the request handling, Widget objects inside the consumer workspaces `ws2` and `ws3` are picked up by the respective webhook admission plugin. The plugin sees the resource's schema comes from an APIBinding, and so it sets up an instance of `{Mutating,Validating}Webhook` to be working with its APIExport's workspace, in `ws1`. Afterwards, normal webhook admission flow continues: the request is dispatched to all eligible webhook configurations inside `ws1` and the object in request is mutated or validated.
43-
* **Admitting local resources.** The second case is when the webhook configuration exists in the same workspace as the object it's handling. The admission plugin sees the resource is not sourced via an APIBinding, and so it looks for eligible webhook configurations locally, and dispatches the request to the webhooks there. The same would of course be true if APIExport and its APIBinding lived in the same workspace: the APIExport would resolve to the same cluster.
78+
This ensures that API providers can enforce consistent validation rules across all consumers of their APIs.
4479

45-
Lastly, objects in admission review are annotated with the name of the workspace that owns that object. For example, when Widget `b` from `ws3` is being validated, its caught by `ValidatingWebhookConfiguration` in `ws1`, but the webhook will see `kcp.io/cluster: ws3` annotation on the reviewed object.
80+
Lastly, objects in admission review are annotated with the name of the workspace that owns that object. For example, when Widget `b` from `ws3` is being validated, its caught by `ValidatingWebhookConfiguration` or `ValidatingAdmissionPolicy` in `ws1`, but the webhook or policy evaluator will see `kcp.io/cluster: ws3` annotation on the reviewed object.

pkg/admission/validatingadmissionpolicy/validating_admission_policy.go

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@ package validatingadmissionpolicy
1818

1919
import (
2020
"context"
21+
"fmt"
2122
"io"
2223
"sync"
2324

2425
"k8s.io/apimachinery/pkg/api/meta"
26+
"k8s.io/apimachinery/pkg/labels"
27+
"k8s.io/apimachinery/pkg/runtime/schema"
2528
"k8s.io/apiserver/pkg/admission"
2629
"k8s.io/apiserver/pkg/admission/initializer"
2730
"k8s.io/apiserver/pkg/admission/plugin/policy/generic"
@@ -33,12 +36,15 @@ import (
3336
"k8s.io/client-go/informers"
3437
"k8s.io/client-go/kubernetes"
3538
"k8s.io/client-go/restmapper"
39+
"k8s.io/client-go/tools/cache"
3640
"k8s.io/klog/v2"
3741

3842
kcpdynamic "github.com/kcp-dev/client-go/dynamic"
3943
kcpkubernetesinformers "github.com/kcp-dev/client-go/informers"
4044
kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes"
4145
"github.com/kcp-dev/logicalcluster/v3"
46+
apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2"
47+
corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1"
4248
kcpinformers "github.com/kcp-dev/sdk/client/informers/externalversions"
4349
corev1alpha1informers "github.com/kcp-dev/sdk/client/informers/externalversions/core/v1alpha1"
4450

@@ -59,7 +65,7 @@ func Register(plugins *admission.Plugins) {
5965
func NewKubeValidatingAdmissionPolicy() *KubeValidatingAdmissionPolicy {
6066
return &KubeValidatingAdmissionPolicy{
6167
Handler: admission.NewHandler(admission.Connect, admission.Create, admission.Delete, admission.Update),
62-
delegates: make(map[logicalcluster.Name]*stoppableValidatingAdmissionPolicy),
68+
delegates: make(map[string]*stoppableValidatingAdmissionPolicy),
6369
}
6470
}
6571

@@ -75,15 +81,18 @@ type KubeValidatingAdmissionPolicy struct {
7581
serverDone <-chan struct{}
7682
authorizer authorizer.Authorizer
7783

84+
getAPIBindings func(clusterName logicalcluster.Name) ([]*apisv1alpha2.APIBinding, error)
85+
7886
lock sync.RWMutex
79-
delegates map[logicalcluster.Name]*stoppableValidatingAdmissionPolicy
87+
delegates map[string]*stoppableValidatingAdmissionPolicy
8088

8189
logicalClusterDeletionMonitorStarter sync.Once
8290
}
8391

8492
var _ admission.ValidationInterface = &KubeValidatingAdmissionPolicy{}
8593
var _ = initializers.WantsKubeClusterClient(&KubeValidatingAdmissionPolicy{})
8694
var _ = initializers.WantsKubeInformers(&KubeValidatingAdmissionPolicy{})
95+
var _ = initializers.WantsKcpInformers(&KubeValidatingAdmissionPolicy{})
8796
var _ = initializers.WantsServerShutdownChannel(&KubeValidatingAdmissionPolicy{})
8897
var _ = initializers.WantsDynamicClusterClient(&KubeValidatingAdmissionPolicy{})
8998
var _ = initializer.WantsAuthorizer(&KubeValidatingAdmissionPolicy{})
@@ -95,6 +104,33 @@ func (k *KubeValidatingAdmissionPolicy) SetKubeClusterClient(kubeClusterClient k
95104

96105
func (k *KubeValidatingAdmissionPolicy) SetKcpInformers(local, global kcpinformers.SharedInformerFactory) {
97106
k.logicalClusterInformer = local.Core().V1alpha1().LogicalClusters()
107+
k.getAPIBindings = func(clusterName logicalcluster.Name) ([]*apisv1alpha2.APIBinding, error) {
108+
return local.Apis().V1alpha2().APIBindings().Lister().Cluster(clusterName).List(labels.Everything())
109+
}
110+
111+
_, _ = local.Core().V1alpha1().LogicalClusters().Informer().AddEventHandler(
112+
cache.ResourceEventHandlerFuncs{
113+
DeleteFunc: func(obj interface{}) {
114+
cl, ok := obj.(*corev1alpha1.LogicalCluster)
115+
if !ok {
116+
return
117+
}
118+
119+
clName := logicalcluster.Name(cl.Annotations[logicalcluster.AnnotationKey])
120+
121+
k.lock.Lock()
122+
defer k.lock.Unlock()
123+
124+
prefix := clName.String() + ":"
125+
for key, delegate := range k.delegates {
126+
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
127+
delete(k.delegates, key)
128+
delegate.stop()
129+
}
130+
}
131+
},
132+
},
133+
)
98134
}
99135

100136
func (k *KubeValidatingAdmissionPolicy) SetKubeInformers(local, global kcpkubernetesinformers.SharedInformerFactory) {
@@ -129,18 +165,42 @@ func (k *KubeValidatingAdmissionPolicy) Validate(ctx context.Context, a admissio
129165
return err
130166
}
131167

132-
delegate, err := k.getOrCreateDelegate(cluster.Name)
168+
sourceCluster, err := k.getSourceClusterForGroupResource(cluster.Name, a.GetResource().GroupResource())
169+
if err != nil {
170+
return err
171+
}
172+
173+
delegate, err := k.getOrCreateDelegate(cluster.Name, sourceCluster)
133174
if err != nil {
134175
return err
135176
}
136177

137178
return delegate.Validate(ctx, a, o)
138179
}
139180

181+
func (k *KubeValidatingAdmissionPolicy) getSourceClusterForGroupResource(clusterName logicalcluster.Name, groupResource schema.GroupResource) (logicalcluster.Name, error) {
182+
objs, err := k.getAPIBindings(clusterName)
183+
if err != nil {
184+
return "", err
185+
}
186+
187+
for _, apiBinding := range objs {
188+
for _, br := range apiBinding.Status.BoundResources {
189+
if br.Group == groupResource.Group && br.Resource == groupResource.Resource {
190+
return logicalcluster.Name(apiBinding.Status.APIExportClusterName), nil
191+
}
192+
}
193+
}
194+
195+
return clusterName, nil
196+
}
197+
140198
// getOrCreateDelegate creates an actual plugin for clusterName.
141-
func (k *KubeValidatingAdmissionPolicy) getOrCreateDelegate(clusterName logicalcluster.Name) (*stoppableValidatingAdmissionPolicy, error) {
199+
func (k *KubeValidatingAdmissionPolicy) getOrCreateDelegate(clusterName logicalcluster.Name, sourceCluster logicalcluster.Name) (*stoppableValidatingAdmissionPolicy, error) {
200+
delegateKey := fmt.Sprintf("%s:%s", clusterName.String(), sourceCluster.String())
201+
142202
k.lock.RLock()
143-
delegate := k.delegates[clusterName]
203+
delegate := k.delegates[delegateKey]
144204
k.lock.RUnlock()
145205

146206
if delegate != nil {
@@ -150,7 +210,7 @@ func (k *KubeValidatingAdmissionPolicy) getOrCreateDelegate(clusterName logicalc
150210
k.lock.Lock()
151211
defer k.lock.Unlock()
152212

153-
delegate = k.delegates[clusterName]
213+
delegate = k.delegates[delegateKey]
154214
if delegate != nil {
155215
return delegate, nil
156216
}
@@ -185,17 +245,17 @@ func (k *KubeValidatingAdmissionPolicy) getOrCreateDelegate(clusterName logicalc
185245
plugin.SetDrainedNotification(ctx.Done())
186246
plugin.SetAuthorizer(k.authorizer)
187247
plugin.SetClusterName(clusterName)
188-
plugin.SetSourceFactory(func(_ informers.SharedInformerFactory, client kubernetes.Interface, dynamicClient dynamic.Interface, restMapper meta.RESTMapper, clusterName logicalcluster.Name) generic.Source[validating.PolicyHook] {
248+
plugin.SetSourceFactory(func(_ informers.SharedInformerFactory, client kubernetes.Interface, dynamicClient dynamic.Interface, restMapper meta.RESTMapper, _ logicalcluster.Name) generic.Source[validating.PolicyHook] {
189249
return generic.NewPolicySource(
190-
k.globalKubeSharedInformerFactory.Admissionregistration().V1().ValidatingAdmissionPolicies().Informer().Cluster(clusterName),
191-
k.globalKubeSharedInformerFactory.Admissionregistration().V1().ValidatingAdmissionPolicyBindings().Informer().Cluster(clusterName),
250+
k.globalKubeSharedInformerFactory.Admissionregistration().V1().ValidatingAdmissionPolicies().Informer().Cluster(sourceCluster),
251+
k.globalKubeSharedInformerFactory.Admissionregistration().V1().ValidatingAdmissionPolicyBindings().Informer().Cluster(sourceCluster),
192252
validating.NewValidatingAdmissionPolicyAccessor,
193253
validating.NewValidatingAdmissionPolicyBindingAccessor,
194254
validating.CompilePolicy,
195255
nil,
196256
dynamicClient,
197257
restMapper,
198-
clusterName,
258+
sourceCluster,
199259
)
200260
})
201261

@@ -204,7 +264,7 @@ func (k *KubeValidatingAdmissionPolicy) getOrCreateDelegate(clusterName logicalc
204264
return nil, err
205265
}
206266

207-
k.delegates[clusterName] = delegate
267+
k.delegates[delegateKey] = delegate
208268

209269
return delegate, nil
210270
}
@@ -213,19 +273,24 @@ func (k *KubeValidatingAdmissionPolicy) logicalClusterDeleted(clusterName logica
213273
k.lock.Lock()
214274
defer k.lock.Unlock()
215275

216-
delegate := k.delegates[clusterName]
217-
218276
logger := klog.Background().WithValues("clusterName", clusterName)
219277

220-
if delegate == nil {
278+
prefix := clusterName.String() + ":"
279+
found := false
280+
for key, delegate := range k.delegates {
281+
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
282+
delete(k.delegates, key)
283+
delegate.stop()
284+
found = true
285+
}
286+
}
287+
288+
if !found {
221289
logger.V(3).Info("received event to stop validating admission policy for logical cluster, but it wasn't in the map")
222290
return
223291
}
224292

225293
logger.V(2).Info("stopping validating admission policy for logical cluster")
226-
227-
delete(k.delegates, clusterName)
228-
delegate.stop()
229294
}
230295

231296
type stoppableValidatingAdmissionPolicy struct {

0 commit comments

Comments
 (0)