From 48f6729070a28695872f4033a441862c37a65f7b Mon Sep 17 00:00:00 2001 From: Maksim Fedotov Date: Fri, 17 Sep 2021 15:22:07 +0300 Subject: [PATCH 1/7] feat: namespace labeling for tenant owners --- api/v1beta1/allowed_list.go | 2 +- api/v1beta1/allowed_list_test.go | 2 +- api/v1beta1/forbidden_list.go | 33 +++++ api/v1beta1/forbidden_list_test.go | 67 +++++++++ api/v1beta1/namespace_options.go | 42 ++++++ api/v1beta1/tenant_annotations.go | 16 +- api/v1beta1/zz_generated.deepcopy.go | 20 +++ controllers/rbac/const.go | 2 +- controllers/tenant/namespaces.go | 47 +++++- main.go | 2 +- pkg/webhook/namespace/errors.go | 53 +++++++ pkg/webhook/namespace/user_metadata.go | 139 ++++++++++++++++++ pkg/webhook/ownerreference/patching.go | 194 ++++++++++++------------- pkg/webhook/route/ownerreference.go | 2 +- pkg/webhook/utils/is_tenant_owner.go | 26 ++++ 15 files changed, 524 insertions(+), 123 deletions(-) create mode 100644 api/v1beta1/forbidden_list.go create mode 100644 api/v1beta1/forbidden_list_test.go create mode 100644 pkg/webhook/namespace/user_metadata.go create mode 100644 pkg/webhook/utils/is_tenant_owner.go diff --git a/api/v1beta1/allowed_list.go b/api/v1beta1/allowed_list.go index d68f4b13..d72359cd 100644 --- a/api/v1beta1/allowed_list.go +++ b/api/v1beta1/allowed_list.go @@ -1,6 +1,6 @@ // Copyright 2020-2021 Clastix Labs // SPDX-License-Identifier: Apache-2.0 - +//nolint:dupl package v1beta1 import ( diff --git a/api/v1beta1/allowed_list_test.go b/api/v1beta1/allowed_list_test.go index f47e9a1e..c2c47607 100644 --- a/api/v1beta1/allowed_list_test.go +++ b/api/v1beta1/allowed_list_test.go @@ -1,6 +1,6 @@ // Copyright 2020-2021 Clastix Labs // SPDX-License-Identifier: Apache-2.0 - +//nolint:dupl package v1beta1 import ( diff --git a/api/v1beta1/forbidden_list.go b/api/v1beta1/forbidden_list.go new file mode 100644 index 00000000..68a5c139 --- /dev/null +++ b/api/v1beta1/forbidden_list.go @@ -0,0 +1,33 @@ +// Copyright 2020-2021 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 +//nolint:dupl +package v1beta1 + +import ( + "regexp" + "sort" + "strings" +) + +type ForbiddenListSpec struct { + Exact []string `json:"denied,omitempty"` + Regex string `json:"deniedRegex,omitempty"` +} + +func (in *ForbiddenListSpec) ExactMatch(value string) (ok bool) { + if len(in.Exact) > 0 { + sort.SliceStable(in.Exact, func(i, j int) bool { + return strings.ToLower(in.Exact[i]) < strings.ToLower(in.Exact[j]) + }) + i := sort.SearchStrings(in.Exact, value) + ok = i < len(in.Exact) && in.Exact[i] == value + } + return +} + +func (in ForbiddenListSpec) RegexMatch(value string) (ok bool) { + if len(in.Regex) > 0 { + ok = regexp.MustCompile(in.Regex).MatchString(value) + } + return +} diff --git a/api/v1beta1/forbidden_list_test.go b/api/v1beta1/forbidden_list_test.go new file mode 100644 index 00000000..46d771db --- /dev/null +++ b/api/v1beta1/forbidden_list_test.go @@ -0,0 +1,67 @@ +// Copyright 2020-2021 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 +//nolint:dupl +package v1beta1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestForbiddenListSpec_ExactMatch(t *testing.T) { + type tc struct { + In []string + True []string + False []string + } + for _, tc := range []tc{ + { + []string{"foo", "bar", "bizz", "buzz"}, + []string{"foo", "bar", "bizz", "buzz"}, + []string{"bing", "bong"}, + }, + { + []string{"one", "two", "three"}, + []string{"one", "two", "three"}, + []string{"a", "b", "c"}, + }, + { + nil, + nil, + []string{"any", "value"}, + }, + } { + a := ForbiddenListSpec{ + Exact: tc.In, + } + for _, ok := range tc.True { + assert.True(t, a.ExactMatch(ok)) + } + for _, ko := range tc.False { + assert.False(t, a.ExactMatch(ko)) + } + } +} + +func TestForbiddenListSpec_RegexMatch(t *testing.T) { + type tc struct { + Regex string + True []string + False []string + } + for _, tc := range []tc{ + {`first-\w+-pattern`, []string{"first-date-pattern", "first-year-pattern"}, []string{"broken", "first-year", "second-date-pattern"}}, + {``, nil, []string{"any", "value"}}, + } { + a := ForbiddenListSpec{ + Regex: tc.Regex, + } + for _, ok := range tc.True { + assert.True(t, a.RegexMatch(ok)) + } + for _, ko := range tc.False { + assert.False(t, a.RegexMatch(ko)) + } + } +} diff --git a/api/v1beta1/namespace_options.go b/api/v1beta1/namespace_options.go index 58b34201..a3abe775 100644 --- a/api/v1beta1/namespace_options.go +++ b/api/v1beta1/namespace_options.go @@ -1,5 +1,7 @@ package v1beta1 +import "strings" + type NamespaceOptions struct { //+kubebuilder:validation:Minimum=1 // Specifies the maximum number of namespaces allowed for that Tenant. Once the namespace quota assigned to the Tenant has been reached, the Tenant owner cannot create further namespaces. Optional. @@ -7,3 +9,43 @@ type NamespaceOptions struct { // Specifies additional labels and annotations the Capsule operator places on any Namespace resource in the Tenant. Optional. AdditionalMetadata *AdditionalMetadataSpec `json:"additionalMetadata,omitempty"` } + +func (t *Tenant) hasForbiddenNamespaceLabelsAnnotations() bool { + if _, ok := t.Annotations[ForbiddenNamespaceLabelsAnnotation]; ok { + return true + } + if _, ok := t.Annotations[ForbiddenNamespaceLabelsRegexpAnnotation]; ok { + return true + } + return false +} + +func (t *Tenant) hasForbiddenNamespaceAnnotationsAnnotations() bool { + if _, ok := t.Annotations[ForbiddenNamespaceAnnotationsAnnotation]; ok { + return true + } + if _, ok := t.Annotations[ForbiddenNamespaceAnnotationsRegexpAnnotation]; ok { + return true + } + return false +} + +func (t *Tenant) ForbiddenUserNamespaceLabels() *ForbiddenListSpec { + if !t.hasForbiddenNamespaceLabelsAnnotations() { + return nil + } + return &ForbiddenListSpec{ + Exact: strings.Split(t.Annotations[ForbiddenNamespaceLabelsAnnotation], ","), + Regex: t.Annotations[ForbiddenNamespaceLabelsRegexpAnnotation], + } +} + +func (t *Tenant) ForbiddenUserNamespaceAnnotations() *ForbiddenListSpec { + if !t.hasForbiddenNamespaceAnnotationsAnnotations() { + return nil + } + return &ForbiddenListSpec{ + Exact: strings.Split(t.Annotations[ForbiddenNamespaceAnnotationsAnnotation], ","), + Regex: t.Annotations[ForbiddenNamespaceAnnotationsRegexpAnnotation], + } +} diff --git a/api/v1beta1/tenant_annotations.go b/api/v1beta1/tenant_annotations.go index b999d2d1..4f1d3597 100644 --- a/api/v1beta1/tenant_annotations.go +++ b/api/v1beta1/tenant_annotations.go @@ -8,12 +8,16 @@ import ( ) const ( - AvailableIngressClassesAnnotation = "capsule.clastix.io/ingress-classes" - AvailableIngressClassesRegexpAnnotation = "capsule.clastix.io/ingress-classes-regexp" - AvailableStorageClassesAnnotation = "capsule.clastix.io/storage-classes" - AvailableStorageClassesRegexpAnnotation = "capsule.clastix.io/storage-classes-regexp" - AllowedRegistriesAnnotation = "capsule.clastix.io/allowed-registries" - AllowedRegistriesRegexpAnnotation = "capsule.clastix.io/allowed-registries-regexp" + AvailableIngressClassesAnnotation = "capsule.clastix.io/ingress-classes" + AvailableIngressClassesRegexpAnnotation = "capsule.clastix.io/ingress-classes-regexp" + AvailableStorageClassesAnnotation = "capsule.clastix.io/storage-classes" + AvailableStorageClassesRegexpAnnotation = "capsule.clastix.io/storage-classes-regexp" + AllowedRegistriesAnnotation = "capsule.clastix.io/allowed-registries" + AllowedRegistriesRegexpAnnotation = "capsule.clastix.io/allowed-registries-regexp" + ForbiddenNamespaceLabelsAnnotation = "capsule.clastix.io/forbidden-namespace-labels" + ForbiddenNamespaceLabelsRegexpAnnotation = "capsule.clastix.io/forbidden-namespace-labels-regexp" + ForbiddenNamespaceAnnotationsAnnotation = "capsule.clastix.io/forbidden-namespace-annotations" + ForbiddenNamespaceAnnotationsRegexpAnnotation = "capsule.clastix.io/forbidden-namespace-annotations-regexp" ) func UsedQuotaFor(resource fmt.Stringer) string { diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 225107a4..73767a57 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -154,6 +154,26 @@ func (in *ExternalServiceIPsSpec) DeepCopy() *ExternalServiceIPsSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ForbiddenListSpec) DeepCopyInto(out *ForbiddenListSpec) { + *out = *in + if in.Exact != nil { + in, out := &in.Exact, &out.Exact + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ForbiddenListSpec. +func (in *ForbiddenListSpec) DeepCopy() *ForbiddenListSpec { + if in == nil { + return nil + } + out := new(ForbiddenListSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IngressOptions) DeepCopyInto(out *IngressOptions) { *out = *in diff --git a/controllers/rbac/const.go b/controllers/rbac/const.go index 09d71e8b..66db6b29 100644 --- a/controllers/rbac/const.go +++ b/controllers/rbac/const.go @@ -35,7 +35,7 @@ var ( { APIGroups: []string{""}, Resources: []string{"namespaces"}, - Verbs: []string{"delete"}, + Verbs: []string{"delete", "patch"}, }, }, }, diff --git a/controllers/tenant/namespaces.go b/controllers/tenant/namespaces.go index d587f330..7f0b37a0 100644 --- a/controllers/tenant/namespaces.go +++ b/controllers/tenant/namespaces.go @@ -1,3 +1,7 @@ +// Copyright 2020-2021 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +//nolint:dupl package tenant import ( @@ -49,6 +53,10 @@ func (r *Manager) syncNamespaceMetadata(namespace string, tnt *capsulev1beta1.Te res, conflictErr = controllerutil.CreateOrUpdate(context.TODO(), r.Client, ns, func() error { annotations := make(map[string]string) + labels := map[string]string{ + "name": namespace, + capsuleLabel: tnt.GetName(), + } if tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.AdditionalMetadata != nil { for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadata.Annotations { @@ -56,6 +64,12 @@ func (r *Manager) syncNamespaceMetadata(namespace string, tnt *capsulev1beta1.Te } } + if tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.AdditionalMetadata != nil { + for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadata.Labels { + labels[k] = v + } + } + if tnt.Spec.NodeSelector != nil { var selector []string for k, v := range tnt.Spec.NodeSelector { @@ -91,20 +105,37 @@ func (r *Manager) syncNamespaceMetadata(namespace string, tnt *capsulev1beta1.Te } } - ns.SetAnnotations(annotations) + if value, ok := tnt.Annotations[capsulev1beta1.ForbiddenNamespaceLabelsAnnotation]; ok { + annotations[capsulev1beta1.ForbiddenNamespaceLabelsAnnotation] = value + } + + if value, ok := tnt.Annotations[capsulev1beta1.ForbiddenNamespaceLabelsRegexpAnnotation]; ok { + annotations[capsulev1beta1.ForbiddenNamespaceLabelsRegexpAnnotation] = value + } - newLabels := map[string]string{ - "name": namespace, - capsuleLabel: tnt.GetName(), + if value, ok := tnt.Annotations[capsulev1beta1.ForbiddenNamespaceAnnotationsAnnotation]; ok { + annotations[capsulev1beta1.ForbiddenNamespaceAnnotationsAnnotation] = value } - if tnt.Spec.NamespaceOptions != nil && tnt.Spec.NamespaceOptions.AdditionalMetadata != nil { - for k, v := range tnt.Spec.NamespaceOptions.AdditionalMetadata.Labels { - newLabels[k] = v + if value, ok := tnt.Annotations[capsulev1beta1.ForbiddenNamespaceAnnotationsRegexpAnnotation]; ok { + annotations[capsulev1beta1.ForbiddenNamespaceAnnotationsRegexpAnnotation] = value + } + + if ns.Annotations == nil { + ns.SetAnnotations(annotations) + } else { + for k, v := range annotations { + ns.Annotations[k] = v } } - ns.SetLabels(newLabels) + if ns.Labels == nil { + ns.SetLabels(labels) + } else { + for k, v := range labels { + ns.Labels[k] = v + } + } return nil }) diff --git a/main.go b/main.go index 10862f0d..c48bdf80 100644 --- a/main.go +++ b/main.go @@ -149,7 +149,7 @@ func main() { webhooksList := append( make([]webhook.Webhook, 0), route.Pod(pod.ImagePullPolicy(), pod.ContainerRegistry(), pod.PriorityClass()), - route.Namespace(utils.InCapsuleGroups(cfg, namespacewebhook.QuotaHandler(), namespacewebhook.FreezeHandler(cfg), namespacewebhook.PrefixHandler(cfg))), + route.Namespace(utils.InCapsuleGroups(cfg, namespacewebhook.QuotaHandler(), namespacewebhook.FreezeHandler(cfg), namespacewebhook.PrefixHandler(cfg), namespacewebhook.UserMetadataHandler())), route.Ingress(ingress.Class(cfg), ingress.Hostnames(cfg), ingress.Collision(cfg), ingress.Wildcard()), route.PVC(pvc.Handler()), route.Service(service.Handler()), diff --git a/pkg/webhook/namespace/errors.go b/pkg/webhook/namespace/errors.go index 6f3f6160..46b8bf94 100644 --- a/pkg/webhook/namespace/errors.go +++ b/pkg/webhook/namespace/errors.go @@ -3,6 +3,27 @@ package namespace +import ( + "fmt" + "strings" + + capsulev1beta1 "github.com/clastix/capsule/api/v1beta1" +) + +func appendForbiddenError(spec *capsulev1beta1.ForbiddenListSpec) (append string) { + append += "Forbidden are " + if len(spec.Exact) > 0 { + append += fmt.Sprintf("one of the following (%s)", strings.Join(spec.Exact, ", ")) + if len(spec.Regex) > 0 { + append += " or " + } + } + if len(spec.Regex) > 0 { + append += fmt.Sprintf("matching the regex %s", spec.Regex) + } + return +} + type namespaceQuotaExceededError struct{} func NewNamespaceQuotaExceededError() error { @@ -12,3 +33,35 @@ func NewNamespaceQuotaExceededError() error { func (namespaceQuotaExceededError) Error() string { return "Cannot exceed Namespace quota: please, reach out to the system administrators" } + +type namespaceLabelForbiddenError struct { + label string + spec *capsulev1beta1.ForbiddenListSpec +} + +func NewNamespaceLabelForbiddenError(label string, forbiddenSpec *capsulev1beta1.ForbiddenListSpec) error { + return &namespaceLabelForbiddenError{ + label: label, + spec: forbiddenSpec, + } +} + +func (f namespaceLabelForbiddenError) Error() string { + return fmt.Sprintf("Label %s is forbidden for namespaces in the current Tenant. %s", f.label, appendForbiddenError(f.spec)) +} + +type namespaceAnnotationForbiddenError struct { + annotation string + spec *capsulev1beta1.ForbiddenListSpec +} + +func NewNamespaceAnnotationForbiddenError(annotation string, forbiddenSpec *capsulev1beta1.ForbiddenListSpec) error { + return &namespaceAnnotationForbiddenError{ + annotation: annotation, + spec: forbiddenSpec, + } +} + +func (f namespaceAnnotationForbiddenError) Error() string { + return fmt.Sprintf("Annotation %s is forbidden for namespaces in the current Tenant. %s", f.annotation, appendForbiddenError(f.spec)) +} diff --git a/pkg/webhook/namespace/user_metadata.go b/pkg/webhook/namespace/user_metadata.go new file mode 100644 index 00000000..72235a96 --- /dev/null +++ b/pkg/webhook/namespace/user_metadata.go @@ -0,0 +1,139 @@ +// Copyright 2020-2021 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +//nolint:dupl +package namespace + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta1 "github.com/clastix/capsule/api/v1beta1" + + capsulewebhook "github.com/clastix/capsule/pkg/webhook" + "github.com/clastix/capsule/pkg/webhook/utils" +) + +type userMetadataHandler struct { +} + +func UserMetadataHandler() capsulewebhook.Handler { + return &userMetadataHandler{} +} + +func (r *userMetadataHandler) validateUserMetadata(tnt *capsulev1beta1.Tenant, recorder record.EventRecorder, labels map[string]string, annotations map[string]string) *admission.Response { + if tnt.ForbiddenUserNamespaceLabels() != nil { + forbiddenLabels := tnt.ForbiddenUserNamespaceLabels() + for label := range labels { + var forbidden, matched bool + forbidden = forbiddenLabels.ExactMatch(label) + matched = forbiddenLabels.RegexMatch(label) + + if forbidden || matched { + recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNamespaceLabel", fmt.Sprintf("Label %s is forbidden for a namespaces of the current Tenant ", label)) + + response := admission.Denied(NewNamespaceLabelForbiddenError(label, forbiddenLabels).Error()) + + return &response + } + } + } + + if tnt.ForbiddenUserNamespaceAnnotations() != nil { + forbiddenAnnotations := tnt.ForbiddenUserNamespaceLabels() + for annotation := range annotations { + var forbidden, matched bool + forbidden = forbiddenAnnotations.ExactMatch(annotation) + matched = forbiddenAnnotations.RegexMatch(annotation) + + if forbidden || matched { + recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNamespaceAnnotation", fmt.Sprintf("Annotation %s is forbidden for a namespaces of the current Tenant ", annotation)) + + response := admission.Denied(NewNamespaceAnnotationForbiddenError(annotation, forbiddenAnnotations).Error()) + + return &response + } + } + } + return nil +} + +func (r *userMetadataHandler) OnCreate(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + ns := &corev1.Namespace{} + if err := decoder.Decode(req, ns); err != nil { + return utils.ErroredResponse(err) + } + + tnt := &capsulev1beta1.Tenant{} + for _, objectRef := range ns.ObjectMeta.OwnerReferences { + // retrieving the selected Tenant + + if err := client.Get(ctx, types.NamespacedName{Name: objectRef.Name}, tnt); err != nil { + return utils.ErroredResponse(err) + } + } + + labels := ns.GetLabels() + annotations := ns.GetAnnotations() + + return r.validateUserMetadata(tnt, recorder, labels, annotations) + } +} + +func (r *userMetadataHandler) OnDelete(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + return nil + } +} + +func (r *userMetadataHandler) OnUpdate(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + oldNs := &corev1.Namespace{} + if err := decoder.DecodeRaw(req.OldObject, oldNs); err != nil { + return utils.ErroredResponse(err) + } + + newNs := &corev1.Namespace{} + if err := decoder.Decode(req, newNs); err != nil { + return utils.ErroredResponse(err) + } + + tnt := &capsulev1beta1.Tenant{} + for _, objectRef := range newNs.ObjectMeta.OwnerReferences { + // retrieving the selected Tenant + + if err := client.Get(ctx, types.NamespacedName{Name: objectRef.Name}, tnt); err != nil { + return utils.ErroredResponse(err) + } + } + + var labels, annotations map[string]string + + for key, value := range newNs.GetLabels() { + if _, ok := oldNs.GetLabels()[key]; !ok { + if labels == nil { + labels = make(map[string]string) + } + labels[key] = value + } + } + + for key, value := range newNs.GetAnnotations() { + if _, ok := oldNs.GetAnnotations()[key]; !ok { + if annotations == nil { + annotations = make(map[string]string) + } + annotations[key] = value + } + } + + return r.validateUserMetadata(tnt, recorder, labels, annotations) + } +} diff --git a/pkg/webhook/ownerreference/patching.go b/pkg/webhook/ownerreference/patching.go index be249f7e..d87d4cbb 100644 --- a/pkg/webhook/ownerreference/patching.go +++ b/pkg/webhook/ownerreference/patching.go @@ -11,7 +11,6 @@ import ( "sort" "strings" - authenticationv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -20,6 +19,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "github.com/clastix/capsule/pkg/webhook/utils" + capsulev1beta1 "github.com/clastix/capsule/api/v1beta1" "github.com/clastix/capsule/pkg/configuration" capsulewebhook "github.com/clastix/capsule/pkg/webhook" @@ -35,63 +36,94 @@ func Handler(cfg configuration.Configuration) capsulewebhook.Handler { } } -func (h *handler) OnCreate(clt client.Client, decoder *admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { +func (h *handler) OnCreate(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + return h.setOwnerRef(ctx, req, client, decoder, recorder) + } +} +func (h *handler) OnDelete(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + return nil + } +} + +func (h *handler) OnUpdate(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - ns := &corev1.Namespace{} - if err := decoder.Decode(req, ns); err != nil { + return h.setOwnerRef(ctx, req, client, decoder, recorder) + } +} + +func (h *handler) setOwnerRef(ctx context.Context, req admission.Request, client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) *admission.Response { + ns := &corev1.Namespace{} + if err := decoder.Decode(req, ns); err != nil { + response := admission.Errored(http.StatusBadRequest, err) + + return &response + } + ln, err := capsulev1beta1.GetTypeLabel(&capsulev1beta1.Tenant{}) + if err != nil { + response := admission.Errored(http.StatusBadRequest, err) + + return &response + } + // If we already had TenantName label on NS -> assign to it + if label, ok := ns.ObjectMeta.Labels[ln]; ok { + // retrieving the selected Tenant + tnt := &capsulev1beta1.Tenant{} + if err = client.Get(ctx, types.NamespacedName{Name: label}, tnt); err != nil { response := admission.Errored(http.StatusBadRequest, err) return &response } - ln, err := capsulev1beta1.GetTypeLabel(&capsulev1beta1.Tenant{}) - if err != nil { - response := admission.Errored(http.StatusBadRequest, err) + // Tenant owner must adhere to user that asked for NS creation + if !utils.IsTenantOwner(tnt.Spec.Owners, req.UserInfo) { + recorder.Eventf(tnt, corev1.EventTypeWarning, "NonOwnedTenant", "Namespace %s cannot be assigned to the current Tenant", ns.GetName()) + + response := admission.Denied("Cannot assign the desired namespace to a non-owned Tenant") return &response } - // If we already had TenantName label on NS -> assign to it - if label, ok := ns.ObjectMeta.Labels[ln]; ok { - // retrieving the selected Tenant - tnt := &capsulev1beta1.Tenant{} - if err = clt.Get(ctx, types.NamespacedName{Name: label}, tnt); err != nil { - response := admission.Errored(http.StatusBadRequest, err) + // Patching the response + response := h.patchResponseForOwnerRef(tnt, ns, recorder) - return &response - } - // Tenant owner must adhere to user that asked for NS creation - if !h.isTenantOwner(tnt.Spec.Owners, req.UserInfo) { - recorder.Eventf(tnt, corev1.EventTypeWarning, "NonOwnedTenant", "Namespace %s cannot be assigned to the current Tenant", ns.GetName()) + return &response + } - response := admission.Denied("Cannot assign the desired namespace to a non-owned Tenant") + // If we forceTenantPrefix -> find Tenant from NS name + var tenants sortedTenants - return &response - } - // Patching the response - response := h.patchResponseForOwnerRef(tnt, ns, recorder) + // Find tenants belonging to user (it can be regular user or ServiceAccount) + if strings.HasPrefix(req.UserInfo.Username, "system:serviceaccount:") { + var tntList *capsulev1beta1.TenantList + + if tntList, err = h.listTenantsForOwnerKind(ctx, "ServiceAccount", req.UserInfo.Username, client); err != nil { + response := admission.Errored(http.StatusBadRequest, err) return &response } - // If we forceTenantPrefix -> find Tenant from NS name - var tenants sortedTenants - - // Find tenants belonging to user (it can be regular user or ServiceAccount) - if strings.HasPrefix(req.UserInfo.Username, "system:serviceaccount:") { - var tntList *capsulev1beta1.TenantList + for _, tnt := range tntList.Items { + tenants = append(tenants, tnt) + } + } else { + var tntList *capsulev1beta1.TenantList - if tntList, err = h.listTenantsForOwnerKind(ctx, "ServiceAccount", req.UserInfo.Username, clt); err != nil { - response := admission.Errored(http.StatusBadRequest, err) + if tntList, err = h.listTenantsForOwnerKind(ctx, "User", req.UserInfo.Username, client); err != nil { + response := admission.Errored(http.StatusBadRequest, err) - return &response - } + return &response + } - for _, tnt := range tntList.Items { - tenants = append(tenants, tnt) - } - } else { - var tntList *capsulev1beta1.TenantList + for _, tnt := range tntList.Items { + tenants = append(tenants, tnt) + } + } - if tntList, err = h.listTenantsForOwnerKind(ctx, "User", req.UserInfo.Username, clt); err != nil { + // Find tenants belonging to user groups + { + for _, group := range req.UserInfo.Groups { + tntList, err := h.listTenantsForOwnerKind(ctx, "Group", group, client) + if err != nil { response := admission.Errored(http.StatusBadRequest, err) return &response @@ -101,65 +133,38 @@ func (h *handler) OnCreate(clt client.Client, decoder *admission.Decoder, record tenants = append(tenants, tnt) } } + } - // Find tenants belonging to user groups - { - for _, group := range req.UserInfo.Groups { - tntList, err := h.listTenantsForOwnerKind(ctx, "Group", group, clt) - if err != nil { - response := admission.Errored(http.StatusBadRequest, err) - - return &response - } - - for _, tnt := range tntList.Items { - tenants = append(tenants, tnt) - } - } - } - - sort.Sort(sort.Reverse(tenants)) + sort.Sort(sort.Reverse(tenants)) - if len(tenants) == 0 { - response := admission.Denied("You do not have any Tenant assigned: please, reach out to the system administrators") + if len(tenants) == 0 { + response := admission.Denied("You do not have any Tenant assigned: please, reach out to the system administrators") - return &response - } + return &response + } - if len(tenants) == 1 { - response := h.patchResponseForOwnerRef(&tenants[0], ns, recorder) + if len(tenants) == 1 { + response := h.patchResponseForOwnerRef(&tenants[0], ns, recorder) - return &response - } + return &response + } - if h.cfg.ForceTenantPrefix() { - for _, tnt := range tenants { - if strings.HasPrefix(ns.GetName(), fmt.Sprintf("%s-", tnt.GetName())) { - response := h.patchResponseForOwnerRef(tnt.DeepCopy(), ns, recorder) + if h.cfg.ForceTenantPrefix() { + for _, tnt := range tenants { + if strings.HasPrefix(ns.GetName(), fmt.Sprintf("%s-", tnt.GetName())) { + response := h.patchResponseForOwnerRef(tnt.DeepCopy(), ns, recorder) - return &response - } + return &response } - response := admission.Denied("The Namespace prefix used doesn't match any available Tenant") - - return &response } - - response := admission.Denied("Unable to assign namespace to tenant. Please use " + ln + " label when creating a namespace") + response := admission.Denied("The Namespace prefix used doesn't match any available Tenant") return &response } -} -func (h *handler) OnDelete(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { - return func(ctx context.Context, req admission.Request) *admission.Response { - return nil - } -} -func (h *handler) OnUpdate(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { - return func(ctx context.Context, req admission.Request) *admission.Response { - return nil - } + response := admission.Denied("Unable to assign namespace to tenant. Please use " + ln + " label when creating a namespace") + + return &response } func (h *handler) patchResponseForOwnerRef(tenant *capsulev1beta1.Tenant, ns *corev1.Namespace, recorder record.EventRecorder) admission.Response { @@ -189,25 +194,6 @@ func (h *handler) listTenantsForOwnerKind(ctx context.Context, ownerKind string, return tntList, err } -func (h *handler) isTenantOwner(owners capsulev1beta1.OwnerListSpec, userInfo authenticationv1.UserInfo) bool { - for _, owner := range owners { - switch owner.Kind { - case "User", "ServiceAccount": - if userInfo.Username == owner.Name { - return true - } - case "Group": - for _, group := range userInfo.Groups { - if group == owner.Name { - return true - } - } - } - } - - return false -} - type sortedTenants []capsulev1beta1.Tenant func (s sortedTenants) Len() int { diff --git a/pkg/webhook/route/ownerreference.go b/pkg/webhook/route/ownerreference.go index f8413fd4..d4e3a098 100644 --- a/pkg/webhook/route/ownerreference.go +++ b/pkg/webhook/route/ownerreference.go @@ -4,7 +4,7 @@ import ( capsulewebhook "github.com/clastix/capsule/pkg/webhook" ) -// +kubebuilder:webhook:path=/namespace-owner-reference,mutating=true,sideEffects=None,admissionReviewVersions=v1,failurePolicy=fail,groups="",resources=namespaces,verbs=create,versions=v1,name=owner.namespace.capsule.clastix.io +// +kubebuilder:webhook:path=/namespace-owner-reference,mutating=true,sideEffects=None,admissionReviewVersions=v1,failurePolicy=fail,groups="",resources=namespaces,verbs=create;update,versions=v1,name=owner.namespace.capsule.clastix.io type webhook struct { handlers []capsulewebhook.Handler diff --git a/pkg/webhook/utils/is_tenant_owner.go b/pkg/webhook/utils/is_tenant_owner.go new file mode 100644 index 00000000..4e131a87 --- /dev/null +++ b/pkg/webhook/utils/is_tenant_owner.go @@ -0,0 +1,26 @@ +package utils + +import ( + authenticationv1 "k8s.io/api/authentication/v1" + + capsulev1beta1 "github.com/clastix/capsule/api/v1beta1" +) + +func IsTenantOwner(owners capsulev1beta1.OwnerListSpec, userInfo authenticationv1.UserInfo) bool { + for _, owner := range owners { + switch owner.Kind { + case "User", "ServiceAccount": + if userInfo.Username == owner.Name { + return true + } + case "Group": + for _, group := range userInfo.Groups { + if group == owner.Name { + return true + } + } + } + } + + return false +} From 5165e831aeec04482fd086b80ec1c0510d5a032f Mon Sep 17 00:00:00 2001 From: Maksim Fedotov Date: Fri, 17 Sep 2021 15:22:22 +0300 Subject: [PATCH 2/7] test(e2e): namespace labeling for tenant owners --- ... => namespace_additional_metadata_test.go} | 0 e2e/namespace_user_metadata_test.go | 84 +++++++++++++++++++ 2 files changed, 84 insertions(+) rename e2e/{namespace_metadata_test.go => namespace_additional_metadata_test.go} (100%) create mode 100644 e2e/namespace_user_metadata_test.go diff --git a/e2e/namespace_metadata_test.go b/e2e/namespace_additional_metadata_test.go similarity index 100% rename from e2e/namespace_metadata_test.go rename to e2e/namespace_additional_metadata_test.go diff --git a/e2e/namespace_user_metadata_test.go b/e2e/namespace_user_metadata_test.go new file mode 100644 index 00000000..b0cf2074 --- /dev/null +++ b/e2e/namespace_user_metadata_test.go @@ -0,0 +1,84 @@ +//+build e2e + +// Copyright 2020-2021 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + + capsulev1beta1 "github.com/clastix/capsule/api/v1beta1" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("creating a Namespace with user-specified labels and annotations", func() { + tnt := &capsulev1beta1.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenant-user-metadata-forbidden", + Annotations: map[string]string{ + capsulev1beta1.ForbiddenNamespaceLabelsAnnotation: "foo,bar", + capsulev1beta1.ForbiddenNamespaceLabelsRegexpAnnotation: "^gatsby-.*$", + capsulev1beta1.ForbiddenNamespaceAnnotationsAnnotation: "foo,bar", + capsulev1beta1.ForbiddenNamespaceAnnotationsRegexpAnnotation: "^gatsby-.*$", + }, + }, + Spec: capsulev1beta1.TenantSpec{ + Owners: capsulev1beta1.OwnerListSpec{ + { + Name: "gatsby", + Kind: "User", + }, + }, + }, + } + + JustBeforeEach(func() { + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + return k8sClient.Create(context.TODO(), tnt) + }).Should(Succeed()) + }) + JustAfterEach(func() { + Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed()) + }) + + It("should allow", func() { + By("specifying non-forbidden labels", func() { + ns := NewNamespace("namespace-user-metadata-allowed-labels") + ns.SetLabels(map[string]string{"bim": "baz"}) + NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed()) + }) + By("specifying non-forbidden annotations", func() { + ns := NewNamespace("namespace-user-metadata-allowed-annotations") + ns.SetAnnotations(map[string]string{"bim": "baz"}) + NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed()) + }) + }) + + + It("should fail", func() { + By("specifying forbidden labels using exact match", func() { + ns := NewNamespace("namespace-user-metadata-forbidden-labels") + ns.SetLabels(map[string]string{"foo": "bar"}) + NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).ShouldNot(Succeed()) + }) + By("specifying forbidden labels using regex match", func() { + ns := NewNamespace("namespace-user-metadata-forbidden-labels") + ns.SetLabels(map[string]string{"gatsby-foo": "bar"}) + NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).ShouldNot(Succeed()) + }) + By("specifying forbidden annotations using exact match", func() { + ns := NewNamespace("namespace-user-metadata-forbidden-labels") + ns.SetAnnotations(map[string]string{"foo": "bar"}) + NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).ShouldNot(Succeed()) + }) + By("specifying forbidden annotations using regex match", func() { + ns := NewNamespace("namespace-user-metadata-forbidden-labels") + ns.SetAnnotations(map[string]string{"gatsby-foo": "bar"}) + NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).ShouldNot(Succeed()) + }) + }) +}) From 3585efa8059ffcc2f02097b0bdd7fffb43bc52b8 Mon Sep 17 00:00:00 2001 From: Maksim Fedotov Date: Fri, 17 Sep 2021 15:22:38 +0300 Subject: [PATCH 3/7] build(kustomize): namespace labeling for tenant owners --- config/webhook/manifests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 77cf6ffb..750256ed 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -22,6 +22,7 @@ webhooks: - v1 operations: - CREATE + - UPDATE resources: - namespaces sideEffects: None From 1f368013c701029d56569e4779fda9aacf306fe6 Mon Sep 17 00:00:00 2001 From: Maksim Fedotov Date: Fri, 17 Sep 2021 15:22:50 +0300 Subject: [PATCH 4/7] build(helm): namespace labeling for tenant owners --- charts/capsule/templates/mutatingwebhookconfiguration.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/charts/capsule/templates/mutatingwebhookconfiguration.yaml b/charts/capsule/templates/mutatingwebhookconfiguration.yaml index 42f4f7bc..76213f0f 100644 --- a/charts/capsule/templates/mutatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/mutatingwebhookconfiguration.yaml @@ -32,6 +32,7 @@ webhooks: - v1 operations: - CREATE + - UPDATE resources: - namespaces scope: '*' From ccf2e51bcaed895e4edb67660f3b0c36b7ab8c54 Mon Sep 17 00:00:00 2001 From: Maksim Fedotov Date: Fri, 17 Sep 2021 15:23:02 +0300 Subject: [PATCH 5/7] docs: namespace labeling for tenant owners --- .../namespace-labels-and-annotations.md | 30 +++++++++++++++++++ docs/operator/use-cases/overview.md | 1 + docs/operator/use-cases/taint-services.md | 4 +-- 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 docs/operator/use-cases/namespace-labels-and-annotations.md diff --git a/docs/operator/use-cases/namespace-labels-and-annotations.md b/docs/operator/use-cases/namespace-labels-and-annotations.md new file mode 100644 index 00000000..36a6434b --- /dev/null +++ b/docs/operator/use-cases/namespace-labels-and-annotations.md @@ -0,0 +1,30 @@ +# Denying user-defined labels or annotations + +By default, capsule allows tenant owners to add and modify any label or annotation on their namespaces. + +But there are some scenarios, when tenant owners should not have an ability to add or modify specific labels or annotations (for example, this can be labels used in [Kubernetes network policies](https://kubernetes.io/docs/concepts/services-networking/network-policies/) which are added by cluster administrator). + +Bill, the cluster admin, can deny Alice to add specific labels and annotations on namespaces: + +```yaml +kubectl apply -f - << EOF +apiVersion: capsule.clastix.io/v1beta1 +kind: Tenant +metadata: + name: oil + annotations: + capsule.clastix.io/forbidden-namespace-labels: foo.acme.net, bar.acme.net + capsule.clastix.io/forbidden-namespace-labels-regexp: .*.acme.net + capsule.clastix.io/forbidden-namespace-annotations: foo.acme.net, bar.acme.net + capsule.clastix.io/forbidden-namespace-annotations-regexp: .*.acme.net +spec: + owners: + - name: alice + kind: User +EOF +``` + +# What’s next +This ends our tour in Capsule use cases. As we improve Capsule, more use cases about multi-tenancy, policy admission control, and cluster governance will be covered in the future. + +Stay tuned! \ No newline at end of file diff --git a/docs/operator/use-cases/overview.md b/docs/operator/use-cases/overview.md index e87dc527..32a2c94a 100644 --- a/docs/operator/use-cases/overview.md +++ b/docs/operator/use-cases/overview.md @@ -40,6 +40,7 @@ Use Capsule to address any of the following scenarios: * [Cordon Tenants](./cordoning-tenant.md) * [Disable Service Types](./service-type.md) * [Taint Services](./taint-services.md) +* [Allow adding labels and annotations on namespaces](./namespace-labels-and-annotations.md) * [Velero Backup Restoration](./velero-backup-restoration.md) > NB: as we improve Capsule, more use cases about multi-tenancy and cluster governance will be covered. diff --git a/docs/operator/use-cases/taint-services.md b/docs/operator/use-cases/taint-services.md index e6936f05..fd427984 100644 --- a/docs/operator/use-cases/taint-services.md +++ b/docs/operator/use-cases/taint-services.md @@ -25,6 +25,4 @@ EOF When Alice creates a service in a namespace, this will inherit the given label and/or annotation. # What’s next -This ends our tour in Capsule use cases. As we improve Capsule, more use cases about multi-tenancy, policy admission control, and cluster governance will be covered in the future. - -Stay tuned! \ No newline at end of file +See how Bill, the cluster admin, can allow Alice to use specific labels or annotations. [Allow adding labels and annotations on namespaces](./namespace-labels-and-annotations.md). From 4e96fc771479eaa94e65ff0dded6098f218ffe36 Mon Sep 17 00:00:00 2001 From: Maksim Fedotov Date: Fri, 17 Sep 2021 15:26:27 +0300 Subject: [PATCH 6/7] build(installer): namespace labeling for tenant owners --- config/install.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/install.yaml b/config/install.yaml index 809610ec..9823b511 100644 --- a/config/install.yaml +++ b/config/install.yaml @@ -1472,6 +1472,7 @@ webhooks: - v1 operations: - CREATE + - UPDATE resources: - namespaces sideEffects: None From 77b31437c63bd492ad23d03b755c547f54e63e98 Mon Sep 17 00:00:00 2001 From: Maksim Fedotov Date: Fri, 17 Sep 2021 15:31:00 +0300 Subject: [PATCH 7/7] feat: namespace labeling for tenant owners. fix linting issues --- controllers/tenant/namespaces.go | 1 - pkg/webhook/namespace/user_metadata.go | 1 - 2 files changed, 2 deletions(-) diff --git a/controllers/tenant/namespaces.go b/controllers/tenant/namespaces.go index 7f0b37a0..ab0fd456 100644 --- a/controllers/tenant/namespaces.go +++ b/controllers/tenant/namespaces.go @@ -1,7 +1,6 @@ // Copyright 2020-2021 Clastix Labs // SPDX-License-Identifier: Apache-2.0 -//nolint:dupl package tenant import ( diff --git a/pkg/webhook/namespace/user_metadata.go b/pkg/webhook/namespace/user_metadata.go index 72235a96..46cf242a 100644 --- a/pkg/webhook/namespace/user_metadata.go +++ b/pkg/webhook/namespace/user_metadata.go @@ -1,7 +1,6 @@ // Copyright 2020-2021 Clastix Labs // SPDX-License-Identifier: Apache-2.0 -//nolint:dupl package namespace import (