Skip to content

Commit f6b9288

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

File tree

3 files changed

+365
-23
lines changed

3 files changed

+365
-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: 81 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,8 +81,10 @@ 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
}
@@ -95,6 +103,33 @@ 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.lock.Lock()
121+
defer k.lock.Unlock()
122+
123+
prefix := clName.String() + ":"
124+
for key, delegate := range k.delegates {
125+
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
126+
delete(k.delegates, key)
127+
delegate.stop()
128+
}
129+
}
130+
},
131+
},
132+
)
98133
}
99134

100135
func (k *KubeValidatingAdmissionPolicy) SetKubeInformers(local, global kcpkubernetesinformers.SharedInformerFactory) {
@@ -129,18 +164,42 @@ func (k *KubeValidatingAdmissionPolicy) Validate(ctx context.Context, a admissio
129164
return err
130165
}
131166

132-
delegate, err := k.getOrCreateDelegate(cluster.Name)
167+
sourceCluster, err := k.getSourceClusterForGroupResource(cluster.Name, a.GetResource().GroupResource())
168+
if err != nil {
169+
return err
170+
}
171+
172+
delegate, err := k.getOrCreateDelegate(cluster.Name, sourceCluster)
133173
if err != nil {
134174
return err
135175
}
136176

137177
return delegate.Validate(ctx, a, o)
138178
}
139179

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

146205
if delegate != nil {
@@ -150,7 +209,7 @@ func (k *KubeValidatingAdmissionPolicy) getOrCreateDelegate(clusterName logicalc
150209
k.lock.Lock()
151210
defer k.lock.Unlock()
152211

153-
delegate = k.delegates[clusterName]
212+
delegate = k.delegates[delegateKey]
154213
if delegate != nil {
155214
return delegate, nil
156215
}
@@ -185,17 +244,17 @@ func (k *KubeValidatingAdmissionPolicy) getOrCreateDelegate(clusterName logicalc
185244
plugin.SetDrainedNotification(ctx.Done())
186245
plugin.SetAuthorizer(k.authorizer)
187246
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] {
247+
plugin.SetSourceFactory(func(_ informers.SharedInformerFactory, client kubernetes.Interface, dynamicClient dynamic.Interface, restMapper meta.RESTMapper, _ logicalcluster.Name) generic.Source[validating.PolicyHook] {
189248
return generic.NewPolicySource(
190-
k.globalKubeSharedInformerFactory.Admissionregistration().V1().ValidatingAdmissionPolicies().Informer().Cluster(clusterName),
191-
k.globalKubeSharedInformerFactory.Admissionregistration().V1().ValidatingAdmissionPolicyBindings().Informer().Cluster(clusterName),
249+
k.globalKubeSharedInformerFactory.Admissionregistration().V1().ValidatingAdmissionPolicies().Informer().Cluster(sourceCluster),
250+
k.globalKubeSharedInformerFactory.Admissionregistration().V1().ValidatingAdmissionPolicyBindings().Informer().Cluster(sourceCluster),
192251
validating.NewValidatingAdmissionPolicyAccessor,
193252
validating.NewValidatingAdmissionPolicyBindingAccessor,
194253
validating.CompilePolicy,
195254
nil,
196255
dynamicClient,
197256
restMapper,
198-
clusterName,
257+
sourceCluster,
199258
)
200259
})
201260

@@ -204,7 +263,7 @@ func (k *KubeValidatingAdmissionPolicy) getOrCreateDelegate(clusterName logicalc
204263
return nil, err
205264
}
206265

207-
k.delegates[clusterName] = delegate
266+
k.delegates[delegateKey] = delegate
208267

209268
return delegate, nil
210269
}
@@ -213,19 +272,24 @@ func (k *KubeValidatingAdmissionPolicy) logicalClusterDeleted(clusterName logica
213272
k.lock.Lock()
214273
defer k.lock.Unlock()
215274

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

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

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

231295
type stoppableValidatingAdmissionPolicy struct {

0 commit comments

Comments
 (0)