diff --git a/e2e/teamrolebinding/e2e_test.go b/e2e/teamrolebinding/e2e_test.go new file mode 100644 index 000000000..3aff5fc30 --- /dev/null +++ b/e2e/teamrolebinding/e2e_test.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build teamrolebindingE2E + +package teamrolebinding + +import ( + "context" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + greenhouseapis "github.com/cloudoperators/greenhouse/api" + greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" + "github.com/cloudoperators/greenhouse/e2e/shared" + "github.com/cloudoperators/greenhouse/e2e/teamrolebinding/scenarios" + "github.com/cloudoperators/greenhouse/internal/clientutil" + "github.com/cloudoperators/greenhouse/internal/test" +) + +const ( + remoteClusterName = "remote-trb-cluster" +) + +var ( + env *shared.TestEnv + ctx context.Context + adminClient client.Client + remoteClient client.Client + testStartTime time.Time + + teamAlpha *greenhousev1alpha1.Team + teamBeta *greenhousev1alpha1.Team + teamRole *greenhousev1alpha1.TeamRole + + trb scenarios.ITRBScenario +) + +func TestTeamRoleBindingE2e(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "TeamRoleBinding E2E Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + ctx = context.Background() + + var err error + env = shared.NewExecutionEnv() + adminClient, err = clientutil.NewK8sClientFromRestClientGetter(env.AdminRestClientGetter) + Expect(err).ToNot(HaveOccurred(), "there should be no error creating the admin client") + remoteClient, err = clientutil.NewK8sClientFromRestClientGetter(env.RemoteRestClientGetter) + Expect(err).ToNot(HaveOccurred(), "there should be no error creating the remote client") + + env = env.WithOrganization(ctx, adminClient, "./testdata/organization.yaml") + + By("creating shared Teams") + teamAlpha = test.NewTeam(ctx, "trb-team-alpha", env.TestNamespace, + test.WithMappedIDPGroup("idp-group-trb-alpha"), + test.WithTeamLabel(greenhouseapis.LabelKeySupportGroup, "true"), + ) + err = adminClient.Create(ctx, teamAlpha) + Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred(), "there should be no error creating teamAlpha") + + teamBeta = test.NewTeam(ctx, "trb-team-beta", env.TestNamespace, + test.WithMappedIDPGroup("idp-group-trb-beta"), + test.WithTeamLabel(greenhouseapis.LabelKeySupportGroup, "true"), + ) + err = adminClient.Create(ctx, teamBeta) + Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred(), "there should be no error creating teamBeta") + + By("onboarding the remote cluster") + shared.OnboardRemoteCluster(ctx, adminClient, env.RemoteKubeConfigBytes, remoteClusterName, env.TestNamespace, teamAlpha.Name) + shared.ClusterIsReady(ctx, adminClient, remoteClusterName, env.TestNamespace) + + By("creating shared TeamRole") + teamRole = test.NewTeamRole(ctx, "trb-test-role", env.TestNamespace) + err = adminClient.Create(ctx, teamRole) + Expect(client.IgnoreAlreadyExists(err)).ToNot(HaveOccurred(), "there should be no error creating teamRole") + + By("building scenario runner") + trb = scenarios.NewScenario( + adminClient, remoteClient, + env.TestNamespace, remoteClusterName, + teamAlpha, teamBeta, + teamRole, + ) + + testStartTime = time.Now().UTC() +}) + +var _ = AfterSuite(func() { + By("deleting shared resources") + test.EventuallyDeleted(ctx, adminClient, teamRole) + test.EventuallyDeleted(ctx, adminClient, teamAlpha) + test.EventuallyDeleted(ctx, adminClient, teamBeta) + + By("off-boarding the remote cluster") + shared.OffBoardRemoteCluster(ctx, adminClient, remoteClient, testStartTime, remoteClusterName, env.TestNamespace) + + env.GenerateGreenhouseControllerLogs(ctx, testStartTime) +}) + +var _ = Describe("TeamRoleBinding E2E", Ordered, func() { + DescribeTable("TeamRoleBinding scenarios", + func(execute func(scenarios.ITRBScenario, context.Context)) { + execute(trb, ctx) + }, + Entry("Single teamRef (baseline)", + func(s scenarios.ITRBScenario, c context.Context) { s.ExecuteSingleTeamRefScenario(c) }, + ), + Entry("Multiple teamRefs", + func(s scenarios.ITRBScenario, c context.Context) { s.ExecuteMultipleTeamRefsScenario(c) }, + ), + Entry("Deprecated teamRef migration", + func(s scenarios.ITRBScenario, c context.Context) { s.ExecuteDeprecatedTeamRefMigrationScenario(c) }, + ), + Entry("Mutation of teamRefs (add/remove)", + func(s scenarios.ITRBScenario, c context.Context) { s.ExecuteTeamRefsMutationScenario(c) }, + ), + Entry("Partial failure (some teams missing)", + func(s scenarios.ITRBScenario, c context.Context) { s.ExecutePartialFailureScenario(c) }, + ), + Entry("Namespace creation with multiple teams", + func(s scenarios.ITRBScenario, c context.Context) { s.ExecuteNamespaceCreationScenario(c) }, + ), + Entry("Cluster selector", + func(s scenarios.ITRBScenario, c context.Context) { s.ExecuteClusterSelectorScenario(c) }, + ), + ) +}) diff --git a/e2e/teamrolebinding/scenarios/scenario.go b/e2e/teamrolebinding/scenarios/scenario.go new file mode 100644 index 000000000..a509626bb --- /dev/null +++ b/e2e/teamrolebinding/scenarios/scenario.go @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scenarios + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-runtime/pkg/client" + + greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" + greenhousev1alpha2 "github.com/cloudoperators/greenhouse/api/v1alpha2" + "github.com/cloudoperators/greenhouse/internal/clientutil" + "github.com/cloudoperators/greenhouse/internal/test" +) + +// ITRBScenario defines all executable TeamRoleBinding E2E scenarios. +type ITRBScenario interface { + // ExecuteSingleTeamRefScenario verifies RBAC for a single teamRefs entry (baseline). + ExecuteSingleTeamRefScenario(ctx context.Context) + // ExecuteMultipleTeamRefsScenario verifies RBAC for two teams in teamRefs. + ExecuteMultipleTeamRefsScenario(ctx context.Context) + // ExecuteDeprecatedTeamRefMigrationScenario verifies the webhook migrates teamRef → teamRefs. + ExecuteDeprecatedTeamRefMigrationScenario(ctx context.Context) + // ExecuteTeamRefsMutationScenario verifies subjects update when teamRefs are modified. + ExecuteTeamRefsMutationScenario(ctx context.Context) + // ExecutePartialFailureScenario verifies RBAC resilience when some teams are missing. + ExecutePartialFailureScenario(ctx context.Context) + // ExecuteNamespaceCreationScenario verifies createNamespaces=true with multiple teams. + ExecuteNamespaceCreationScenario(ctx context.Context) + // ExecuteClusterSelectorScenario verifies RBAC is applied only to clusters matching a selector. + ExecuteClusterSelectorScenario(ctx context.Context) +} + +// scenario holds the shared state for all TeamRoleBinding E2E scenarios. +type scenario struct { + adminClient client.Client + remoteClient client.Client + namespace string + clusterName string + teamAlpha *greenhousev1alpha1.Team + teamBeta *greenhousev1alpha1.Team + teamRole *greenhousev1alpha1.TeamRole +} + +// NewScenario constructs an ITRBScenario from the given test context. +func NewScenario( + adminClient, remoteClient client.Client, + namespace, clusterName string, + teamAlpha, teamBeta *greenhousev1alpha1.Team, + teamRole *greenhousev1alpha1.TeamRole, +) ITRBScenario { + + GinkgoHelper() + return &scenario{ + adminClient: adminClient, + remoteClient: remoteClient, + namespace: namespace, + clusterName: clusterName, + teamAlpha: teamAlpha, + teamBeta: teamBeta, + teamRole: teamRole, + } +} + +// createTRB is a helper that creates or patches a TeamRoleBinding and returns it. +func (s *scenario) createTRB(ctx context.Context, name string, opts ...func(*greenhousev1alpha2.TeamRoleBinding)) *greenhousev1alpha2.TeamRoleBinding { + GinkgoHelper() + trb := test.NewTeamRoleBinding(ctx, name, s.namespace) + _, err := clientutil.CreateOrPatch(ctx, s.adminClient, trb, func() error { + for _, opt := range opts { + opt(trb) + } + return nil + }) + Expect(err).ToNot(HaveOccurred(), "there should be no error creating or patching %T %s", trb, trb.GetName()) + return trb +} + +// cleanup deletes a TeamRoleBinding if it is non-nil, waiting for it to disappear. +func (s *scenario) cleanup(ctx context.Context, trb *greenhousev1alpha2.TeamRoleBinding) { + GinkgoHelper() + test.EventuallyDeleted(ctx, s.adminClient, trb) +} diff --git a/e2e/teamrolebinding/scenarios/trb_cluster_selector.go b/e2e/teamrolebinding/scenarios/trb_cluster_selector.go new file mode 100644 index 000000000..524f99cf7 --- /dev/null +++ b/e2e/teamrolebinding/scenarios/trb_cluster_selector.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scenarios + +import ( + "context" + "slices" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + 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/util/rand" + "sigs.k8s.io/controller-runtime/pkg/client" + + greenhousev1alpha1 "github.com/cloudoperators/greenhouse/api/v1alpha1" + greenhousev1alpha2 "github.com/cloudoperators/greenhouse/api/v1alpha2" + "github.com/cloudoperators/greenhouse/internal/clientutil" + "github.com/cloudoperators/greenhouse/internal/test" +) + +// ExecuteClusterSelectorScenario verifies that a TeamRoleBinding with a label-based +// clusterSelector applies RBAC only to clusters whose labels match the selector, and that RBAC +// is removed from a cluster when it no longer matches (label change). +func (s *scenario) ExecuteClusterSelectorScenario(ctx context.Context) { + GinkgoHelper() + var trb *greenhousev1alpha2.TeamRoleBinding + + // A unique label key used only by this test run to avoid interference. + labelKey := "trb-selector-test-" + rand.String(6) + const labelMatch = "match" + const labelNoMatch = "no-match" + + By("adding a unique label to the remote cluster for the selector test") + cluster := &greenhousev1alpha1.Cluster{} + Expect(s.adminClient.Get(ctx, client.ObjectKey{Name: s.clusterName, Namespace: s.namespace}, cluster)). + To(Succeed(), "remote cluster should exist") + + _, err := clientutil.CreateOrPatch(ctx, s.adminClient, cluster, func() error { + if cluster.Labels == nil { + cluster.Labels = make(map[string]string) + } + cluster.Labels[labelKey] = labelMatch + return nil + }) + Expect(err).ToNot(HaveOccurred(), "there should be no error labeling the cluster") + + // Restore the cluster label on cleanup regardless of test outcome. + DeferCleanup(func() { + _, err := clientutil.CreateOrPatch(ctx, s.adminClient, cluster, func() error { + delete(cluster.Labels, labelKey) + return nil + }) + Expect(err).ToNot(HaveOccurred(), "there should be no error restoring the cluster label during cleanup") + s.cleanup(ctx, trb) + }) + + By("creating a TeamRoleBinding with a label selector matching the remote cluster") + trb = s.createTRB(ctx, "trb-selector", + test.WithTeamRoleRef(s.teamRole.Name), + test.WithTeamRefs(s.teamAlpha.Name, s.teamBeta.Name), + test.WithClusterSelector(metav1.LabelSelector{ + MatchLabels: map[string]string{labelKey: labelMatch}, + }), + ) + + By("verifying the ClusterRoleBinding is created on the matching remote cluster with subjects for both teams") + remoteCRB := &rbacv1.ClusterRoleBinding{} + Eventually(func(g Gomega) { + g.Expect(s.remoteClient.Get(ctx, client.ObjectKey{Name: trb.GetRBACName()}, remoteCRB)). + To(Succeed(), "ClusterRoleBinding should be created on the matching cluster") + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamAlpha.Spec.MappedIDPGroup + })).To(BeTrue(), "subjects should contain teamAlpha's IDP group") + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamBeta.Spec.MappedIDPGroup + })).To(BeTrue(), "subjects should contain teamBeta's IDP group") + }).Should(Succeed(), "ClusterRoleBinding should exist on the matching cluster with subjects for both teams") + + By("verifying the TeamRoleBinding PropagationStatus references only the matching cluster") + Eventually(func(g Gomega) { + g.Expect(s.adminClient.Get(ctx, client.ObjectKeyFromObject(trb), trb)).To(Succeed()) + g.Expect(trb.Status.PropagationStatus).To(HaveLen(1), + "only one cluster should appear in PropagationStatus") + g.Expect(trb.Status.PropagationStatus[0].ClusterName).To(Equal(s.clusterName)) + g.Expect(trb.Status.PropagationStatus[0].Condition.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed(), "PropagationStatus should only show the matching cluster") + + By("changing the cluster label so it no longer matches the selector") + _, err = clientutil.CreateOrPatch(ctx, s.adminClient, cluster, func() error { + cluster.Labels[labelKey] = labelNoMatch + return nil + }) + Expect(err).ToNot(HaveOccurred(), "there should be no error updating cluster label") + + By("verifying the ClusterRoleBinding is removed after the cluster label no longer matches") + Eventually(func(g Gomega) { + err := s.remoteClient.Get(ctx, client.ObjectKey{Name: trb.GetRBACName()}, remoteCRB) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue(), + "ClusterRoleBinding should be removed after cluster label no longer matches the selector") + }).Should(Succeed(), "ClusterRoleBinding should be cleaned up when cluster no longer matches selector") +} diff --git a/e2e/teamrolebinding/scenarios/trb_deprecated_migration.go b/e2e/teamrolebinding/scenarios/trb_deprecated_migration.go new file mode 100644 index 000000000..6bbc019b6 --- /dev/null +++ b/e2e/teamrolebinding/scenarios/trb_deprecated_migration.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scenarios + +import ( + "context" + "slices" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + greenhousev1alpha2 "github.com/cloudoperators/greenhouse/api/v1alpha2" + "github.com/cloudoperators/greenhouse/internal/test" +) + +// ExecuteDeprecatedTeamRefMigrationScenario verifies that the mutating webhook migrates the +// deprecated singular teamRef field into teamRefs, and that RBAC is applied correctly. +func (s *scenario) ExecuteDeprecatedTeamRefMigrationScenario(ctx context.Context) { + GinkgoHelper() + var trb *greenhousev1alpha2.TeamRoleBinding + DeferCleanup(func() { s.cleanup(ctx, trb) }) + + By("creating a TeamRoleBinding using the deprecated teamRef field") + trb = s.createTRB(ctx, "trb-deprecated", + test.WithTeamRoleRef(s.teamRole.Name), + test.WithTeamRef(s.teamAlpha.Name), + test.WithClusterName(s.clusterName), + ) + + By("verifying the webhook migrated teamRef into teamRefs") + Eventually(func(g Gomega) { + g.Expect(s.adminClient.Get(ctx, client.ObjectKeyFromObject(trb), trb)).To(Succeed()) + g.Expect(trb.Spec.TeamRefs).To(ContainElement(s.teamAlpha.Name), + "teamRefs should contain the migrated teamRef value") + }).Should(Succeed(), "teamRef should be migrated into teamRefs by the webhook") + + By("verifying the ClusterRoleBinding is created on the remote cluster with the correct subject") + remoteCRB := &rbacv1.ClusterRoleBinding{} + Eventually(func(g Gomega) { + g.Expect(s.remoteClient.Get(ctx, client.ObjectKey{Name: trb.GetRBACName()}, remoteCRB)). + To(Succeed(), "ClusterRoleBinding should exist on the remote cluster") + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamAlpha.Spec.MappedIDPGroup + })).To(BeTrue(), "subjects should contain teamAlpha's IDP group after migration") + }).Should(Succeed(), "ClusterRoleBinding should be created with the correct subject after teamRef migration") + + By("verifying the TeamRoleBinding RBACReady status is True") + Eventually(func(g Gomega) { + g.Expect(s.adminClient.Get(ctx, client.ObjectKeyFromObject(trb), trb)).To(Succeed()) + cond := trb.Status.GetConditionByType(greenhousev1alpha2.RBACReady) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed(), "RBACReady should be True after deprecated teamRef migration") +} diff --git a/e2e/teamrolebinding/scenarios/trb_multi_teams.go b/e2e/teamrolebinding/scenarios/trb_multi_teams.go new file mode 100644 index 000000000..bfa6455ad --- /dev/null +++ b/e2e/teamrolebinding/scenarios/trb_multi_teams.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scenarios + +import ( + "context" + "slices" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + greenhousev1alpha2 "github.com/cloudoperators/greenhouse/api/v1alpha2" + "github.com/cloudoperators/greenhouse/internal/test" +) + +// ExecuteMultipleTeamRefsScenario verifies that a TeamRoleBinding with multiple entries in +// teamRefs creates a ClusterRoleBinding whose subjects include all referenced teams' IDP groups. +func (s *scenario) ExecuteMultipleTeamRefsScenario(ctx context.Context) { + GinkgoHelper() + var trb *greenhousev1alpha2.TeamRoleBinding + DeferCleanup(func() { s.cleanup(ctx, trb) }) + + By("creating a TeamRoleBinding referencing two teams") + trb = s.createTRB(ctx, "trb-multi", + test.WithTeamRoleRef(s.teamRole.Name), + test.WithTeamRefs(s.teamAlpha.Name, s.teamBeta.Name), + test.WithClusterName(s.clusterName), + ) + + By("verifying the ClusterRoleBinding is created on the remote cluster with subjects for both teams") + remoteCRB := &rbacv1.ClusterRoleBinding{} + Eventually(func(g Gomega) { + g.Expect(s.remoteClient.Get(ctx, client.ObjectKey{Name: trb.GetRBACName()}, remoteCRB)). + To(Succeed(), "ClusterRoleBinding should exist on the remote cluster") + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamAlpha.Spec.MappedIDPGroup + })).To(BeTrue(), "subjects should contain teamAlpha's IDP group") + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamBeta.Spec.MappedIDPGroup + })).To(BeTrue(), "subjects should contain teamBeta's IDP group") + }).Should(Succeed(), "ClusterRoleBinding should be created with subjects for both teams") + + By("verifying the TeamRoleBinding RBACReady status is True") + Eventually(func(g Gomega) { + g.Expect(s.adminClient.Get(ctx, client.ObjectKeyFromObject(trb), trb)).To(Succeed()) + cond := trb.Status.GetConditionByType(greenhousev1alpha2.RBACReady) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed(), "RBACReady should be True") +} diff --git a/e2e/teamrolebinding/scenarios/trb_mutation.go b/e2e/teamrolebinding/scenarios/trb_mutation.go new file mode 100644 index 000000000..d79e67f8b --- /dev/null +++ b/e2e/teamrolebinding/scenarios/trb_mutation.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scenarios + +import ( + "context" + "slices" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + greenhousev1alpha2 "github.com/cloudoperators/greenhouse/api/v1alpha2" + "github.com/cloudoperators/greenhouse/internal/clientutil" + "github.com/cloudoperators/greenhouse/internal/test" +) + +// ExecuteTeamRefsMutationScenario verifies that updating teamRefs on an existing TeamRoleBinding +// causes the ClusterRoleBinding subjects on the remote cluster to be updated accordingly. +func (s *scenario) ExecuteTeamRefsMutationScenario(ctx context.Context) { + GinkgoHelper() + var trb *greenhousev1alpha2.TeamRoleBinding + DeferCleanup(func() { s.cleanup(ctx, trb) }) + + By("creating a TeamRoleBinding with a single team") + trb = s.createTRB(ctx, "trb-mutate", + test.WithTeamRoleRef(s.teamRole.Name), + test.WithTeamRefs(s.teamAlpha.Name), + test.WithClusterName(s.clusterName), + ) + + remoteCRB := &rbacv1.ClusterRoleBinding{} + By("verifying the initial ClusterRoleBinding has only teamAlpha subjects") + Eventually(func(g Gomega) { + g.Expect(s.remoteClient.Get(ctx, client.ObjectKey{Name: trb.GetRBACName()}, remoteCRB)).To(Succeed()) + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamAlpha.Spec.MappedIDPGroup + })).To(BeTrue(), "initially teamAlpha's IDP group should be in subjects") + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamBeta.Spec.MappedIDPGroup + })).To(BeFalse(), "teamBeta's IDP group should not yet be in subjects") + }).Should(Succeed(), "initial subjects should only contain teamAlpha") + + By("adding teamBeta to teamRefs") + _, err := clientutil.CreateOrPatch(ctx, s.adminClient, trb, func() error { + trb.Spec.TeamRefs = []string{s.teamAlpha.Name, s.teamBeta.Name} + return nil + }) + Expect(err).ToNot(HaveOccurred(), "there should be no error adding teamBeta to teamRefs") + + By("verifying ClusterRoleBinding subjects now include both teams") + Eventually(func(g Gomega) { + g.Expect(s.remoteClient.Get(ctx, client.ObjectKey{Name: trb.GetRBACName()}, remoteCRB)).To(Succeed()) + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamAlpha.Spec.MappedIDPGroup + })).To(BeTrue(), "teamAlpha's IDP group should remain in subjects") + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamBeta.Spec.MappedIDPGroup + })).To(BeTrue(), "teamBeta's IDP group should now appear in subjects") + }).Should(Succeed(), "subjects should contain both teams after adding teamBeta") + + By("removing teamAlpha from teamRefs") + _, err = clientutil.CreateOrPatch(ctx, s.adminClient, trb, func() error { + trb.Spec.TeamRefs = []string{s.teamBeta.Name} + return nil + }) + Expect(err).ToNot(HaveOccurred(), "there should be no error removing teamAlpha from teamRefs") + + By("verifying ClusterRoleBinding subjects now only contain teamBeta") + Eventually(func(g Gomega) { + g.Expect(s.remoteClient.Get(ctx, client.ObjectKey{Name: trb.GetRBACName()}, remoteCRB)).To(Succeed()) + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamAlpha.Spec.MappedIDPGroup + })).To(BeFalse(), "teamAlpha's IDP group should have been removed from subjects") + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamBeta.Spec.MappedIDPGroup + })).To(BeTrue(), "teamBeta's IDP group should remain in subjects") + }).Should(Succeed(), "subjects should only contain teamBeta after removing teamAlpha") + + By("verifying TeamRoleBinding status remains ready after mutation") + Eventually(func(g Gomega) { + g.Expect(s.adminClient.Get(ctx, client.ObjectKeyFromObject(trb), trb)).To(Succeed()) + cond := trb.Status.GetConditionByType(greenhousev1alpha2.RBACReady) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed(), "RBACReady should stay True after mutation") +} diff --git a/e2e/teamrolebinding/scenarios/trb_namespace_creation.go b/e2e/teamrolebinding/scenarios/trb_namespace_creation.go new file mode 100644 index 000000000..ae6755b8b --- /dev/null +++ b/e2e/teamrolebinding/scenarios/trb_namespace_creation.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scenarios + +import ( + "context" + "slices" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" + "sigs.k8s.io/controller-runtime/pkg/client" + + greenhousev1alpha2 "github.com/cloudoperators/greenhouse/api/v1alpha2" + "github.com/cloudoperators/greenhouse/internal/test" +) + +// ExecuteNamespaceCreationScenario verifies that, when createNamespaces=true is set on a +// TeamRoleBinding that references multiple teams, namespaces are created on the remote cluster +// and each namespace receives a RoleBinding whose subjects include all teams' IDP groups. +func (s *scenario) ExecuteNamespaceCreationScenario(ctx context.Context) { + GinkgoHelper() + + // Use unique namespace names per run so that leftover resources from a previous + // failed run on a shared/long-lived cluster do not interfere. + suffix := rand.String(6) + nsOne := "trb-e2e-ns-one-" + suffix + nsTwo := "trb-e2e-ns-two-" + suffix + + var trb *greenhousev1alpha2.TeamRoleBinding + DeferCleanup(func() { + // Delete the TRB first – this removes the RoleBindings but not the namespaces. + s.cleanup(ctx, trb) + // Explicitly remove the namespaces the TRB created on the remote cluster. + nsOneObj := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsOne}} + test.EventuallyDeleted(ctx, s.remoteClient, nsOneObj) + nsTwoObj := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsTwo}} + test.EventuallyDeleted(ctx, s.remoteClient, nsTwoObj) + }) + + By("creating a TeamRoleBinding with createNamespaces=true referencing two teams") + trb = s.createTRB(ctx, "trb-ns-create", + test.WithTeamRoleRef(s.teamRole.Name), + test.WithTeamRefs(s.teamAlpha.Name, s.teamBeta.Name), + test.WithClusterName(s.clusterName), + test.WithNamespaces(nsOne, nsTwo), + test.WithCreateNamespace(true), + ) + + By("verifying RoleBinding in namespace one is created on the remote cluster with subjects for both teams") + rb1 := &rbacv1.RoleBinding{} + Eventually(func(g Gomega) { + g.Expect(s.remoteClient.Get(ctx, + types.NamespacedName{Name: trb.GetRBACName(), Namespace: nsOne}, + rb1)).To(Succeed(), "RoleBinding in nsOne should exist on remote cluster") + g.Expect(slices.ContainsFunc(rb1.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamAlpha.Spec.MappedIDPGroup + })).To(BeTrue(), "RoleBinding in nsOne should contain teamAlpha's IDP group") + g.Expect(slices.ContainsFunc(rb1.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamBeta.Spec.MappedIDPGroup + })).To(BeTrue(), "RoleBinding in nsOne should contain teamBeta's IDP group") + }).Should(Succeed(), "RoleBinding should be created in nsOne with subjects for both teams") + + By("verifying RoleBinding in namespace two is created on the remote cluster with subjects for both teams") + rb2 := &rbacv1.RoleBinding{} + Eventually(func(g Gomega) { + g.Expect(s.remoteClient.Get(ctx, + types.NamespacedName{Name: trb.GetRBACName(), Namespace: nsTwo}, + rb2)).To(Succeed(), "RoleBinding in nsTwo should exist on remote cluster") + g.Expect(slices.ContainsFunc(rb2.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamAlpha.Spec.MappedIDPGroup + })).To(BeTrue(), "RoleBinding in nsTwo should contain teamAlpha's IDP group") + g.Expect(slices.ContainsFunc(rb2.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamBeta.Spec.MappedIDPGroup + })).To(BeTrue(), "RoleBinding in nsTwo should contain teamBeta's IDP group") + }).Should(Succeed(), "RoleBinding should be created in nsTwo with subjects for both teams") + + By("verifying the TeamRoleBinding RBACReady status is True") + Eventually(func(g Gomega) { + g.Expect(s.adminClient.Get(ctx, client.ObjectKeyFromObject(trb), trb)).To(Succeed()) + cond := trb.Status.GetConditionByType(greenhousev1alpha2.RBACReady) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed(), "RBACReady should be True after namespace creation with multiple teams") +} diff --git a/e2e/teamrolebinding/scenarios/trb_partial_failure.go b/e2e/teamrolebinding/scenarios/trb_partial_failure.go new file mode 100644 index 000000000..36fc01e3f --- /dev/null +++ b/e2e/teamrolebinding/scenarios/trb_partial_failure.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scenarios + +import ( + "context" + "slices" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + 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/util/rand" + "sigs.k8s.io/controller-runtime/pkg/client" + + greenhousev1alpha2 "github.com/cloudoperators/greenhouse/api/v1alpha2" + "github.com/cloudoperators/greenhouse/internal/test" +) + +// ExecutePartialFailureScenario covers two sub-cases: +// 1. One valid team + one missing team → RBAC is applied for the valid team and the status +// message mentions the missing team. +// 2. All referenced teams missing → RBACReady=False with TeamNotFound reason. +func (s *scenario) ExecutePartialFailureScenario(ctx context.Context) { + GinkgoHelper() + + // Unique suffix per run to avoid collisions with leftover resources. + suffix := rand.String(6) + + // ── Sub-case 1: one valid, one non-existent ────────────────────────────── + By("Sub-case 1: one valid team and one non-existent team") + var trbPartial *greenhousev1alpha2.TeamRoleBinding + DeferCleanup(func() { s.cleanup(ctx, trbPartial) }) + + trbPartial = s.createTRB(ctx, "trb-partial-"+suffix, + test.WithTeamRoleRef(s.teamRole.Name), + test.WithTeamRefs(s.teamAlpha.Name, "non-existent-team"), + test.WithClusterName(s.clusterName), + ) + + By("verifying the ClusterRoleBinding is still created for the valid team with the correct subject") + remoteCRB := &rbacv1.ClusterRoleBinding{} + Eventually(func(g Gomega) { + g.Expect(s.remoteClient.Get(ctx, client.ObjectKey{Name: trbPartial.GetRBACName()}, remoteCRB)). + To(Succeed(), "ClusterRoleBinding should be created despite the missing team") + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamAlpha.Spec.MappedIDPGroup + })).To(BeTrue(), "subjects should contain teamAlpha's IDP group") + }).Should(Succeed(), "ClusterRoleBinding should exist with the valid team's subject") + + By("verifying the status reports the partial failure for the missing team") + Eventually(func(g Gomega) { + g.Expect(s.adminClient.Get(ctx, client.ObjectKeyFromObject(trbPartial), trbPartial)).To(Succeed()) + cond := trbPartial.Status.GetConditionByType(greenhousev1alpha2.RBACReady) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(cond.Message).To(ContainSubstring("non-existent-team"), + "status message should mention the missing team") + }).Should(Succeed(), "status should reflect partial success with missing team information") + + // ── Sub-case 2: all teams missing ──────────────────────────────────────── + By("Sub-case 2: all referenced teams are missing") + var trbAllMissing *greenhousev1alpha2.TeamRoleBinding + DeferCleanup(func() { s.cleanup(ctx, trbAllMissing) }) + + trbAllMissing = s.createTRB(ctx, "trb-all-missing-"+suffix, + test.WithTeamRoleRef(s.teamRole.Name), + test.WithTeamRefs("ghost-team-1", "ghost-team-2"), + test.WithClusterName(s.clusterName), + ) + + By("verifying RBACReady=False with TeamNotFound reason") + Eventually(func(g Gomega) { + g.Expect(s.adminClient.Get(ctx, client.ObjectKeyFromObject(trbAllMissing), trbAllMissing)).To(Succeed()) + cond := trbAllMissing.Status.GetConditionByType(greenhousev1alpha2.RBACReady) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(greenhousev1alpha2.TeamNotFound)) + }).Should(Succeed(), "RBACReady should be False when all referenced teams are missing") + + By("verifying no ClusterRoleBinding is created on the remote cluster") + missingCRB := &rbacv1.ClusterRoleBinding{} + Consistently(func() bool { + return apierrors.IsNotFound( + s.remoteClient.Get(ctx, client.ObjectKey{Name: trbAllMissing.GetRBACName()}, missingCRB), + ) + }, "5s", "200ms").Should(BeTrue(), "ClusterRoleBinding should not be created when all teams are missing") +} diff --git a/e2e/teamrolebinding/scenarios/trb_single_team.go b/e2e/teamrolebinding/scenarios/trb_single_team.go new file mode 100644 index 000000000..9e4eca003 --- /dev/null +++ b/e2e/teamrolebinding/scenarios/trb_single_team.go @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package scenarios + +import ( + "context" + "slices" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + greenhousev1alpha2 "github.com/cloudoperators/greenhouse/api/v1alpha2" + "github.com/cloudoperators/greenhouse/internal/test" +) + +// ExecuteSingleTeamRefScenario verifies that a TeamRoleBinding with a single entry in +// teamRefs creates a ClusterRoleBinding whose subjects include that team's IDP group. +func (s *scenario) ExecuteSingleTeamRefScenario(ctx context.Context) { + GinkgoHelper() + var trb *greenhousev1alpha2.TeamRoleBinding + DeferCleanup(func() { s.cleanup(ctx, trb) }) + + By("creating a TeamRoleBinding with a single team") + trb = s.createTRB(ctx, "trb-single", + test.WithTeamRoleRef(s.teamRole.Name), + test.WithTeamRefs(s.teamAlpha.Name), + test.WithClusterName(s.clusterName), + ) + + By("verifying the ClusterRoleBinding is created on the remote cluster with the correct subject") + remoteCRB := &rbacv1.ClusterRoleBinding{} + Eventually(func(g Gomega) { + g.Expect(s.remoteClient.Get(ctx, client.ObjectKey{Name: trb.GetRBACName()}, remoteCRB)). + To(Succeed(), "ClusterRoleBinding should exist on the remote cluster") + g.Expect(slices.ContainsFunc(remoteCRB.Subjects, func(sub rbacv1.Subject) bool { + return sub.Kind == rbacv1.GroupKind && sub.Name == s.teamAlpha.Spec.MappedIDPGroup + })).To(BeTrue(), "subjects should contain teamAlpha's IDP group") + }).Should(Succeed(), "ClusterRoleBinding should be created with the correct subject") + + By("verifying the TeamRoleBinding RBACReady status is True") + Eventually(func(g Gomega) { + g.Expect(s.adminClient.Get(ctx, client.ObjectKeyFromObject(trb), trb)).To(Succeed()) + cond := trb.Status.GetConditionByType(greenhousev1alpha2.RBACReady) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed(), "RBACReady should be True") +} diff --git a/e2e/teamrolebinding/testdata/organization.yaml b/e2e/teamrolebinding/testdata/organization.yaml new file mode 100644 index 000000000..1f6ca8afd --- /dev/null +++ b/e2e/teamrolebinding/testdata/organization.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors +# SPDX-License-Identifier: Apache-2.0 + +# Cluster Scoped Resource +apiVersion: greenhouse.sap/v1alpha1 +kind: Organization +metadata: + name: teamrolebinding-e2e +spec: + description: e2e organization for teamrolebinding + displayName: TeamRoleBindingE2E + mappedOrgAdminIdPGroup: TEST_ORG_ADMIN \ No newline at end of file