diff --git a/pkg/features/kube_features.go b/pkg/features/kube_features.go index 401564faf96..10ad47e4b1e 100644 --- a/pkg/features/kube_features.go +++ b/pkg/features/kube_features.go @@ -293,6 +293,13 @@ const ( // fallback to using it's cgroupDriver option. KubeletCgroupDriverFromCRI featuregate.Feature = "KubeletCgroupDriverFromCRI" + // owner: @vinayakankugoyal + // kep: http://kep.k8s.io/2862 + // + // Enable fine-grained kubelet API authorization for webhook based + // authorization. + KubeletFineGrainedAuthz featuregate.Feature = "KubeletFineGrainedAuthz" + // owner: @AkihiroSuda // // Enables support for running kubelet in a user namespace. diff --git a/pkg/features/versioned_kube_features.go b/pkg/features/versioned_kube_features.go index 47f39308413..d6351bf2d31 100644 --- a/pkg/features/versioned_kube_features.go +++ b/pkg/features/versioned_kube_features.go @@ -412,6 +412,10 @@ var defaultVersionedKubernetesFeatureGates = map[featuregate.Feature]featuregate {Version: version.MustParse("1.31"), Default: true, PreRelease: featuregate.Beta}, }, + KubeletFineGrainedAuthz: { + {Version: version.MustParse("1.32"), Default: false, PreRelease: featuregate.Alpha}, + }, + KubeletInUserNamespace: { {Version: version.MustParse("1.22"), Default: false, PreRelease: featuregate.Alpha}, }, diff --git a/pkg/kubelet/server/auth.go b/pkg/kubelet/server/auth.go index 5316ba45765..568f78b0484 100644 --- a/pkg/kubelet/server/auth.go +++ b/pkg/kubelet/server/auth.go @@ -24,26 +24,30 @@ import ( "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/server/healthz" + utilfeature "k8s.io/apiserver/pkg/util/feature" + "k8s.io/component-base/configz" "k8s.io/klog/v2" + "k8s.io/kubernetes/pkg/features" ) // KubeletAuth implements AuthInterface type KubeletAuth struct { // authenticator identifies the user for requests to the Kubelet API authenticator.Request - // authorizerAttributeGetter builds authorization.Attributes for a request to the Kubelet API - authorizer.RequestAttributesGetter + // KubeletRequestAttributesGetter builds authorization.Attributes for a request to the Kubelet API + NodeRequestAttributesGetter // authorizer determines whether a given authorization.Attributes is allowed authorizer.Authorizer } // NewKubeletAuth returns a kubelet.AuthInterface composed of the given authenticator, attribute getter, and authorizer -func NewKubeletAuth(authenticator authenticator.Request, authorizerAttributeGetter authorizer.RequestAttributesGetter, authorizer authorizer.Authorizer) AuthInterface { +func NewKubeletAuth(authenticator authenticator.Request, authorizerAttributeGetter NodeRequestAttributesGetter, authorizer authorizer.Authorizer) AuthInterface { return &KubeletAuth{authenticator, authorizerAttributeGetter, authorizer} } // NewNodeAuthorizerAttributesGetter creates a new authorizer.RequestAttributesGetter for the node. -func NewNodeAuthorizerAttributesGetter(nodeName types.NodeName) authorizer.RequestAttributesGetter { +func NewNodeAuthorizerAttributesGetter(nodeName types.NodeName) NodeRequestAttributesGetter { return nodeAuthorizerAttributesGetter{nodeName: nodeName} } @@ -60,10 +64,15 @@ func isSubpath(subpath, path string) bool { // Default attributes are: {apiVersion=v1,verb=,resource=nodes,name=,subresource=proxy} // More specific verb/resource is set for the following request patterns: // -// /stats/* => verb=, resource=nodes, name=, subresource=stats -// /metrics/* => verb=, resource=nodes, name=, subresource=metrics -// /logs/* => verb=, resource=nodes, name=, subresource=log -func (n nodeAuthorizerAttributesGetter) GetRequestAttributes(u user.Info, r *http.Request) authorizer.Attributes { +// /stats/* => verb=, resource=nodes, name=, subresource(s)=stats +// /metrics/* => verb=, resource=nodes, name=, subresource(s)=metrics +// /logs/* => verb=, resource=nodes, name=, subresource(s)=log +// /checkpoint/* => verb=, resource=nodes, name=, subresource(s)=checkpoint +// /pods/* => verb=, resource=nodes, name=, subresource(s)=pods,proxy +// /runningPods/* => verb=, resource=nodes, name=, subresource(s)=pods,proxy +// /healthz/* => verb=, resource=nodes, name=, subresource(s)=healthz,proxy +// /configz => verb=, resource=nodes, name=, subresource(s)=configz,proxy +func (n nodeAuthorizerAttributesGetter) GetRequestAttributes(u user.Info, r *http.Request) []authorizer.Attributes { apiVerb := "" switch r.Method { @@ -81,35 +90,54 @@ func (n nodeAuthorizerAttributesGetter) GetRequestAttributes(u user.Info, r *htt requestPath := r.URL.Path - // Default attributes mirror the API attributes that would allow this access to the kubelet API - attrs := authorizer.AttributesRecord{ - User: u, - Verb: apiVerb, - Namespace: "", - APIGroup: "", - APIVersion: "v1", - Resource: "nodes", - Subresource: "proxy", - Name: string(n.nodeName), - ResourceRequest: true, - Path: requestPath, + var subresources []string + if utilfeature.DefaultFeatureGate.Enabled(features.KubeletFineGrainedAuthz) { + switch { + case isSubpath(requestPath, podsPath): + subresources = append(subresources, "pods") + case isSubpath(requestPath, healthz.DefaultHealthzPath): + subresources = append(subresources, "healthz") + case isSubpath(requestPath, configz.DefaultConfigzPath): + subresources = append(subresources, "configz") + // We put runningpods last since it will allocate a new string on every + // check since the handler path has a trailing slash. + case isSubpath(requestPath, runningPodsPath): + subresources = append(subresources, "pods") + } } - // Override subresource for specific paths - // This allows subdividing access to the kubelet API switch { case isSubpath(requestPath, statsPath): - attrs.Subresource = "stats" + subresources = append(subresources, "stats") case isSubpath(requestPath, metricsPath): - attrs.Subresource = "metrics" + subresources = append(subresources, "metrics") case isSubpath(requestPath, logsPath): // "log" to match other log subresources (pods/log, etc) - attrs.Subresource = "log" + subresources = append(subresources, "log") case isSubpath(requestPath, checkpointPath): - attrs.Subresource = "checkpoint" + subresources = append(subresources, "checkpoint") + default: + subresources = append(subresources, "proxy") } - klog.V(5).InfoS("Node request attributes", "user", attrs.GetUser().GetName(), "verb", attrs.GetVerb(), "resource", attrs.GetResource(), "subresource", attrs.GetSubresource()) + var attrs []authorizer.Attributes + for _, subresource := range subresources { + attr := authorizer.AttributesRecord{ + User: u, + Verb: apiVerb, + Namespace: "", + APIGroup: "", + APIVersion: "v1", + Resource: "nodes", + Subresource: subresource, + Name: string(n.nodeName), + ResourceRequest: true, + Path: requestPath, + } + attrs = append(attrs, attr) + } + + klog.V(5).InfoS("Node request attributes", "user", attrs[0].GetUser().GetName(), "verb", attrs[0].GetVerb(), "resource", attrs[0].GetResource(), "subresource(s)", subresources) return attrs } diff --git a/pkg/kubelet/server/auth_test.go b/pkg/kubelet/server/auth_test.go index b6a32bbe68e..baefad83484 100644 --- a/pkg/kubelet/server/auth_test.go +++ b/pkg/kubelet/server/auth_test.go @@ -24,6 +24,9 @@ import ( "github.com/stretchr/testify/require" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" + utilfeature "k8s.io/apiserver/pkg/util/feature" + featuregatetesting "k8s.io/component-base/featuregate/testing" + "k8s.io/kubernetes/pkg/features" ) func TestIsSubPath(t *testing.T) { @@ -61,16 +64,19 @@ func TestIsSubPath(t *testing.T) { } func TestGetRequestAttributes(t *testing.T) { - for _, test := range AuthzTestCases() { - t.Run(test.Method+":"+test.Path, func(t *testing.T) { - getter := NewNodeAuthorizerAttributesGetter(authzTestNodeName) + for _, fineGrained := range []bool{false, true} { + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.KubeletFineGrainedAuthz, fineGrained) + for _, test := range AuthzTestCases(fineGrained) { + t.Run(test.Method+":"+test.Path, func(t *testing.T) { + getter := NewNodeAuthorizerAttributesGetter(authzTestNodeName) - req, err := http.NewRequest(test.Method, "https://localhost:1234"+test.Path, nil) - require.NoError(t, err) - attrs := getter.GetRequestAttributes(AuthzTestUser(), req) + req, err := http.NewRequest(test.Method, "https://localhost:1234"+test.Path, nil) + require.NoError(t, err) + attrs := getter.GetRequestAttributes(AuthzTestUser(), req) - test.AssertAttributes(t, attrs) - }) + test.AssertAttributes(t, attrs) + }) + } } } @@ -82,60 +88,78 @@ const ( type AuthzTestCase struct { Method, Path string - ExpectedVerb, ExpectedSubresource string + ExpectedVerb string + ExpectedSubresources []string } -func (a *AuthzTestCase) AssertAttributes(t *testing.T, attrs authorizer.Attributes) { - expectedAttributes := authorizer.AttributesRecord{ - User: AuthzTestUser(), - APIGroup: "", - APIVersion: "v1", - Verb: a.ExpectedVerb, - Resource: "nodes", - Name: authzTestNodeName, - Subresource: a.ExpectedSubresource, - ResourceRequest: true, - Path: a.Path, +func (a *AuthzTestCase) AssertAttributes(t *testing.T, attrs []authorizer.Attributes) { + var expectedAttributes []authorizer.AttributesRecord + for _, subresource := range a.ExpectedSubresources { + expectedAttributes = append(expectedAttributes, authorizer.AttributesRecord{ + User: AuthzTestUser(), + APIGroup: "", + APIVersion: "v1", + Verb: a.ExpectedVerb, + Resource: "nodes", + Name: authzTestNodeName, + Subresource: subresource, + ResourceRequest: true, + Path: a.Path, + }) } - assert.Equal(t, expectedAttributes, attrs) + assert.Equal(t, len(attrs), len(expectedAttributes)) + for i := range attrs { + assert.Equal(t, attrs[i], expectedAttributes[i]) + } } func AuthzTestUser() user.Info { return &user.DefaultInfo{Name: authzTestUserName} } -func AuthzTestCases() []AuthzTestCase { +func AuthzTestCases(fineGrained bool) []AuthzTestCase { // Path -> ExpectedSubresource - testPaths := map[string]string{ - "/attach/{podNamespace}/{podID}/{containerName}": "proxy", - "/attach/{podNamespace}/{podID}/{uid}/{containerName}": "proxy", - "/checkpoint/{podNamespace}/{podID}/{containerName}": "checkpoint", - "/configz": "proxy", - "/containerLogs/{podNamespace}/{podID}/{containerName}": "proxy", - "/debug/flags/v": "proxy", - "/debug/pprof/{subpath:*}": "proxy", - "/exec/{podNamespace}/{podID}/{containerName}": "proxy", - "/exec/{podNamespace}/{podID}/{uid}/{containerName}": "proxy", - "/healthz": "proxy", - "/healthz/log": "proxy", - "/healthz/ping": "proxy", - "/healthz/syncloop": "proxy", - "/logs/": "log", - "/logs/{logpath:*}": "log", - "/metrics": "metrics", - "/metrics/cadvisor": "metrics", - "/metrics/probes": "metrics", - "/metrics/resource": "metrics", - "/pods/": "proxy", - "/portForward/{podNamespace}/{podID}": "proxy", - "/portForward/{podNamespace}/{podID}/{uid}": "proxy", - "/run/{podNamespace}/{podID}/{containerName}": "proxy", - "/run/{podNamespace}/{podID}/{uid}/{containerName}": "proxy", - "/runningpods/": "proxy", - "/stats/": "stats", - "/stats/summary": "stats", + testPaths := map[string][]string{ + "/attach/{podNamespace}/{podID}/{containerName}": {"proxy"}, + "/attach/{podNamespace}/{podID}/{uid}/{containerName}": {"proxy"}, + "/checkpoint/{podNamespace}/{podID}/{containerName}": {"checkpoint"}, + "/configz": {"proxy"}, + "/containerLogs/{podNamespace}/{podID}/{containerName}": {"proxy"}, + "/debug/flags/v": {"proxy"}, + "/debug/pprof/{subpath:*}": {"proxy"}, + "/exec/{podNamespace}/{podID}/{containerName}": {"proxy"}, + "/exec/{podNamespace}/{podID}/{uid}/{containerName}": {"proxy"}, + "/healthz": {"proxy"}, + "/healthz/log": {"proxy"}, + "/healthz/ping": {"proxy"}, + "/healthz/syncloop": {"proxy"}, + "/logs/": {"log"}, + "/logs/{logpath:*}": {"log"}, + "/metrics": {"metrics"}, + "/metrics/cadvisor": {"metrics"}, + "/metrics/probes": {"metrics"}, + "/metrics/resource": {"metrics"}, + "/pods/": {"proxy"}, + "/portForward/{podNamespace}/{podID}": {"proxy"}, + "/portForward/{podNamespace}/{podID}/{uid}": {"proxy"}, + "/run/{podNamespace}/{podID}/{containerName}": {"proxy"}, + "/run/{podNamespace}/{podID}/{uid}/{containerName}": {"proxy"}, + "/runningpods/": {"proxy"}, + "/stats/": {"stats"}, + "/stats/summary": {"stats"}, } + + if fineGrained { + testPaths["/healthz"] = append([]string{"healthz"}, testPaths["/healthz"]...) + testPaths["/healthz/log"] = append([]string{"healthz"}, testPaths["/healthz/log"]...) + testPaths["/healthz/ping"] = append([]string{"healthz"}, testPaths["/healthz/ping"]...) + testPaths["/healthz/syncloop"] = append([]string{"healthz"}, testPaths["/healthz/syncloop"]...) + testPaths["/pods/"] = append([]string{"pods"}, testPaths["/pods/"]...) + testPaths["/runningpods/"] = append([]string{"pods"}, testPaths["/runningpods/"]...) + testPaths["/configz"] = append([]string{"configz"}, testPaths["/configz"]...) + } + testCases := []AuthzTestCase{} for path, subresource := range testPaths { testCases = append(testCases, diff --git a/pkg/kubelet/server/server.go b/pkg/kubelet/server/server.go index 1c550a18789..e81aa8c454e 100644 --- a/pkg/kubelet/server/server.go +++ b/pkg/kubelet/server/server.go @@ -54,6 +54,7 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/server/healthz" "k8s.io/apiserver/pkg/server/httplog" @@ -101,6 +102,8 @@ const ( checkpointPath = "/checkpoint/" pprofBasePath = "/debug/pprof/" debugFlagPath = "/debug/flags/v" + podsPath = "/pods" + runningPodsPath = "/runningpods/" ) // Server is a http.Handler which exposes kubelet functionality over HTTP. @@ -240,10 +243,14 @@ func ListenAndServePodResources(endpoint string, providers podresources.PodResou } } +type NodeRequestAttributesGetter interface { + GetRequestAttributes(u user.Info, r *http.Request) []authorizer.Attributes +} + // AuthInterface contains all methods required by the auth filters type AuthInterface interface { authenticator.Request - authorizer.RequestAttributesGetter + NodeRequestAttributesGetter authorizer.Authorizer } @@ -317,19 +324,35 @@ func (s *Server) InstallAuthFilter() { // Get authorization attributes attrs := s.auth.GetRequestAttributes(info.User, req.Request) + var allowed bool + var msg string + var subresources []string + for _, attr := range attrs { + subresources = append(subresources, attr.GetSubresource()) + decision, _, err := s.auth.Authorize(req.Request.Context(), attr) + if err != nil { + klog.ErrorS(err, "Authorization error", "user", attr.GetUser().GetName(), "verb", attr.GetVerb(), "resource", attr.GetResource(), "subresource", attr.GetSubresource()) + msg = fmt.Sprintf("Authorization error (user=%s, verb=%s, resource=%s, subresource=%s)", attr.GetUser().GetName(), attr.GetVerb(), attr.GetResource(), attr.GetSubresource()) + resp.WriteErrorString(http.StatusInternalServerError, msg) + return - // Authorize - decision, _, err := s.auth.Authorize(req.Request.Context(), attrs) - if err != nil { - klog.ErrorS(err, "Authorization error", "user", attrs.GetUser().GetName(), "verb", attrs.GetVerb(), "resource", attrs.GetResource(), "subresource", attrs.GetSubresource()) - msg := fmt.Sprintf("Authorization error (user=%s, verb=%s, resource=%s, subresource=%s)", attrs.GetUser().GetName(), attrs.GetVerb(), attrs.GetResource(), attrs.GetSubresource()) - resp.WriteErrorString(http.StatusInternalServerError, msg) - return + } + + if decision == authorizer.DecisionAllow { + allowed = true + break + } } - if decision != authorizer.DecisionAllow { - klog.V(2).InfoS("Forbidden", "user", attrs.GetUser().GetName(), "verb", attrs.GetVerb(), "resource", attrs.GetResource(), "subresource", attrs.GetSubresource()) - msg := fmt.Sprintf("Forbidden (user=%s, verb=%s, resource=%s, subresource=%s)", attrs.GetUser().GetName(), attrs.GetVerb(), attrs.GetResource(), attrs.GetSubresource()) - resp.WriteErrorString(http.StatusForbidden, msg) + + if !allowed { + if len(attrs) == 0 { + klog.ErrorS(fmt.Errorf("could not determine attributes for request"), "Authorization error") + resp.WriteErrorString(http.StatusForbidden, "Authorization error: could not determine attributes for request") + return + } + // The attributes only differ by subresource so we just use the first one. + klog.V(2).InfoS("Forbidden", "user", attrs[0].GetUser().GetName(), "verb", attrs[0].GetVerb(), "resource", attrs[0].GetResource(), "subresource(s)", subresources) + resp.WriteErrorString(http.StatusForbidden, fmt.Sprintf("Forbidden (user=%s, verb=%s, resource=%s, subresource(s)=%v)\n", attrs[0].GetUser().GetName(), attrs[0].GetVerb(), attrs[0].GetResource(), subresources)) return } @@ -381,7 +404,7 @@ func (s *Server) InstallDefaultHandlers() { s.addMetricsBucketMatcher("pods") ws := new(restful.WebService) ws. - Path("/pods"). + Path(podsPath). Produces(restful.MIME_JSON) ws.Route(ws.GET(""). To(s.getPods). @@ -541,7 +564,7 @@ func (s *Server) InstallDebuggingHandlers() { s.addMetricsBucketMatcher("runningpods") ws = new(restful.WebService) ws. - Path("/runningpods/"). + Path(runningPodsPath). Produces(restful.MIME_JSON) ws.Route(ws.GET(""). To(s.getRunningPods). @@ -565,7 +588,7 @@ func (s *Server) InstallDebuggingDisabledHandlers() { s.addMetricsBucketMatcher("logs") paths := []string{ "/run/", "/exec/", "/attach/", "/portForward/", "/containerLogs/", - "/runningpods/", pprofBasePath, logsPath} + runningPodsPath, pprofBasePath, logsPath} for _, p := range paths { s.restfulCont.Handle(p, h) } diff --git a/pkg/kubelet/server/server_test.go b/pkg/kubelet/server/server_test.go index 7ffeb684960..d882ef1251e 100644 --- a/pkg/kubelet/server/server_test.go +++ b/pkg/kubelet/server/server_test.go @@ -291,14 +291,14 @@ func (*fakeKubelet) GetCgroupCPUAndMemoryStats(cgroupName string, updateStats bo type fakeAuth struct { authenticateFunc func(*http.Request) (*authenticator.Response, bool, error) - attributesFunc func(user.Info, *http.Request) authorizer.Attributes + attributesFunc func(user.Info, *http.Request) []authorizer.Attributes authorizeFunc func(authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) } func (f *fakeAuth) AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error) { return f.authenticateFunc(req) } -func (f *fakeAuth) GetRequestAttributes(u user.Info, req *http.Request) authorizer.Attributes { +func (f *fakeAuth) GetRequestAttributes(u user.Info, req *http.Request) []authorizer.Attributes { return f.attributesFunc(u, req) } func (f *fakeAuth) Authorize(ctx context.Context, a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { @@ -349,8 +349,8 @@ func newServerTestWithDebuggingHandlers(kubeCfg *kubeletconfiginternal.KubeletCo authenticateFunc: func(req *http.Request) (*authenticator.Response, bool, error) { return &authenticator.Response{User: &user.DefaultInfo{Name: "test"}}, true, nil }, - attributesFunc: func(u user.Info, req *http.Request) authorizer.Attributes { - return &authorizer.AttributesRecord{User: u} + attributesFunc: func(u user.Info, req *http.Request) []authorizer.Attributes { + return []authorizer.Attributes{&authorizer.AttributesRecord{User: u}} }, authorizeFunc: func(a authorizer.Attributes) (decision authorizer.Decision, reason string, err error) { return authorizer.DecisionAllow, "", nil @@ -546,7 +546,7 @@ func TestAuthzCoverage(t *testing.T) { } } - for _, tc := range AuthzTestCases() { + for _, tc := range AuthzTestCases(false) { expectedCases[tc.Method+":"+tc.Path] = true } @@ -566,7 +566,7 @@ func TestAuthFilters(t *testing.T) { attributesGetter := NewNodeAuthorizerAttributesGetter(authzTestNodeName) - for _, tc := range AuthzTestCases() { + for _, tc := range AuthzTestCases(false) { t.Run(tc.Method+":"+tc.Path, func(t *testing.T) { var ( expectedUser = AuthzTestUser() @@ -580,14 +580,14 @@ func TestAuthFilters(t *testing.T) { calledAuthenticate = true return &authenticator.Response{User: expectedUser}, true, nil } - fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) authorizer.Attributes { + fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) []authorizer.Attributes { calledAttributes = true require.Equal(t, expectedUser, u) return attributesGetter.GetRequestAttributes(u, req) } fw.fakeAuth.authorizeFunc = func(a authorizer.Attributes) (decision authorizer.Decision, reason string, err error) { calledAuthorize = true - tc.AssertAttributes(t, a) + tc.AssertAttributes(t, []authorizer.Attributes{a}) return authorizer.DecisionNoOpinion, "", nil } @@ -609,7 +609,7 @@ func TestAuthFilters(t *testing.T) { func TestAuthenticationError(t *testing.T) { var ( expectedUser = &user.DefaultInfo{Name: "test"} - expectedAttributes = &authorizer.AttributesRecord{User: expectedUser} + expectedAttributes = []authorizer.Attributes{&authorizer.AttributesRecord{User: expectedUser}} calledAuthenticate = false calledAuthorize = false @@ -622,7 +622,7 @@ func TestAuthenticationError(t *testing.T) { calledAuthenticate = true return &authenticator.Response{User: expectedUser}, true, nil } - fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) authorizer.Attributes { + fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) []authorizer.Attributes { calledAttributes = true return expectedAttributes } @@ -647,7 +647,7 @@ func TestAuthenticationError(t *testing.T) { func TestAuthenticationFailure(t *testing.T) { var ( expectedUser = &user.DefaultInfo{Name: "test"} - expectedAttributes = &authorizer.AttributesRecord{User: expectedUser} + expectedAttributes = []authorizer.Attributes{&authorizer.AttributesRecord{User: expectedUser}} calledAuthenticate = false calledAuthorize = false @@ -660,7 +660,7 @@ func TestAuthenticationFailure(t *testing.T) { calledAuthenticate = true return nil, false, nil } - fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) authorizer.Attributes { + fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) []authorizer.Attributes { calledAttributes = true return expectedAttributes } @@ -685,7 +685,7 @@ func TestAuthenticationFailure(t *testing.T) { func TestAuthorizationSuccess(t *testing.T) { var ( expectedUser = &user.DefaultInfo{Name: "test"} - expectedAttributes = &authorizer.AttributesRecord{User: expectedUser} + expectedAttributes = []authorizer.Attributes{&authorizer.AttributesRecord{User: expectedUser}} calledAuthenticate = false calledAuthorize = false @@ -698,7 +698,7 @@ func TestAuthorizationSuccess(t *testing.T) { calledAuthenticate = true return &authenticator.Response{User: expectedUser}, true, nil } - fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) authorizer.Attributes { + fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) []authorizer.Attributes { calledAttributes = true return expectedAttributes } @@ -1528,3 +1528,107 @@ func TestTrimURLPath(t *testing.T) { assert.Equalf(t, test.expected, getURLRootPath(test.path), "path is: %s", test.path) } } + +func TestFineGrainedAuthz(t *testing.T) { + // Enable features.ContainerCheckpoint during test + featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.KubeletFineGrainedAuthz, true) + + fw := newServerTest() + defer fw.testHTTPServer.Close() + + attributesGetter := NewNodeAuthorizerAttributesGetter(authzTestNodeName) + + testCases := []struct { + name string + path string + expectedSubResources []string + authorizer func(authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) + wantStatusCode int + wantCalledAuthorizeCount int + }{ + { + name: "both subresources rejected", + path: "/configz", + expectedSubResources: []string{"configz", "proxy"}, + authorizer: func(authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + return authorizer.DecisionNoOpinion, "", nil + }, + wantStatusCode: 403, + wantCalledAuthorizeCount: 2, + }, + { + name: "fine grained rejected, proxy accepted", + path: "/configz", + expectedSubResources: []string{"configz", "proxy"}, + authorizer: func(a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + switch a.GetSubresource() { + case "configz": + return authorizer.DecisionNoOpinion, "", nil + case "proxy": + return authorizer.DecisionAllow, "", nil + default: + return authorizer.DecisionNoOpinion, "", fmt.Errorf("unexpected subresource %v", a.GetSubresource()) + } + }, + wantStatusCode: 200, + wantCalledAuthorizeCount: 2, + }, + { + name: "fine grained accepted", + path: "/configz", + expectedSubResources: []string{"configz", "proxy"}, + authorizer: func(a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + switch a.GetSubresource() { + case "configz": + return authorizer.DecisionAllow, "", nil + case "proxy": + return authorizer.DecisionNoOpinion, "", fmt.Errorf("did not expect code to reach here") + default: + return authorizer.DecisionNoOpinion, "", fmt.Errorf("unexpected subresource %v", a.GetSubresource()) + } + }, + wantStatusCode: 200, + wantCalledAuthorizeCount: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + calledAuthenticate := false + calledAuthorizeCount := 0 + calledAttributes := false + + fw.fakeAuth.authenticateFunc = func(req *http.Request) (*authenticator.Response, bool, error) { + calledAuthenticate = true + return &authenticator.Response{User: AuthzTestUser()}, true, nil + } + fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) []authorizer.Attributes { + calledAttributes = true + attrs := attributesGetter.GetRequestAttributes(u, req) + var gotSubresources []string + for _, attr := range attrs { + gotSubresources = append(gotSubresources, attr.GetSubresource()) + } + require.Equal(t, tc.expectedSubResources, gotSubresources) + return attrs + } + fw.fakeAuth.authorizeFunc = func(a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + calledAuthorizeCount += 1 + return tc.authorizer(a) + } + + req, err := http.NewRequest("GET", fw.testHTTPServer.URL+tc.path, nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, tc.wantStatusCode, resp.StatusCode) + assert.True(t, calledAuthenticate, "Authenticate was not called") + assert.True(t, calledAttributes, "Attributes were not called") + assert.Equal(t, tc.wantCalledAuthorizeCount, calledAuthorizeCount) + }) + } +} diff --git a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go index 58333c3ec02..f4ef4340c8a 100644 --- a/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go +++ b/plugin/pkg/auth/authorizer/rbac/bootstrappolicy/policy.go @@ -388,17 +388,6 @@ func ClusterRoles() []rbacv1.ClusterRole { eventsRule(), }, }, - { - // a role to use for full access to the kubelet API - ObjectMeta: metav1.ObjectMeta{Name: "system:kubelet-api-admin"}, - Rules: []rbacv1.PolicyRule{ - // Allow read-only access to the Node API objects - rbacv1helpers.NewRule("get", "list", "watch").Groups(legacyGroup).Resources("nodes").RuleOrDie(), - // Allow all API calls to the nodes - rbacv1helpers.NewRule("proxy").Groups(legacyGroup).Resources("nodes").RuleOrDie(), - rbacv1helpers.NewRule("*").Groups(legacyGroup).Resources("nodes/proxy", "nodes/metrics", "nodes/stats", "nodes/log").RuleOrDie(), - }, - }, { // a role to use for bootstrapping a node's client certificates ObjectMeta: metav1.ObjectMeta{Name: "system:node-bootstrapper"}, @@ -530,6 +519,25 @@ func ClusterRoles() []rbacv1.ClusterRole { }, }) + // Add the cluster role system:kubelet-api-admin + kubeletAPIAdminRules := []rbacv1.PolicyRule{ + // Allow read-only access to the Node API objects + rbacv1helpers.NewRule("get", "list", "watch").Groups(legacyGroup).Resources("nodes").RuleOrDie(), + // Allow all API calls to the nodes + rbacv1helpers.NewRule("proxy").Groups(legacyGroup).Resources("nodes").RuleOrDie(), + rbacv1helpers.NewRule("*").Groups(legacyGroup).Resources("nodes/proxy", "nodes/metrics", "nodes/stats", "nodes/log").RuleOrDie(), + } + + if utilfeature.DefaultFeatureGate.Enabled(features.KubeletFineGrainedAuthz) { + kubeletAPIAdminRules = append(kubeletAPIAdminRules, rbacv1helpers.NewRule("*").Groups(legacyGroup).Resources("nodes/pods", "nodes/healthz", "nodes/configz").RuleOrDie()) + } + + roles = append(roles, rbacv1.ClusterRole{ + // a role to use for full access to the kubelet API + ObjectMeta: metav1.ObjectMeta{Name: "system:kubelet-api-admin"}, + Rules: kubeletAPIAdminRules, + }) + // node-proxier role is used by kube-proxy. nodeProxierRules := []rbacv1.PolicyRule{ rbacv1helpers.NewRule("list", "watch").Groups(legacyGroup).Resources("services", "endpoints").RuleOrDie(), diff --git a/staging/src/k8s.io/apiserver/pkg/server/healthz/healthz.go b/staging/src/k8s.io/apiserver/pkg/server/healthz/healthz.go index 76f5745b336..73092933119 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/healthz/healthz.go +++ b/staging/src/k8s.io/apiserver/pkg/server/healthz/healthz.go @@ -35,6 +35,8 @@ import ( "k8s.io/klog/v2" ) +const DefaultHealthzPath = "/healthz" + // HealthChecker is a named healthz checker. type HealthChecker interface { Name() string @@ -154,7 +156,7 @@ func NamedCheck(name string, check func(r *http.Request) error) HealthChecker { // exactly one call to InstallHandler. Calling InstallHandler more // than once for the same mux will result in a panic. func InstallHandler(mux mux, checks ...HealthChecker) { - InstallPathHandler(mux, "/healthz", checks...) + InstallPathHandler(mux, DefaultHealthzPath, checks...) } // InstallReadyzHandler registers handlers for health checking on the path diff --git a/staging/src/k8s.io/component-base/configz/configz.go b/staging/src/k8s.io/component-base/configz/configz.go index 60e4f938ade..53e809d34c4 100644 --- a/staging/src/k8s.io/component-base/configz/configz.go +++ b/staging/src/k8s.io/component-base/configz/configz.go @@ -47,6 +47,8 @@ import ( "sync" ) +const DefaultConfigzPath = "/configz" + var ( configsGuard sync.RWMutex configs = map[string]*Config{} @@ -61,7 +63,7 @@ type Config struct { // InstallHandler adds an HTTP handler on the given mux for the "/configz" // endpoint which serves all registered ComponentConfigs in JSON format. func InstallHandler(m mux) { - m.Handle("/configz", http.HandlerFunc(handle)) + m.Handle(DefaultConfigzPath, http.HandlerFunc(handle)) } type mux interface { diff --git a/test/featuregates_linter/test_data/versioned_feature_list.yaml b/test/featuregates_linter/test_data/versioned_feature_list.yaml index cd039bf5608..251d40830d1 100644 --- a/test/featuregates_linter/test_data/versioned_feature_list.yaml +++ b/test/featuregates_linter/test_data/versioned_feature_list.yaml @@ -546,6 +546,12 @@ lockToDefault: false preRelease: Beta version: "1.31" +- name: KubeletFineGrainedAuthz + versionedSpecs: + - default: false + lockToDefault: false + preRelease: Alpha + version: "1.32" - name: KubeletInUserNamespace versionedSpecs: - default: false