[Backport release-1.3] fix(api): prevent IDOR in TenantNamespace Get and Watch handlers (#2524)
# Description Backport of #2471 to `release-1.3`.
This commit is contained in:
commit
09edf16b85
2 changed files with 612 additions and 19 deletions
|
|
@ -17,6 +17,8 @@ import (
|
|||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metainternal "k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
|
@ -24,6 +26,7 @@ import (
|
|||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
"k8s.io/klog/v2"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
|
||||
|
|
@ -123,8 +126,18 @@ func (r *REST) Get(
|
|||
return nil, apierrors.NewNotFound(r.gvr.GroupResource(), name)
|
||||
}
|
||||
|
||||
// Check if user has access to this namespace
|
||||
hasAccess, err := r.hasAccessToNamespace(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
// Return Forbidden to follow standard K8s RBAC behavior
|
||||
return nil, apierrors.NewForbidden(r.gvr.GroupResource(), name, fmt.Errorf("access denied"))
|
||||
}
|
||||
|
||||
ns := &corev1.Namespace{}
|
||||
err := r.c.Get(ctx, types.NamespacedName{Namespace: "", Name: name}, ns, &client.GetOptions{Raw: opts})
|
||||
err = r.c.Get(ctx, types.NamespacedName{Namespace: "", Name: name}, ns, &client.GetOptions{Raw: opts})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -143,11 +156,33 @@ func (r *REST) Get(
|
|||
// -----------------------------------------------------------------------------
|
||||
|
||||
func (r *REST) Watch(ctx context.Context, opts *metainternal.ListOptions) (watch.Interface, error) {
|
||||
// Extract user identity once for the lifetime of the watch — it does not
|
||||
// change between events and rebuilding it per event is wasteful.
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if !ok {
|
||||
return nil, apierrors.NewUnauthorized("user missing in context")
|
||||
}
|
||||
username := u.GetName()
|
||||
groups := make(map[string]struct{})
|
||||
for _, group := range u.GetGroups() {
|
||||
groups[group] = struct{}{}
|
||||
}
|
||||
|
||||
nsList := &corev1.NamespaceList{}
|
||||
nsWatch, err := r.w.Watch(ctx, nsList, &client.ListOptions{Raw: &metav1.ListOptions{
|
||||
|
||||
// Build upstream watch options with field and label selectors
|
||||
rawOpts := &metav1.ListOptions{
|
||||
Watch: true,
|
||||
ResourceVersion: opts.ResourceVersion,
|
||||
}})
|
||||
}
|
||||
if opts.FieldSelector != nil {
|
||||
rawOpts.FieldSelector = opts.FieldSelector.String()
|
||||
}
|
||||
if opts.LabelSelector != nil {
|
||||
rawOpts.LabelSelector = opts.LabelSelector.String()
|
||||
}
|
||||
|
||||
nsWatch, err := r.w.Watch(ctx, nsList, &client.ListOptions{Raw: rawOpts})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -189,6 +224,30 @@ func (r *REST) Watch(ctx context.Context, opts *metainternal.ListOptions) (watch
|
|||
continue
|
||||
}
|
||||
|
||||
// Apply defensive filtering for field and label selectors
|
||||
if opts.FieldSelector != nil {
|
||||
if !opts.FieldSelector.Matches(fields.Set{"metadata.name": ns.Name}) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if opts.LabelSelector != nil {
|
||||
if !opts.LabelSelector.Matches(labels.Set(ns.Labels)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has access to this namespace using the cached
|
||||
// identity — avoids re-extracting user/groups on every event.
|
||||
hasAccess, err := r.hasAccessToNamespaceForUser(ctx, ns.Name, username, groups)
|
||||
if err != nil {
|
||||
klog.ErrorS(err, "Failed to check access for namespace in watch", "namespace", ns.Name)
|
||||
continue
|
||||
}
|
||||
if !hasAccess {
|
||||
// User doesn't have access, skip this event
|
||||
continue
|
||||
}
|
||||
|
||||
out := &corev1alpha1.TenantNamespace{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: corev1alpha1.SchemeGroupVersion.String(),
|
||||
|
|
@ -309,6 +368,26 @@ func (r *REST) makeList(src *corev1.NamespaceList, allowed []string) *corev1alph
|
|||
return out
|
||||
}
|
||||
|
||||
// matchesSubject checks if a RoleBinding subject matches the user's identity.
|
||||
// It handles Group, User, and ServiceAccount subjects with proper namespace fallback.
|
||||
func matchesSubject(subj rbacv1.Subject, bindingNamespace, username string, groups map[string]struct{}) bool {
|
||||
switch subj.Kind {
|
||||
case "Group":
|
||||
_, ok := groups[subj.Name]
|
||||
return ok
|
||||
case "User":
|
||||
return subj.Name == username
|
||||
case "ServiceAccount":
|
||||
saNamespace := subj.Namespace
|
||||
if saNamespace == "" {
|
||||
saNamespace = bindingNamespace
|
||||
}
|
||||
return username == fmt.Sprintf("system:serviceaccount:%s:%s", saNamespace, subj.Name)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *REST) filterAccessible(
|
||||
ctx context.Context,
|
||||
names []string,
|
||||
|
|
@ -347,22 +426,9 @@ func (r *REST) filterAccessible(
|
|||
subjectLoop:
|
||||
for j := range rbs.Items[i].Subjects {
|
||||
subj := rbs.Items[i].Subjects[j]
|
||||
switch subj.Kind {
|
||||
case "Group":
|
||||
if _, ok = groups[subj.Name]; ok {
|
||||
allowedNameSet[rbs.Items[i].Namespace] = struct{}{}
|
||||
break subjectLoop
|
||||
}
|
||||
case "User":
|
||||
if subj.Name == u.GetName() {
|
||||
allowedNameSet[rbs.Items[i].Namespace] = struct{}{}
|
||||
break subjectLoop
|
||||
}
|
||||
case "ServiceAccount":
|
||||
if u.GetName() == fmt.Sprintf("system:serviceaccount:%s:%s", subj.Namespace, subj.Name) {
|
||||
allowedNameSet[rbs.Items[i].Namespace] = struct{}{}
|
||||
break subjectLoop
|
||||
}
|
||||
if matchesSubject(subj, rbs.Items[i].Namespace, u.GetName(), groups) {
|
||||
allowedNameSet[rbs.Items[i].Namespace] = struct{}{}
|
||||
break subjectLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -373,6 +439,60 @@ func (r *REST) filterAccessible(
|
|||
return allowed, nil
|
||||
}
|
||||
|
||||
// hasAccessToNamespace checks if the user has access to a single namespace.
|
||||
// This is optimized for Get/Watch operations where we check one namespace at a time.
|
||||
// It lists RoleBindings only in the target namespace instead of all cluster RoleBindings.
|
||||
func (r *REST) hasAccessToNamespace(
|
||||
ctx context.Context,
|
||||
namespace string,
|
||||
) (bool, error) {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("user missing in context")
|
||||
}
|
||||
groups := make(map[string]struct{})
|
||||
for _, group := range u.GetGroups() {
|
||||
groups[group] = struct{}{}
|
||||
}
|
||||
return r.hasAccessToNamespaceForUser(ctx, namespace, u.GetName(), groups)
|
||||
}
|
||||
|
||||
// hasAccessToNamespaceForUser is the inner check that does not re-extract user
|
||||
// identity from context. Use this in hot paths (e.g. the Watch loop) where the
|
||||
// caller has already cached the user name and groups.
|
||||
func (r *REST) hasAccessToNamespaceForUser(
|
||||
ctx context.Context,
|
||||
namespace, username string,
|
||||
groups map[string]struct{},
|
||||
) (bool, error) {
|
||||
// Check privileged groups
|
||||
if _, ok := groups["system:masters"]; ok {
|
||||
return true, nil
|
||||
}
|
||||
if _, ok := groups["cozystack-cluster-admin"]; ok {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// List RoleBindings only in the target namespace
|
||||
rbs := &rbacv1.RoleBindingList{}
|
||||
err := r.c.List(ctx, rbs, client.InNamespace(namespace))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to list rolebindings in %s: %w", namespace, err)
|
||||
}
|
||||
|
||||
// Check if user is in any RoleBinding subjects
|
||||
for i := range rbs.Items {
|
||||
for j := range rbs.Items[i].Subjects {
|
||||
subj := rbs.Items[i].Subjects[j]
|
||||
if matchesSubject(subj, rbs.Items[i].Namespace, username, groups) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Boiler-plate
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,10 +3,20 @@
|
|||
package tenantnamespace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
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/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
"k8s.io/apiserver/pkg/endpoints/request"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
|
||||
)
|
||||
|
||||
func TestMakeListSortsAlphabetically(t *testing.T) {
|
||||
|
|
@ -38,3 +48,466 @@ func TestMakeListSortsAlphabetically(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Security tests for IDOR fix
|
||||
|
||||
func TestHasAccessToNamespace_WithUserAccess(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
_ = rbacv1.AddToScheme(scheme)
|
||||
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
|
||||
}
|
||||
|
||||
rb := &rbacv1.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-binding",
|
||||
Namespace: "tenant-test",
|
||||
},
|
||||
Subjects: []rbacv1.Subject{
|
||||
{Kind: "User", Name: "test-user", APIGroup: "rbac.authorization.k8s.io"},
|
||||
},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
APIGroup: "rbac.authorization.k8s.io",
|
||||
Kind: "Role",
|
||||
Name: "test-role",
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns, rb).
|
||||
Build()
|
||||
|
||||
r := &REST{
|
||||
c: client,
|
||||
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
|
||||
}
|
||||
|
||||
u := &user.DefaultInfo{
|
||||
Name: "test-user",
|
||||
Groups: []string{"system:authenticated"},
|
||||
}
|
||||
ctx := request.WithUser(context.Background(), u)
|
||||
|
||||
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !hasAccess {
|
||||
t.Error("expected user to have access, but got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAccessToNamespace_WithoutAccess(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
_ = rbacv1.AddToScheme(scheme)
|
||||
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
|
||||
}
|
||||
|
||||
// RoleBinding for different user
|
||||
rb := &rbacv1.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-binding",
|
||||
Namespace: "tenant-test",
|
||||
},
|
||||
Subjects: []rbacv1.Subject{
|
||||
{Kind: "User", Name: "other-user", APIGroup: "rbac.authorization.k8s.io"},
|
||||
},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
APIGroup: "rbac.authorization.k8s.io",
|
||||
Kind: "Role",
|
||||
Name: "test-role",
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns, rb).
|
||||
Build()
|
||||
|
||||
r := &REST{
|
||||
c: client,
|
||||
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
|
||||
}
|
||||
|
||||
u := &user.DefaultInfo{
|
||||
Name: "test-user",
|
||||
Groups: []string{"system:authenticated"},
|
||||
}
|
||||
ctx := request.WithUser(context.Background(), u)
|
||||
|
||||
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if hasAccess {
|
||||
t.Error("expected user to NOT have access, but got true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAccessToNamespace_WithGroupAccess(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
_ = rbacv1.AddToScheme(scheme)
|
||||
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
|
||||
}
|
||||
|
||||
rb := &rbacv1.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-binding",
|
||||
Namespace: "tenant-test",
|
||||
},
|
||||
Subjects: []rbacv1.Subject{
|
||||
{Kind: "Group", Name: "test-group", APIGroup: "rbac.authorization.k8s.io"},
|
||||
},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
APIGroup: "rbac.authorization.k8s.io",
|
||||
Kind: "Role",
|
||||
Name: "test-role",
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns, rb).
|
||||
Build()
|
||||
|
||||
r := &REST{
|
||||
c: client,
|
||||
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
|
||||
}
|
||||
|
||||
u := &user.DefaultInfo{
|
||||
Name: "test-user",
|
||||
Groups: []string{"system:authenticated", "test-group"},
|
||||
}
|
||||
ctx := request.WithUser(context.Background(), u)
|
||||
|
||||
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !hasAccess {
|
||||
t.Error("expected user to have access via group, but got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAccessToNamespace_SystemMasters(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
_ = rbacv1.AddToScheme(scheme)
|
||||
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns).
|
||||
Build()
|
||||
|
||||
r := &REST{
|
||||
c: client,
|
||||
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
|
||||
}
|
||||
|
||||
u := &user.DefaultInfo{
|
||||
Name: "admin",
|
||||
Groups: []string{"system:masters"},
|
||||
}
|
||||
ctx := request.WithUser(context.Background(), u)
|
||||
|
||||
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !hasAccess {
|
||||
t.Error("expected system:masters to have access, but got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAccessToNamespace_CozyAdminGroup(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
_ = rbacv1.AddToScheme(scheme)
|
||||
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns).
|
||||
Build()
|
||||
|
||||
r := &REST{
|
||||
c: client,
|
||||
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
|
||||
}
|
||||
|
||||
u := &user.DefaultInfo{
|
||||
Name: "cozy-admin",
|
||||
Groups: []string{"cozystack-cluster-admin"},
|
||||
}
|
||||
ctx := request.WithUser(context.Background(), u)
|
||||
|
||||
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !hasAccess {
|
||||
t.Error("expected cozystack-cluster-admin to have access, but got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAccessToNamespace_ServiceAccount(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
_ = rbacv1.AddToScheme(scheme)
|
||||
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
|
||||
}
|
||||
|
||||
rb := &rbacv1.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-binding",
|
||||
Namespace: "tenant-test",
|
||||
},
|
||||
Subjects: []rbacv1.Subject{
|
||||
{Kind: "ServiceAccount", Name: "test-sa", Namespace: "tenant-test"},
|
||||
},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
APIGroup: "rbac.authorization.k8s.io",
|
||||
Kind: "Role",
|
||||
Name: "test-role",
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns, rb).
|
||||
Build()
|
||||
|
||||
r := &REST{
|
||||
c: client,
|
||||
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
|
||||
}
|
||||
|
||||
u := &user.DefaultInfo{
|
||||
Name: "system:serviceaccount:tenant-test:test-sa",
|
||||
Groups: []string{"system:authenticated"},
|
||||
}
|
||||
ctx := request.WithUser(context.Background(), u)
|
||||
|
||||
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !hasAccess {
|
||||
t.Error("expected service account to have access, but got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAccessToNamespace_ServiceAccountEmptyNamespace(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
_ = rbacv1.AddToScheme(scheme)
|
||||
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
|
||||
}
|
||||
|
||||
// ServiceAccount subject with empty namespace should default to RoleBinding namespace
|
||||
rb := &rbacv1.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-binding",
|
||||
Namespace: "tenant-test",
|
||||
},
|
||||
Subjects: []rbacv1.Subject{
|
||||
{Kind: "ServiceAccount", Name: "test-sa", Namespace: ""}, // Empty namespace
|
||||
},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
APIGroup: "rbac.authorization.k8s.io",
|
||||
Kind: "Role",
|
||||
Name: "test-role",
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns, rb).
|
||||
Build()
|
||||
|
||||
r := &REST{
|
||||
c: client,
|
||||
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
|
||||
}
|
||||
|
||||
u := &user.DefaultInfo{
|
||||
Name: "system:serviceaccount:tenant-test:test-sa",
|
||||
Groups: []string{"system:authenticated"},
|
||||
}
|
||||
ctx := request.WithUser(context.Background(), u)
|
||||
|
||||
hasAccess, err := r.hasAccessToNamespace(ctx, "tenant-test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !hasAccess {
|
||||
t.Error("expected service account with empty namespace to have access, but got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_WithAccess(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
_ = rbacv1.AddToScheme(scheme)
|
||||
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
|
||||
}
|
||||
|
||||
rb := &rbacv1.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-binding",
|
||||
Namespace: "tenant-test",
|
||||
},
|
||||
Subjects: []rbacv1.Subject{
|
||||
{Kind: "User", Name: "test-user", APIGroup: "rbac.authorization.k8s.io"},
|
||||
},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
APIGroup: "rbac.authorization.k8s.io",
|
||||
Kind: "Role",
|
||||
Name: "test-role",
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns, rb).
|
||||
Build()
|
||||
|
||||
r := &REST{
|
||||
c: client,
|
||||
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
|
||||
}
|
||||
|
||||
u := &user.DefaultInfo{
|
||||
Name: "test-user",
|
||||
Groups: []string{"system:authenticated"},
|
||||
}
|
||||
ctx := request.WithUser(context.Background(), u)
|
||||
|
||||
obj, err := r.Get(ctx, "tenant-test", &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
tn, ok := obj.(*corev1alpha1.TenantNamespace)
|
||||
if !ok {
|
||||
t.Fatalf("expected *TenantNamespace, got %T", obj)
|
||||
}
|
||||
if tn.Name != "tenant-test" {
|
||||
t.Errorf("expected name %q, got %q", "tenant-test", tn.Name)
|
||||
}
|
||||
if tn.Kind != "TenantNamespace" {
|
||||
t.Errorf("expected Kind=TenantNamespace, got %q", tn.Kind)
|
||||
}
|
||||
if tn.APIVersion != corev1alpha1.SchemeGroupVersion.String() {
|
||||
t.Errorf("expected APIVersion=%q, got %q", corev1alpha1.SchemeGroupVersion.String(), tn.APIVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_WithoutAccess(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
_ = rbacv1.AddToScheme(scheme)
|
||||
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"},
|
||||
}
|
||||
|
||||
// RoleBinding for different user
|
||||
rb := &rbacv1.RoleBinding{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-binding",
|
||||
Namespace: "tenant-test",
|
||||
},
|
||||
Subjects: []rbacv1.Subject{
|
||||
{Kind: "User", Name: "other-user", APIGroup: "rbac.authorization.k8s.io"},
|
||||
},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
APIGroup: "rbac.authorization.k8s.io",
|
||||
Kind: "Role",
|
||||
Name: "test-role",
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns, rb).
|
||||
Build()
|
||||
|
||||
r := &REST{
|
||||
c: client,
|
||||
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
|
||||
}
|
||||
|
||||
u := &user.DefaultInfo{
|
||||
Name: "test-user",
|
||||
Groups: []string{"system:authenticated"},
|
||||
}
|
||||
ctx := request.WithUser(context.Background(), u)
|
||||
|
||||
obj, err := r.Get(ctx, "tenant-test", &metav1.GetOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if obj != nil {
|
||||
t.Errorf("expected nil object, got %v", obj)
|
||||
}
|
||||
|
||||
// Verify it returns Forbidden to follow standard K8s RBAC behavior
|
||||
if !apierrors.IsForbidden(err) {
|
||||
t.Errorf("expected Forbidden error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_NonTenantNamespace(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
_ = rbacv1.AddToScheme(scheme)
|
||||
|
||||
client := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
Build()
|
||||
|
||||
r := &REST{
|
||||
c: client,
|
||||
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
|
||||
}
|
||||
|
||||
u := &user.DefaultInfo{
|
||||
Name: "test-user",
|
||||
Groups: []string{"system:masters"},
|
||||
}
|
||||
ctx := request.WithUser(context.Background(), u)
|
||||
|
||||
obj, err := r.Get(ctx, "default", &metav1.GetOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-tenant namespace, got nil")
|
||||
}
|
||||
if obj != nil {
|
||||
t.Errorf("expected nil object, got %v", obj)
|
||||
}
|
||||
if !apierrors.IsNotFound(err) {
|
||||
t.Errorf("expected NotFound error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue