diff --git a/cmd/app/run.go b/cmd/app/run.go index 9ade74905..09c5b076f 100644 --- a/cmd/app/run.go +++ b/cmd/app/run.go @@ -23,6 +23,7 @@ import ( "github.com/Improwised/kube-oidc-proxy/pkg/proxy" "github.com/Improwised/kube-oidc-proxy/pkg/proxy/crd" "github.com/Improwised/kube-oidc-proxy/pkg/util" + "github.com/Improwised/kube-oidc-proxy/pkg/util/authorizer" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -71,22 +72,17 @@ func buildRunCommand(stopCh <-chan struct{}, opts *options.Options) *cobra.Comma } } + rbacAuthorizer := authorizer.NewRBACAuthorizer() + // Initialize CAPI RBAC watcher if available - capiRBACWatcher, err := crd.NewCAPIRbacWatcher(clusterConfigs) + capiRBACWatcher, err := crd.NewCAPIRbacWatcher(clusterConfigs, rbacAuthorizer) if err != nil { klog.Errorf("Failed to initialize CAPI RBAC watcher: %v", err) capiRBACWatcher = nil // Continue without watcher if initialization fails } // Create cluster manager to handle dynamic clusters - clusterManager, err := clustermanager.NewClusterManager( - stopCh, - opts.App.TokenPassthrough.Enabled, - opts.App.TokenPassthrough.Audiences, - clusterRBACConfigs, - capiRBACWatcher, - opts.App.MaxGoroutines, - ) + clusterManager, err := clustermanager.NewClusterManager(opts.App.TokenPassthrough.Enabled, opts.App.TokenPassthrough.Audiences, clusterRBACConfigs, capiRBACWatcher, opts.App.MaxGoroutines, rbacAuthorizer) if err != nil { return fmt.Errorf("failed to create cluster manager: %w", err) } diff --git a/go.mod b/go.mod index ddcdfe3e9..8830bba04 100644 --- a/go.mod +++ b/go.mod @@ -11,10 +11,10 @@ require ( github.com/onsi/gomega v1.35.1 github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.8.1 - github.com/spf13/pflag v1.0.5 + github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.9.0 - golang.org/x/term v0.30.0 + golang.org/x/term v0.33.0 gopkg.in/square/go-jose.v2 v2.6.0 k8s.io/api v0.32.0 k8s.io/apiextensions-apiserver v0.32.0 @@ -28,10 +28,7 @@ require ( sigs.k8s.io/kind v0.24.0 ) -require ( - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - k8s.io/controller-manager v0.32.0 // indirect -) +require github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect replace ( github.com/emicklei/go-restful => github.com/emicklei/go-restful/v3 v3.8.0 @@ -59,7 +56,7 @@ require ( github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -121,16 +118,16 @@ require ( go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.36.0 // indirect + golang.org/x/crypto v0.40.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/mod v0.21.0 // indirect - golang.org/x/net v0.38.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/net v0.42.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.7.0 // indirect - golang.org/x/tools v0.26.0 // indirect + golang.org/x/tools v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/grpc v1.65.0 // indirect @@ -141,7 +138,6 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 - k8s.io/component-helpers v0.32.0 // indirect k8s.io/kms v0.32.0 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect diff --git a/go.sum b/go.sum index 34feb5503..690c26dd4 100644 --- a/go.sum +++ b/go.sum @@ -28,7 +28,7 @@ github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -46,8 +46,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -210,10 +210,11 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -288,8 +289,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbR golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= -golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -297,8 +298,8 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -306,8 +307,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -324,16 +325,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -342,8 +343,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= -golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -399,10 +400,6 @@ k8s.io/client-go v0.32.0 h1:DimtMcnN/JIKZcrSrstiwvvZvLjG0aSxy8PxN8IChp8= k8s.io/client-go v0.32.0/go.mod h1:boDWvdM1Drk4NJj/VddSLnx59X3OPgwrOo0vGbtq9+8= k8s.io/component-base v0.32.0 h1:d6cWHZkCiiep41ObYQS6IcgzOUQUNpywm39KVYaUqzU= k8s.io/component-base v0.32.0/go.mod h1:JLG2W5TUxUu5uDyKiH2R/7NnxJo1HlPoRIIbVLkK5eM= -k8s.io/component-helpers v0.32.0 h1:pQEEBmRt3pDJJX98cQvZshDgJFeKRM4YtYkMmfOlczw= -k8s.io/component-helpers v0.32.0/go.mod h1:9RuClQatbClcokXOcDWSzFKQm1huIf0FzQlPRpizlMc= -k8s.io/controller-manager v0.32.0 h1:tpQl1rvH4huFB6Avl1nhowZHtZoCNWqn6OYdZPl7Ybc= -k8s.io/controller-manager v0.32.0/go.mod h1:JRuYnYCkKj3NgBTy+KNQKIUm/lJRoDAvGbfdEmk9LhY= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kms v0.32.0 h1:jwOfunHIrcdYl5FRcA+uUKKtg6qiqoPCwmS2T3XTYL4= diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go index 1f994fe72..e22ca4368 100644 --- a/pkg/cluster/cluster.go +++ b/pkg/cluster/cluster.go @@ -12,7 +12,6 @@ import ( "github.com/Improwised/kube-oidc-proxy/pkg/util" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - "k8s.io/kubernetes/plugin/pkg/auth/authorizer/rbac" ) // Cluster represents a Kubernetes cluster configuration and its associated resources. @@ -23,7 +22,6 @@ type Cluster struct { Kubeclient *kubernetes.Clientset // Kubernetes client for interacting with the cluster TokenReviewer *tokenreview.TokenReview // Token reviewer for validating tokens SubjectAccessReviewer *subjectaccessreview.SubjectAccessReview // Reviewer for subject access requests - Authorizer *rbac.RBACAuthorizer // RBAC authorizer for access control RBACConfig *util.RBAC // RBAC configuration for the cluster ProxyHandler *httputil.ReverseProxy // Reverse proxy handler for forwarding requests ClientTransport http.RoundTripper // Transport for authenticated requests diff --git a/pkg/clustermanager/clustermanager.go b/pkg/clustermanager/clustermanager.go index 32dbd396b..5ceeab7e6 100644 --- a/pkg/clustermanager/clustermanager.go +++ b/pkg/clustermanager/clustermanager.go @@ -14,9 +14,11 @@ import ( "github.com/Improwised/kube-oidc-proxy/pkg/proxy/subjectaccessreview" "github.com/Improwised/kube-oidc-proxy/pkg/proxy/tokenreview" "github.com/Improwised/kube-oidc-proxy/pkg/util" + "github.com/Improwised/kube-oidc-proxy/pkg/util/authorizer" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apiserver/pkg/authentication/user" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" @@ -50,9 +52,6 @@ type ClusterManager struct { // capiRbacWatcher watches for CAPI RBAC changes and applies them to clusters capiRbacWatcher *crd.CAPIRbacWatcher - // stopCh is a channel used to signal the manager to stop watching for changes - stopCh <-chan struct{} - // maxGoroutines limits concurrent cluster initialization operations maxGoroutines int @@ -62,6 +61,8 @@ type ClusterManager struct { // secretController is the controller that watches for secret changes secretController *SecretController + + RBACAuthorizer authorizer.Interface } // SecretController is a Kubernetes controller that watches for changes to secrets @@ -91,17 +92,17 @@ type SecretController struct { // NewClusterManager creates a new ClusterManager instance with the provided configuration. // // Parameters: -// - stopCh: Channel used to signal when to stop watching for cluster changes // - tokenPassthroughEnabled: Whether to enable token passthrough for authentication // - audiences: List of valid token audiences for token review // - clustersRoleConfigMap: Map of cluster names to their RBAC configurations // - capiRbacWatcher: Watcher for CAPI RBAC changes // - maxGoroutines: Maximum number of concurrent goroutines for cluster operations +// - rbacAuthorizer: RBAC authorizer interface for permission checking // // Returns: // - A new ClusterManager instance and nil error on success // - nil and an error if configuration fails -func NewClusterManager(stopCh <-chan struct{}, tokenPassthroughEnabled bool, audiences []string, clustersRoleConfigMap map[string]util.RBAC, capiRbacWatcher *crd.CAPIRbacWatcher, maxGoroutines int) (*ClusterManager, error) { +func NewClusterManager(tokenPassthroughEnabled bool, audiences []string, clustersRoleConfigMap map[string]util.RBAC, capiRbacWatcher *crd.CAPIRbacWatcher, maxGoroutines int, rbacAuthorizer authorizer.Interface) (*ClusterManager, error) { // Build Kubernetes configuration for the management cluster config, err := util.BuildConfiguration() if err != nil { @@ -118,12 +119,12 @@ func NewClusterManager(stopCh <-chan struct{}, tokenPassthroughEnabled bool, aud return &ClusterManager{ clusters: make(map[string]*cluster.Cluster), clientset: client, - stopCh: stopCh, tokenPassthroughEnabled: tokenPassthroughEnabled, audiences: audiences, clustersRoleConfigMap: clustersRoleConfigMap, capiRbacWatcher: capiRbacWatcher, maxGoroutines: maxGoroutines, + RBACAuthorizer: rbacAuthorizer, }, nil } @@ -510,6 +511,8 @@ func (cm *ClusterManager) RemoveCluster(name string) { // Check if the cluster exists before removing if _, exists := cm.clusters[name]; exists { + // Remove all permissions for this cluster from the trie + cm.RBACAuthorizer.RemoveClusterPermissions(name) delete(cm.clusters, name) klog.Infof("Removed cluster: %s", name) } else { @@ -614,10 +617,13 @@ func (cm *ClusterManager) ClusterSetup(cluster *cluster.Cluster) error { } // Load RBAC configuration into the cluster - if err = rbac.LoadRBAC(cluster); err != nil { + + if err = rbac.LoadRBAC(cluster, cm.RBACAuthorizer); err != nil { return fmt.Errorf("failed to load RBAC configuration: %w", err) } + cm.RBACAuthorizer.UpdatePermissionTrie(cluster.RBACConfig, cluster.Name) + klog.V(5).Infof("Cluster setup complete for cluster: %s", cluster.Name) return nil } @@ -664,3 +670,27 @@ func (cm *ClusterManager) StopSecretController() { klog.V(4).Info("Secret controller will stop when context is cancelled") } } + +// CheckPermission checks if a subject has permission to perform an action on a resource +func (cm *ClusterManager) CheckPermission(groups []string, subjectName, cluster, namespace, apiGroup, resource, resourceName, verb string) bool { + if cm.RBACAuthorizer == nil { + klog.Warningf("RBACAuthorizer is nil, denying permission check for subject %s", subjectName) + return false + } + + attrs := authorizer.Attributes{ + User: &user.DefaultInfo{ + Name: subjectName, + Groups: groups, + }, + Verb: verb, + Cluster: cluster, + Namespace: namespace, + APIGroup: apiGroup, + Resource: resource, + ResourceName: resourceName, + IsResourceRequest: true, + } + + return cm.RBACAuthorizer.CheckPermission(attrs) +} diff --git a/pkg/clustermanager/clustermanager_test.go b/pkg/clustermanager/clustermanager_test.go index da05318d1..3cca24a2b 100644 --- a/pkg/clustermanager/clustermanager_test.go +++ b/pkg/clustermanager/clustermanager_test.go @@ -6,18 +6,33 @@ import ( "github.com/Improwised/kube-oidc-proxy/pkg/cluster" "github.com/Improwised/kube-oidc-proxy/pkg/util" + "github.com/Improwised/kube-oidc-proxy/pkg/util/authorizer" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" ) +// newTestClusterManager creates a properly initialized ClusterManager for testing +func newTestClusterManager() *ClusterManager { + fakeClient := fake.NewSimpleClientset() + rbacAuthorizer := authorizer.NewRBACAuthorizer() + + return &ClusterManager{ + clusters: make(map[string]*cluster.Cluster), + clientset: fakeClient, + tokenPassthroughEnabled: false, + audiences: []string{}, + clustersRoleConfigMap: make(map[string]util.RBAC), + RBACAuthorizer: rbacAuthorizer, + maxGoroutines: 10, + } +} + // TestAddAndRetrieveCluster tests adding a cluster and retrieving it func TestAddAndRetrieveCluster(t *testing.T) { // Create a ClusterManager - cm := &ClusterManager{ - clusters: make(map[string]*cluster.Cluster), - } + cm := newTestClusterManager() // Create a test cluster testCluster := &cluster.Cluster{ @@ -39,9 +54,7 @@ func TestAddAndRetrieveCluster(t *testing.T) { // TestUpdateExistingCluster tests updating an existing cluster func TestUpdateExistingCluster(t *testing.T) { // Create a ClusterManager - cm := &ClusterManager{ - clusters: make(map[string]*cluster.Cluster), - } + cm := newTestClusterManager() // Create a test cluster originalCluster := &cluster.Cluster{ @@ -74,9 +87,7 @@ func TestUpdateExistingCluster(t *testing.T) { // TestRemoveCluster tests removing a cluster func TestRemoveCluster(t *testing.T) { // Create a ClusterManager - cm := &ClusterManager{ - clusters: make(map[string]*cluster.Cluster), - } + cm := newTestClusterManager() // Create a test cluster testCluster := &cluster.Cluster{ @@ -100,9 +111,7 @@ func TestRemoveCluster(t *testing.T) { // TestGetAllClusters tests retrieving all clusters func TestGetAllClusters(t *testing.T) { // Create a ClusterManager - cm := &ClusterManager{ - clusters: make(map[string]*cluster.Cluster), - } + cm := newTestClusterManager() // Create test clusters cluster1 := &cluster.Cluster{ @@ -140,21 +149,9 @@ func TestGetAllClusters(t *testing.T) { // TestHandleInvalidKubeconfig tests handling invalid kubeconfig func TestHandleInvalidKubeconfig(t *testing.T) { - // Create a stop channel for the ClusterManager - stopCh := make(chan struct{}) - defer close(stopCh) - - // Create a fake Kubernetes clientset - fakeClient := fake.NewSimpleClientset() - // Create a ClusterManager with the fake client - cm := &ClusterManager{ - clusters: make(map[string]*cluster.Cluster), - clientset: fakeClient, - stopCh: stopCh, - tokenPassthroughEnabled: false, - clustersRoleConfigMap: make(map[string]util.RBAC), - } + // Create a ClusterManager + cm := newTestClusterManager() // Create a test secret with invalid kubeconfig data secret := &corev1.Secret{ @@ -177,21 +174,9 @@ func TestHandleInvalidKubeconfig(t *testing.T) { // TestStaticClusterPersistence tests that static clusters persist when dynamic clusters are updated func TestStaticClusterPersistence(t *testing.T) { - // Create a stop channel for the ClusterManager - stopCh := make(chan struct{}) - defer close(stopCh) - // Create a fake Kubernetes clientset - fakeClient := fake.NewSimpleClientset() - - // Create a ClusterManager with the fake client - cm := &ClusterManager{ - clusters: make(map[string]*cluster.Cluster), - clientset: fakeClient, - stopCh: stopCh, - tokenPassthroughEnabled: false, - clustersRoleConfigMap: make(map[string]util.RBAC), - } + // Create a ClusterManager + cm := newTestClusterManager() // Add a static cluster staticCluster := &cluster.Cluster{ @@ -247,9 +232,7 @@ func TestStaticClusterPersistence(t *testing.T) { // TestRemoveDynamicClusters tests removing dynamic clusters func TestRemoveDynamicClusters(t *testing.T) { // Create a ClusterManager - cm := &ClusterManager{ - clusters: make(map[string]*cluster.Cluster), - } + cm := newTestClusterManager() // Add some clusters cluster1 := &cluster.Cluster{ @@ -281,17 +264,9 @@ func TestRemoveDynamicClusters(t *testing.T) { // TestNewSecretController tests creating a new SecretController func TestNewSecretController(t *testing.T) { - // Create a fake Kubernetes client - fakeClient := fake.NewSimpleClientset() - // Create a ClusterManager with the fake client - cm := &ClusterManager{ - clusters: make(map[string]*cluster.Cluster), - clientset: fakeClient, - tokenPassthroughEnabled: false, - audiences: []string{}, - clustersRoleConfigMap: make(map[string]util.RBAC), - } + // Create a ClusterManager + cm := newTestClusterManager() // Test creating a SecretController controller, err := NewSecretController(cm, "test-namespace", "test-secret") diff --git a/pkg/proxy/crd/processor.go b/pkg/proxy/crd/processor.go index de9e46080..62d21d8a4 100644 --- a/pkg/proxy/crd/processor.go +++ b/pkg/proxy/crd/processor.go @@ -5,13 +5,11 @@ import ( "github.com/Improwised/kube-oidc-proxy/constants" "github.com/Improwised/kube-oidc-proxy/pkg/cluster" - "github.com/Improwised/kube-oidc-proxy/pkg/util" v1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/klog/v2" - rbacvalidation "k8s.io/kubernetes/pkg/registry/rbac/validation" ) // convertUnstructured is a generic conversion helper @@ -377,14 +375,8 @@ func (ctrl *CAPIRbacWatcher) ProcessExistingRBACObjects() { // rebuildAllAuthorizers updates RBAC authorizers for all clusters. func (ctrl *CAPIRbacWatcher) RebuildAllAuthorizers() { for _, c := range ctrl.clusters { - _, staticRoles := rbacvalidation.NewTestRuleResolver( - c.RBACConfig.Roles, - c.RBACConfig.RoleBindings, - c.RBACConfig.ClusterRoles, - c.RBACConfig.ClusterRoleBindings, - ) - klog.V(5).Infof("Rebuilding authorizer for cluster: %s", c.Name) - c.Authorizer = util.NewAuthorizer(staticRoles) + ctrl.authorizer.UpdatePermissionTrie(c.RBACConfig, c.Name) + } } diff --git a/pkg/proxy/crd/processor_test.go b/pkg/proxy/crd/processor_test.go index d18c6ee37..d9f45e631 100644 --- a/pkg/proxy/crd/processor_test.go +++ b/pkg/proxy/crd/processor_test.go @@ -7,6 +7,7 @@ import ( "github.com/Improwised/kube-oidc-proxy/constants" "github.com/Improwised/kube-oidc-proxy/pkg/cluster" "github.com/Improwised/kube-oidc-proxy/pkg/util" + "github.com/Improwised/kube-oidc-proxy/pkg/util/authorizer" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "k8s.io/api/rbac/v1" @@ -177,12 +178,58 @@ func TestRebuildAllAuthorizers(t *testing.T) { }}, }, } - watcher := &CAPIRbacWatcher{clusters: []*cluster.Cluster{testCluster}} + + // Create a mock authorizer to capture calls to UpdatePermissionTrie + type updateCall struct { + rbacConfig *util.RBAC + clusterName string + } + var calls []updateCall + + // Create a mock authorizer + mockAuthorizer := &mockAuthorizer{ + updatePermissionTrieFunc: func(rbacConfig *util.RBAC, clusterName string) { + calls = append(calls, updateCall{rbacConfig, clusterName}) + }, + } + + watcher := &CAPIRbacWatcher{ + clusters: []*cluster.Cluster{testCluster}, + authorizer: mockAuthorizer, + } watcher.RebuildAllAuthorizers() - // Verify authorizer is created - assert.NotNil(t, testCluster.Authorizer) + // Verify UpdatePermissionTrie was called with correct parameters + assert.Len(t, calls, 1) + assert.Equal(t, testCluster.RBACConfig, calls[0].rbacConfig) + assert.Equal(t, "cluster1", calls[0].clusterName) +} + +// mockAuthorizer implements authorizer.Interface for testing +type mockAuthorizer struct { + updatePermissionTrieFunc func(rbacConfig *util.RBAC, clusterName string) + checkPermissionFunc func(attrs authorizer.Attributes) bool + removeClusterPermissionsFunc func(cluster string) +} + +func (m *mockAuthorizer) UpdatePermissionTrie(rbacConfig *util.RBAC, clusterName string) { + if m.updatePermissionTrieFunc != nil { + m.updatePermissionTrieFunc(rbacConfig, clusterName) + } +} + +func (m *mockAuthorizer) CheckPermission(attrs authorizer.Attributes) bool { + if m.checkPermissionFunc != nil { + return m.checkPermissionFunc(attrs) + } + return false +} + +func (m *mockAuthorizer) RemoveClusterPermissions(cluster string) { + if m.removeClusterPermissionsFunc != nil { + m.removeClusterPermissionsFunc(cluster) + } } func TestApplyToClusters(t *testing.T) { diff --git a/pkg/proxy/crd/watcher.go b/pkg/proxy/crd/watcher.go index d10fdf749..046ddc4e3 100644 --- a/pkg/proxy/crd/watcher.go +++ b/pkg/proxy/crd/watcher.go @@ -6,6 +6,7 @@ import ( "github.com/Improwised/kube-oidc-proxy/pkg/cluster" "github.com/Improwised/kube-oidc-proxy/pkg/util" + "github.com/Improwised/kube-oidc-proxy/pkg/util/authorizer" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" @@ -20,10 +21,11 @@ type CAPIRbacWatcher struct { CAPIRoleBindingInformer cache.SharedIndexInformer clusters []*cluster.Cluster initialProcessingComplete bool + authorizer authorizer.Interface mu sync.RWMutex } -func NewCAPIRbacWatcher(clusters []*cluster.Cluster) (*CAPIRbacWatcher, error) { +func NewCAPIRbacWatcher(clusters []*cluster.Cluster, auth authorizer.Interface) (*CAPIRbacWatcher, error) { clusterConfig, err := util.BuildConfiguration() if err != nil { @@ -49,6 +51,7 @@ func NewCAPIRbacWatcher(clusters []*cluster.Cluster) (*CAPIRbacWatcher, error) { CAPIRoleBindingInformer: capiRoleBindingInformer, CAPIClusterRoleBindingInformer: capiClusterRoleBindingInformer, clusters: clusters, + authorizer: auth, } watcher.RegisterEventHandlers() diff --git a/pkg/proxy/handlers.go b/pkg/proxy/handlers.go index 55567138f..1568490a0 100644 --- a/pkg/proxy/handlers.go +++ b/pkg/proxy/handlers.go @@ -7,9 +7,7 @@ import ( "strings" authuser "k8s.io/apiserver/pkg/authentication/user" - genericapifilters "k8s.io/apiserver/pkg/endpoints/filters" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" - "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/transport" "k8s.io/klog/v2" "k8s.io/kubernetes/pkg/kubeapiserver/admission/exclusion" @@ -40,7 +38,6 @@ func (p *Proxy) WithRBACHandler(handler http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { clusterName := p.GetClusterName(req.URL.Path) - ClusterConfig := p.clusterManager.GetCluster(clusterName) req.URL.Path = strings.TrimPrefix(req.URL.Path, "/"+clusterName) reqInfo, err := p.requestInfo.NewRequestInfo(req) @@ -63,16 +60,23 @@ func (p *Proxy) WithRBACHandler(handler http.Handler) http.Handler { return } } - - // add request info into context req = req.WithContext(context.WithRequestInfo(req.Context(), reqInfo)) - - // validate resource request if reqInfo.IsResourceRequest { - authHandler := genericapifilters.WithAuthorization(handler, ClusterConfig.Authorizer, scheme.Codecs) - req.URL.Path = "/" + clusterName + req.URL.Path - authHandler.ServeHTTP(rw, req) - return + + user, ok := genericapirequest.UserFrom(req.Context()) + if !ok { + p.handleError(rw, req, errUnauthorized) + return + } + + // Check permission using our custom authorizer + authorized := p.clusterManager.CheckPermission(user.GetGroups(), user.GetName(), clusterName, reqInfo.Namespace, reqInfo.APIGroup, reqInfo.Resource, reqInfo.Name, reqInfo.Verb) + + if !authorized { + klog.Infof("user %s not authorized to %s %s in namespace %s", user.GetName(), reqInfo.Verb, reqInfo.Resource, reqInfo.Namespace) + p.handleError(rw, req, errUnauthorized) + return + } } // Eg. non resource request diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index a2d6cf366..fb06737d4 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -57,6 +57,7 @@ type ClusterManager interface { GetCluster(name string) *cluster.Cluster GetAllClusters() []*cluster.Cluster RemoveCluster(name string) + CheckPermission(groups []string, subjectName, cluster, namespace, apiGroup, resource, resourceName, verb string) bool } type errorHandlerFn func(http.ResponseWriter, *http.Request, error) diff --git a/pkg/proxy/proxy_test.go b/pkg/proxy/proxy_test.go index c3be2a700..ef52b8f22 100644 --- a/pkg/proxy/proxy_test.go +++ b/pkg/proxy/proxy_test.go @@ -82,6 +82,10 @@ func (m *mockClusterManager) RemoveCluster(name string) { delete(m.clusters, name) } +func (m *mockClusterManager) CheckPermission(grops []string, subjectName, cluster, namespace, apiGroup, resource, resourceName, verb string) bool { + return true +} + type fakeRW struct { buffer []byte header http.Header diff --git a/pkg/proxy/rbac/rbac.go b/pkg/proxy/rbac/rbac.go index 18a9e2295..0f3a8c084 100644 --- a/pkg/proxy/rbac/rbac.go +++ b/pkg/proxy/rbac/rbac.go @@ -5,13 +5,12 @@ import ( "fmt" "github.com/Improwised/kube-oidc-proxy/pkg/cluster" - "github.com/Improwised/kube-oidc-proxy/pkg/util" + "github.com/Improwised/kube-oidc-proxy/pkg/util/authorizer" corev1 "k8s.io/api/core/v1" v1 "k8s.io/api/rbac/v1" apisv1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/watch" "k8s.io/klog/v2" - rbacvalidation "k8s.io/kubernetes/pkg/registry/rbac/validation" ) var defalutRole = map[string]v1.PolicyRule{ @@ -42,16 +41,16 @@ var defalutRole = map[string]v1.PolicyRule{ }, } -func LoadRBAC(cluster *cluster.Cluster) error { +func LoadRBAC(cluster *cluster.Cluster, authorizer authorizer.Interface) error { // First load existing RBAC resources from the cluster - err := loadExistingRBAC(cluster) + err := loadExistingRBAC(cluster, authorizer) if err != nil { return fmt.Errorf("failed to load existing RBAC: %v", err) } // Set up watchers for RBAC resources - err = setupRBACWatchers(cluster) + err = setupRBACWatchers(cluster, authorizer) if err != nil { return fmt.Errorf("failed to setup RBAC watchers: %v", err) } @@ -160,14 +159,14 @@ func LoadRBAC(cluster *cluster.Cluster) error { } - updateAuthorizer(cluster) + authorizer.UpdatePermissionTrie(cluster.RBACConfig, cluster.Name) } }() return nil } -func loadExistingRBAC(cluster *cluster.Cluster) error { +func loadExistingRBAC(cluster *cluster.Cluster, authorizer authorizer.Interface) error { // List existing ClusterRoles clusterRoles, err := cluster.Kubeclient.RbacV1().ClusterRoles().List(context.Background(), apisv1.ListOptions{}) if err != nil { @@ -215,12 +214,12 @@ func loadExistingRBAC(cluster *cluster.Cluster) error { } - updateAuthorizer(cluster) + authorizer.UpdatePermissionTrie(cluster.RBACConfig, cluster.Name) return nil } -func setupRBACWatchers(cluster *cluster.Cluster) error { +func setupRBACWatchers(cluster *cluster.Cluster, authorizer authorizer.Interface) error { // Watch ClusterRoles watchClusterRoles, err := cluster.Kubeclient.RbacV1().ClusterRoles().Watch(context.Background(), apisv1.ListOptions{}) if err != nil { @@ -266,7 +265,7 @@ func setupRBACWatchers(cluster *cluster.Cluster) error { } } } - updateAuthorizer(cluster) + authorizer.UpdatePermissionTrie(cluster.RBACConfig, cluster.Name) } }() @@ -297,7 +296,7 @@ func setupRBACWatchers(cluster *cluster.Cluster) error { } } } - updateAuthorizer(cluster) + authorizer.UpdatePermissionTrie(cluster.RBACConfig, cluster.Name) } }() @@ -327,10 +326,10 @@ func setupRBACWatchers(cluster *cluster.Cluster) error { } // Start Role watcher for this namespace - go watchNamespaceRoles(watchRoles, cluster) + go watchNamespaceRoles(watchRoles, cluster, authorizer) // Start RoleBinding watcher for this namespace - go watchNamespaceRoleBindings(watchRoleBindings, cluster) + go watchNamespaceRoleBindings(watchRoleBindings, cluster, authorizer) } } }() @@ -338,7 +337,7 @@ func setupRBACWatchers(cluster *cluster.Cluster) error { return nil } -func watchNamespaceRoles(watchRoles watch.Interface, cluster *cluster.Cluster) { +func watchNamespaceRoles(watchRoles watch.Interface, cluster *cluster.Cluster, authorizer authorizer.Interface) { for event := range watchRoles.ResultChan() { role, ok := event.Object.(*v1.Role) if !ok { @@ -364,11 +363,11 @@ func watchNamespaceRoles(watchRoles watch.Interface, cluster *cluster.Cluster) { } } } - updateAuthorizer(cluster) + authorizer.UpdatePermissionTrie(cluster.RBACConfig, cluster.Name) } } -func watchNamespaceRoleBindings(watchRoleBindings watch.Interface, cluster *cluster.Cluster) { +func watchNamespaceRoleBindings(watchRoleBindings watch.Interface, cluster *cluster.Cluster, authorizer authorizer.Interface) { for event := range watchRoleBindings.ResultChan() { rb, ok := event.Object.(*v1.RoleBinding) if !ok { @@ -394,16 +393,6 @@ func watchNamespaceRoleBindings(watchRoleBindings watch.Interface, cluster *clus } } } - updateAuthorizer(cluster) + authorizer.UpdatePermissionTrie(cluster.RBACConfig, cluster.Name) } } - -func updateAuthorizer(cluster *cluster.Cluster) { - _, staticRoles := rbacvalidation.NewTestRuleResolver( - cluster.RBACConfig.Roles, - cluster.RBACConfig.RoleBindings, - cluster.RBACConfig.ClusterRoles, - cluster.RBACConfig.ClusterRoleBindings, - ) - cluster.Authorizer = util.NewAuthorizer(staticRoles) -} diff --git a/pkg/util/authorizer.go b/pkg/util/authorizer.go deleted file mode 100644 index fbe4d6cd5..000000000 --- a/pkg/util/authorizer.go +++ /dev/null @@ -1,10 +0,0 @@ -package util - -import ( - rbacvalidation "k8s.io/kubernetes/pkg/registry/rbac/validation" - rbac "k8s.io/kubernetes/plugin/pkg/auth/authorizer/rbac" -) - -func NewAuthorizer(r *rbacvalidation.StaticRoles) *rbac.RBACAuthorizer { - return rbac.New(r, r, r, r) -} diff --git a/pkg/util/authorizer/authorizer.go b/pkg/util/authorizer/authorizer.go new file mode 100644 index 000000000..e5907bca6 --- /dev/null +++ b/pkg/util/authorizer/authorizer.go @@ -0,0 +1,221 @@ +package authorizer + +import ( + "github.com/Improwised/kube-oidc-proxy/pkg/util" + v1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/klog/v2" +) + +// Attributes holds all the details about a request to be checked. +type Attributes struct { + User user.Info + Verb string + Cluster string + + IsResourceRequest bool + Path string + Namespace string + APIGroup string + Resource string + ResourceName string +} + +// Interface describes what an authorizer can do. +type Interface interface { + // CheckPermission checks if a user can perform an action. + CheckPermission(attrs Attributes) bool + + // UpdatePermissionTrie loads all RBAC rules from a cluster. + UpdatePermissionTrie(rbacConfig *util.RBAC, clusterName string) + + // RemoveClusterPermissions deletes all rules for a specific cluster. + RemoveClusterPermissions(cluster string) +} + +// RBACAuthorizer checks permissions using a PermissionTrie. +type RBACAuthorizer struct { + trie *PermissionTrie +} + +// NewRBACAuthorizer creates a new, empty authorizer. +func NewRBACAuthorizer() Interface { + trie := NewPermissionTrie() + return &RBACAuthorizer{ + trie: trie, + } +} + +// UpdatePermissionTrie loads a fresh set of RBAC rules for a cluster. +func (a *RBACAuthorizer) UpdatePermissionTrie(rbacConfig *util.RBAC, clusterName string) { + // First, delete all old rules for this cluster. + a.RemoveClusterPermissions(clusterName) + + // Combine rules from ClusterRoles that include other roles. + resolvedClusterRoles := resolveAggregatedRoles(rbacConfig.ClusterRoles) + clusterRolesMap := make(map[string]*v1.ClusterRole, len(resolvedClusterRoles)) + for _, cr := range resolvedClusterRoles { + clusterRolesMap[cr.Name] = cr + } + + rolesMap := make(map[string]*v1.Role, len(rbacConfig.Roles)) + for _, r := range rbacConfig.Roles { + rolesMap[r.Namespace+"/"+r.Name] = r + } + + // Process RoleBindings + for _, binding := range rbacConfig.RoleBindings { + // A RoleBinding can reference a Role or a ClusterRole. + // If it references a ClusterRole, the permissions are granted only within the RoleBinding's namespace. + if binding.RoleRef.Kind == "Role" { + if role, found := rolesMap[binding.Namespace+"/"+binding.RoleRef.Name]; found { + for _, subject := range binding.Subjects { + a.addRulesForSubject(subject, clusterName, binding.Namespace, role.Rules) + } + } + } else if binding.RoleRef.Kind == "ClusterRole" { + if clusterRole, found := clusterRolesMap[binding.RoleRef.Name]; found { + for _, subject := range binding.Subjects { + a.addRulesForSubject(subject, clusterName, binding.Namespace, clusterRole.Rules) + } + } + } + } + + // Process ClusterRoleBindings + for _, binding := range rbacConfig.ClusterRoleBindings { + if binding.RoleRef.Kind == "ClusterRole" { + if clusterRole, found := clusterRolesMap[binding.RoleRef.Name]; found { + for _, subject := range binding.Subjects { + // Grant ClusterRole permissions cluster-wide + a.addRulesForSubject(subject, clusterName, "", clusterRole.Rules) + } + } + } + } +} + +// addRulesForSubject is a helper that adds all permissions from a rule set for one subject. +func (a *RBACAuthorizer) addRulesForSubject(subject v1.Subject, clusterName, namespace string, rules []v1.PolicyRule) { + subjectType := SubjectType(subject.Kind) + if subjectType != SubjectTypeUser && subjectType != SubjectTypeGroup && subjectType != SubjectTypeServiceAccount { + return + } + + for _, rule := range rules { + for _, verb := range rule.Verbs { + // Add permissions for URL paths. + for _, url := range rule.NonResourceURLs { + a.trie.AddURLPermission(subjectType, subject.Name, clusterName, url, verb) + } + + // Add permissions for API resources (like "pods"). + apiGroups := rule.APIGroups + if len(apiGroups) == 0 { + apiGroups = []string{""} + } + for _, apiGroup := range apiGroups { + for _, resource := range rule.Resources { + a.trie.AddResourcePermission(subjectType, subject.Name, clusterName, namespace, apiGroup, resource, verb, rule.ResourceNames) + } + } + } + } +} + +// RemoveClusterPermissions deletes all rules associated with a single cluster. +func (a *RBACAuthorizer) RemoveClusterPermissions(cluster string) { + a.trie.mu.Lock() + defer a.trie.mu.Unlock() + for subjectKey, subjectNode := range a.trie.subjectNodes { + if _, exists := subjectNode.clusterNodes[cluster]; exists { + delete(subjectNode.clusterNodes, cluster) + + if len(subjectNode.clusterNodes) == 0 { + delete(a.trie.subjectNodes, subjectKey) + } + } + } +} + +// CheckPermission checks if a user or any of their groups has permission. +func (a *RBACAuthorizer) CheckPermission(attrs Attributes) bool { + // 1. Check permissions for the user directly. + if a.checkSubjectPermission(attrs.User.GetName(), SubjectTypeUser, attrs) { + return true + } + + // 2. Check permissions for each of the user's groups. + for _, group := range attrs.User.GetGroups() { + if a.checkSubjectPermission(group, SubjectTypeGroup, attrs) { + return true + } + } + + return false +} + +// checkSubjectPermission is a helper that checks permissions for a single subject. +func (a *RBACAuthorizer) checkSubjectPermission(subjectName string, subjectType SubjectType, attrs Attributes) bool { + if attrs.IsResourceRequest { + return a.trie.CheckResourcePermission(subjectType, subjectName, attrs.Cluster, attrs.Namespace, attrs.APIGroup, attrs.Resource, attrs.ResourceName, attrs.Verb) + } + return a.trie.CheckURLPermission(subjectType, subjectName, attrs.Cluster, attrs.Path, attrs.Verb) +} + +// resolveAggregatedRoles finds ClusterRoles that include other roles and combines their rules. +func resolveAggregatedRoles(clusterRoles []*v1.ClusterRole) []*v1.ClusterRole { + rolesByName := make(map[string]*v1.ClusterRole, len(clusterRoles)) + for _, role := range clusterRoles { + rolesByName[role.Name] = role.DeepCopy() + } + + for _, role := range rolesByName { + resolveStack := make(map[string]bool) + resolveAggregation(role, rolesByName, resolveStack) + } + + resolvedRoles := make([]*v1.ClusterRole, 0, len(rolesByName)) + for _, role := range rolesByName { + resolvedRoles = append(resolvedRoles, role) + } + return resolvedRoles +} + +// resolveAggregation is a helper that recursively combines rules from aggregated roles. +func resolveAggregation(role *v1.ClusterRole, rolesByName map[string]*v1.ClusterRole, resolveStack map[string]bool) { + // Avoid infinite loops if roles include each other. + if resolveStack[role.Name] { + klog.Warningf("Warning: cycle detected in ClusterRole aggregation involving %s\n", role.Name) + return + } + resolveStack[role.Name] = true + defer func() { resolveStack[role.Name] = false }() + + if role.AggregationRule == nil { + return + } + + for _, selector := range role.AggregationRule.ClusterRoleSelectors { + parsedSelector, err := metav1.LabelSelectorAsSelector(&selector) + if err != nil { + klog.Warningf("Warning: could not parse label selector in ClusterRole %s: %v\n", role.Name, err) + continue + } + + // Find other roles that match the selector. + for _, otherRole := range rolesByName { + if otherRole.Name == role.Name { + continue + } + if parsedSelector.Matches(labels.Set(otherRole.Labels)) { + // First, resolve the other role's aggregations. + resolveAggregation(otherRole, rolesByName, resolveStack) + // Then, append its rules to the current role. + role.Rules = append(role.Rules, otherRole.Rules...) + } + } + } +} diff --git a/pkg/util/authorizer/authorizer_test.go b/pkg/util/authorizer/authorizer_test.go new file mode 100644 index 000000000..9843955ff --- /dev/null +++ b/pkg/util/authorizer/authorizer_test.go @@ -0,0 +1,268 @@ +package authorizer + +import ( + "testing" + + "github.com/Improwised/kube-oidc-proxy/pkg/util" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/authentication/user" +) + +func TestNewRBACAuthorizer(t *testing.T) { + auth := NewRBACAuthorizer() + assert.NotNil(t, auth, "NewRBACAuthorizer should not return nil") + + rbacAuth, ok := auth.(*RBACAuthorizer) + assert.True(t, ok, "auth should be of type *RBACAuthorizer") + assert.NotNil(t, rbacAuth.trie, "Authorizer trie should not be nil") +} + +func TestUpdateAndCheckPermissions(t *testing.T) { + auth := NewRBACAuthorizer() + clusterName := "test-cluster" + + rbacConfig := &util.RBAC{ + Roles: []*v1.Role{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pod-reader", Namespace: "ns1"}, + Rules: []v1.PolicyRule{{ + APIGroups: []string{"core"}, + Resources: []string{"pods"}, + Verbs: []string{"get", "list"}, + }}, + }, + }, + ClusterRoles: []*v1.ClusterRole{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node-reader"}, + Rules: []v1.PolicyRule{{ + APIGroups: []string{"core"}, + Resources: []string{"nodes"}, + Verbs: []string{"get"}, + }}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "metrics-viewer"}, + Rules: []v1.PolicyRule{{ + NonResourceURLs: []string{"/metrics"}, + Verbs: []string{"get"}, + }}, + }, + }, + RoleBindings: []*v1.RoleBinding{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "ns1"}, + Subjects: []v1.Subject{{Kind: "User", Name: "test-user"}}, + RoleRef: v1.RoleRef{Kind: "Role", Name: "pod-reader"}, + }, + }, + ClusterRoleBindings: []*v1.ClusterRoleBinding{ + { + Subjects: []v1.Subject{{Kind: "Group", Name: "test-group"}}, + RoleRef: v1.RoleRef{Kind: "ClusterRole", Name: "node-reader"}, + }, + { + Subjects: []v1.Subject{{Kind: "User", Name: "metrics-user"}}, + RoleRef: v1.RoleRef{Kind: "ClusterRole", Name: "metrics-viewer"}, + }, + }, + } + + auth.UpdatePermissionTrie(rbacConfig, clusterName) + + testCases := []struct { + name string + attributes Attributes + expected bool + }{ + { + name: "User with direct RoleBinding permission", + attributes: Attributes{ + User: &user.DefaultInfo{Name: "test-user"}, + Cluster: clusterName, + IsResourceRequest: true, + Namespace: "ns1", + APIGroup: "core", + Resource: "pods", + Verb: "get", + }, + expected: true, + }, + { + name: "User with direct RoleBinding, wrong verb", + attributes: Attributes{ + User: &user.DefaultInfo{Name: "test-user"}, + Cluster: clusterName, + IsResourceRequest: true, + Namespace: "ns1", + APIGroup: "core", + Resource: "pods", + Verb: "delete", + }, + expected: false, + }, + { + name: "User in group with ClusterRoleBinding permission", + attributes: Attributes{ + User: &user.DefaultInfo{Name: "some-user", Groups: []string{"test-group"}}, + Cluster: clusterName, + IsResourceRequest: true, + APIGroup: "core", + Resource: "nodes", + Verb: "get", + }, + expected: true, + }, + { + name: "User in group, wrong resource", + attributes: Attributes{ + User: &user.DefaultInfo{Name: "some-user", Groups: []string{"test-group"}}, + Cluster: clusterName, + IsResourceRequest: true, + APIGroup: "core", + Resource: "services", + Verb: "get", + }, + expected: false, + }, + { + name: "User with NonResourceURL permission", + attributes: Attributes{ + User: &user.DefaultInfo{Name: "metrics-user"}, + Cluster: clusterName, + IsResourceRequest: false, + Path: "/metrics", + Verb: "get", + }, + expected: true, + }, + { + name: "User with NonResourceURL, wrong path", + attributes: Attributes{ + User: &user.DefaultInfo{Name: "metrics-user"}, + Cluster: clusterName, + IsResourceRequest: false, + Path: "/logs", + Verb: "get", + }, + expected: false, + }, + { + name: "User with no permissions", + attributes: Attributes{ + User: &user.DefaultInfo{Name: "unauthorized-user"}, + Cluster: clusterName, + IsResourceRequest: true, + Namespace: "ns1", + APIGroup: "core", + Resource: "pods", + Verb: "get", + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := auth.CheckPermission(tc.attributes) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestRemoveClusterPermissions(t *testing.T) { + auth := NewRBACAuthorizer() + clusterA := "cluster-a" + clusterB := "cluster-b" + user := &user.DefaultInfo{Name: "test-user"} + + rbacConfigA := &util.RBAC{ + ClusterRoles: []*v1.ClusterRole{{ + ObjectMeta: metav1.ObjectMeta{Name: "reader"}, + Rules: []v1.PolicyRule{{Resources: []string{"pods"}, Verbs: []string{"get"}}}, + }}, + ClusterRoleBindings: []*v1.ClusterRoleBinding{{ + Subjects: []v1.Subject{{Kind: "User", Name: user.GetName()}}, + RoleRef: v1.RoleRef{Kind: "ClusterRole", Name: "reader"}, + }}, + } + + auth.UpdatePermissionTrie(rbacConfigA, clusterA) + auth.UpdatePermissionTrie(rbacConfigA, clusterB) + + attrs := Attributes{User: user, IsResourceRequest: true, Resource: "pods", Verb: "get"} + + attrs.Cluster = clusterA + assert.True(t, auth.CheckPermission(attrs), "Permission should exist on cluster-a before removal") + + attrs.Cluster = clusterB + assert.True(t, auth.CheckPermission(attrs), "Permission should exist on cluster-b") + + auth.RemoveClusterPermissions(clusterA) + + attrs.Cluster = clusterA + assert.False(t, auth.CheckPermission(attrs), "Permission should be gone from cluster-a after removal") + + attrs.Cluster = clusterB + assert.True(t, auth.CheckPermission(attrs), "Permission should still exist on cluster-b") +} + +func TestResolveAggregatedRoles(t *testing.T) { + roleA := &v1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "role-a", Labels: map[string]string{"tier": "1"}}, + Rules: []v1.PolicyRule{{Verbs: []string{"get"}}}, + } + roleB := &v1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "role-b", Labels: map[string]string{"tier": "2"}}, + Rules: []v1.PolicyRule{{Verbs: []string{"list"}}}, + AggregationRule: &v1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + {MatchLabels: map[string]string{"tier": "1"}}, + }, + }, + } + roleCWithCycle := &v1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "role-c", Labels: map[string]string{"cycle": "true"}}, + AggregationRule: &v1.AggregationRule{ + ClusterRoleSelectors: []metav1.LabelSelector{ + {MatchLabels: map[string]string{"cycle": "true"}}, + }, + }, + } + + roles := []*v1.ClusterRole{roleA, roleB, roleCWithCycle} + resolvedRoles := resolveAggregatedRoles(roles) + + var resolvedB *v1.ClusterRole + for _, r := range resolvedRoles { + if r.Name == "role-b" { + resolvedB = r + } + } + + assert.NotNil(t, resolvedB, "Resolved role B should exist") + // role-b should have its own "list" rule and role-a's "get" rule. + assert.Len(t, resolvedB.Rules, 2, "Resolved role B should have 2 rules") + + hasGet := false + hasList := false + for _, rule := range resolvedB.Rules { + if rule.Verbs[0] == "get" { + hasGet = true + } + if rule.Verbs[0] == "list" { + hasList = true + } + } + assert.True(t, hasGet, "Resolved role B should have 'get' verb") + assert.True(t, hasList, "Resolved role B should have 'list' verb") + + // Test for cycle detection, just ensure it terminates without error. + // The function prints a warning, which we can't easily check here, + // but termination is the most important part. + assert.NotPanics(t, func() { + resolveAggregatedRoles([]*v1.ClusterRole{roleCWithCycle}) + }) +} diff --git a/pkg/util/authorizer/permissiontrie.go b/pkg/util/authorizer/permissiontrie.go new file mode 100644 index 000000000..73ff01300 --- /dev/null +++ b/pkg/util/authorizer/permissiontrie.go @@ -0,0 +1,335 @@ +package authorizer + +import ( + "sync" +) + +// Verb constants for bitmask +const ( + VerbGet uint32 = 1 << iota + VerbList + VerbWatch + VerbCreate + VerbUpdate + VerbPatch + VerbDelete + VerbDeleteCollection + VerbImpersonate + VerbPost + VerbPut + VerbAll // Wildcard +) + +// CollectionVerbs represents verbs that act on a collection of resources. +const CollectionVerbs = VerbList | VerbWatch | VerbCreate | VerbDeleteCollection + +// verbMap translates verb strings to their bitmask representation +var verbMap = map[string]uint32{ + "get": VerbGet, + "list": VerbList, + "watch": VerbWatch, + "create": VerbCreate, + "update": VerbUpdate, + "patch": VerbPatch, + "delete": VerbDelete, + "deletecollection": VerbDeleteCollection, + "impersonate": VerbImpersonate, + "post": VerbPost, + "put": VerbPut, + "*": VerbAll, +} + +// PermissionTrie holds all permissions in a tree-like structure for fast lookups. +type PermissionTrie struct { + subjectNodes map[string]*SubjectNode + mu sync.RWMutex +} + +// SubjectType is the type of entity (user, group, etc.). +type SubjectType string + +const ( + SubjectTypeUser SubjectType = "User" + SubjectTypeGroup SubjectType = "Group" + SubjectTypeServiceAccount SubjectType = "ServiceAccount" +) + +// SubjectNode is a branch in the trie for a specific user or group. +type SubjectNode struct { + subjectType SubjectType + clusterNodes map[string]*ClusterNode +} + +// ClusterNode is a branch for a specific Kubernetes cluster. +type ClusterNode struct { + namespaceNodes map[string]*NamespaceNode + nonResourceURLs map[string]*URLNode +} + +// URLNode holds permissions for a non-resource URL path. +type URLNode struct { + verbs uint32 +} + +// NamespaceNode is a branch for a specific namespace. +type NamespaceNode struct { + apiGroupNodes map[string]*APIGroupNode +} + +// APIGroupNode is a branch for a specific API group (e.g., "apps"). +type APIGroupNode struct { + resourceNodes map[string]*ResourceNode +} + +// ResourceNode holds the final permissions for a resource (e.g., "pods"). +type ResourceNode struct { + verbs uint32 + resourceNames map[string]struct{} // nil means all names are allowed +} + +// NewPermissionTrie creates a new, empty permission trie. +func NewPermissionTrie() *PermissionTrie { + return &PermissionTrie{ + subjectNodes: make(map[string]*SubjectNode), + } +} + +// AddResourcePermission adds a permission for a specific API resource. +func (t *PermissionTrie) AddResourcePermission(subjectType SubjectType, subjectName, cluster, namespace, apiGroup, resource, verb string, resourceNames []string) { + verbBit, ok := verbMap[verb] + if !ok { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + + subjectNode := t.getOrCreateSubjectNode(subjectType, subjectName) + clusterNode := subjectNode.getOrCreateClusterNode(cluster) + namespaceNode := clusterNode.getOrCreateNamespaceNode(namespace) + apiGroupNode := namespaceNode.getOrCreateAPIGroupNode(apiGroup) + resourceNode := apiGroupNode.getOrCreateResourceNode(resource) + + resourceNode.verbs |= verbBit + + if len(resourceNames) > 0 { + if resourceNode.resourceNames == nil { + resourceNode.resourceNames = make(map[string]struct{}) + } + for _, name := range resourceNames { + resourceNode.resourceNames[name] = struct{}{} + } + } +} + +// AddURLPermission adds a permission for a non-resource URL path. +func (t *PermissionTrie) AddURLPermission(subjectType SubjectType, subjectName, cluster, url, verb string) { + verbBit, ok := verbMap[verb] + if !ok { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + + subjectNode := t.getOrCreateSubjectNode(subjectType, subjectName) + clusterNode := subjectNode.getOrCreateClusterNode(cluster) + urlNode := clusterNode.getOrCreateURLNode(url) + urlNode.verbs |= verbBit +} + +// CheckResourcePermission checks if a subject has permission for a resource. +func (t *PermissionTrie) CheckResourcePermission(subjectType SubjectType, subjectName, cluster, namespace, apiGroup, resource, resourceName, verb string) bool { + verbBit, ok := verbMap[verb] + if !ok { + return false + } + + t.mu.RLock() + defer t.mu.RUnlock() + + subjectKey := getSubjectKey(subjectType, subjectName) + subjectNode, exists := t.subjectNodes[subjectKey] + if !exists { + return false + } + + clusterNode, exists := subjectNode.clusterNodes[cluster] + if !exists { + return false + } + + // 1. Check for rules in the specific namespace. + if namespaceNode, exists := clusterNode.namespaceNodes[namespace]; exists { + if namespaceNode.check(apiGroup, resource, resourceName, verbBit) { + return true + } + } + + // 2. If no permission, check for cluster-wide rules (in namespace ""). + if namespace != "" { + if namespaceNode, exists := clusterNode.namespaceNodes[""]; exists { + if namespaceNode.check(apiGroup, resource, resourceName, verbBit) { + return true + } + } + } + + return false +} + +// CheckURLPermission checks if a subject has permission for a URL path. +func (t *PermissionTrie) CheckURLPermission(subjectType SubjectType, subjectName, cluster, url, verb string) bool { + verbBit, ok := verbMap[verb] + if !ok { + return false + } + + t.mu.RLock() + defer t.mu.RUnlock() + + subjectKey := getSubjectKey(subjectType, subjectName) + subjectNode, exists := t.subjectNodes[subjectKey] + if !exists { + return false + } + + clusterNode, exists := subjectNode.clusterNodes[cluster] + if !exists { + return false + } + + // Check the specific URL, then the wildcard "*" URL. + if urlNode, exists := clusterNode.nonResourceURLs[url]; exists { + if (urlNode.verbs&VerbAll != 0) || (urlNode.verbs&verbBit != 0) { + return true + } + } + if urlNode, exists := clusterNode.nonResourceURLs["*"]; exists { + if (urlNode.verbs&VerbAll != 0) || (urlNode.verbs&verbBit != 0) { + return true + } + } + + return false +} + +// check is an internal helper to search for permissions down the trie. +func (n *NamespaceNode) check(apiGroup, resource, resourceName string, verbBit uint32) bool { + + if apiGroupNode, exists := n.apiGroupNodes[apiGroup]; exists { + if apiGroupNode.check(resource, resourceName, verbBit) { + return true + } + } + if apiGroupNode, exists := n.apiGroupNodes["*"]; exists { + if apiGroupNode.check(resource, resourceName, verbBit) { + return true + } + } + + return false +} + +// check is an internal helper to search for permissions down the trie. +func (a *APIGroupNode) check(resource, resourceName string, verbBit uint32) bool { + if resourceNode, exists := a.resourceNodes[resource]; exists { + if resourceNode.check(resourceName, verbBit) { + return true + } + } + if resourceNode, exists := a.resourceNodes["*"]; exists { + if resourceNode.check(resourceName, verbBit) { + return true + } + } + return false +} + +// check is an internal helper to search for permissions down the trie. +func (r *ResourceNode) check(resourceName string, verbBit uint32) bool { + verbAllowed := (r.verbs&VerbAll != 0) || (r.verbs&verbBit != 0) + if !verbAllowed { + return false + } + + // Collection-level verbs are not restricted by resourceNames. + if (verbBit & CollectionVerbs) != 0 { + return true + } + + // For item-level verbs, if resourceNames is nil, it's a wildcard. + if r.resourceNames == nil { + return true + } + // Otherwise, check if the specific name is in our list. + _, exists := r.resourceNames[resourceName] + return exists +} + +// getOrCreateSubjectNode finds or creates a new node for a subject. +func (t *PermissionTrie) getOrCreateSubjectNode(subjectType SubjectType, subjectName string) *SubjectNode { + subjectKey := getSubjectKey(subjectType, subjectName) + if _, exists := t.subjectNodes[subjectKey]; !exists { + t.subjectNodes[subjectKey] = &SubjectNode{ + subjectType: subjectType, + clusterNodes: make(map[string]*ClusterNode), + } + } + return t.subjectNodes[subjectKey] +} + +// getOrCreateClusterNode finds or creates a new node for a cluster. +func (s *SubjectNode) getOrCreateClusterNode(cluster string) *ClusterNode { + if _, exists := s.clusterNodes[cluster]; !exists { + s.clusterNodes[cluster] = &ClusterNode{ + namespaceNodes: make(map[string]*NamespaceNode), + nonResourceURLs: make(map[string]*URLNode), + } + } + return s.clusterNodes[cluster] +} + +// getOrCreateURLNode finds or creates a new node for a URL. +func (c *ClusterNode) getOrCreateURLNode(url string) *URLNode { + if _, exists := c.nonResourceURLs[url]; !exists { + c.nonResourceURLs[url] = &URLNode{verbs: 0} + } + return c.nonResourceURLs[url] +} + +// getOrCreateNamespaceNode finds or creates a new node for a namespace. +func (c *ClusterNode) getOrCreateNamespaceNode(namespace string) *NamespaceNode { + if _, exists := c.namespaceNodes[namespace]; !exists { + c.namespaceNodes[namespace] = &NamespaceNode{ + apiGroupNodes: make(map[string]*APIGroupNode), + } + } + return c.namespaceNodes[namespace] +} + +// getOrCreateAPIGroupNode finds or creates a new node for an API group. +func (n *NamespaceNode) getOrCreateAPIGroupNode(apiGroup string) *APIGroupNode { + if _, exists := n.apiGroupNodes[apiGroup]; !exists { + n.apiGroupNodes[apiGroup] = &APIGroupNode{ + resourceNodes: make(map[string]*ResourceNode), + } + } + return n.apiGroupNodes[apiGroup] +} + +// getOrCreateResourceNode finds or creates a new node for a resource. +func (a *APIGroupNode) getOrCreateResourceNode(resource string) *ResourceNode { + if _, exists := a.resourceNodes[resource]; !exists { + a.resourceNodes[resource] = &ResourceNode{ + verbs: 0, + resourceNames: nil, + } + } + return a.resourceNodes[resource] +} + +// getSubjectKey creates a unique ID string for a subject. +func getSubjectKey(subjectType SubjectType, subjectName string) string { + return string(subjectType) + ":" + subjectName +} diff --git a/pkg/util/authorizer/permissiontrie_test.go b/pkg/util/authorizer/permissiontrie_test.go new file mode 100644 index 000000000..61f5c8781 --- /dev/null +++ b/pkg/util/authorizer/permissiontrie_test.go @@ -0,0 +1,209 @@ +package authorizer + +import ( + "testing" +) + +func TestPermissionTrie_AddAndCheckResourcePermission(t *testing.T) { + trie := NewPermissionTrie() + subjectType := SubjectTypeUser + subjectName := "test-user" + cluster := "test-cluster" + namespace := "test-ns" + apiGroup := "apps" + resource := "deployments" + verb := "get" + resourceName := "test-deployment" + + trie.AddResourcePermission(subjectType, subjectName, cluster, namespace, apiGroup, resource, verb, []string{resourceName}) + + testCases := []struct { + name string + subjectType SubjectType + subjectName string + cluster string + namespace string + apiGroup string + resource string + resourceName string + verb string + expected bool + }{ + {"ExactMatch", subjectType, subjectName, cluster, namespace, apiGroup, resource, resourceName, verb, true}, + {"WrongVerb", subjectType, subjectName, cluster, namespace, apiGroup, resource, resourceName, "list", false}, + {"WrongResourceName", subjectType, subjectName, cluster, namespace, apiGroup, resource, "wrong-deployment", "get", false}, + {"WrongResource", subjectType, subjectName, cluster, namespace, apiGroup, "pods", resourceName, verb, false}, + {"WrongAPIGroup", subjectType, subjectName, cluster, namespace, "batch", resource, resourceName, verb, false}, + {"WrongNamespace", subjectType, subjectName, cluster, "wrong-ns", apiGroup, resource, resourceName, verb, false}, + {"WrongCluster", subjectType, subjectName, "wrong-cluster", namespace, apiGroup, resource, resourceName, verb, false}, + {"WrongSubjectName", subjectType, "wrong-user", cluster, namespace, apiGroup, resource, resourceName, verb, false}, + {"WrongSubjectType", SubjectTypeGroup, subjectName, cluster, namespace, apiGroup, resource, resourceName, verb, false}, + {"InvalidVerb", subjectType, subjectName, cluster, namespace, apiGroup, resource, resourceName, "invalid", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := trie.CheckResourcePermission(tc.subjectType, tc.subjectName, tc.cluster, tc.namespace, tc.apiGroup, tc.resource, tc.resourceName, tc.verb) + if got != tc.expected { + t.Errorf("Expected permission to be %v, but got %v", tc.expected, got) + } + }) + } +} + +func TestPermissionTrie_Wildcards(t *testing.T) { + trie := NewPermissionTrie() + subject := "wildcard-user" + cluster := "test-cluster" + + // Verb wildcard + trie.AddResourcePermission(SubjectTypeUser, subject, cluster, "ns1", "apps", "pods", "*", nil) + // Resource wildcard + trie.AddResourcePermission(SubjectTypeUser, subject, cluster, "ns1", "apps", "*", "get", nil) + // APIGroup wildcard + trie.AddResourcePermission(SubjectTypeUser, subject, cluster, "ns1", "*", "deployments", "list", nil) + // Namespace wildcard (cluster-level) + trie.AddResourcePermission(SubjectTypeUser, subject, cluster, "", "batch", "jobs", "watch", nil) + // ResourceName wildcard + trie.AddResourcePermission(SubjectTypeUser, subject, cluster, "ns2", "core", "secrets", "get", nil) + // URL wildcard + trie.AddURLPermission(SubjectTypeUser, subject, cluster, "*", "get") + + testCases := []struct { + name string + check func() bool + expected bool + }{ + {"VerbWildcard_AllowsGet", func() bool { + return trie.CheckResourcePermission(SubjectTypeUser, subject, cluster, "ns1", "apps", "pods", "pod1", "get") + }, true}, + {"VerbWildcard_AllowsDelete", func() bool { + return trie.CheckResourcePermission(SubjectTypeUser, subject, cluster, "ns1", "apps", "pods", "pod1", "delete") + }, true}, + {"ResourceWildcard_AllowsPods", func() bool { + return trie.CheckResourcePermission(SubjectTypeUser, subject, cluster, "ns1", "apps", "pods", "pod1", "get") + }, true}, + {"ResourceWildcard_AllowsServices", func() bool { + return trie.CheckResourcePermission(SubjectTypeUser, subject, cluster, "ns1", "apps", "services", "svc1", "get") + }, true}, + {"APIGroupWildcard_AllowsApps", func() bool { + return trie.CheckResourcePermission(SubjectTypeUser, subject, cluster, "ns1", "apps", "deployments", "dep1", "list") + }, true}, + {"APIGroupWildcard_AllowsBatch", func() bool { + return trie.CheckResourcePermission(SubjectTypeUser, subject, cluster, "ns1", "batch", "deployments", "dep1", "list") + }, true}, + {"NamespaceWildcard_AllowsInNamespace", func() bool { + return trie.CheckResourcePermission(SubjectTypeUser, subject, cluster, "some-ns", "batch", "jobs", "job1", "watch") + }, true}, + {"NamespaceWildcard_AllowsInEmptyNamespace", func() bool { + return trie.CheckResourcePermission(SubjectTypeUser, subject, cluster, "", "batch", "jobs", "job1", "watch") + }, true}, + {"ResourceNameWildcard_AllowsAnyName", func() bool { + return trie.CheckResourcePermission(SubjectTypeUser, subject, cluster, "ns2", "core", "secrets", "secret1", "get") + }, true}, + {"ResourceNameWildcard_AllowsOtherName", func() bool { + return trie.CheckResourcePermission(SubjectTypeUser, subject, cluster, "ns2", "core", "secrets", "secret2", "get") + }, true}, + {"URLWildcard_AllowsSpecificURL", func() bool { return trie.CheckURLPermission(SubjectTypeUser, subject, cluster, "/api/v1/pods", "get") }, true}, + {"URLWildcard_AllowsOtherURL", func() bool { return trie.CheckURLPermission(SubjectTypeUser, subject, cluster, "/metrics", "get") }, true}, + {"URLWildcard_DeniesWrongVerb", func() bool { return trie.CheckURLPermission(SubjectTypeUser, subject, cluster, "/api/v1/pods", "post") }, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.check(); got != tc.expected { + t.Errorf("Expected permission to be %v, but got %v", tc.expected, got) + } + }) + } +} + +func TestPermissionTrie_AddAndCheckURLPermission(t *testing.T) { + trie := NewPermissionTrie() + subject := "url-user" + cluster := "c1" + url := "/api/v1/nodes" + verb := "get" + + trie.AddURLPermission(SubjectTypeUser, subject, cluster, url, verb) + trie.AddURLPermission(SubjectTypeUser, subject, cluster, "/metrics", "*") + + testCases := []struct { + name string + url string + verb string + expected bool + }{ + {"ExactMatch", url, verb, true}, + {"WrongVerb", url, "post", false}, + {"WrongURL", "/api/v1/pods", verb, false}, + {"VerbWildcard_AllowsGet", "/metrics", "get", true}, + {"VerbWildcard_AllowsPost", "/metrics", "post", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := trie.CheckURLPermission(SubjectTypeUser, subject, cluster, tc.url, tc.verb) + if got != tc.expected { + t.Errorf("Expected permission for URL %s with verb %s to be %v, but got %v", tc.url, tc.verb, tc.expected, got) + } + }) + } +} + +func TestPermissionTrie_RemoveClusterPermissions(t *testing.T) { + trie := NewPermissionTrie() + subject := "multi-cluster-user" + + // Add permissions for two clusters + trie.AddResourcePermission(SubjectTypeUser, subject, "cluster-a", "default", "core", "pods", "get", nil) + trie.AddURLPermission(SubjectTypeUser, subject, "cluster-a", "/logs", "get") + trie.AddResourcePermission(SubjectTypeUser, subject, "cluster-b", "default", "core", "pods", "get", nil) + + // Verify permissions exist + if !trie.CheckResourcePermission(SubjectTypeUser, subject, "cluster-a", "default", "core", "pods", "pod1", "get") { + t.Fatal("Expected permission to exist in cluster-a before removal") + } + if !trie.CheckResourcePermission(SubjectTypeUser, subject, "cluster-b", "default", "core", "pods", "pod1", "get") { + t.Fatal("Expected permission to exist in cluster-b before removal") + } + + // Remove cluster-a + auth := &RBACAuthorizer{trie: trie} + auth.RemoveClusterPermissions("cluster-a") + + // Verify permissions for cluster-a are gone + if trie.CheckResourcePermission(SubjectTypeUser, subject, "cluster-a", "default", "core", "pods", "pod1", "get") { + t.Error("Expected resource permission to be removed from cluster-a, but it still exists") + } + if trie.CheckURLPermission(SubjectTypeUser, subject, "cluster-a", "/logs", "get") { + t.Error("Expected URL permission to be removed from cluster-a, but it still exists") + } + + // Verify permissions for cluster-b remain + if !trie.CheckResourcePermission(SubjectTypeUser, subject, "cluster-b", "default", "core", "pods", "pod1", "get") { + t.Error("Expected permission to remain for cluster-b, but it was removed") + } + + // Verify subject node was not deleted because it still has cluster-b + subjectKey := getSubjectKey(SubjectTypeUser, subject) + if _, exists := trie.subjectNodes[subjectKey]; !exists { + t.Error("SubjectNode was deleted but should not have been") + } + + // Now remove cluster-b and check that the subject node is also removed + auth.RemoveClusterPermissions("cluster-b") + if _, exists := trie.subjectNodes[subjectKey]; exists { + t.Error("SubjectNode was not deleted but should have been") + } +} + +func TestPermissionTrie_EmptyTrie(t *testing.T) { + trie := NewPermissionTrie() + if trie.CheckResourcePermission(SubjectTypeUser, "any-user", "any-cluster", "any-ns", "any-group", "any-res", "any-name", "get") { + t.Error("Expected empty trie to always deny resource permission") + } + if trie.CheckURLPermission(SubjectTypeUser, "any-user", "any-cluster", "/any/url", "get") { + t.Error("Expected empty trie to always deny URL permission") + } +}