Skip to content

Commit b601e41

Browse files
authored
Add PermissionClaims (#304)
* Add permissionClaims API Signed-off-by: Mangirdas Judeikis <mangirdas@judeikis.lt> On-behalf-of: @SAP mangirdas.judeikis@sap.com * add XListType=map --------- Signed-off-by: Mangirdas Judeikis <mangirdas@judeikis.lt>
1 parent 7b954bb commit b601e41

46 files changed

Lines changed: 4081 additions & 417 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ all: build
118118
check: verify lint test test-e2e test-e2e-contribs
119119
.PHONY: check
120120

121-
GOMODS := $(shell find . -name 'go.mod' -exec dirname {} \; | grep -v hack/tools)
121+
GOMODS := $(shell find . -name 'go.mod' -exec dirname {} \; | grep -v hack/tools | grep -v ./dex)
122122

123123
ldflags:
124124
@echo $(LDFLAGS)
@@ -245,8 +245,12 @@ GO_TEST = $(GOTESTSUM) $(GOTESTSUM_ARGS) --
245245
endif
246246

247247
COUNT ?= 1
248-
NPROC ?= $$(( $(shell nproc) / 2 ))
249-
E2E_PARALLELISM ?= $$(( $(NPROC) > 1 ? $(NPROC) : 1))
248+
# Only set parallelism if user specified E2E_PARALLELISM
249+
ifdef E2E_PARALLELISM
250+
E2E_PARALLELISM_FLAG := -p $(E2E_PARALLELISM) -parallel $(E2E_PARALLELISM)
251+
else
252+
E2E_PARALLELISM_FLAG :=
253+
endif
250254

251255
$(DEX):
252256
mkdir -p $(TOOLS_DIR)
@@ -280,15 +284,15 @@ test-e2e: $(KCP) $(DEX) build ## Run e2e tests
280284
$(MAKE) run-kcp &>.kcp/kcp.log & KCP_PID=$$!; \
281285
trap 'kill -TERM $$KCP_PID; rm -rf .kcp' TERM INT EXIT && \
282286
echo "Waiting for kcp to be ready (check .kcp/kcp.log)." && while ! KUBECONFIG=.kcp/admin.kubeconfig kubectl get --raw /readyz &>/dev/null; do sleep 1; echo -n "."; done && echo && \
283-
KUBECONFIG=$$PWD/.kcp/admin.kubeconfig GOOS=$(OS) GOARCH=$(ARCH) $(GO_TEST) -race -count $(COUNT) -p $(E2E_PARALLELISM) -parallel $(E2E_PARALLELISM) $(WHAT) $(TEST_ARGS)
287+
KUBECONFIG=$$PWD/.kcp/admin.kubeconfig GOOS=$(OS) GOARCH=$(ARCH) $(GO_TEST) -race -count $(COUNT) $(E2E_PARALLELISM_FLAG) $(WHAT) $(TEST_ARGS)
284288

285289
CONTRIBS_E2E := $(patsubst %,test-e2e-contrib-%,$(CONTRIBS))
286290

287291
.PHONY: test-e2e-contribs $(CONTRIBS_E2E)
288292
test-e2e-contribs: $(CONTRIBS_E2E) ## Run e2e tests for external integrations
289293
test-e2e-contrib-kcp: $(DEX) $(KCP)
290294
$(CONTRIBS_E2E):
291-
cd contrib/$(patsubst test-e2e-contrib-%,%,$@) && $(GO_TEST) -race -count $(COUNT) -p $(E2E_PARALLELISM) -parallel $(E2E_PARALLELISM) ./test/e2e/...
295+
cd contrib/$(patsubst test-e2e-contrib-%,%,$@) && $(GO_TEST) -race -count $(COUNT) $(E2E_PARALLELISM_FLAG) ./test/e2e/...
292296

293297
.PHONY: test
294298
ifdef USE_GOTESTSUM

backend/controllers/clusterbinding/clusterbinding_reconcile.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,11 @@ func (r *reconciler) ensureRBACClusterRole(ctx context.Context, client client.Cl
158158
Resources: []string{"boundschemas"},
159159
Verbs: []string{"get", "list", "watch"},
160160
},
161+
{
162+
APIGroups: []string{kubebindv1alpha2.GroupName},
163+
Resources: []string{"boundschemas/status"},
164+
Verbs: []string{"get", "update", "patch"},
165+
},
161166
}}
162167
for _, export := range exports {
163168
// Collect unique GroupResources and sort for stable rule ordering.

backend/controllers/serviceexport/serviceexport_controller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func NewAPIServiceExportReconciler(
5959
opts controller.TypedOptions[mcreconcile.Request],
6060
) (*APIServiceExportReconciler, error) {
6161
if err := mgr.GetFieldIndexer().IndexField(ctx, &kubebindv1alpha2.APIServiceExport{}, indexers.ServiceExportByBoundSchema,
62-
indexers.IndexServiceExportByBoundSchema); err != nil {
62+
indexers.IndexServiceExportByBoundSchemaControllerRuntime); err != nil {
6363
return nil, fmt.Errorf("failed to setup ServiceExportByBoundSchema indexer: %w", err)
6464
}
6565

backend/controllers/serviceexportrequest/serviceexportrequest_controller.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,10 @@ func NewAPIServiceExportRequestReconciler(
8383
informerScope: scope,
8484
clusterScopedIsolation: isolation,
8585
schemaSource: schemaSource,
86-
getBoundSchema: func(ctx context.Context, cache cache.Cache, namespace, name string) (*kubebindv1alpha2.BoundSchema, error) {
86+
getBoundSchema: func(ctx context.Context, cl client.Client, namespace, name string) (*kubebindv1alpha2.BoundSchema, error) {
8787
var schema kubebindv1alpha2.BoundSchema
8888
key := types.NamespacedName{Namespace: namespace, Name: name}
89-
if err := cache.Get(ctx, key, &schema); err != nil {
89+
if err := cl.Get(ctx, key, &schema); err != nil {
9090
return nil, err
9191
}
9292
return &schema, nil

backend/controllers/serviceexportrequest/serviceexportrequest_reconcile.go

Lines changed: 170 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type reconciler struct {
4444
clusterScopedIsolation kubebindv1alpha2.Isolation
4545
schemaSource string
4646

47-
getBoundSchema func(ctx context.Context, cache cache.Cache, namespace, name string) (*kubebindv1alpha2.BoundSchema, error)
47+
getBoundSchema func(ctx context.Context, cl client.Client, namespace, name string) (*kubebindv1alpha2.BoundSchema, error)
4848
createBoundSchema func(ctx context.Context, cl client.Client, schema *kubebindv1alpha2.BoundSchema) error
4949

5050
getServiceExport func(ctx context.Context, cache cache.Cache, ns, name string) (*kubebindv1alpha2.APIServiceExport, error)
@@ -53,14 +53,21 @@ type reconciler struct {
5353
}
5454

5555
func (r *reconciler) reconcile(ctx context.Context, cl client.Client, cache cache.Cache, req *kubebindv1alpha2.APIServiceExportRequest) error {
56+
// We must ensure schemas are created in form of boundSchemas first for the validation.
57+
// Worst case scenario if validation fails, we will reuse schemas for same consumer once issues are fixed.
5658
if err := r.ensureBoundSchemas(ctx, cl, cache, req); err != nil {
5759
conditions.SetSummary(req)
58-
return err
60+
return fmt.Errorf("failed to ensure bound schemas: %w", err)
61+
}
62+
63+
if err := r.validate(ctx, cl, req); err != nil {
64+
conditions.SetSummary(req)
65+
return fmt.Errorf("failed to validate APIServiceExportRequest: %w", err)
5966
}
6067

6168
if err := r.ensureExports(ctx, cl, cache, req); err != nil {
6269
conditions.SetSummary(req)
63-
return err
70+
return fmt.Errorf("failed to ensure exports: %w", err)
6471
}
6572

6673
// TODO(mjudeikis): we could potentially add finallizer to APIServiceExport above or "adopt" boundschemas
@@ -72,104 +79,77 @@ func (r *reconciler) reconcile(ctx context.Context, cl client.Client, cache cach
7279
return nil
7380
}
7481

75-
func (r *reconciler) ensureBoundSchemas(ctx context.Context, cl client.Client, cache cache.Cache, req *kubebindv1alpha2.APIServiceExportRequest) error {
76-
// Ensure all bound schemas exist
77-
for _, res := range req.Spec.Resources {
78-
parts := strings.SplitN(r.schemaSource, ".", 3)
79-
if len(parts) != 3 { // We check this in validation, but just in case.
80-
return fmt.Errorf("malformed schema source: %q", r.schemaSource)
81-
}
82+
// getExportedSchemas will list all schemas, exported by current backend.
83+
// Important: getExportedSchemas is using client.Client to list resources, not cache.
84+
// This is due to fact we use dynamic client and unstructured.Unstructured to get schemas and it
85+
// does not quite work with dynamic cache informers:
86+
// failed to get informer for *unstructured.UnstructuredList apis.kcp.io/v1alpha1, Kind=APIResourceSchemaList: failed to find newly started informer for apis.kcp.io/v1alpha1, Kind=APIResourceSchema"}.
87+
func (r *reconciler) getExportedSchemas(ctx context.Context, cl client.Client) (kubebindv1alpha2.ExportedSchemas, error) {
88+
parts := strings.SplitN(r.schemaSource, ".", 3)
89+
if len(parts) != 3 { // We check this in validation, but just in case.
90+
return nil, fmt.Errorf("malformed schema source: %q", r.schemaSource)
91+
}
8292

83-
gvk := schema.GroupVersionKind{
84-
Kind: parts[0],
85-
Version: parts[1],
86-
Group: parts[2],
87-
}
93+
gvk := schema.GroupVersionKind{
94+
Kind: parts[0],
95+
Version: parts[1],
96+
Group: parts[2],
97+
}
8898

89-
// Ensure we have the List kind
90-
listGVK := gvk
91-
if !strings.HasSuffix(listGVK.Kind, "List") {
92-
listGVK.Kind += "List"
93-
}
99+
// Ensure we have the List kind
100+
listGVK := gvk
101+
if !strings.HasSuffix(listGVK.Kind, "List") {
102+
listGVK.Kind += "List"
103+
}
94104

95-
list := &unstructured.UnstructuredList{}
96-
list.SetGroupVersionKind(listGVK)
105+
list := &unstructured.UnstructuredList{}
106+
list.SetGroupVersionKind(listGVK)
97107

98-
// TODO(mjudeikis): This is hardcoded here and in handlers.go for now.
99-
labelSelector := labels.Set{
100-
resources.ExportedCRDsLabel: "true",
101-
}
108+
// TODO(mjudeikis): This is hardcoded here and in handlers.go for now.
109+
labelSelector := labels.Set{
110+
resources.ExportedCRDsLabel: "true",
111+
}
102112

103-
listOpts := []client.ListOption{}
104-
listOpts = append(listOpts, client.MatchingLabelsSelector{Selector: labelSelector.AsSelector()})
113+
listOpts := []client.ListOption{}
114+
listOpts = append(listOpts, client.MatchingLabelsSelector{Selector: labelSelector.AsSelector()})
105115

106-
if err := cl.List(ctx, list, listOpts...); err != nil {
107-
return err
108-
}
116+
if err := cl.List(ctx, list, listOpts...); err != nil {
117+
return nil, err
118+
}
109119

110-
for _, item := range list.Items {
111-
var schemaFailed bool
112-
obj := item.UnstructuredContent()
113-
group, ok, err := unstructured.NestedString(obj, "spec", "group")
114-
if !ok || err != nil || group == "" {
115-
klog.FromContext(ctx).Error(err, "Skipping invalid schema: missing group", "ns", item.GetNamespace(), "name", item.GetName())
116-
schemaFailed = true
117-
}
118-
plural, ok, err := unstructured.NestedString(obj, "spec", "names", "plural")
119-
if !ok || err != nil || plural == "" {
120-
klog.FromContext(ctx).Error(err, "Skipping invalid schema: missing names.plural", "ns", item.GetNamespace(), "name", item.GetName())
121-
schemaFailed = true
122-
}
120+
boundSchemas := make(kubebindv1alpha2.ExportedSchemas, len(list.Items))
121+
for _, item := range list.Items {
122+
boundSchema, err := helpers.UnstructuredToBoundSchema(item)
123+
if err != nil {
124+
return nil, err
125+
}
126+
boundSchemas[boundSchema.ResourceGroupName()] = boundSchema
127+
}
123128

124-
scope, ok, err := unstructured.NestedString(obj, "spec", "scope")
125-
if !ok || err != nil || scope == "" {
126-
klog.FromContext(ctx).Error(err, "Skipping invalid schema: missing scope", "ns", item.GetNamespace(), "name", item.GetName())
127-
schemaFailed = true
128-
}
129+
return boundSchemas, nil
130+
}
129131

130-
if schemaFailed {
131-
conditions.MarkFalse(
132-
req,
133-
kubebindv1alpha2.APIServiceExportRequestConditionExportsReady,
134-
"APIServiceExportRequestInvalid",
135-
conditionsapi.ConditionSeverityError,
136-
"APIServiceExportRequest %s is invalid: resource %s/%s has invalid schema",
137-
req.Name, group, plural,
138-
)
139-
req.Status.Phase = kubebindv1alpha2.APIServiceExportRequestPhaseFailed
140-
return fmt.Errorf("resource %s/%s is invalid", group, plural)
141-
}
132+
func (r *reconciler) ensureBoundSchemas(ctx context.Context, cl client.Client, cache cache.Cache, req *kubebindv1alpha2.APIServiceExportRequest) error {
133+
exportedSchemas, err := r.getExportedSchemas(ctx, cl)
134+
if err != nil {
135+
return err
136+
}
142137

143-
if group == res.Group && plural == res.Resource {
144-
// Important: This checks if the resource are correctly scoped. If consumer is namespaced, we can't allow this.
145-
// We terminate early to prevent triggering other controllers.
146-
if r.informerScope.String() != scope && r.informerScope != kubebindv1alpha2.ClusterScope {
147-
conditions.MarkFalse(
148-
req,
149-
kubebindv1alpha2.APIServiceExportRequestConditionExportsReady,
150-
"APIServiceExportRequestInvalid",
151-
conditionsapi.ConditionSeverityError,
152-
"APIServiceExportRequest %s is invalid: resource %s/%s has scope %q which is incompatible with backend informer scope %q",
153-
req.Name, group, plural, scope, r.informerScope,
154-
)
155-
req.Status.Phase = kubebindv1alpha2.APIServiceExportRequestPhaseFailed
156-
req.Status.TerminalMessage = conditions.GetMessage(req, kubebindv1alpha2.APIServiceExportRequestConditionExportsReady)
157-
// We can't proceed with this request.
158-
return fmt.Errorf("resource %s/%s has scope %q which is incompatible with backend informer scope %q", group, plural, scope, r.informerScope)
159-
}
138+
// Ensure all bound schemas exist
139+
for _, res := range req.Spec.Resources {
140+
if len(res.Versions) == 0 {
141+
continue
142+
}
160143

161-
// https://github.com/kube-bind/kube-bind/issues/297 to fix.
162-
boundSchema, err := helpers.UnstructuredToBoundSchema(item)
163-
if err != nil {
164-
return err
165-
}
144+
for _, boundSchema := range exportedSchemas {
145+
if boundSchema.Spec.Group == res.Group && boundSchema.Spec.Names.Plural == res.Resource {
166146
boundSchema.Name = res.ResourceGroupName()
167147
boundSchema.Namespace = req.Namespace
168148
boundSchema.Spec.InformerScope = r.informerScope
169149
boundSchema.ResourceVersion = ""
170150

171-
obj, err := r.getBoundSchema(ctx, cache, boundSchema.Namespace, boundSchema.Name)
172-
if err != nil && !apierrors.IsNotFound(err) {
151+
obj, err := r.getBoundSchema(ctx, cl, boundSchema.Namespace, boundSchema.Name)
152+
if err != nil && !apierrors.IsNotFound(err) && !strings.Contains(err.Error(), "no matches for kind") {
173153
return err
174154
}
175155

@@ -196,7 +176,7 @@ func (r *reconciler) ensureExports(ctx context.Context, cl client.Client, cache
196176
if req.Status.Phase == kubebindv1alpha2.APIServiceExportRequestPhasePending {
197177
for _, res := range req.Spec.Resources {
198178
name := res.ResourceGroupName()
199-
boundSchema, err := r.getBoundSchema(ctx, cache, req.Namespace, name)
179+
boundSchema, err := r.getBoundSchema(ctx, cl, req.Namespace, name)
200180
if err != nil {
201181
if apierrors.IsNotFound(err) {
202182
conditions.MarkFalse(
@@ -258,6 +238,7 @@ func (r *reconciler) ensureExports(ctx context.Context, cl client.Client, cache
258238
Versions: res.Versions,
259239
})
260240
}
241+
export.Spec.PermissionClaims = req.Spec.PermissionClaims
261242

262243
logger.V(1).Info("Creating APIServiceExport", "name", export.Name, "namespace", export.Namespace)
263244
if err := r.createServiceExport(ctx, cl, export); err != nil {
@@ -283,3 +264,106 @@ func (r *reconciler) ensureExports(ctx context.Context, cl client.Client, cache
283264

284265
return nil
285266
}
267+
268+
// Validate validates if the APIServiceExportRequest is in a valid state.
269+
// Currently it validates if all requested schemas are of the same scope and
270+
// if claimable apis are allowed and valid.
271+
//
272+
// TODO: Move this to validatingAdmissionWebhook as this is not really part of reconciliation.
273+
// https://github.com/kube-bind/kube-bind/issues/325
274+
func (r *reconciler) validate(ctx context.Context, cl client.Client, req *kubebindv1alpha2.APIServiceExportRequest) error {
275+
exportedSchemas, err := r.getExportedSchemas(ctx, cl)
276+
if err != nil {
277+
return err
278+
}
279+
280+
if len(exportedSchemas) == 0 {
281+
conditions.MarkFalse(
282+
req,
283+
kubebindv1alpha2.APIServiceExportRequestConditionExportsReady,
284+
"SchemaNotFound",
285+
conditionsapi.ConditionSeverityError,
286+
"Schema not found",
287+
)
288+
return fmt.Errorf("no exported schemas found")
289+
}
290+
291+
first := apiextensionsv1.ResourceScope("")
292+
for _, res := range req.Spec.Resources {
293+
boundSchema, ok := exportedSchemas[res.ResourceGroupName()]
294+
if !ok {
295+
conditions.MarkFalse(
296+
req,
297+
kubebindv1alpha2.APIServiceExportRequestConditionExportsReady,
298+
"SchemaNotFound",
299+
conditionsapi.ConditionSeverityError,
300+
"Schema %s not found",
301+
res.ResourceGroupName(),
302+
)
303+
return fmt.Errorf("schema %s not found", res.ResourceGroupName())
304+
}
305+
if first == apiextensionsv1.ResourceScope("") {
306+
first = boundSchema.Spec.Scope
307+
continue
308+
}
309+
if boundSchema.Spec.Scope != first {
310+
conditions.MarkFalse(req,
311+
kubebindv1alpha2.APIServiceExportRequestConditionExportsReady,
312+
"DifferentScopes",
313+
conditionsapi.ConditionSeverityError,
314+
"Different scopes found: %v",
315+
boundSchema.Spec.Scope,
316+
)
317+
return fmt.Errorf("different scopes found for claimed resources: %v", boundSchema.Name)
318+
}
319+
}
320+
321+
// Add validation if claimable apis are valid here
322+
for _, claim := range req.Spec.PermissionClaims {
323+
if !isClaimableAPI(claim) {
324+
conditions.MarkFalse(
325+
req,
326+
kubebindv1alpha2.APIServiceExportConditionPermissionClaim,
327+
"InvalidPermissionClaim",
328+
conditionsapi.ConditionSeverityError,
329+
"Resource %s is not a valid claimable API",
330+
claim.GroupResource.String(),
331+
)
332+
req.Status.Phase = kubebindv1alpha2.APIServiceExportRequestPhaseFailed
333+
req.Status.TerminalMessage = conditions.GetMessage(req, kubebindv1alpha2.APIServiceExportConditionPermissionClaim)
334+
return fmt.Errorf("resource %s is not a valid claimable API", claim.GroupResource.String())
335+
}
336+
}
337+
338+
// Add validation for duplicate group/resource combinations
339+
seenGroupResources := make(map[string]bool)
340+
for _, claim := range req.Spec.PermissionClaims {
341+
key := claim.Group + "/" + claim.Resource
342+
if seenGroupResources[key] {
343+
conditions.MarkFalse(
344+
req,
345+
kubebindv1alpha2.APIServiceExportConditionPermissionClaim,
346+
"DuplicatePermissionClaim",
347+
conditionsapi.ConditionSeverityError,
348+
"Duplicate permission claim found for group/resource %s",
349+
claim.GroupResource.String(),
350+
)
351+
req.Status.Phase = kubebindv1alpha2.APIServiceExportRequestPhaseFailed
352+
req.Status.TerminalMessage = conditions.GetMessage(req, kubebindv1alpha2.APIServiceExportConditionPermissionClaim)
353+
return fmt.Errorf("duplicate permission claim found for group/resource %s", claim.GroupResource.String())
354+
}
355+
seenGroupResources[key] = true
356+
}
357+
358+
return nil
359+
}
360+
361+
// isClaimableAPI checks if a permission claim is for a claimable API.
362+
func isClaimableAPI(claim kubebindv1alpha2.PermissionClaim) bool {
363+
for _, api := range kubebindv1alpha2.ClaimableAPIs {
364+
if claim.Group == api.GroupVersionResource.Group && claim.Resource == api.Names.Plural {
365+
return true
366+
}
367+
}
368+
return false
369+
}

0 commit comments

Comments
 (0)