From e8ac32d3647531bb2f515c0fc4520a90ddeba32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= <26610571+oliverbaehler@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:09:23 +0200 Subject: [PATCH] feat: add administrator capabilities (#2049) * feat: implement performance optimizations Signed-off-by: Oliver Baehler * feat: add administrator capabilities Signed-off-by: Oliver Baehler * feat: add administrator capabilities Signed-off-by: Oliver Baehler * chore * feat: add administrator capabilities Signed-off-by: Oliver Baehler --------- Signed-off-by: Oliver Baehler --- Makefile | 2 +- cmd/controller/main.go | 1 - e2e/config_administrators_test.go | 14 +- e2e/config_namespace_hijacking_test.go | 130 ++++++++ ...config_namespace_tenant_assignment_test.go | 290 ++++++++++++++++++ ...espace_selecting_tenant_with_label_test.go | 22 +- e2e/utils_test.go | 28 +- internal/controllers/tenant/limitranges.go | 4 + internal/controllers/tenant/namespaces.go | 5 +- .../controllers/tenant/networkpolicies.go | 4 + internal/controllers/tenant/resourcequotas.go | 24 +- internal/controllers/tenant/rolebindings.go | 4 + internal/controllers/tenant/rulestatus.go | 1 + internal/controllers/tenant/utils.go | 120 +++++++- internal/controllers/tenant/utils_test.go | 185 +++++++++++ .../webhook/namespace/mutation/assignment.go | 168 ++++++++-- .../namespace/mutation/assignment_test.go | 264 ++++++++++++++++ internal/webhook/namespace/mutation/guard.go | 121 -------- .../namespace/mutation/handler_test.go | 138 +++++++++ .../webhook/namespace/validation/handler.go | 20 ++ .../rules/generic/validation/metadata.go | 2 +- pkg/tenant/metadata_test.go | 73 +++++ pkg/tenant/metdata.go | 17 + 23 files changed, 1441 insertions(+), 196 deletions(-) create mode 100644 e2e/config_namespace_tenant_assignment_test.go create mode 100644 internal/controllers/tenant/utils_test.go create mode 100644 internal/webhook/namespace/mutation/assignment_test.go delete mode 100644 internal/webhook/namespace/mutation/guard.go create mode 100644 internal/webhook/namespace/mutation/handler_test.go diff --git a/Makefile b/Makefile index 0135fdcf..21466415 100644 --- a/Makefile +++ b/Makefile @@ -243,7 +243,7 @@ dev-setup-argocd: dev-setup-fluxcd @printf " \033[1mkubectl get secret -n argocd argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d\033[0m\n\n" @printf " \033[1mkubectl port-forward svc/argocd-server 9091:80 -n argocd\033[0m\n\n" -dev-setup-cert-manager: +dev-setup-cert-manager: dev-setup-fluxcd @$(KUBECTL) kustomize --load-restrictor='LoadRestrictionsNone' hack/distro/cert-manager | envsubst | kubectl apply -f - dev-setup-fluxcd: diff --git a/cmd/controller/main.go b/cmd/controller/main.go index 5ad6a5bc..9374d367 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -773,7 +773,6 @@ func main() { namespacemutation.MetadataHandler(cfg), // Tenant metadata must be resolved before applying namespace rules. namespacemutation.RulesMetadataHandler(cfg), - namespacemutation.NamespacePatchGuardHandler(cfg), ), ), route.ResourcePoolMutation(resourcepool.PoolMutationHandler(ctrl.Log.WithName("webhooks").WithName("resourcepool"))), diff --git a/e2e/config_administrators_test.go b/e2e/config_administrators_test.go index d4dfd023..21f29fb6 100644 --- a/e2e/config_administrators_test.go +++ b/e2e/config_administrators_test.go @@ -195,19 +195,21 @@ var _ = Describe("Administrators", Ordered, Label("namespace", "permissions", "a By("creating namespace with no label", func() { ns := NewNamespace("") NamespaceCreation(ns, admin, defaultTimeoutInterval).Should(Succeed()) + DeferCleanup(func() { EventuallyDeletion(ns) }) - Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed()) - Expect(len(ns.OwnerReferences)).To(Equal(0)) + ExpectNamespaceNotAssignedToTenant(context.Background(), ns.Name) }) - By("creating namespace with no label", func() { + By("joining an existing unassigned namespace", func() { ns := NewNamespace("") NamespaceCreation(ns, admin, defaultTimeoutInterval).Should(Succeed()) + DeferCleanup(func() { EventuallyDeletion(ns) }) - Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed()) - Expect(len(ns.OwnerReferences)).To(Equal(0)) + ExpectNamespaceNotAssignedToTenant(context.Background(), ns.Name) - PatchTenantLabelForNamespace(tnt1, ns, ownerClient(admin), defaultTimeoutInterval).Should(Succeed()) + Eventually(func() error { + return PatchTenantAssignmentForNamespace(tnt1, ns, ownerClient(admin)) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) NamespaceIsPartOfTenant(tnt1, ns).Should(Succeed()) }) }) diff --git a/e2e/config_namespace_hijacking_test.go b/e2e/config_namespace_hijacking_test.go index a699c994..649f5e4e 100644 --- a/e2e/config_namespace_hijacking_test.go +++ b/e2e/config_namespace_hijacking_test.go @@ -27,6 +27,13 @@ import ( ) var _ = Describe("creating several Namespaces for a Tenant", Ordered, Label("config", "namespace", "hijack"), func() { + administrator := rbac.UserSpec{ + Name: "e2e-namespace-hijacking-admin", + Kind: rbac.UserOwner, + } + + var originalConfigurationSpec *capsulev1beta2.CapsuleConfigurationSpec + t1 := &capsulev1beta2.Tenant{ ObjectMeta: metav1.ObjectMeta{ Name: "e2e-ns-attack-1", @@ -290,6 +297,27 @@ var _ = Describe("creating several Namespaces for a Tenant", Ordered, Label("con }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } + BeforeAll(func() { + configuration := &capsulev1beta2.CapsuleConfiguration{} + Expect(k8sClient.Get( + context.Background(), + types.NamespacedName{Name: defaultConfigurationName}, + configuration, + )).To(Succeed()) + + originalConfigurationSpec = configuration.Spec.DeepCopy() + + ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec.Administrators = append(configuration.Spec.Administrators, administrator) + }) + }) + + AfterAll(func() { + ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec = *originalConfigurationSpec + }) + }) + JustBeforeEach(func() { waitForTenantNamespacesDeletion(t1.Name, t2.Name, t3.Name) @@ -1096,6 +1124,96 @@ var _ = Describe("creating several Namespaces for a Tenant", Ordered, Label("con } }) + It("Administrators can migrate managed namespaces to another Tenant", func() { + tenantA := getTenant(t1.Name) + tenantB := getTenant(t2.Name) + + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tenantA.GetName(), + }) + NamespaceCreation(ns, t1.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(t1, ns).Should(Succeed()) + DeferCleanup(func() { EventuallyDeletion(ns) }) + + ref, err := GetTenantOwnerReferenceAsPatch(tenantB) + Expect(err).NotTo(HaveOccurred()) + + patch := map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + meta.TenantLabel: tenantB.GetName(), + }, + "ownerReferences": []map[string]interface{}{ref}, + }, + } + + Eventually(func() error { + return PatchNamespace(ns, ownerClient(administrator), patch) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + Eventually(func(g Gomega) { + current := getNamespace(ns.Name) + + g.Expect(current.Labels).To(HaveKeyWithValue(meta.TenantLabel, tenantB.GetName())) + g.Expect(tenantOwnerReferences(current)).To(Equal([]string{tenantB.GetName()})) + g.Expect(hasTenantOwnerReference(current, tenantB)).To(BeTrue()) + g.Expect(hasTenantOwnerReference(current, tenantA)).To(BeFalse()) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + NamespaceIsNotPartOfTenant(t1, ns).Should(Succeed()) + NamespaceIsPartOfTenant(t2, ns).Should(Succeed()) + }) + + It("Administrators can remove complete Tenant ownership while partial removal is reverted", func() { + tenant := getTenant(t1.Name) + + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tenant.GetName(), + }) + NamespaceCreation(ns, t1.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(t1, ns).Should(Succeed()) + DeferCleanup(func() { EventuallyDeletion(ns) }) + + adminClient := ownerClient(administrator) + Eventually(func() error { + _, err := adminClient.CoreV1().Namespaces().Patch( + context.TODO(), + ns.Name, + types.MergePatchType, + []byte(`{"metadata":{"annotations":{"e2e.capsule.clastix.io/admin":"true"}}}`), + metav1.PatchOptions{}, + ) + + return err + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + Eventually(func() error { + _, err := adminClient.CoreV1().Namespaces().Patch( + context.TODO(), + ns.Name, + types.MergePatchType, + []byte(fmt.Sprintf(`{"metadata":{"labels":{"%s":null}}}`, meta.TenantLabel)), + metav1.PatchOptions{}, + ) + + return err + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + expectOriginalTenantOwnership(ns.Name, tenant) + + patch := map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + meta.TenantLabel: nil, + }, + "ownerReferences": []interface{}{}, + }, + } + Expect(PatchNamespace(ns, adminClient, patch)).To(Succeed()) + + ExpectNamespaceNotAssignedToTenant(context.TODO(), ns.Name) + NamespaceIsNotPartOfTenant(t1, ns).Should(Succeed()) + }) + It("Owners can not migrate managed namespaces to another Tenant", func() { tenantA := getTenant(t1.Name) tenantB := getTenant(t2.Name) @@ -1157,6 +1275,18 @@ var _ = Describe("creating several Namespaces for a Tenant", Ordered, Label("con Expect(err).ToNot(HaveOccurred()) expectOriginalTenantOwnership(ns.Name, tenant) + + patchRemoveOwnership := map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + meta.TenantLabel: nil, + }, + "ownerReferences": []interface{}{}, + }, + } + + Expect(PatchNamespace(ns, cs, patchRemoveOwnership)).ToNot(Succeed()) + expectOriginalTenantOwnership(ns.Name, tenant) } }) diff --git a/e2e/config_namespace_tenant_assignment_test.go b/e2e/config_namespace_tenant_assignment_test.go new file mode 100644 index 00000000..86d6e44f --- /dev/null +++ b/e2e/config_namespace_tenant_assignment_test.go @@ -0,0 +1,290 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/utils" +) + +var _ = Describe("Capsule administrators changing existing Namespace tenant assignment", Ordered, Serial, + Label("config", "namespace", "administrators", "assignment", "rolebindings"), func() { + administrator := rbac.UserSpec{ + Name: "e2e-namespace-assignment-administrator", + Kind: rbac.UserOwner, + } + unprivilegedAdmin := rbac.UserSpec{ + Name: "admin", + Kind: rbac.UserOwner, + } + ownerA := rbac.OwnerSpec{CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Name: "e2e-namespace-assignment-owner-a", + Kind: rbac.UserOwner, + }, + ClusterRoles: []string{"admin"}, + }} + ownerB := rbac.OwnerSpec{CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Name: "e2e-namespace-assignment-owner-b", + Kind: rbac.UserOwner, + }, + ClusterRoles: []string{"view"}, + }} + + tenantA := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-namespace-assignment-a", + Labels: map[string]string{"env": "e2e"}, + }, + Spec: capsulev1beta2.TenantSpec{Owners: rbac.OwnerListSpec{ownerA}}, + } + tenantB := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-namespace-assignment-b", + Labels: map[string]string{"env": "e2e"}, + }, + Spec: capsulev1beta2.TenantSpec{Owners: rbac.OwnerListSpec{ownerB}}, + } + + bindingA := ownerA.CoreOwnerSpec.ToAdditionalRolebindings()[0] + bindingB := ownerB.CoreOwnerSpec.ToAdditionalRolebindings()[0] + + var originalConfigurationSpec *capsulev1beta2.CapsuleConfigurationSpec + + BeforeAll(func() { + configuration := &capsulev1beta2.CapsuleConfiguration{} + Expect(k8sClient.Get( + context.Background(), + client.ObjectKey{Name: defaultConfigurationName}, + configuration, + )).To(Succeed()) + + originalConfigurationSpec = configuration.Spec.DeepCopy() + + }) + + JustBeforeEach(func() { + ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec.Administrators = rbac.UserListSpec{administrator} + }) + + for _, tnt := range []*capsulev1beta2.Tenant{tenantA, tenantB} { + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + + return k8sClient.Create(context.Background(), tnt) + }).Should(Succeed()) + TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval) + } + }) + + JustAfterEach(func() { + EventuallyDeletion(tenantA) + EventuallyDeletion(tenantB) + + ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec = *originalConfigurationSpec + }) + }) + + newUnassignedNamespace := func() *corev1.Namespace { + ns := NewNamespace("") + NamespaceCreation(ns, administrator, defaultTimeoutInterval).Should(Succeed()) + DeferCleanup(func() { EventuallyDeletion(ns) }) + + ExpectNamespaceNotAssignedToTenant(context.Background(), ns.Name) + + return ns + } + + patchAssignment := func( + ns *corev1.Namespace, + tnt *capsulev1beta2.Tenant, + cs kubernetes.Interface, + ) error { + return PatchTenantAssignmentForNamespace(tnt, ns, cs) + } + + patchDetachment := func(ns *corev1.Namespace, cs kubernetes.Interface) error { + return PatchNamespace(ns, cs, map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + meta.TenantLabel: nil, + }, + "ownerReferences": []interface{}{}, + }, + }) + } + + expectAssignment := func( + ns *corev1.Namespace, + assigned, unassigned *capsulev1beta2.Tenant, + ) { + Eventually(func(g Gomega) { + current := &corev1.Namespace{} + g.Expect(k8sClient.Get( + context.Background(), + types.NamespacedName{Name: ns.Name}, + current, + )).To(Succeed()) + g.Expect(current.Labels).To(HaveKeyWithValue(meta.TenantLabel, assigned.Name)) + g.Expect(tenantOwnerReferences(current)).To(Equal([]string{assigned.Name})) + g.Expect(hasTenantOwnerReferenceByNameAndUID(current, assigned.Name, assigned.UID)).To(BeTrue()) + g.Expect(hasTenantOwnerReferenceByNameAndUID(current, unassigned.Name, unassigned.UID)).To(BeFalse()) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + NamespaceIsPartOfTenant(assigned, ns).Should(Succeed()) + NamespaceIsNotPartOfTenant(unassigned, ns).Should(Succeed()) + } + + expectRoleBindings := func( + ns *corev1.Namespace, + present map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec, + absent ...rbac.AdditionalRoleBindingsSpec, + ) { + Eventually(func(g Gomega) { + for tnt, binding := range present { + name := meta.NameForManagedRoleBindings(utils.RoleBindingHashFunc(binding)) + roleBinding := &rbacv1.RoleBinding{} + err := k8sClient.Get(context.Background(), client.ObjectKey{ + Namespace: ns.Name, + Name: name, + }, roleBinding) + g.Expect(err).NotTo(HaveOccurred(), + "expected managed RoleBinding %s/%s for Tenant %s", ns.Name, name, tnt.Name) + g.Expect(roleBinding.RoleRef).To(Equal(rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: binding.ClusterRoleName, + })) + g.Expect(roleBinding.Subjects).To(ConsistOf(binding.Subjects)) + g.Expect(roleBinding.Labels).To(HaveKeyWithValue(meta.NewTenantLabel, tnt.Name)) + g.Expect(roleBinding.Labels).To(HaveKeyWithValue( + meta.NewManagedByCapsuleLabel, + meta.ValueController, + )) + } + + for _, binding := range absent { + name := meta.NameForManagedRoleBindings(utils.RoleBindingHashFunc(binding)) + roleBinding := &rbacv1.RoleBinding{} + err := k8sClient.Get(context.Background(), client.ObjectKey{ + Namespace: ns.Name, + Name: name, + }, roleBinding) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), + "expected managed RoleBinding %s/%s to be absent, got %v", ns.Name, name, err) + } + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + expectUnassigned := func(ns *corev1.Namespace) { + ExpectNamespaceNotAssignedToTenant(context.Background(), ns.Name) + NamespaceIsNotPartOfTenant(tenantA, ns).Should(Succeed()) + NamespaceIsNotPartOfTenant(tenantB, ns).Should(Succeed()) + } + + It("allows a configured administrator to join, migrate, and unjoin an existing Namespace", func() { + ns := newUnassignedNamespace() + adminClient := ownerClient(administrator) + + By("starting without either Tenant's managed RoleBinding") + expectRoleBindings(ns, nil, bindingA, bindingB) + + By("joining the existing Namespace to the first Tenant") + Eventually(func() error { + return patchAssignment(ns, tenantA, adminClient) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + expectAssignment(ns, tenantA, tenantB) + expectRoleBindings(ns, map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec{ + tenantA: bindingA, + }, bindingB) + + By("migrating the Namespace from the first Tenant to the second Tenant") + Eventually(func() error { + return patchAssignment(ns, tenantB, adminClient) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + expectAssignment(ns, tenantB, tenantA) + expectRoleBindings(ns, map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec{ + tenantB: bindingB, + }, bindingA) + + By("unjoining the Namespace from the second Tenant") + Eventually(func() error { + return patchDetachment(ns, adminClient) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + expectUnassigned(ns) + expectRoleBindings(ns, nil, bindingA, bindingB) + }) + + It("rejects join, migration, and unjoin attempts from other users", func() { + actors := []struct { + name string + user rbac.UserSpec + }{ + {name: "an unconfigured admin user", user: unprivilegedAdmin}, + {name: "a Tenant owner", user: ownerA.UserSpec}, + } + + for _, actor := range actors { + By(fmt.Sprintf("checking all assignment transitions for %s", actor.name)) + ns := newUnassignedNamespace() + actorClient := ownerClient(actor.user) + adminClient := ownerClient(administrator) + + expectRoleBindings(ns, nil, bindingA, bindingB) + + By(fmt.Sprintf("rejecting a join by %s", actor.name)) + Expect(patchAssignment(ns, tenantA, actorClient)).To(HaveOccurred()) + expectUnassigned(ns) + expectRoleBindings(ns, nil, bindingA, bindingB) + + By("preparing an administrator-owned transition baseline") + Eventually(func() error { + return patchAssignment(ns, tenantA, adminClient) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + expectAssignment(ns, tenantA, tenantB) + expectRoleBindings(ns, map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec{ + tenantA: bindingA, + }, bindingB) + + By(fmt.Sprintf("rejecting a migration by %s", actor.name)) + Expect(patchAssignment(ns, tenantB, actorClient)).To(HaveOccurred()) + expectAssignment(ns, tenantA, tenantB) + expectRoleBindings(ns, map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec{ + tenantA: bindingA, + }, bindingB) + + By(fmt.Sprintf("rejecting an unjoin by %s", actor.name)) + Expect(patchDetachment(ns, actorClient)).To(HaveOccurred()) + expectAssignment(ns, tenantA, tenantB) + expectRoleBindings(ns, map[*capsulev1beta2.Tenant]rbac.AdditionalRoleBindingsSpec{ + tenantA: bindingA, + }, bindingB) + + By("detaching with the configured administrator before cleanup") + Eventually(func() error { + return patchDetachment(ns, adminClient) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + expectUnassigned(ns) + expectRoleBindings(ns, nil, bindingA, bindingB) + } + }) + }) diff --git a/e2e/namespace_selecting_tenant_with_label_test.go b/e2e/namespace_selecting_tenant_with_label_test.go index b5f68ad4..0cfc9ba3 100644 --- a/e2e/namespace_selecting_tenant_with_label_test.go +++ b/e2e/namespace_selecting_tenant_with_label_test.go @@ -144,18 +144,20 @@ var _ = Describe("creating a Namespace with Tenant selector when user owns multi patch := map[string]interface{}{ "metadata": map[string]interface{}{ - "labels": map[string]string{}, + "labels": map[string]interface{}{ + meta.TenantLabel: nil, + }, "ownerReferences": []map[string]interface{}{ref}, }, } err = PatchNamespace(ns, ownerClient(t2.Spec.Owners[0].UserSpec), patch) - Expect(err).NotTo(HaveOccurred()) + Expect(err).To(HaveOccurred()) new := &corev1.Namespace{} k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, new) - NamespaceIsPartOfTenant(t1, ns).Should(Succeed()) + NamespaceIsPartOfTenant(t1, new).Should(Succeed()) }) By("assigning to the Namespace the Capsule Tenant label (Empty Ownerreferences)", func() { @@ -169,7 +171,7 @@ var _ = Describe("creating a Namespace with Tenant selector when user owns multi } err := PatchNamespace(ns, ownerClient(t2.Spec.Owners[0].UserSpec), patch) - Expect(err).NotTo(HaveOccurred()) + Expect(err).To(HaveOccurred()) new := &corev1.Namespace{} k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, new) @@ -178,22 +180,22 @@ var _ = Describe("creating a Namespace with Tenant selector when user owns multi }) By("assigning to the Namespace the Capsule Tenant label (Empty Ownerreferences) - Without Label", func() { - ns.Labels = map[string]string{} - patch := map[string]interface{}{ "metadata": map[string]interface{}{ - "labels": map[string]string{}, + "labels": map[string]interface{}{ + meta.TenantLabel: nil, + }, "ownerReferences": []string{}, }, } err := PatchNamespace(ns, ownerClient(t2.Spec.Owners[0].UserSpec), patch) - Expect(err).NotTo(HaveOccurred()) + Expect(err).To(HaveOccurred()) new := &corev1.Namespace{} k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, new) - NamespaceIsPartOfTenant(t1, ns).Should(Succeed()) + NamespaceIsPartOfTenant(t1, new).Should(Succeed()) }) By("assigning to the Namespace the Capsule Tenant label (2nd Tenant Label + Ownerreference)", func() { @@ -210,7 +212,7 @@ var _ = Describe("creating a Namespace with Tenant selector when user owns multi } err = PatchNamespace(ns, ownerClient(t2.Spec.Owners[0].UserSpec), patch) - Expect(err).NotTo(HaveOccurred()) + Expect(err).To(HaveOccurred()) new := &corev1.Namespace{} k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, new) diff --git a/e2e/utils_test.go b/e2e/utils_test.go index 646c7dba..87848611 100644 --- a/e2e/utils_test.go +++ b/e2e/utils_test.go @@ -366,18 +366,24 @@ func GetTenantOwnerReferenceAsPatch( } -func PatchTenantLabelForNamespace(tnt *capsulev1beta2.Tenant, ns *corev1.Namespace, cs kubernetes.Interface, timeout time.Duration) AsyncAssertion { - return Eventually(func() (err error) { - patch := map[string]interface{}{ - "metadata": map[string]interface{}{ - "labels": map[string]interface{}{ - meta.TenantLabel: tnt.GetName(), - }, - }, - } +func PatchTenantAssignmentForNamespace( + tnt *capsulev1beta2.Tenant, + ns *corev1.Namespace, + cs kubernetes.Interface, +) error { + ref, err := GetTenantOwnerReferenceAsPatch(tnt) + if err != nil { + return err + } - return PatchNamespace(ns, cs, patch) - }, timeout, defaultPollInterval) + return PatchNamespace(ns, cs, map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + meta.TenantLabel: tnt.GetName(), + }, + "ownerReferences": []map[string]interface{}{ref}, + }, + }) } func PatchNamespace(ns *corev1.Namespace, cs kubernetes.Interface, patch map[string]interface{}) error { diff --git a/internal/controllers/tenant/limitranges.go b/internal/controllers/tenant/limitranges.go index 75d0a0f5..a956bcfe 100644 --- a/internal/controllers/tenant/limitranges.go +++ b/internal/controllers/tenant/limitranges.go @@ -23,6 +23,10 @@ import ( // func (r *Manager) syncLimitRanges(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) error { + if err := r.runGarbageCollection(ctx, tenant, &corev1.LimitRange{}); err != nil { + return err + } + // getting requested LimitRange keys //nolint:staticcheck keys := make([]string, 0, len(tenant.Spec.LimitRanges.Items)) diff --git a/internal/controllers/tenant/namespaces.go b/internal/controllers/tenant/namespaces.go index bb522eb2..886acfbb 100644 --- a/internal/controllers/tenant/namespaces.go +++ b/internal/controllers/tenant/namespaces.go @@ -37,7 +37,10 @@ func (r *Manager) reconcileNamespaces( return r.reconcileDeletingTenantNamespaces(ctx, log, tnt) } - return r.reconcileActiveTenantNamespaces(ctx, log, tnt) + reconcileErr := r.reconcileActiveTenantNamespaces(ctx, log, tnt) + garbageCollectionErr := r.runGarbageCollection(ctx, tnt, &capsulev1beta2.RuleStatus{}) + + return errors.Join(reconcileErr, garbageCollectionErr) } func (r *Manager) reconcileDeletingTenantNamespaces( diff --git a/internal/controllers/tenant/networkpolicies.go b/internal/controllers/tenant/networkpolicies.go index bb7319a1..9e25862c 100644 --- a/internal/controllers/tenant/networkpolicies.go +++ b/internal/controllers/tenant/networkpolicies.go @@ -24,6 +24,10 @@ import ( // func (r *Manager) syncNetworkPolicies(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) error { + if err := r.runGarbageCollection(ctx, tenant, &networkingv1.NetworkPolicy{}); err != nil { + return err + } + //nolint:staticcheck keys := make([]string, 0, len(tenant.Spec.NetworkPolicies.Items)) diff --git a/internal/controllers/tenant/resourcequotas.go b/internal/controllers/tenant/resourcequotas.go index 2705ec5c..309fb08a 100644 --- a/internal/controllers/tenant/resourcequotas.go +++ b/internal/controllers/tenant/resourcequotas.go @@ -45,8 +45,11 @@ import ( // // In case of Namespace-scoped Resource Budget, we're just replicating the resources across all registered Namespaces. -//nolint:cyclop func (r *Manager) syncResourceQuotas(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) (err error) { //nolint:gocognit + if err := r.runGarbageCollection(ctx, tenant, &corev1.ResourceQuota{}); err != nil { + return err + } + // Remove prior metrics, to avoid cleaning up for metrics of deleted ResourceQuotas r.Metrics.DeleteTenantResourceMetrics(tenant.Name) // Expose the namespace quota and usage as metrics for the tenant @@ -204,22 +207,9 @@ func (r *Manager) syncResourceQuotas(ctx context.Context, log logr.Logger, tenan keys = append(keys, strconv.Itoa(i)) } - group := new(errgroup.Group) - - for _, ns := range tenant.Status.Spaces { - namespace := ns.Name - - cond := ns.Conditions.GetConditionByType(meta.ReadyCondition) - if cond != nil && cond.Reason == meta.TerminatingReason { - continue - } - - group.Go(func() error { - return r.syncResourceQuota(ctx, log, tenant, namespace, keys) - }) - } - - return group.Wait() + return runForTenantNamespaces(ctx, tenant, func(ctx context.Context, namespace string) error { + return r.syncResourceQuota(ctx, log, tenant, namespace, keys) + }) } func (r *Manager) syncResourceQuota(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant, namespace string, keys []string) (err error) { diff --git a/internal/controllers/tenant/rolebindings.go b/internal/controllers/tenant/rolebindings.go index bcdeed3e..73a3e675 100644 --- a/internal/controllers/tenant/rolebindings.go +++ b/internal/controllers/tenant/rolebindings.go @@ -25,6 +25,10 @@ import ( // Sync the dynamic Tenant Owner specific cluster-roles and additional Role Bindings, which can be used in many ways: // applying Pod Security Policies or giving access to CRDs or specific API groups. func (r *Manager) syncRoleBindings(ctx context.Context, log logr.Logger, tenant *capsulev1beta2.Tenant) (err error) { + if err := r.runGarbageCollection(ctx, tenant, &rbacv1.RoleBinding{}); err != nil { + return err + } + namespaceBindings := map[string]map[string]rbac.AdditionalRoleBindingsSpec{} for _, ns := range tenant.Status.Spaces { diff --git a/internal/controllers/tenant/rulestatus.go b/internal/controllers/tenant/rulestatus.go index 2fa782c2..fd8a0624 100644 --- a/internal/controllers/tenant/rulestatus.go +++ b/internal/controllers/tenant/rulestatus.go @@ -61,6 +61,7 @@ func (r *Manager) ensureRuleStatus( } labels[meta.NewManagedByCapsuleLabel] = meta.ValueController + labels[meta.NewTenantLabel] = tnt.Name labels[meta.CapsuleNameLabel] = rule.GetName() rule.SetLabels(labels) diff --git a/internal/controllers/tenant/utils.go b/internal/controllers/tenant/utils.go index 815ad50f..9ea0c85b 100644 --- a/internal/controllers/tenant/utils.go +++ b/internal/controllers/tenant/utils.go @@ -49,11 +49,19 @@ func runForTenantNamespaces( tnt *capsulev1beta2.Tenant, fn func(context.Context, string) error, ) error { - errs := make(chan error, len(tnt.Status.Spaces)) + return runForNamespaces(ctx, readyTenantNamespaces(tnt), fn) +} + +func runForNamespaces( + ctx context.Context, + namespaces []string, + fn func(context.Context, string) error, +) error { + errs := make(chan error, len(namespaces)) group := new(errgroup.Group) group.SetLimit(8) - for _, namespace := range readyTenantNamespaces(tnt) { + for _, namespace := range namespaces { group.Go(func() error { if err := fn(ctx, namespace); err != nil { errs <- fmt.Errorf("namespace %q: %w", namespace, err) @@ -75,6 +83,84 @@ func runForTenantNamespaces( return errors.Join(joined...) } +// runGarbageCollection removes resources managed by a Tenant from live +// namespaces which are no longer assigned to that Tenant. Resources in +// terminating namespaces are left to Kubernetes' namespace garbage collector. +func (r *Manager) runGarbageCollection( + ctx context.Context, + tnt *capsulev1beta2.Tenant, + obj client.Object, +) error { + list, err := managedObjectList(obj) + if err != nil { + return err + } + + selector := labels.SelectorFromSet(labels.Set{ + meta.NewManagedByCapsuleLabel: meta.ValueController, + meta.NewTenantLabel: tnt.Name, + }) + + if err := r.List(ctx, list, &client.ListOptions{LabelSelector: selector}); err != nil { + return err + } + + tenantNamespaces := make(map[string]struct{}, len(tnt.Status.Spaces)) + for _, namespace := range tnt.Status.Spaces { + tenantNamespaces[namespace.Name] = struct{}{} + } + + garbageNamespaces := make(map[string]struct{}) + + for _, namespace := range managedObjectNamespaces(list) { + if _, assigned := tenantNamespaces[namespace]; !assigned { + garbageNamespaces[namespace] = struct{}{} + } + } + + namespaces := make([]string, 0, len(garbageNamespaces)) + for namespace := range garbageNamespaces { + namespaces = append(namespaces, namespace) + } + + reader := r.reader + if reader == nil { + reader = r.Client + } + + return runForNamespaces(ctx, namespaces, func(ctx context.Context, namespace string) error { + ns := &corev1.Namespace{} + if err := reader.Get(ctx, client.ObjectKey{Name: namespace}, ns); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + + return err + } + + if ns.DeletionTimestamp != nil { + return nil + } + + deleteTarget, ok := obj.DeepCopyObject().(client.Object) + if !ok { + return fmt.Errorf("unsupported managed resource type %T", obj) + } + + err := r.DeleteAllOf(ctx, deleteTarget, &client.DeleteAllOfOptions{ + ListOptions: client.ListOptions{ + LabelSelector: selector, + Namespace: namespace, + }, + }) + if apierrors.IsNotFound(err) || apierrors.HasStatusCause(err, corev1.NamespaceTerminatingCause) { + return nil + } + + return err + }) +} + // pruningResources is taking care of removing the no more requested sub-resources as LimitRange, ResourceQuota or // NetworkPolicy using the "exists" and "notin" LabelSelector to perform an outer-join removal. func (r *Manager) pruningResources(ctx context.Context, ns string, keys []string, obj client.Object) (err error) { @@ -159,6 +245,8 @@ func managedObjectList(obj client.Object) (client.ObjectList, error) { return &corev1.ResourceQuotaList{}, nil case *rbacv1.RoleBinding: return &rbacv1.RoleBindingList{}, nil + case *capsulev1beta2.RuleStatus: + return &capsulev1beta2.RuleStatusList{}, nil default: return nil, fmt.Errorf("unsupported managed resource type %T", obj) } @@ -174,7 +262,35 @@ func managedObjectListLength(list client.ObjectList) int { return len(typed.Items) case *rbacv1.RoleBindingList: return len(typed.Items) + case *capsulev1beta2.RuleStatusList: + return len(typed.Items) default: return 0 } } + +func managedObjectNamespaces(list client.ObjectList) []string { + switch typed := list.(type) { + case *networkingv1.NetworkPolicyList: + return namespacesForItems(typed.Items, func(item networkingv1.NetworkPolicy) string { return item.Namespace }) + case *corev1.LimitRangeList: + return namespacesForItems(typed.Items, func(item corev1.LimitRange) string { return item.Namespace }) + case *corev1.ResourceQuotaList: + return namespacesForItems(typed.Items, func(item corev1.ResourceQuota) string { return item.Namespace }) + case *rbacv1.RoleBindingList: + return namespacesForItems(typed.Items, func(item rbacv1.RoleBinding) string { return item.Namespace }) + case *capsulev1beta2.RuleStatusList: + return namespacesForItems(typed.Items, func(item capsulev1beta2.RuleStatus) string { return item.Namespace }) + default: + return nil + } +} + +func namespacesForItems[T any](items []T, namespaceFor func(T) string) []string { + namespaces := make([]string, 0, len(items)) + for _, item := range items { + namespaces = append(namespaces, namespaceFor(item)) + } + + return namespaces +} diff --git a/internal/controllers/tenant/utils_test.go b/internal/controllers/tenant/utils_test.go new file mode 100644 index 00000000..b4096a33 --- /dev/null +++ b/internal/controllers/tenant/utils_test.go @@ -0,0 +1,185 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenant + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" +) + +func TestReadyTenantNamespaces(t *testing.T) { + t.Parallel() + + tenant := &capsulev1beta2.Tenant{ + Status: capsulev1beta2.TenantStatus{ + Spaces: []*capsulev1beta2.TenantStatusNamespaceItem{ + {Name: "without-condition"}, + { + Name: "ready", + Conditions: meta.ConditionList{{ + Type: meta.ReadyCondition, + Status: metav1.ConditionTrue, + }}, + }, + { + Name: "not-ready", + Conditions: meta.ConditionList{{ + Type: meta.ReadyCondition, + Status: metav1.ConditionFalse, + }}, + }, + { + Name: "terminating", + Conditions: meta.ConditionList{{ + Type: meta.TerminatingCondition, + Status: metav1.ConditionTrue, + }}, + }, + }, + }, + } + + got := readyTenantNamespaces(tenant) + if len(got) != 2 || got[0] != "without-condition" || got[1] != "ready" { + t.Fatalf("readyTenantNamespaces() = %v, want [without-condition ready]", got) + } +} + +func TestRunGarbageCollection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + new func(string, string, map[string]string) client.Object + }{ + { + name: "LimitRange", + new: func(name, namespace string, labels map[string]string) client.Object { + return &corev1.LimitRange{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: labels}} + }, + }, + { + name: "NetworkPolicy", + new: func(name, namespace string, labels map[string]string) client.Object { + return &networkingv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: labels}} + }, + }, + { + name: "ResourceQuota", + new: func(name, namespace string, labels map[string]string) client.Object { + return &corev1.ResourceQuota{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: labels}} + }, + }, + { + name: "RoleBinding", + new: func(name, namespace string, labels map[string]string) client.Object { + return &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: labels}} + }, + }, + { + name: "RuleStatus", + new: func(name, namespace string, labels map[string]string) client.Object { + return &capsulev1beta2.RuleStatus{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Labels: labels}} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + if err := networkingv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + if err := rbacv1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + now := metav1.Now() + namespaces := []client.Object{ + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "current"}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "departed"}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "terminating", + DeletionTimestamp: &now, + Finalizers: []string{"test.projectcapsule.dev/finalizer"}, + }}, + } + + managedLabels := map[string]string{ + meta.NewManagedByCapsuleLabel: meta.ValueController, + meta.NewTenantLabel: "green", + } + objects := []client.Object{ + tt.new("managed", "current", managedLabels), + tt.new("managed", "departed", managedLabels), + tt.new("managed", "terminating", managedLabels), + tt.new("unmanaged", "departed", map[string]string{meta.NewTenantLabel: "green"}), + tt.new("other-tenant", "departed", map[string]string{ + meta.NewManagedByCapsuleLabel: meta.ValueController, + meta.NewTenantLabel: "blue", + }), + } + + allObjects := append(namespaces, objects...) + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(allObjects...).Build() + manager := &Manager{Client: cl, reader: cl} + tenant := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{Name: "green"}, + Status: capsulev1beta2.TenantStatus{Spaces: []*capsulev1beta2.TenantStatusNamespaceItem{{ + Name: "current", + Conditions: meta.ConditionList{{ + Type: meta.ReadyCondition, + Status: metav1.ConditionFalse, + }}, + }}}, + } + + if err := manager.runGarbageCollection(context.Background(), tenant, tt.new("", "", nil)); err != nil { + t.Fatal(err) + } + + assertObjectExists(t, cl, tt.new("managed", "current", nil), true) + assertObjectExists(t, cl, tt.new("managed", "departed", nil), false) + assertObjectExists(t, cl, tt.new("managed", "terminating", nil), true) + assertObjectExists(t, cl, tt.new("unmanaged", "departed", nil), true) + assertObjectExists(t, cl, tt.new("other-tenant", "departed", nil), true) + }) + } +} + +func assertObjectExists(t *testing.T, cl client.Client, obj client.Object, want bool) { + t.Helper() + + err := cl.Get(context.Background(), client.ObjectKeyFromObject(obj), obj) + if want && err != nil { + t.Fatalf("expected %T %s to exist: %v", obj, client.ObjectKeyFromObject(obj), err) + } + + if !want && !apierrors.IsNotFound(err) { + t.Fatalf("expected %T %s to be deleted, got: %v", obj, client.ObjectKeyFromObject(obj), err) + } +} diff --git a/internal/webhook/namespace/mutation/assignment.go b/internal/webhook/namespace/mutation/assignment.go index ae0227f7..f747a9e5 100644 --- a/internal/webhook/namespace/mutation/assignment.go +++ b/internal/webhook/namespace/mutation/assignment.go @@ -98,9 +98,9 @@ func (h *ownerReferenceHandler) OnUpdate( recorder events.EventRecorder, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - tnt, err := resolveTenantForNamespaceUpdate(ctx, reader, user, h.cfg, oldNs, newNs) - if err != nil { - return ad.ErroredResponse(err) + tnt, errResponse := h.tenantForUpdate(ctx, reader, user, oldNs, newNs, req, recorder) + if errResponse != nil { + return errResponse } if tnt == nil { @@ -124,6 +124,10 @@ func (h *ownerReferenceHandler) OnUpdate( } labels := newNs.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + tenant.AddNamespaceNameLabels(labels, newNs) tenant.AddTenantNameLabel(labels, tnt) newNs.SetLabels(labels) @@ -132,34 +136,148 @@ func (h *ownerReferenceHandler) OnUpdate( } } -func resolveTenantForNamespaceUpdate( +func (h *ownerReferenceHandler) tenantForUpdate( ctx context.Context, - c client.Reader, + reader client.Reader, user users.AdmissionUser, - cfg configuration.Configuration, oldNs, newNs *corev1.Namespace, -) (*capsulev1beta2.Tenant, error) { - // 1) try old ownerRefs - if tnt, err := tenant.GetTenantByOwnerreferences(ctx, c, oldNs.OwnerReferences); err != nil { - return nil, err - } else if tnt != nil { - return tnt, nil - } - - // 2) try new ownerRefs - if tnt, err := tenant.GetTenantByOwnerreferences(ctx, c, newNs.OwnerReferences); err != nil { - return nil, err - } else if tnt != nil { - return tnt, nil - } - - // 3) Controller/admin is allowed to resolve by label only. + req admission.Request, + recorder events.EventRecorder, +) (*capsulev1beta2.Tenant, *admission.Response) { if user.IsAdmin() { - return tenant.GetTenantByLabels(ctx, c, newNs) + requestedTenant, err := requestedNamespaceTenant(ctx, reader, newNs) + if err != nil { + return nil, denyNamespacePatch(ctx, req, oldNs, recorder, err.Error()) + } + + return requestedTenant, nil } - // 4) fall back to labels + user - return tenant.GetTenantByLabelsAndUser(ctx, c, cfg, newNs, user) + oldTenant, err := tenant.ResolveNamespaceTenant(ctx, reader, oldNs) + if err != nil { + return nil, ad.ErroredResponse(err) + } + + if oldTenant == nil { + requestedTenant, requestedErr := requestedNamespaceTenant(ctx, reader, newNs) + if requestedErr != nil { + return nil, denyNamespacePatch(ctx, req, oldNs, recorder, requestedErr.Error()) + } + + if requestedTenant != nil { + return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not be patched into a tenant") + } + + return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "namespace is not owned by any tenant") + } + + if namespaceReferencesTenant(newNs, oldTenant) { + if !tenant.NamespaceIsOwned(ctx, reader, h.cfg, oldNs, oldTenant, user) { + return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "denied patch request for this namespace") + } + + return oldTenant, nil + } + + requestedTenant, requestedErr := requestedNamespaceTenant(ctx, reader, newNs) + if requestedErr != nil { + return nil, denyNamespacePatch(ctx, req, oldNs, recorder, requestedErr.Error()) + } + + if requestedTenant == nil { + return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not remove tenant ownership") + } + + if oldTenant.GetName() != requestedTenant.GetName() || oldTenant.GetUID() != requestedTenant.GetUID() { + return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not be migrated between tenants") + } + + if !tenant.NamespaceIsOwned(ctx, reader, h.cfg, oldNs, oldTenant, user) { + return nil, denyNamespacePatch(ctx, req, oldNs, recorder, "denied patch request for this namespace") + } + + // Tenant owners may patch managed namespaces, but only administrators can + // change their Tenant assignment. Returning the old Tenant lets this handler + // normalize partial or attempted assignment changes back to the current one. + return oldTenant, nil +} + +func namespaceReferencesTenant(ns *corev1.Namespace, tnt *capsulev1beta2.Tenant) bool { + if tenant.TenanLabelValue(ns) == tnt.GetName() { + return true + } + + for _, ref := range tenant.TenantOwnerReferences(ns) { + if tenant.IsTenantOwnerReferenceForTenant(ref, tnt) { + return true + } + } + + return false +} + +func requestedNamespaceTenant( + ctx context.Context, + reader client.Reader, + ns *corev1.Namespace, +) (*capsulev1beta2.Tenant, error) { + label := tenant.TenanLabelValue(ns) + refs := tenant.TenantOwnerReferences(ns) + + if len(refs) > 1 { + return nil, fmt.Errorf("namespace can not have multiple Tenant ownerReferences") + } + + name := label + if len(refs) == 1 { + if name != "" && name != refs[0].Name { + return nil, fmt.Errorf("namespace label %q does not match owner reference %q", name, refs[0].Name) + } + + name = refs[0].Name + } + + if name == "" { + return nil, nil + } + + tnt := &capsulev1beta2.Tenant{} + if err := reader.Get(ctx, client.ObjectKey{Name: name}, tnt); err != nil { + return nil, err + } + + if len(refs) == 1 && refs[0].UID != "" && refs[0].UID != tnt.GetUID() { + return nil, fmt.Errorf( + "tenant ownerReference UID mismatch for %q: namespace references UID %q but tenant has UID %q", + name, + refs[0].UID, + tnt.GetUID(), + ) + } + + return tnt, nil +} + +func denyNamespacePatch( + ctx context.Context, + req admission.Request, + ns *corev1.Namespace, + recorder events.EventRecorder, + message string, +) *admission.Response { + if ns != nil { + recorder.LabeledEvent( + ns, + corev1.EventTypeWarning, + events.ReasonNamespaceHijack, + events.ActionValidationDenied, + "namespace disallows patching relevant metadata", + ). + WithRequestAnnotations(req). + Emit(ctx) + } + + return ad.Deny(message) } func assignToTenant( diff --git a/internal/webhook/namespace/mutation/assignment_test.go b/internal/webhook/namespace/mutation/assignment_test.go new file mode 100644 index 00000000..3dd91250 --- /dev/null +++ b/internal/webhook/namespace/mutation/assignment_test.go @@ -0,0 +1,264 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package mutation + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + capevents "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/tenant" + "github.com/projectcapsule/capsule/pkg/users" +) + +func TestOwnerReferenceHandlerAllowsAdminTenantMigration(t *testing.T) { + t.Parallel() + + green := testTenant("green", "green-uid") + blue := testTenant("blue", "blue-uid") + oldNs := testTenantNamespace("workloads", green) + newNs := testTenantNamespace("workloads", blue) + c := testClient(t, green, blue) + + response := (&ownerReferenceHandler{}).OnUpdate( + c, + c, + users.AdmissionUser{Type: users.AdmissionUserAdmin}, + newNs, + oldNs, + nil, + nil, + )(context.Background(), admission.Request{}) + + if response != nil { + t.Fatalf("expected migration to proceed, got response %#v", response) + } + assertTenantAssignment(t, newNs, blue) +} + +func TestOwnerReferenceHandlerAllowsAdminTenantDetachment(t *testing.T) { + t.Parallel() + + green := testTenant("green", "green-uid") + oldNs := testTenantNamespace("workloads", green) + newNs := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: oldNs.GetName()}} + c := testClient(t, green) + + response := (&ownerReferenceHandler{}).OnUpdate( + c, + c, + users.AdmissionUser{Type: users.AdmissionUserAdmin}, + newNs, + oldNs, + nil, + nil, + )(context.Background(), admission.Request{}) + + if response != nil { + t.Fatalf("expected detachment to proceed, got response %#v", response) + } + if tenant.HasTenantReference(newNs) { + t.Fatalf("detached namespace still has tenant ownership: %#v", newNs.ObjectMeta) + } +} + +func TestOwnerReferenceHandlerRejectsTenantOwnerAssignmentChanges(t *testing.T) { + t.Parallel() + + owner := rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Name: "alice", Kind: rbac.UserOwner}} + green := testTenant("green", "green-uid") + green.Status.Owners = rbac.OwnerStatusListSpec{owner} + blue := testTenant("blue", "blue-uid") + oldNs := testTenantNamespace("workloads", green) + recorder := capevents.NewEventRecorder(nil, logr.Discard(), nil, nil) + + tests := []struct { + name string + new *corev1.Namespace + }{ + {name: "migration", new: testTenantNamespace("workloads", blue)}, + { + name: "label migration with empty ownerReferences", + new: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "workloads", + Labels: map[string]string{meta.TenantLabel: blue.Name}, + }}, + }, + {name: "detachment", new: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "workloads"}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := testClient(t, green, blue) + newNs := tt.new.DeepCopy() + + response := (&ownerReferenceHandler{}).OnUpdate( + c, + c, + users.AdmissionUser{Type: users.AdmissionUserCapsule, Username: owner.Name}, + newNs, + oldNs.DeepCopy(), + nil, + recorder, + )(context.Background(), admission.Request{}) + + if response == nil || response.Allowed { + t.Fatalf("expected assignment change to be denied, got %#v", response) + } + }) + } +} + +func TestOwnerReferenceHandlerRepairsTenantOwnerReferences(t *testing.T) { + t.Parallel() + + owner := rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Name: "alice", Kind: rbac.UserOwner}} + green := testTenant("green", "green-uid") + green.Status.Owners = rbac.OwnerStatusListSpec{owner} + oldNs := testTenantNamespace("workloads", green) + recorder := capevents.NewEventRecorder(nil, logr.Discard(), nil, nil) + + tests := []struct { + name string + new *corev1.Namespace + }{ + { + name: "missing reference", + new: func() *corev1.Namespace { + ns := oldNs.DeepCopy() + ns.OwnerReferences = nil + + return ns + }(), + }, + { + name: "additional reference", + new: func() *corev1.Namespace { + ns := oldNs.DeepCopy() + ns.OwnerReferences = append(ns.OwnerReferences, metav1.OwnerReference{ + APIVersion: capsulev1beta2.GroupVersion.String(), + Kind: tenant.ObjectReferenceTenantKind, + Name: "blue", + UID: "blue-uid", + }) + + return ns + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := testClient(t, green) + newNs := tt.new.DeepCopy() + + response := (&ownerReferenceHandler{}).OnUpdate( + c, + c, + users.AdmissionUser{Type: users.AdmissionUserCapsule, Username: owner.Name}, + newNs, + oldNs.DeepCopy(), + nil, + recorder, + )(context.Background(), admission.Request{}) + + if response != nil { + t.Fatalf("expected ownerReferences to be normalized, got %#v", response) + } + assertTenantAssignment(t, newNs, green) + }) + } +} + +func TestOwnerReferenceHandlerRejectsNonOwnerJoin(t *testing.T) { + t.Parallel() + + green := testTenant("green", "green-uid") + c := testClient(t, green) + recorder := capevents.NewEventRecorder(nil, logr.Discard(), nil, nil) + + response := (&ownerReferenceHandler{}).OnUpdate( + c, + c, + users.AdmissionUser{Type: users.AdmissionUserCapsule, Username: "alice"}, + testTenantNamespace("workloads", green), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "workloads"}}, + nil, + recorder, + )(context.Background(), admission.Request{}) + + if response == nil || response.Allowed { + t.Fatalf("expected unmanaged namespace join to be denied, got %#v", response) + } +} + +func testTenant(name string, uid types.UID) *capsulev1beta2.Tenant { + return &capsulev1beta2.Tenant{ObjectMeta: metav1.ObjectMeta{Name: name, UID: uid}} +} + +func testTenantNamespace(name string, tnt *capsulev1beta2.Tenant) *corev1.Namespace { + return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{meta.TenantLabel: tnt.GetName()}, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: capsulev1beta2.GroupVersion.String(), + Kind: tenant.ObjectReferenceTenantKind, + Name: tnt.GetName(), + UID: tnt.GetUID(), + }}, + }} +} + +func assertTenantAssignment(t *testing.T, ns *corev1.Namespace, tnt *capsulev1beta2.Tenant) { + t.Helper() + + if got := tenant.TenanLabelValue(ns); got != tnt.GetName() { + t.Fatalf("tenant label = %q, want %q", got, tnt.GetName()) + } + + refs := tenant.TenantOwnerReferences(ns) + if len(refs) != 1 || !tenant.IsTenantOwnerReferenceForTenant(refs[0], tnt) { + t.Fatalf("tenant ownerReferences = %#v, want only tenant %q", refs, tnt.GetName()) + } +} + +func testClient(t *testing.T, tenants ...*capsulev1beta2.Tenant) client.Client { + t.Helper() + + objects := make([]client.Object, 0, len(tenants)) + for _, tnt := range tenants { + objects = append(objects, tnt) + } + + return fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(objects...). + Build() +} + +func testScheme(t *testing.T) *runtime.Scheme { + t.Helper() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("add core API to scheme: %v", err) + } + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("add Capsule API to scheme: %v", err) + } + + return scheme +} diff --git a/internal/webhook/namespace/mutation/guard.go b/internal/webhook/namespace/mutation/guard.go deleted file mode 100644 index 6671cb03..00000000 --- a/internal/webhook/namespace/mutation/guard.go +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright 2020-2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package mutation - -import ( - "context" - - corev1 "k8s.io/api/core/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" - - ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - "github.com/projectcapsule/capsule/pkg/runtime/configuration" - "github.com/projectcapsule/capsule/pkg/runtime/events" - "github.com/projectcapsule/capsule/pkg/runtime/handlers" - "github.com/projectcapsule/capsule/pkg/tenant" - "github.com/projectcapsule/capsule/pkg/users" -) - -type namespacePatchGuardHandler struct { - cfg configuration.Configuration -} - -func NamespacePatchGuardHandler(cfg configuration.Configuration) handlers.TypedHandlerWithUser[*corev1.Namespace] { - return &namespacePatchGuardHandler{cfg: cfg} -} - -func (h *namespacePatchGuardHandler) OnCreate( - client.Client, - client.Reader, - users.AdmissionUser, - *corev1.Namespace, - admission.Decoder, - events.EventRecorder, -) handlers.Func { - return func(context.Context, admission.Request) *admission.Response { - return nil - } -} - -func (h *namespacePatchGuardHandler) OnDelete( - client.Client, - client.Reader, - users.AdmissionUser, - *corev1.Namespace, - admission.Decoder, - events.EventRecorder, -) handlers.Func { - return func(context.Context, admission.Request) *admission.Response { - return nil - } -} - -func (h *namespacePatchGuardHandler) OnUpdate( - _ client.Client, - reader client.Reader, - user users.AdmissionUser, - newNs *corev1.Namespace, - oldNs *corev1.Namespace, - _ admission.Decoder, - recorder events.EventRecorder, -) handlers.Func { - return func(ctx context.Context, req admission.Request) *admission.Response { - if user.IsAdmin() { - return nil - } - - oldTenant, err := tenant.ResolveNamespaceTenant(ctx, reader, oldNs) - if err != nil { - return ad.ErroredResponse(err) - } - - newTenant, err := tenant.ResolveNamespaceTenant(ctx, reader, newNs) - if err != nil { - return ad.ErroredResponse(err) - } - - switch { - case oldTenant == nil && newTenant == nil: - return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace is not owned by any tenant") - - case oldTenant == nil && newTenant != nil: - return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not be patched into a tenant") - - case oldTenant != nil && newTenant == nil: - return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not remove tenant ownership") - - case oldTenant.GetName() != newTenant.GetName() || oldTenant.GetUID() != newTenant.GetUID(): - return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not be migrated between tenants") - } - - if !tenant.NamespaceIsOwned(ctx, reader, h.cfg, oldNs, oldTenant, user) { - return denyNamespacePatch(ctx, req, oldNs, recorder, "denied patch request for this namespace") - } - - return nil - } -} - -func denyNamespacePatch( - ctx context.Context, - req admission.Request, - ns *corev1.Namespace, - recorder events.EventRecorder, - message string, -) *admission.Response { - if ns != nil { - recorder.LabeledEvent( - ns, - corev1.EventTypeWarning, - events.ReasonNamespaceHijack, - events.ActionValidationDenied, - "namespace disallows patching relevant metadata", - ). - WithRequestAnnotations(req). - Emit(ctx) - } - - return ad.Deny(message) -} diff --git a/internal/webhook/namespace/mutation/handler_test.go b/internal/webhook/namespace/mutation/handler_test.go new file mode 100644 index 00000000..c122659f --- /dev/null +++ b/internal/webhook/namespace/mutation/handler_test.go @@ -0,0 +1,138 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package mutation + +import ( + "context" + "encoding/json" + "testing" + + "github.com/go-logr/logr" + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + capevents "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func TestNamespaceHandlerDoesNotInterceptUnlabelledAdministratorCreate(t *testing.T) { + t.Parallel() + + ctx := context.Background() + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + const ( + configurationName = "capsule" + administratorName = "configured-administrator" + ) + + configurationObject := &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: configurationName}, + Spec: capsulev1beta2.CapsuleConfigurationSpec{ + Administrators: rbac.UserListSpec{{ + Name: administratorName, + Kind: rbac.UserOwner, + }}, + }, + } + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(configurationObject). + Build() + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, nil, configurationName) + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "unassigned", + Labels: map[string]string{"example.com/label": "value"}, + }} + raw, err := json.Marshal(ns) + if err != nil { + t.Fatal(err) + } + + response := NamespaceHandler(cfg).OnCreate( + cl, + cl, + admission.NewDecoder(scheme), + nil, + )(ctx, admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Object: runtime.RawExtension{Raw: raw}, + UserInfo: authenticationv1.UserInfo{ + Username: administratorName, + }, + }}) + + if response != nil { + t.Fatalf("expected unlabelled administrator create not to be intercepted, got %#v", response) + } +} + +func TestNamespaceHandlerRejectsTenantOwnerLabelMigrationWithEmptyOwnerReferences(t *testing.T) { + t.Parallel() + + ctx := context.Background() + scheme := testScheme(t) + owner := rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Name: "alice", Kind: rbac.UserOwner}} + green := testTenant("green", "green-uid") + green.Status.Owners = rbac.OwnerStatusListSpec{owner} + blue := testTenant("blue", "blue-uid") + configurationObject := &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, + Status: capsulev1beta2.CapsuleConfigurationStatus{ + Users: rbac.UserListSpec{owner.UserSpec}, + }, + } + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(configurationObject, green, blue). + Build() + cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, nil, configurationObject.Name) + recorder := capevents.NewEventRecorder(nil, logr.Discard(), nil, nil) + + oldNs := testTenantNamespace("workloads", green) + newNs := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: oldNs.Name, + Labels: map[string]string{meta.TenantLabel: blue.Name}, + }} + oldRaw, err := json.Marshal(oldNs) + if err != nil { + t.Fatal(err) + } + newRaw, err := json.Marshal(newNs) + if err != nil { + t.Fatal(err) + } + + response := NamespaceHandler(cfg, OwnerReferenceHandler(cfg)).OnUpdate( + cl, + cl, + admission.NewDecoder(scheme), + recorder, + )(ctx, admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Object: runtime.RawExtension{Raw: newRaw}, + OldObject: runtime.RawExtension{Raw: oldRaw}, + UserInfo: authenticationv1.UserInfo{ + Username: owner.Name, + }, + }}) + + if response == nil || response.Allowed { + t.Fatalf("expected label migration patch to be denied, got %#v", response) + } +} diff --git a/internal/webhook/namespace/validation/handler.go b/internal/webhook/namespace/validation/handler.go index f6351fc1..a23155a0 100644 --- a/internal/webhook/namespace/validation/handler.go +++ b/internal/webhook/namespace/validation/handler.go @@ -122,6 +122,7 @@ func (h *handler) OnDelete( } } +//nolint:cyclop func (h *handler) OnUpdate( c client.Client, reader client.Reader, @@ -142,8 +143,13 @@ func (h *handler) OnUpdate( } oldHasTenantReference := tenant.HasTenantReference(oldNs) + newHasTenantReference := tenant.HasTenantReference(ns) + if user.IsAdmin() && !tenant.HasConsistentTenantReference(ns) { + return ad.Deny("tenant label and ownerReference must both be set consistently or both be absent") + } + if !user.IsAdmin() { switch { case !oldHasTenantReference && newHasTenantReference: @@ -189,6 +195,8 @@ func (h *handler) OnUpdate( } } + adminTenantTransition := user.IsAdmin() && namespaceTenantChanged(oldTenant, newTenant) + tnt := newTenant if !user.IsAdmin() { tnt = oldTenant @@ -202,6 +210,10 @@ func (h *handler) OnUpdate( return terminating } + if adminTenantTransition { + return nil + } + for _, hndl := range h.handlers { if response := hndl.OnUpdate(c, reader, user, ns, oldNs, decoder, recorder, tnt)(ctx, req); response != nil { return response @@ -212,6 +224,14 @@ func (h *handler) OnUpdate( } } +func namespaceTenantChanged(oldTenant, newTenant *capsulev1beta2.Tenant) bool { + if oldTenant == nil || newTenant == nil { + return oldTenant != newTenant + } + + return oldTenant.GetName() != newTenant.GetName() || oldTenant.GetUID() != newTenant.GetUID() +} + func (h *handler) rejectOnTermination( ctx context.Context, c client.Reader, diff --git a/internal/webhook/rules/generic/validation/metadata.go b/internal/webhook/rules/generic/validation/metadata.go index f026f3a6..97bed94f 100644 --- a/internal/webhook/rules/generic/validation/metadata.go +++ b/internal/webhook/rules/generic/validation/metadata.go @@ -243,7 +243,7 @@ func (h *genericRules) controlledMetadataEntries( for key, value := range annotations { matched, err := h.matchesMetadataKey(selector, key) if err != nil { - return nil, fmt.Errorf("invalid label selector %q: %w", selector, err) + return nil, fmt.Errorf("invalid annotation selector %q: %w", selector, err) } if !matched { diff --git a/pkg/tenant/metadata_test.go b/pkg/tenant/metadata_test.go index 38b4b750..e3428307 100644 --- a/pkg/tenant/metadata_test.go +++ b/pkg/tenant/metadata_test.go @@ -8,6 +8,7 @@ import ( "testing" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" @@ -81,6 +82,78 @@ func TestAddTenantNameLabel(t *testing.T) { } } +func TestHasConsistentTenantReference(t *testing.T) { + t.Parallel() + + tenantRef := func(name string) metav1.OwnerReference { + return metav1.OwnerReference{ + APIVersion: capsulev1beta2.GroupVersion.String(), + Kind: tenant.ObjectReferenceTenantKind, + Name: name, + } + } + + tests := []struct { + name string + ns *corev1.Namespace + want bool + }{ + {name: "nil namespace", want: true}, + {name: "unmanaged namespace", ns: &corev1.Namespace{}, want: true}, + { + name: "matching label and owner reference", + ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{meta.TenantLabel: "green"}, + OwnerReferences: []metav1.OwnerReference{tenantRef("green")}, + }}, + want: true, + }, + { + name: "label only", + ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{meta.TenantLabel: "green"}, + }}, + want: false, + }, + { + name: "owner reference only", + ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + OwnerReferences: []metav1.OwnerReference{tenantRef("green")}, + }}, + want: false, + }, + { + name: "mismatched label and owner reference", + ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{meta.TenantLabel: "green"}, + OwnerReferences: []metav1.OwnerReference{tenantRef("blue")}, + }}, + want: false, + }, + { + name: "multiple tenant owner references", + ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{meta.TenantLabel: "green"}, + OwnerReferences: []metav1.OwnerReference{ + tenantRef("green"), + tenantRef("blue"), + }, + }}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tenant.HasConsistentTenantReference(tt.ns); got != tt.want { + t.Fatalf("HasConsistentTenantReference() = %t, want %t", got, tt.want) + } + }) + } +} + func TestBuildInstanceMetadataForNamespace_NoInstance(t *testing.T) { t.Parallel() diff --git a/pkg/tenant/metdata.go b/pkg/tenant/metdata.go index 8f3e1fa3..ccb23373 100644 --- a/pkg/tenant/metdata.go +++ b/pkg/tenant/metdata.go @@ -39,6 +39,23 @@ func HasTenantReference(ns *corev1.Namespace) bool { return false } +// HasConsistentTenantReference reports whether Tenant ownership is either +// completely absent or represented by one matching label and ownerReference. +func HasConsistentTenantReference(ns *corev1.Namespace) bool { + if ns == nil { + return true + } + + label := TenanLabelValue(ns) + refs := TenantOwnerReferences(ns) + + if label == "" { + return len(refs) == 0 + } + + return len(refs) == 1 && refs[0].Name == label +} + func TenantOwnerReferences(ns *corev1.Namespace) []metav1.OwnerReference { if ns == nil { return nil