@@ -17,13 +17,16 @@ import (
1717 apierrors "k8s.io/apimachinery/pkg/api/errors"
1818 metainternal "k8s.io/apimachinery/pkg/apis/meta/internalversion"
1919 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
20+ "k8s.io/apimachinery/pkg/fields"
21+ "k8s.io/apimachinery/pkg/labels"
2022 "k8s.io/apimachinery/pkg/runtime"
2123 "k8s.io/apimachinery/pkg/runtime/schema"
2224 "k8s.io/apimachinery/pkg/types"
2325 "k8s.io/apimachinery/pkg/util/duration"
2426 "k8s.io/apimachinery/pkg/watch"
2527 "k8s.io/apiserver/pkg/endpoints/request"
2628 "k8s.io/apiserver/pkg/registry/rest"
29+ "k8s.io/klog/v2"
2730 "sigs.k8s.io/controller-runtime/pkg/client"
2831
2932 corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
@@ -123,8 +126,18 @@ func (r *REST) Get(
123126 return nil , apierrors .NewNotFound (r .gvr .GroupResource (), name )
124127 }
125128
129+ // Check if user has access to this namespace
130+ hasAccess , err := r .hasAccessToNamespace (ctx , name )
131+ if err != nil {
132+ return nil , err
133+ }
134+ if ! hasAccess {
135+ // Return Forbidden to follow standard K8s RBAC behavior
136+ return nil , apierrors .NewForbidden (r .gvr .GroupResource (), name , fmt .Errorf ("access denied" ))
137+ }
138+
126139 ns := & corev1.Namespace {}
127- err : = r .c .Get (ctx , types.NamespacedName {Namespace : "" , Name : name }, ns , & client.GetOptions {Raw : opts })
140+ err = r .c .Get (ctx , types.NamespacedName {Namespace : "" , Name : name }, ns , & client.GetOptions {Raw : opts })
128141 if err != nil {
129142 return nil , err
130143 }
@@ -143,11 +156,33 @@ func (r *REST) Get(
143156// -----------------------------------------------------------------------------
144157
145158func (r * REST ) Watch (ctx context.Context , opts * metainternal.ListOptions ) (watch.Interface , error ) {
159+ // Extract user identity once for the lifetime of the watch — it does not
160+ // change between events and rebuilding it per event is wasteful.
161+ u , ok := request .UserFrom (ctx )
162+ if ! ok {
163+ return nil , apierrors .NewUnauthorized ("user missing in context" )
164+ }
165+ username := u .GetName ()
166+ groups := make (map [string ]struct {})
167+ for _ , group := range u .GetGroups () {
168+ groups [group ] = struct {}{}
169+ }
170+
146171 nsList := & corev1.NamespaceList {}
147- nsWatch , err := r .w .Watch (ctx , nsList , & client.ListOptions {Raw : & metav1.ListOptions {
172+
173+ // Build upstream watch options with field and label selectors
174+ rawOpts := & metav1.ListOptions {
148175 Watch : true ,
149176 ResourceVersion : opts .ResourceVersion ,
150- }})
177+ }
178+ if opts .FieldSelector != nil {
179+ rawOpts .FieldSelector = opts .FieldSelector .String ()
180+ }
181+ if opts .LabelSelector != nil {
182+ rawOpts .LabelSelector = opts .LabelSelector .String ()
183+ }
184+
185+ nsWatch , err := r .w .Watch (ctx , nsList , & client.ListOptions {Raw : rawOpts })
151186 if err != nil {
152187 return nil , err
153188 }
@@ -189,6 +224,30 @@ func (r *REST) Watch(ctx context.Context, opts *metainternal.ListOptions) (watch
189224 continue
190225 }
191226
227+ // Apply defensive filtering for field and label selectors
228+ if opts .FieldSelector != nil {
229+ if ! opts .FieldSelector .Matches (fields.Set {"metadata.name" : ns .Name }) {
230+ continue
231+ }
232+ }
233+ if opts .LabelSelector != nil {
234+ if ! opts .LabelSelector .Matches (labels .Set (ns .Labels )) {
235+ continue
236+ }
237+ }
238+
239+ // Check if user has access to this namespace using the cached
240+ // identity — avoids re-extracting user/groups on every event.
241+ hasAccess , err := r .hasAccessToNamespaceForUser (ctx , ns .Name , username , groups )
242+ if err != nil {
243+ klog .ErrorS (err , "Failed to check access for namespace in watch" , "namespace" , ns .Name )
244+ continue
245+ }
246+ if ! hasAccess {
247+ // User doesn't have access, skip this event
248+ continue
249+ }
250+
192251 out := & corev1alpha1.TenantNamespace {
193252 TypeMeta : metav1.TypeMeta {
194253 APIVersion : corev1alpha1 .SchemeGroupVersion .String (),
@@ -309,6 +368,26 @@ func (r *REST) makeList(src *corev1.NamespaceList, allowed []string) *corev1alph
309368 return out
310369}
311370
371+ // matchesSubject checks if a RoleBinding subject matches the user's identity.
372+ // It handles Group, User, and ServiceAccount subjects with proper namespace fallback.
373+ func matchesSubject (subj rbacv1.Subject , bindingNamespace , username string , groups map [string ]struct {}) bool {
374+ switch subj .Kind {
375+ case "Group" :
376+ _ , ok := groups [subj .Name ]
377+ return ok
378+ case "User" :
379+ return subj .Name == username
380+ case "ServiceAccount" :
381+ saNamespace := subj .Namespace
382+ if saNamespace == "" {
383+ saNamespace = bindingNamespace
384+ }
385+ return username == fmt .Sprintf ("system:serviceaccount:%s:%s" , saNamespace , subj .Name )
386+ default :
387+ return false
388+ }
389+ }
390+
312391func (r * REST ) filterAccessible (
313392 ctx context.Context ,
314393 names []string ,
@@ -347,22 +426,9 @@ func (r *REST) filterAccessible(
347426 subjectLoop:
348427 for j := range rbs .Items [i ].Subjects {
349428 subj := rbs .Items [i ].Subjects [j ]
350- switch subj .Kind {
351- case "Group" :
352- if _ , ok = groups [subj .Name ]; ok {
353- allowedNameSet [rbs .Items [i ].Namespace ] = struct {}{}
354- break subjectLoop
355- }
356- case "User" :
357- if subj .Name == u .GetName () {
358- allowedNameSet [rbs .Items [i ].Namespace ] = struct {}{}
359- break subjectLoop
360- }
361- case "ServiceAccount" :
362- if u .GetName () == fmt .Sprintf ("system:serviceaccount:%s:%s" , subj .Namespace , subj .Name ) {
363- allowedNameSet [rbs .Items [i ].Namespace ] = struct {}{}
364- break subjectLoop
365- }
429+ if matchesSubject (subj , rbs .Items [i ].Namespace , u .GetName (), groups ) {
430+ allowedNameSet [rbs .Items [i ].Namespace ] = struct {}{}
431+ break subjectLoop
366432 }
367433 }
368434 }
@@ -373,6 +439,60 @@ func (r *REST) filterAccessible(
373439 return allowed , nil
374440}
375441
442+ // hasAccessToNamespace checks if the user has access to a single namespace.
443+ // This is optimized for Get/Watch operations where we check one namespace at a time.
444+ // It lists RoleBindings only in the target namespace instead of all cluster RoleBindings.
445+ func (r * REST ) hasAccessToNamespace (
446+ ctx context.Context ,
447+ namespace string ,
448+ ) (bool , error ) {
449+ u , ok := request .UserFrom (ctx )
450+ if ! ok {
451+ return false , fmt .Errorf ("user missing in context" )
452+ }
453+ groups := make (map [string ]struct {})
454+ for _ , group := range u .GetGroups () {
455+ groups [group ] = struct {}{}
456+ }
457+ return r .hasAccessToNamespaceForUser (ctx , namespace , u .GetName (), groups )
458+ }
459+
460+ // hasAccessToNamespaceForUser is the inner check that does not re-extract user
461+ // identity from context. Use this in hot paths (e.g. the Watch loop) where the
462+ // caller has already cached the user name and groups.
463+ func (r * REST ) hasAccessToNamespaceForUser (
464+ ctx context.Context ,
465+ namespace , username string ,
466+ groups map [string ]struct {},
467+ ) (bool , error ) {
468+ // Check privileged groups
469+ if _ , ok := groups ["system:masters" ]; ok {
470+ return true , nil
471+ }
472+ if _ , ok := groups ["cozystack-cluster-admin" ]; ok {
473+ return true , nil
474+ }
475+
476+ // List RoleBindings only in the target namespace
477+ rbs := & rbacv1.RoleBindingList {}
478+ err := r .c .List (ctx , rbs , client .InNamespace (namespace ))
479+ if err != nil {
480+ return false , fmt .Errorf ("failed to list rolebindings in %s: %w" , namespace , err )
481+ }
482+
483+ // Check if user is in any RoleBinding subjects
484+ for i := range rbs .Items {
485+ for j := range rbs .Items [i ].Subjects {
486+ subj := rbs .Items [i ].Subjects [j ]
487+ if matchesSubject (subj , rbs .Items [i ].Namespace , username , groups ) {
488+ return true , nil
489+ }
490+ }
491+ }
492+
493+ return false , nil
494+ }
495+
376496// -----------------------------------------------------------------------------
377497// Boiler-plate
378498// -----------------------------------------------------------------------------
0 commit comments