Skip to content

Commit 1869e2c

Browse files
authored
Merge pull request #3743 from olamilekan000/implement-crossworskapce-validating-admission-policy
add cross-workspace implementation for ValidatingAdmissionPolicy
2 parents fc8bd0e + 65cc18f commit 1869e2c

File tree

3 files changed

+333
-15
lines changed

3 files changed

+333
-15
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: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
"sync"
2323

2424
"k8s.io/apimachinery/pkg/api/meta"
25+
"k8s.io/apimachinery/pkg/labels"
26+
"k8s.io/apimachinery/pkg/runtime/schema"
2527
"k8s.io/apiserver/pkg/admission"
2628
"k8s.io/apiserver/pkg/admission/initializer"
2729
"k8s.io/apiserver/pkg/admission/plugin/policy/generic"
@@ -33,12 +35,15 @@ import (
3335
"k8s.io/client-go/informers"
3436
"k8s.io/client-go/kubernetes"
3537
"k8s.io/client-go/restmapper"
38+
"k8s.io/client-go/tools/cache"
3639
"k8s.io/klog/v2"
3740

3841
kcpdynamic "github.com/kcp-dev/client-go/dynamic"
3942
kcpkubernetesinformers "github.com/kcp-dev/client-go/informers"
4043
kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes"
4144
"github.com/kcp-dev/logicalcluster/v3"
45+
apisv1alpha2 "github.com/kcp-dev/sdk/apis/apis/v1alpha2"
46+
corev1alpha1 "github.com/kcp-dev/sdk/apis/core/v1alpha1"
4247
kcpinformers "github.com/kcp-dev/sdk/client/informers/externalversions"
4348
corev1alpha1informers "github.com/kcp-dev/sdk/client/informers/externalversions/core/v1alpha1"
4449

@@ -75,15 +80,18 @@ type KubeValidatingAdmissionPolicy struct {
7580
serverDone <-chan struct{}
7681
authorizer authorizer.Authorizer
7782

78-
lock sync.RWMutex
79-
delegates map[logicalcluster.Name]*stoppableValidatingAdmissionPolicy
83+
getAPIBindings func(clusterName logicalcluster.Name) ([]*apisv1alpha2.APIBinding, error)
84+
85+
delegatesLock sync.RWMutex
86+
delegates map[logicalcluster.Name]*stoppableValidatingAdmissionPolicy
8087

8188
logicalClusterDeletionMonitorStarter sync.Once
8289
}
8390

8491
var _ admission.ValidationInterface = &KubeValidatingAdmissionPolicy{}
8592
var _ = initializers.WantsKubeClusterClient(&KubeValidatingAdmissionPolicy{})
8693
var _ = initializers.WantsKubeInformers(&KubeValidatingAdmissionPolicy{})
94+
var _ = initializers.WantsKcpInformers(&KubeValidatingAdmissionPolicy{})
8795
var _ = initializers.WantsServerShutdownChannel(&KubeValidatingAdmissionPolicy{})
8896
var _ = initializers.WantsDynamicClusterClient(&KubeValidatingAdmissionPolicy{})
8997
var _ = initializer.WantsAuthorizer(&KubeValidatingAdmissionPolicy{})
@@ -95,6 +103,32 @@ func (k *KubeValidatingAdmissionPolicy) SetKubeClusterClient(kubeClusterClient k
95103

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

100134
func (k *KubeValidatingAdmissionPolicy) SetKubeInformers(local, global kcpkubernetesinformers.SharedInformerFactory) {
@@ -129,26 +163,48 @@ func (k *KubeValidatingAdmissionPolicy) Validate(ctx context.Context, a admissio
129163
return err
130164
}
131165

132-
delegate, err := k.getOrCreateDelegate(cluster.Name)
166+
sourceCluster, err := k.getSourceClusterForGroupResource(cluster.Name, a.GetResource().GroupResource())
167+
if err != nil {
168+
return err
169+
}
170+
171+
delegate, err := k.getOrCreateDelegate(sourceCluster)
133172
if err != nil {
134173
return err
135174
}
136175

137176
return delegate.Validate(ctx, a, o)
138177
}
139178

179+
func (k *KubeValidatingAdmissionPolicy) getSourceClusterForGroupResource(clusterName logicalcluster.Name, groupResource schema.GroupResource) (logicalcluster.Name, error) {
180+
objs, err := k.getAPIBindings(clusterName)
181+
if err != nil {
182+
return "", err
183+
}
184+
185+
for _, apiBinding := range objs {
186+
for _, br := range apiBinding.Status.BoundResources {
187+
if br.Group == groupResource.Group && br.Resource == groupResource.Resource {
188+
return logicalcluster.Name(apiBinding.Status.APIExportClusterName), nil
189+
}
190+
}
191+
}
192+
193+
return clusterName, nil
194+
}
195+
140196
// getOrCreateDelegate creates an actual plugin for clusterName.
141197
func (k *KubeValidatingAdmissionPolicy) getOrCreateDelegate(clusterName logicalcluster.Name) (*stoppableValidatingAdmissionPolicy, error) {
142-
k.lock.RLock()
198+
k.delegatesLock.RLock()
143199
delegate := k.delegates[clusterName]
144-
k.lock.RUnlock()
200+
k.delegatesLock.RUnlock()
145201

146202
if delegate != nil {
147203
return delegate, nil
148204
}
149205

150-
k.lock.Lock()
151-
defer k.lock.Unlock()
206+
k.delegatesLock.Lock()
207+
defer k.delegatesLock.Unlock()
152208

153209
delegate = k.delegates[clusterName]
154210
if delegate != nil {
@@ -210,8 +266,8 @@ func (k *KubeValidatingAdmissionPolicy) getOrCreateDelegate(clusterName logicalc
210266
}
211267

212268
func (k *KubeValidatingAdmissionPolicy) logicalClusterDeleted(clusterName logicalcluster.Name) {
213-
k.lock.Lock()
214-
defer k.lock.Unlock()
269+
k.delegatesLock.Lock()
270+
defer k.delegatesLock.Unlock()
215271

216272
delegate := k.delegates[clusterName]
217273

0 commit comments

Comments
 (0)