Skip to content

Commit 42ca3e6

Browse files
authored
test(api): add read-path authorization tests for TenantNamespace registry (#2864)
2 parents 25a6b56 + 0c1b438 commit 42ca3e6

3 files changed

Lines changed: 923 additions & 14 deletions

File tree

‎pkg/registry/core/tenantnamespace/rest.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ func (r *REST) Watch(ctx context.Context, opts *metainternal.ListOptions) (watch
216216
pw := watch.NewProxyWatcher(events)
217217

218218
go func() {
219+
// This goroutine is the sole writer to events; closing it on exit
220+
// signals end-of-stream to the consumer (ProxyWatcher.Stop does not
221+
// close the channel it proxies).
222+
defer close(events)
219223
defer pw.Stop()
220224
defer nsWatch.Stop()
221225

‎pkg/registry/core/tenantnamespace/rest_test.go‎

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package tenantnamespace
44

55
import (
66
"context"
7+
"errors"
78
"testing"
89

910
corev1 "k8s.io/api/core/v1"
@@ -14,7 +15,9 @@ import (
1415
"k8s.io/apimachinery/pkg/runtime/schema"
1516
"k8s.io/apiserver/pkg/authentication/user"
1617
"k8s.io/apiserver/pkg/endpoints/request"
18+
"sigs.k8s.io/controller-runtime/pkg/client"
1719
"sigs.k8s.io/controller-runtime/pkg/client/fake"
20+
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
1821

1922
corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
2023
)
@@ -267,6 +270,290 @@ func TestHasAccessToNamespace_CozyAdminGroup(t *testing.T) {
267270
}
268271
}
269272

273+
// TestList_FiltersUnauthorizedNamespaces asserts the List path returns only
274+
// tenant namespaces the user can access: RoleBindings for other users don't
275+
// grant anything, and multiple matching RoleBindings in one namespace don't
276+
// duplicate it in the result.
277+
func TestList_FiltersUnauthorizedNamespaces(t *testing.T) {
278+
roleRef := rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: "test-role"}
279+
client := fake.NewClientBuilder().
280+
WithScheme(newTestScheme(t)).
281+
WithObjects(
282+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-allowed"}},
283+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-denied"}},
284+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "kube-system"}},
285+
&rbacv1.RoleBinding{
286+
ObjectMeta: metav1.ObjectMeta{Name: "user-binding", Namespace: "tenant-allowed"},
287+
Subjects: []rbacv1.Subject{{Kind: "User", Name: "test-user", APIGroup: "rbac.authorization.k8s.io"}},
288+
RoleRef: roleRef,
289+
},
290+
&rbacv1.RoleBinding{
291+
ObjectMeta: metav1.ObjectMeta{Name: "group-binding", Namespace: "tenant-allowed"},
292+
Subjects: []rbacv1.Subject{{Kind: "Group", Name: "system:authenticated", APIGroup: "rbac.authorization.k8s.io"}},
293+
RoleRef: roleRef,
294+
},
295+
&rbacv1.RoleBinding{
296+
ObjectMeta: metav1.ObjectMeta{Name: "other-binding", Namespace: "tenant-denied"},
297+
Subjects: []rbacv1.Subject{{Kind: "User", Name: "other-user", APIGroup: "rbac.authorization.k8s.io"}},
298+
RoleRef: roleRef,
299+
},
300+
).
301+
Build()
302+
303+
r := &REST{
304+
c: client,
305+
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
306+
}
307+
308+
u := &user.DefaultInfo{Name: "test-user", Groups: []string{"system:authenticated"}}
309+
ctx := request.WithUser(context.Background(), u)
310+
311+
obj, err := r.List(ctx, nil)
312+
if err != nil {
313+
t.Fatalf("unexpected error: %v", err)
314+
}
315+
list, ok := obj.(*corev1alpha1.TenantNamespaceList)
316+
if !ok {
317+
t.Fatalf("expected *TenantNamespaceList, got %T", obj)
318+
}
319+
if len(list.Items) != 1 {
320+
t.Fatalf("expected 1 item, got %d: %+v", len(list.Items), list.Items)
321+
}
322+
if list.Items[0].Name != "tenant-allowed" {
323+
t.Errorf("expected tenant-allowed, got %q", list.Items[0].Name)
324+
}
325+
}
326+
327+
// TestList_PrivilegedGroups_SeeAllTenantNamespaces asserts privileged groups
328+
// get every tenant namespace (and only tenant namespaces) without any
329+
// RoleBindings, sorted by name.
330+
func TestList_PrivilegedGroups_SeeAllTenantNamespaces(t *testing.T) {
331+
for _, group := range []string{"system:masters", "cozystack-cluster-admin"} {
332+
t.Run(group, func(t *testing.T) {
333+
client := fake.NewClientBuilder().
334+
WithScheme(newTestScheme(t)).
335+
WithObjects(
336+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-zebra"}},
337+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-alpha"}},
338+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}},
339+
).
340+
Build()
341+
342+
r := &REST{
343+
c: client,
344+
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
345+
}
346+
347+
u := &user.DefaultInfo{Name: "admin", Groups: []string{group}}
348+
ctx := request.WithUser(context.Background(), u)
349+
350+
obj, err := r.List(ctx, nil)
351+
if err != nil {
352+
t.Fatalf("unexpected error: %v", err)
353+
}
354+
list, ok := obj.(*corev1alpha1.TenantNamespaceList)
355+
if !ok {
356+
t.Fatalf("expected *TenantNamespaceList, got %T", obj)
357+
}
358+
if len(list.Items) != 2 {
359+
t.Fatalf("expected 2 items, got %d: %+v", len(list.Items), list.Items)
360+
}
361+
for i, want := range []string{"tenant-alpha", "tenant-zebra"} {
362+
if list.Items[i].Name != want {
363+
t.Errorf("item %d: expected %q, got %q", i, want, list.Items[i].Name)
364+
}
365+
}
366+
})
367+
}
368+
}
369+
370+
// TestList_MissingUser_ReturnsError asserts List fails when no user is in the
371+
// context instead of returning an unfiltered list.
372+
func TestList_MissingUser_ReturnsError(t *testing.T) {
373+
client := fake.NewClientBuilder().
374+
WithScheme(newTestScheme(t)).
375+
WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}}).
376+
Build()
377+
378+
r := &REST{
379+
c: client,
380+
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
381+
}
382+
383+
obj, err := r.List(context.Background(), nil)
384+
if err == nil {
385+
t.Fatal("expected error for missing user in context, got nil")
386+
}
387+
if obj != nil {
388+
t.Errorf("expected nil list on error, got %+v", obj)
389+
}
390+
}
391+
392+
// TestList_RoleBindingInNonTenantNamespace_Ignored asserts a RoleBinding
393+
// outside the tenant namespace set grants nothing: filterAccessible
394+
// intersects RoleBinding namespaces with the tenant name-set.
395+
func TestList_RoleBindingInNonTenantNamespace_Ignored(t *testing.T) {
396+
client := fake.NewClientBuilder().
397+
WithScheme(newTestScheme(t)).
398+
WithObjects(
399+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}},
400+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}},
401+
&rbacv1.RoleBinding{
402+
ObjectMeta: metav1.ObjectMeta{Name: "default-binding", Namespace: "default"},
403+
Subjects: []rbacv1.Subject{{Kind: "User", Name: "test-user", APIGroup: "rbac.authorization.k8s.io"}},
404+
RoleRef: rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: "test-role"},
405+
},
406+
).
407+
Build()
408+
409+
r := &REST{
410+
c: client,
411+
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
412+
}
413+
414+
u := &user.DefaultInfo{Name: "test-user", Groups: []string{"system:authenticated"}}
415+
ctx := request.WithUser(context.Background(), u)
416+
417+
obj, err := r.List(ctx, nil)
418+
if err != nil {
419+
t.Fatalf("unexpected error: %v", err)
420+
}
421+
list, ok := obj.(*corev1alpha1.TenantNamespaceList)
422+
if !ok {
423+
t.Fatalf("expected *TenantNamespaceList, got %T", obj)
424+
}
425+
if len(list.Items) != 0 {
426+
t.Errorf("expected empty list, got %+v", list.Items)
427+
}
428+
}
429+
430+
// TestList_NamespaceListError_Propagates asserts a failure to list namespaces
431+
// is returned to the caller.
432+
func TestList_NamespaceListError_Propagates(t *testing.T) {
433+
wantErr := errors.New("namespace list failed")
434+
fc := fake.NewClientBuilder().
435+
WithScheme(newTestScheme(t)).
436+
WithInterceptorFuncs(interceptor.Funcs{
437+
List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error {
438+
if _, ok := list.(*corev1.NamespaceList); ok {
439+
return wantErr
440+
}
441+
return c.List(ctx, list, opts...)
442+
},
443+
}).
444+
Build()
445+
446+
r := &REST{
447+
c: fc,
448+
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
449+
}
450+
451+
u := &user.DefaultInfo{Name: "test-user", Groups: []string{"system:authenticated"}}
452+
ctx := request.WithUser(context.Background(), u)
453+
454+
if _, err := r.List(ctx, nil); !errors.Is(err, wantErr) {
455+
t.Fatalf("expected %v, got %v", wantErr, err)
456+
}
457+
}
458+
459+
// TestList_RoleBindingListError_Propagates asserts a failure to list
460+
// RoleBindings during access filtering fails the List instead of returning an
461+
// unfiltered or silently truncated result.
462+
func TestList_RoleBindingListError_Propagates(t *testing.T) {
463+
wantErr := errors.New("rolebinding list failed")
464+
fc := fake.NewClientBuilder().
465+
WithScheme(newTestScheme(t)).
466+
WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}}).
467+
WithInterceptorFuncs(interceptor.Funcs{
468+
List: func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error {
469+
if _, ok := list.(*rbacv1.RoleBindingList); ok {
470+
return wantErr
471+
}
472+
return c.List(ctx, list, opts...)
473+
},
474+
}).
475+
Build()
476+
477+
r := &REST{
478+
c: fc,
479+
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
480+
}
481+
482+
u := &user.DefaultInfo{Name: "test-user", Groups: []string{"system:authenticated"}}
483+
ctx := request.WithUser(context.Background(), u)
484+
485+
if _, err := r.List(ctx, nil); !errors.Is(err, wantErr) {
486+
t.Fatalf("expected %v, got %v", wantErr, err)
487+
}
488+
}
489+
490+
// TestGet_MissingUser_ReturnsError asserts Get fails when no user is in the
491+
// context.
492+
func TestGet_MissingUser_ReturnsError(t *testing.T) {
493+
client := fake.NewClientBuilder().
494+
WithScheme(newTestScheme(t)).
495+
WithObjects(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenant-test"}}).
496+
Build()
497+
498+
r := &REST{
499+
c: client,
500+
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
501+
}
502+
503+
if _, err := r.Get(context.Background(), "tenant-test", &metav1.GetOptions{}); err == nil {
504+
t.Fatal("expected error for missing user in context, got nil")
505+
}
506+
}
507+
508+
// TestGet_NamespaceNotFound asserts the backing Get error surfaces when the
509+
// namespace disappears after the access check passes.
510+
func TestGet_NamespaceNotFound(t *testing.T) {
511+
client := fake.NewClientBuilder().WithScheme(newTestScheme(t)).Build()
512+
513+
r := &REST{
514+
c: client,
515+
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
516+
}
517+
518+
u := &user.DefaultInfo{Name: "admin", Groups: []string{"system:masters"}}
519+
ctx := request.WithUser(context.Background(), u)
520+
521+
_, err := r.Get(ctx, "tenant-missing", &metav1.GetOptions{})
522+
if err == nil {
523+
t.Fatal("expected error, got nil")
524+
}
525+
if !apierrors.IsNotFound(err) {
526+
t.Fatalf("expected NotFound, got %v", err)
527+
}
528+
}
529+
530+
// TestMatchesSubject_UnknownKind asserts unknown subject kinds never match.
531+
func TestMatchesSubject_UnknownKind(t *testing.T) {
532+
subj := rbacv1.Subject{Kind: "UnknownKind", Name: "test-user"}
533+
if matchesSubject(subj, "tenant-test", "test-user", map[string]struct{}{}) {
534+
t.Error("expected unknown subject kind not to match")
535+
}
536+
}
537+
538+
func TestHasAccessToNamespace_MissingUser(t *testing.T) {
539+
client := fake.NewClientBuilder().
540+
WithScheme(newTestScheme(t)).
541+
Build()
542+
543+
r := &REST{
544+
c: client,
545+
gvr: schema.GroupVersionResource{Group: "core.cozystack.io", Version: "v1alpha1", Resource: "tenantnamespaces"},
546+
}
547+
548+
hasAccess, err := r.hasAccessToNamespace(context.Background(), "tenant-test")
549+
if err == nil {
550+
t.Fatal("expected error for missing user in context, got nil")
551+
}
552+
if hasAccess {
553+
t.Error("expected no access when user is missing in context")
554+
}
555+
}
556+
270557
func TestHasAccessToNamespace_ServiceAccount(t *testing.T) {
271558
scheme := runtime.NewScheme()
272559
_ = corev1.AddToScheme(scheme)

0 commit comments

Comments
 (0)