Files
open-cluster-management/test/integration/placement/debugger_test.go
T
3da28e4bf7 🐛 fix(placement): correct debug permission checks and reject bad methods (#1623)
Map GET/POST debug requests to get/create SAR verbs and return 403 on
denial. Reject unsupported HTTP methods with 405 instead of treating
them as GET. Update the GET RBAC integration test Role to grant get.

Also free unused disk on e2e runners before image builds to reduce
"no space left on device" flakes.

Fixes open-cluster-management-io/ocm#1622

Signed-off-by: Roke Jung <roke@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 07:27:40 +00:00

389 lines
15 KiB
Go

package placement
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"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/util/rand"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/client-go/tools/cache"
controllers "open-cluster-management.io/ocm/pkg/placement/controllers"
"open-cluster-management.io/ocm/pkg/placement/debugger"
testinghelpers "open-cluster-management.io/ocm/pkg/placement/helpers/testing"
)
var _ = ginkgo.Describe("DebugService", func() {
var namespace string
var placementName string
var clusterSet1Name string
var suffix string
ginkgo.BeforeEach(func() {
suffix = rand.String(5)
namespace = fmt.Sprintf("ns-%s", suffix)
placementName = fmt.Sprintf("placement-%s", suffix)
clusterSet1Name = fmt.Sprintf("clusterset-%s", suffix)
// create testing namespace
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: namespace,
},
}
_, err := kubeClient.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
})
ginkgo.AfterEach(func() {
err := kubeClient.CoreV1().Namespaces().Delete(context.Background(), namespace, metav1.DeleteOptions{})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
assertCleanupClusters()
})
ginkgo.Context("Debug Endpoint Permission Check with Real RBAC", func() {
var serviceAccountName string
var roleName string
var roleBindingName string
ginkgo.BeforeEach(func() {
serviceAccountName = fmt.Sprintf("test-sa-%s", suffix)
roleName = fmt.Sprintf("test-role-%s", suffix)
roleBindingName = fmt.Sprintf("test-rb-%s", suffix)
})
ginkgo.AfterEach(func() {
ginkgo.By("Cleanup RBAC resources")
_ = kubeClient.CoreV1().ServiceAccounts(namespace).Delete(context.Background(), serviceAccountName, metav1.DeleteOptions{})
_ = kubeClient.RbacV1().Roles(namespace).Delete(context.Background(), roleName, metav1.DeleteOptions{})
_ = kubeClient.RbacV1().RoleBindings(namespace).Delete(context.Background(), roleBindingName, metav1.DeleteOptions{})
_ = clusterClient.ClusterV1beta1().Placements(namespace).Delete(context.Background(), placementName, metav1.DeleteOptions{})
})
ginkgo.It("Should allow request with valid ServiceAccount token and proper RBAC", func() {
// Create ClusterSet, binding and clusters
assertBindingClusterSet(clusterSet1Name, namespace)
assertCreatingClusters(clusterSet1Name, 2)
// Create placement
placement := testinghelpers.NewPlacement(namespace, placementName).
WithClusterSets(clusterSet1Name).
Build()
assertCreatingPlacement(placement)
// Create ServiceAccount
sa := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: serviceAccountName,
Namespace: namespace,
},
}
_, err := kubeClient.CoreV1().ServiceAccounts(namespace).Create(context.Background(), sa, metav1.CreateOptions{})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Create Role with permission to get placements (GET debug endpoint checks get verb)
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: roleName,
Namespace: namespace,
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{"cluster.open-cluster-management.io"},
Resources: []string{"placements"},
Verbs: []string{"get"},
},
},
}
_, err = kubeClient.RbacV1().Roles(namespace).Create(context.Background(), role, metav1.CreateOptions{})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Create RoleBinding
roleBinding := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: roleBindingName,
Namespace: namespace,
},
Subjects: []rbacv1.Subject{
{
Kind: "ServiceAccount",
Name: serviceAccountName,
Namespace: namespace,
},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: roleName,
},
}
_, err = kubeClient.RbacV1().RoleBindings(namespace).Create(context.Background(), roleBinding, metav1.CreateOptions{})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Create debugger using helper function
ctx, stopFunc := context.WithCancel(context.Background())
defer stopFunc()
debug, clusterInformers, err := controllers.NewDebuggerWithInformers(ctx, restConfig)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Start informers and wait for cache sync
clusterInformers.Start(ctx.Done())
gomega.Expect(cache.WaitForCacheSync(ctx.Done(),
clusterInformers.Cluster().V1beta1().Placements().Informer().HasSynced,
clusterInformers.Cluster().V1beta1().PlacementDecisions().Informer().HasSynced,
clusterInformers.Cluster().V1().ManagedClusters().Informer().HasSynced,
clusterInformers.Cluster().V1beta2().ManagedClusterSets().Informer().HasSynced,
clusterInformers.Cluster().V1beta2().ManagedClusterSetBindings().Informer().HasSynced,
clusterInformers.Cluster().V1alpha1().AddOnPlacementScores().Informer().HasSynced,
)).To(gomega.BeTrue())
// Create httptest server with handler that injects user info into context
ginkgo.By("Create test server with user context injection")
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Inject user info into request context (simulating GenericAPIServer authentication)
userInfo := &user.DefaultInfo{
Name: fmt.Sprintf("system:serviceaccount:%s:%s", namespace, serviceAccountName),
Groups: []string{
"system:serviceaccounts",
fmt.Sprintf("system:serviceaccounts:%s", namespace),
},
}
ctx := request.WithUser(r.Context(), userInfo)
r = r.WithContext(ctx)
debug.Handler(w, r)
})
server := httptest.NewServer(handler)
defer server.Close()
// Send request without Authorization header (user info is injected by handler)
ginkgo.By("Send GET request with user context")
req, err := http.NewRequest("GET", fmt.Sprintf("%s%s%s/%s", server.URL, debugger.DebugPath, namespace, placementName), nil)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
resp, err := http.DefaultClient.Do(req)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
defer resp.Body.Close()
// Verify response - should succeed
var result debugger.DebugResult
err = json.NewDecoder(resp.Body).Decode(&result)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
ginkgo.By("Verify request was authorized")
gomega.Expect(result.Error).To(gomega.BeEmpty(), "Should not have permission error with valid user and RBAC")
gomega.Expect(result.Placement).ToNot(gomega.BeNil())
})
ginkgo.It("Should reject request when ServiceAccount lacks RBAC permission", func() {
// Create ClusterSet, binding and clusters
assertBindingClusterSet(clusterSet1Name, namespace)
assertCreatingClusters(clusterSet1Name, 2)
// Create placement
placement := testinghelpers.NewPlacement(namespace, placementName).
WithClusterSets(clusterSet1Name).
Build()
assertCreatingPlacement(placement)
// Create ServiceAccount WITHOUT any RBAC permissions
sa := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: serviceAccountName,
Namespace: namespace,
},
}
_, err := kubeClient.CoreV1().ServiceAccounts(namespace).Create(context.Background(), sa, metav1.CreateOptions{})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Create debugger using helper function
ctx, stopFunc := context.WithCancel(context.Background())
defer stopFunc()
debug, clusterInformers, err := controllers.NewDebuggerWithInformers(ctx, restConfig)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Start informers and wait for cache sync
clusterInformers.Start(ctx.Done())
gomega.Expect(cache.WaitForCacheSync(ctx.Done(),
clusterInformers.Cluster().V1beta1().Placements().Informer().HasSynced,
clusterInformers.Cluster().V1beta1().PlacementDecisions().Informer().HasSynced,
clusterInformers.Cluster().V1().ManagedClusters().Informer().HasSynced,
clusterInformers.Cluster().V1beta2().ManagedClusterSets().Informer().HasSynced,
clusterInformers.Cluster().V1beta2().ManagedClusterSetBindings().Informer().HasSynced,
clusterInformers.Cluster().V1alpha1().AddOnPlacementScores().Informer().HasSynced,
)).To(gomega.BeTrue())
// Create httptest server with handler that injects user info into context
ginkgo.By("Create test server with user context injection")
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Inject user info into request context (simulating GenericAPIServer authentication)
userInfo := &user.DefaultInfo{
Name: fmt.Sprintf("system:serviceaccount:%s:%s", namespace, serviceAccountName),
Groups: []string{
"system:serviceaccounts",
fmt.Sprintf("system:serviceaccounts:%s", namespace),
},
}
ctx := request.WithUser(r.Context(), userInfo)
r = r.WithContext(ctx)
debug.Handler(w, r)
})
server := httptest.NewServer(handler)
defer server.Close()
// Send request (user has no RBAC permission)
ginkgo.By("Send GET request with user lacking RBAC permission")
req, err := http.NewRequest("GET", fmt.Sprintf("%s%s%s/%s", server.URL, debugger.DebugPath, namespace, placementName), nil)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
resp, err := http.DefaultClient.Do(req)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
defer resp.Body.Close()
// Verify response - should be rejected
var result debugger.DebugResult
err = json.NewDecoder(resp.Body).Decode(&result)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
ginkgo.By("Verify request was rejected due to lack of permission")
gomega.Expect(resp.StatusCode).To(gomega.Equal(http.StatusForbidden))
gomega.Expect(result.Error).ToNot(gomega.BeEmpty(), "Should have permission error")
gomega.Expect(result.Error).To(gomega.ContainSubstring("does not have permission to get placements"))
})
ginkgo.It("Should accept POST request with placement JSON and valid token", func() {
// Create ClusterSet, binding and clusters (no placement in cluster)
assertBindingClusterSet(clusterSet1Name, namespace)
assertCreatingClusters(clusterSet1Name, 3)
// Create ServiceAccount
sa := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: serviceAccountName,
Namespace: namespace,
},
}
_, err := kubeClient.CoreV1().ServiceAccounts(namespace).Create(context.Background(), sa, metav1.CreateOptions{})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Create Role with permission to create placements
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: roleName,
Namespace: namespace,
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{"cluster.open-cluster-management.io"},
Resources: []string{"placements"},
Verbs: []string{"create"},
},
},
}
_, err = kubeClient.RbacV1().Roles(namespace).Create(context.Background(), role, metav1.CreateOptions{})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Create RoleBinding
roleBinding := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: roleBindingName,
Namespace: namespace,
},
Subjects: []rbacv1.Subject{
{
Kind: "ServiceAccount",
Name: serviceAccountName,
Namespace: namespace,
},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "Role",
Name: roleName,
},
}
_, err = kubeClient.RbacV1().RoleBindings(namespace).Create(context.Background(), roleBinding, metav1.CreateOptions{})
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Create debugger using helper function
ctx, stopFunc := context.WithCancel(context.Background())
defer stopFunc()
debug, clusterInformers, err := controllers.NewDebuggerWithInformers(ctx, restConfig)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Start informers and wait for cache sync
clusterInformers.Start(ctx.Done())
gomega.Expect(cache.WaitForCacheSync(ctx.Done(),
clusterInformers.Cluster().V1beta1().Placements().Informer().HasSynced,
clusterInformers.Cluster().V1beta1().PlacementDecisions().Informer().HasSynced,
clusterInformers.Cluster().V1().ManagedClusters().Informer().HasSynced,
clusterInformers.Cluster().V1beta2().ManagedClusterSets().Informer().HasSynced,
clusterInformers.Cluster().V1beta2().ManagedClusterSetBindings().Informer().HasSynced,
clusterInformers.Cluster().V1alpha1().AddOnPlacementScores().Informer().HasSynced,
)).To(gomega.BeTrue())
// Create httptest server with handler that injects user info into context
ginkgo.By("Create test server with user context injection")
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Inject user info into request context (simulating GenericAPIServer authentication)
userInfo := &user.DefaultInfo{
Name: fmt.Sprintf("system:serviceaccount:%s:%s", namespace, serviceAccountName),
Groups: []string{
"system:serviceaccounts",
fmt.Sprintf("system:serviceaccounts:%s", namespace),
},
}
ctx := request.WithUser(r.Context(), userInfo)
r = r.WithContext(ctx)
debug.Handler(w, r)
})
server := httptest.NewServer(handler)
defer server.Close()
// Prepare placement JSON for POST request
ginkgo.By("Prepare placement JSON for POST request")
placement := testinghelpers.NewPlacement(namespace, placementName).
WithClusterSets(clusterSet1Name).
WithNOC(2).
Build()
placementJSON, err := json.Marshal(placement)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
// Send POST request
ginkgo.By("Send POST request with placement JSON and user context")
req, err := http.NewRequest("POST", server.URL+debugger.DebugPath, bytes.NewBuffer(placementJSON))
gomega.Expect(err).ToNot(gomega.HaveOccurred())
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
defer resp.Body.Close()
// Verify response
var result debugger.DebugResult
err = json.NewDecoder(resp.Body).Decode(&result)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
ginkgo.By("Verify POST request was successful")
gomega.Expect(result.Error).To(gomega.BeEmpty(), "Should not have error with valid user and RBAC")
gomega.Expect(result.Placement).ToNot(gomega.BeNil())
gomega.Expect(result.Placement.Name).To(gomega.Equal(placementName))
gomega.Expect(result.Placement.Namespace).To(gomega.Equal(namespace))
gomega.Expect(result.FilterResults).ToNot(gomega.BeEmpty(), "Should return filter results")
})
})
})