diff --git a/internal/concierge/impersonator/impersonator.go b/internal/concierge/impersonator/impersonator.go index 9c06d3f6d..b145856d0 100644 --- a/internal/concierge/impersonator/impersonator.go +++ b/internal/concierge/impersonator/impersonator.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2026 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package impersonator @@ -236,12 +236,12 @@ func newInternal( serverConfig.AuditPolicyRuleEvaluator = policy.NewFakePolicyRuleEvaluator(auditinternal.LevelMetadata, nil) serverConfig.AuditBackend = &auditfake.Backend{} - // Probe the API server to figure out if anonymous auth is enabled. - anonymousAuthEnabled, err := isAnonymousAuthEnabled(kubeClientUnsafeForProxying.JSONConfig) + // Probe the Kubernetes API server to figure out if anonymous auth is enabled. + anonymousAuthProbeResult, err := isAnonymousAuthEnabled(kubeClientUnsafeForProxying.JSONConfig) if err != nil { return nil, fmt.Errorf("could not detect if anonymous authentication is enabled: %w", err) } - plog.Debug("anonymous authentication probed", "anonymousAuthEnabled", anonymousAuthEnabled) + plog.Debug("anonymous authentication probed", "results", anonymousAuthProbeResult) // if we ever start unioning a TCR bearer token authenticator with serverConfig.Authenticator // then we will need to update the related assumption in tokenPassthroughRoundTripper @@ -251,8 +251,15 @@ func newInternal( RequestFunc: func(req *http.Request) (*authenticator.Response, bool, error) { resp, ok, err := delegatingAuthenticator.AuthenticateRequest(req) - // anonymous auth is enabled so no further check is necessary - if anonymousAuthEnabled { + reqIsHealthCheck := isRequestForHealthCheck(req) + + if anonymousAuthProbeResult.HealthCheckEndpointsAllowAnonAuth && reqIsHealthCheck { + // anonymous auth is enabled for health check endpoints, so no further check is necessary + return resp, ok, err + } + + if anonymousAuthProbeResult.OtherEndpointsAllowAnonAuth && !reqIsHealthCheck { + // anonymous auth is enabled for all other endpoints, so no further check is necessary return resp, ok, err } @@ -266,6 +273,10 @@ func newInternal( return resp, ok, err } + // If we got this far, then anonymous auth is disabled for this request's path, + // and authentication succeeded, and the user is system:anonymous. + // Now we want to allow these anonymous users to make requests _only_ to the TCR endpoint. + reqInfo, ok := genericapirequest.RequestInfoFrom(req.Context()) if !ok { return nil, false, constable.Error("no RequestInfo found in the context") @@ -276,7 +287,7 @@ func newInternal( return nil, false, nil } - // any resource besides TKR should not be authenticated + // any resource besides TKR should not be authenticated because anonymous auth is disabled on this cluster if !isTokenCredReq(reqInfo) { return nil, false, nil } @@ -370,7 +381,46 @@ func getReverseProxyClient(baseConfig *rest.Config, cache tokenclient.ExpiringSi return kubeclient.New(kubeclient.WithConfig(impersonationProxyRestConfig)) } -func isAnonymousAuthEnabled(config *rest.Config) (bool, error) { +func isRequestForHealthCheck(req *http.Request) bool { + if req == nil || req.URL == nil { + // Shouldn't really happen but easy enough to handle here. + return false + } + + path := req.URL.Path + + // See https://kubernetes.io/docs/reference/using-api/health-checks. + // Although there are sub-paths for these endpoints, e.g. /healthz/etcd, the sub-paths are not made available + // for anonymous auth by GKE when anonymous auth is otherwise disabled, so let's mirror that behavior here. + switch path { + case "/healthz", "/readyz", "/livez": + return true + default: + return false + } +} + +// anonAuthEnablement allows us to characterize the most common configurations for anonymous authentication: +// +// 1. Anonymous auth is disabled for the whole k8s API (e.g. AKS clusters). +// 2. Anonymous auth is enabled for the whole k8s API (e.g. GKE clusters before Kubernetes 1.35). +// 3. In newer clusters, anonymous auth can be selectively enabled only for certain API paths. +// See https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-authenticator-configuration. +// Usually this is used to enable anonymous auth only for the three health check endpoints, and disable it for +// all other endpoints. This configuration was adpoted by GKE clusters starting with Kube 1.35 (as the default, but +// can be overridden by the user during cluster creation to instead allow anon auth for the whole API) +// and by EKS clusters starting with Kube 1.32. +// +// Of course, other configurations of which API paths allow should anonymous auth are possible, but we have no practical +// way of auto-detecting them here. We could alternatively add new Pinniped configuration options instead of trying to +// auto-detect anonymous authentication enablement here, but that would put extra burdon on the user to configure it +// correctly. In practice, its almost always one of the three configurations described above. +type anonAuthEnablement struct { + HealthCheckEndpointsAllowAnonAuth bool + OtherEndpointsAllowAnonAuth bool +} + +func isAnonymousAuthEnabled(config *rest.Config) (*anonAuthEnablement, error) { anonymousConfig := kubeclient.SecureAnonymousClientConfig(config) // we do not need either of these but RESTClientFor complains if they are not set @@ -380,32 +430,54 @@ func isAnonymousAuthEnabled(config *rest.Config) (bool, error) { // in case anyone looking at audit logs wants to know who is making the anonymous request anonymousConfig.UserAgent = rest.DefaultKubernetesUserAgent() - rc, err := rest.RESTClientFor(anonymousConfig) + anonClient, err := rest.RESTClientFor(anonymousConfig) if err != nil { - return false, err + return nil, err } + healthzAllowsAnonAuth, err := isAnonymousAuthEnabledForEndpoint(anonClient.Get().AbsPath("/healthz")) + if err != nil { + return nil, fmt.Errorf("error from healthz API: %w", err) + } + + // As a heuristic to determine if anonymous auth is enabled for all other API endpoints, + // we probe one representative endpoint which should exist on all clusters. We assume that + // the result found at this endpoint is representative of the result for all non-heath endpoints. + otherAPIAllowsAnonAuth, err := isAnonymousAuthEnabledForEndpoint( + anonClient.Get().AbsPath("/api/v1/nodes").Param("limit", "1"), + ) + if err != nil { + return nil, fmt.Errorf("error from v1 nodes API: %w", err) + } + + return &anonAuthEnablement{ + HealthCheckEndpointsAllowAnonAuth: healthzAllowsAnonAuth, + OtherEndpointsAllowAnonAuth: otherAPIAllowsAnonAuth, + }, nil +} + +func isAnonymousAuthEnabledForEndpoint(anonReq *rest.Request) (bool, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - _, errHealthz := rc.Get().AbsPath("/healthz").DoRaw(ctx) + _, err := anonReq.DoRaw(ctx) switch { - // 200 ok on healthz clearly indicates authentication success - case errHealthz == nil: + // 200 ok clearly indicates authentication success + case err == nil: return true, nil - // we are authenticated but not authorized. anonymous authentication is enabled - case apierrors.IsForbidden(errHealthz): + // we are authenticated but not authorized: anonymous authentication is enabled at the endpoint + case apierrors.IsForbidden(err): return true, nil // failure to authenticate will return unauthorized (http misnomer) - case apierrors.IsUnauthorized(errHealthz): + case apierrors.IsUnauthorized(err): return false, nil // any other error is unexpected default: - return false, errHealthz + return false, err } } diff --git a/internal/concierge/impersonator/impersonator_test.go b/internal/concierge/impersonator/impersonator_test.go index 09c3409f7..9e476069b 100644 --- a/internal/concierge/impersonator/impersonator_test.go +++ b/internal/concierge/impersonator/impersonator_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2026 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package impersonator @@ -101,19 +101,21 @@ func TestImpersonator(t *testing.T) { } tests := []struct { - name string - clientCert clientCert - clientImpersonateUser rest.ImpersonationConfig - clientMutateHeaders func(http.Header) - clientNextProtos []string - kubeAPIServerStatusCode int - kubeAPIServerHealthz http.Handler - anonymousAuthDisabled bool - noServiceAcctTokenInCache bool // when true, no available service account token for the impersonator to use - wantKubeAPIServerRequestHeaders func(credentialID string) http.Header - wantError string - wantConstructionError string - wantAuthorizerAttributes func(credentialID string) []authorizer.AttributesRecord + name string + clientCert clientCert + clientImpersonateUser rest.ImpersonationConfig + clientMutateHeaders func(http.Header) + clientNextProtos []string + kubeAPIServerStatusCode int + kubeAPIServerHealthz http.Handler + kubeAPIServerNodes http.Handler + anonymousAuthForHealthDisabled bool + anonymousAuthForOtherAPIsDisabled bool + noServiceAcctTokenInCache bool // when true, no available service account token for the impersonator to use + wantKubeAPIServerRequestHeaders func(credentialID string) http.Header + wantError string + wantConstructionError string + wantAuthorizerAttributes func(credentialID string) []authorizer.AttributesRecord }{ { name: "happy path", @@ -140,7 +142,7 @@ func TestImpersonator(t *testing.T) { }, }, { - name: "happy path with forbidden healthz", + name: "happy path with forbidden healthz (anonymous auth for health checks is allowed)", clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}), kubeAPIServerHealthz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) @@ -168,13 +170,104 @@ func TestImpersonator(t *testing.T) { }, }, { - name: "happy path with unauthorized healthz", + name: "happy path with forbidden nodes (anonymous auth for other APIs is allowed)", + clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}), + kubeAPIServerNodes: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("no nodes for you")) + }), + wantKubeAPIServerRequestHeaders: func(credentialID string) http.Header { + return http.Header{ + "Impersonate-User": {"test-username"}, + "Impersonate-Group": {"test-group1", "test-group2", "system:authenticated"}, + "Authorization": {"Bearer some-service-account-token"}, + "User-Agent": {"test-agent"}, + "Accept": {"application/vnd.kubernetes.protobuf,application/json"}, + "Accept-Encoding": {"gzip"}, + "X-Forwarded-For": {"127.0.0.1"}, + "Impersonate-Extra-Authentication.kubernetes.io%2fcredential-Id": {credentialID}, + } + }, + wantAuthorizerAttributes: func(credentialID string) []authorizer.AttributesRecord { + return []authorizer.AttributesRecord{ + { + User: defaultInfoForTestUsername(credentialID), + Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces", + }, + } + }, + }, + { + name: "happy path with unauthorized healthz (anonymous auth for health checks is disallowed)", clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}), kubeAPIServerHealthz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte("no healthz for you")) }), - anonymousAuthDisabled: true, + anonymousAuthForHealthDisabled: true, + wantKubeAPIServerRequestHeaders: func(credentialID string) http.Header { + return http.Header{ + "Impersonate-User": {"test-username"}, + "Impersonate-Group": {"test-group1", "test-group2", "system:authenticated"}, + "Authorization": {"Bearer some-service-account-token"}, + "User-Agent": {"test-agent"}, + "Accept": {"application/vnd.kubernetes.protobuf,application/json"}, + "Accept-Encoding": {"gzip"}, + "X-Forwarded-For": {"127.0.0.1"}, + "Impersonate-Extra-Authentication.kubernetes.io%2fcredential-Id": {credentialID}, + } + }, + wantAuthorizerAttributes: func(credentialID string) []authorizer.AttributesRecord { + return []authorizer.AttributesRecord{ + { + User: defaultInfoForTestUsername(credentialID), + Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces", + }, + } + }, + }, + { + name: "happy path with unauthorized nodes (anonymous auth for other APIs is disallowed)", + clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}), + kubeAPIServerNodes: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("no nodes for you")) + }), + anonymousAuthForOtherAPIsDisabled: true, + wantKubeAPIServerRequestHeaders: func(credentialID string) http.Header { + return http.Header{ + "Impersonate-User": {"test-username"}, + "Impersonate-Group": {"test-group1", "test-group2", "system:authenticated"}, + "Authorization": {"Bearer some-service-account-token"}, + "User-Agent": {"test-agent"}, + "Accept": {"application/vnd.kubernetes.protobuf,application/json"}, + "Accept-Encoding": {"gzip"}, + "X-Forwarded-For": {"127.0.0.1"}, + "Impersonate-Extra-Authentication.kubernetes.io%2fcredential-Id": {credentialID}, + } + }, + wantAuthorizerAttributes: func(credentialID string) []authorizer.AttributesRecord { + return []authorizer.AttributesRecord{ + { + User: defaultInfoForTestUsername(credentialID), + Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces", + }, + } + }, + }, + { + name: "happy path with unauthorized healthz and nodes (anonymous auth for everything is disallowed)", + clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}), + kubeAPIServerHealthz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("no healthz for you")) + }), + kubeAPIServerNodes: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("no nodes for you")) + }), + anonymousAuthForHealthDisabled: true, + anonymousAuthForOtherAPIsDisabled: true, wantKubeAPIServerRequestHeaders: func(credentialID string) http.Header { return http.Header{ "Impersonate-User": {"test-username"}, @@ -747,7 +840,16 @@ func TestImpersonator(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("broken")) }), - wantConstructionError: `could not detect if anonymous authentication is enabled: an error on the server ("broken") has prevented the request from succeeding`, + wantConstructionError: `could not detect if anonymous authentication is enabled: error from healthz API: an error on the server ("broken") has prevented the request from succeeding`, + wantAuthorizerAttributes: nil, + }, + { + name: "unexpected nodes response", + kubeAPIServerNodes: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("broken")) + }), + wantConstructionError: `could not detect if anonymous authentication is enabled: error from v1 nodes API: an error on the server ("broken") has prevented the request from succeeding`, wantAuthorizerAttributes: nil, }, { @@ -832,7 +934,7 @@ func TestImpersonator(t *testing.T) { var testKubeAPIServerSawHeaders http.Header testKubeAPIServer, testKubeAPIServerCA := tlsserver.TestServerIPv4(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tlsConfigFunc := func(rootCAs *x509.CertPool) *tls.Config { - // Requests to get configmaps, flowcontrol requests, and healthz requests + // Requests to get configmaps, nodes, flowcontrol requests, and healthz requests // are not done by our http round trippers that specify only one protocol // (either http1.1 or http2, not both). // For all other requests from the impersonator, if it is not an upgrade @@ -844,7 +946,8 @@ func TestImpersonator(t *testing.T) { case "/api/v1/namespaces/kube-system/configmaps", fmt.Sprintf("/apis/flowcontrol.apiserver.k8s.io/%s/prioritylevelconfigurations", priorityLevelConfigurationsVersion), fmt.Sprintf("/apis/flowcontrol.apiserver.k8s.io/%s/flowschemas", flowSchemasVersion), - "/healthz": + "/healthz", + "/api/v1/nodes": default: if !httpstream.IsUpgradeRequest(r) { secure.NextProtos = []string{secure.NextProtos[0]} @@ -900,6 +1003,15 @@ func TestImpersonator(t *testing.T) { _, _ = fmt.Fprint(w, "probed") return + case "/readyz", "/readyz/etcd", "/livez": + require.Equal(t, http.MethodGet, r.Method) + + // match the KAS endpoint's behavior + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + _, _ = fmt.Fprint(w, "ok") + return + case "/healthz": require.Equal(t, http.MethodGet, r.Method) require.Empty(t, r.Header.Get("Authorization")) @@ -916,6 +1028,24 @@ func TestImpersonator(t *testing.T) { _, _ = fmt.Fprint(w, "ok") return + case "/api/v1/nodes": + // In these tests, the test client doesn't call the nodes API through the impersonator, + // but the impersonator production code uses the nodes API to probe whether anonymous auth + // is enabled or not, similar to what it does with the healthz API. + require.Equal(t, http.MethodGet, r.Method) + require.Empty(t, r.Header.Get("Authorization")) + require.Contains(t, r.Header.Get("User-Agent"), "kubernetes") + + if tt.kubeAPIServerNodes != nil { + tt.kubeAPIServerNodes.ServeHTTP(w, r) + return + } + + // by default just return success + w.Header().Add("Content-Type", "application/json; charset=UTF-8") + _, _ = fmt.Fprint(w, `{}`) + return + case "/apis/login.concierge.pinniped.dev/v1alpha1/tokencredentialrequests": require.Equal(t, http.MethodPost, r.Method) @@ -1142,8 +1272,40 @@ func TestImpersonator(t *testing.T) { rc, err := rest.RESTClientFor(anonymousConfig) require.NoError(t, err) + // It would be nice to also call /healthz through the impersonator here, but this unit test makes it + // difficult because of the expectations that it sets differently for APIs that are expected to be called + // by external clients versus those expected to be called by the impersonator itself. + // We can test calling /readyz and /livez, but note that calling /healthz should also work the same. + readyzBody, errReadyz := rc.Get().AbsPath("/readyz").DoRaw(ctx) + if tt.anonymousAuthForHealthDisabled { + require.True(t, apierrors.IsUnauthorized(errReadyz), errReadyz) + require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(readyzBody)) + } else { + require.NoError(t, errReadyz) + require.Equal(t, "ok", string(readyzBody)) + } + + // We don't treat sub-paths of health check endpoints as health check endpoints. Treat sub-paths as "other" endpoints. + readyzEtcdBody, errReadyzEtcd := rc.Get().AbsPath("/readyz/etcd").DoRaw(ctx) + if tt.anonymousAuthForOtherAPIsDisabled { + require.True(t, apierrors.IsUnauthorized(errReadyzEtcd), errReadyzEtcd) + require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(readyzEtcdBody)) + } else { + require.NoError(t, errReadyzEtcd) + require.Equal(t, "ok", string(readyzEtcdBody)) + } + + livezBody, errLivez := rc.Get().AbsPath("/livez").DoRaw(ctx) + if tt.anonymousAuthForHealthDisabled { + require.True(t, apierrors.IsUnauthorized(errLivez), errLivez) + require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(livezBody)) + } else { + require.NoError(t, errLivez) + require.Equal(t, "ok", string(livezBody)) + } + probeBody, errProbe := rc.Get().AbsPath("/probe").DoRaw(ctx) - if tt.anonymousAuthDisabled { + if tt.anonymousAuthForOtherAPIsDisabled { require.True(t, apierrors.IsUnauthorized(errProbe), errProbe) require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(probeBody)) } else { @@ -1151,8 +1313,9 @@ func TestImpersonator(t *testing.T) { require.Equal(t, "probed", string(probeBody)) } + // Fetch other resource that just happens to also be called tokencredentialrequests, but belongs to a different API group/version. notTCRBody, errNotTCR := rc.Get().Resource("tokencredentialrequests").DoRaw(ctx) - if tt.anonymousAuthDisabled { + if tt.anonymousAuthForOtherAPIsDisabled { require.True(t, apierrors.IsUnauthorized(errNotTCR), errNotTCR) require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(notTCRBody)) } else { @@ -1161,7 +1324,7 @@ func TestImpersonator(t *testing.T) { } ducksBody, errDucks := rc.Get().Resource("ducks").DoRaw(ctx) - if tt.anonymousAuthDisabled { + if tt.anonymousAuthForOtherAPIsDisabled { require.True(t, apierrors.IsUnauthorized(errDucks), errDucks) require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(ducksBody)) } else { @@ -1194,8 +1357,24 @@ func TestImpersonator(t *testing.T) { Verb: "create", Namespace: "", APIGroup: "login.concierge.walrus.tld", APIVersion: "v1alpha1", Resource: "tokencredentialrequests", Subresource: "", Name: "", ResourceRequest: true, Path: "/apis/login.concierge.walrus.tld/v1alpha1/tokencredentialrequests", }, ) - if !tt.anonymousAuthDisabled { + if !tt.anonymousAuthForHealthDisabled { wantAuthorizerAttributes = append(wantAuthorizerAttributes, + authorizer.AttributesRecord{ + User: &user.DefaultInfo{Name: "system:anonymous", UID: "", Groups: []string{"system:unauthenticated"}, Extra: nil}, + Verb: "get", Namespace: "", APIGroup: "", APIVersion: "", Resource: "", Subresource: "", Name: "", ResourceRequest: false, Path: "/readyz", + }, + authorizer.AttributesRecord{ + User: &user.DefaultInfo{Name: "system:anonymous", UID: "", Groups: []string{"system:unauthenticated"}, Extra: nil}, + Verb: "get", Namespace: "", APIGroup: "", APIVersion: "", Resource: "", Subresource: "", Name: "", ResourceRequest: false, Path: "/livez", + }, + ) + } + if !tt.anonymousAuthForOtherAPIsDisabled { + wantAuthorizerAttributes = append(wantAuthorizerAttributes, + authorizer.AttributesRecord{ + User: &user.DefaultInfo{Name: "system:anonymous", UID: "", Groups: []string{"system:unauthenticated"}, Extra: nil}, + Verb: "get", Namespace: "", APIGroup: "", APIVersion: "", Resource: "", Subresource: "", Name: "", ResourceRequest: false, Path: "/readyz/etcd", + }, authorizer.AttributesRecord{ User: &user.DefaultInfo{Name: "system:anonymous", UID: "", Groups: []string{"system:unauthenticated"}, Extra: nil}, Verb: "get", Namespace: "", APIGroup: "", APIVersion: "", Resource: "", Subresource: "", Name: "", ResourceRequest: false, Path: "/probe", @@ -2262,3 +2441,77 @@ func (r *attributeRecorder) record(attributes authorizer.Attributes) { defer r.lock.Unlock() r.attributes = append(r.attributes, *attributes.(*authorizer.AttributesRecord)) } + +func Test_isRequestForHealthCheck(t *testing.T) { + tests := []struct { + path string + want bool + }{ + { + path: "/healthz", + want: true, + }, + { + path: "/livez", + want: true, + }, + { + path: "/readyz", + want: true, + }, + { + path: "/healthz/anything", + want: false, + }, + { + path: "/livez/anything", + want: false, + }, + { + path: "/readyz/anything", + want: false, + }, + { + path: "/readyz/anything/", + want: false, + }, + { + path: "/healthz/", + want: false, + }, + { + path: "/livez/", + want: false, + }, + { + path: "/readyz/", + want: false, + }, + { + path: "/other", + want: false, + }, + { + path: "/something/else", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + u := url.URL{Path: tt.path} + got := isRequestForHealthCheck(&http.Request{URL: &u}) + require.Equal(t, tt.want, got) + }) + } + + t.Run("nil req", func(t *testing.T) { + got := isRequestForHealthCheck(nil) + require.False(t, got) + }) + + t.Run("nil URL", func(t *testing.T) { + got := isRequestForHealthCheck(&http.Request{}) + require.False(t, got) + }) +} diff --git a/test/cluster_capabilities/aks.yaml b/test/cluster_capabilities/aks.yaml index 8bdfa98ef..f03a44b96 100644 --- a/test/cluster_capabilities/aks.yaml +++ b/test/cluster_capabilities/aks.yaml @@ -1,4 +1,4 @@ -# Copyright 2021 the Pinniped contributors. All Rights Reserved. +# Copyright 2021-2026 the Pinniped contributors. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # The name of the cluster type. @@ -13,9 +13,13 @@ capabilities: # Will the cluster successfully provision a load balancer if requested? hasExternalLoadBalancerProvider: true - # Does the cluster allow requests without authentication? - # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests - anonymousAuthenticationSupported: false - # Are LDAP ports on the Internet reachable without interference from network firewalls or proxies? canReachInternetLDAPPorts: true + + # Does the cluster allow requests without authentication? + # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests + anonymousAuthentication: + healthEndpoints: + allowed: false + otherEndpoints: + allowed: false diff --git a/test/cluster_capabilities/eks.yaml b/test/cluster_capabilities/eks.yaml index 304922f87..f13f15100 100644 --- a/test/cluster_capabilities/eks.yaml +++ b/test/cluster_capabilities/eks.yaml @@ -1,4 +1,4 @@ -# Copyright 2021 the Pinniped contributors. All Rights Reserved. +# Copyright 2021-2026 the Pinniped contributors. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # The name of the cluster type. @@ -13,9 +13,14 @@ capabilities: # Will the cluster successfully provision a load balancer if requested? hasExternalLoadBalancerProvider: true - # Does the cluster allow requests without authentication? - # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests - anonymousAuthenticationSupported: true - # Are LDAP ports on the Internet reachable without interference from network firewalls or proxies? canReachInternetLDAPPorts: true + + # Does the cluster allow requests without authentication? + # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests + # Amazon disabled anonymous authentication in EKS clusters starting with k8s 1.32. + anonymousAuthentication: + healthEndpoints: + allowed: true + otherEndpoints: + allowedIfK8sMinorVersionLessThan: 32 diff --git a/test/cluster_capabilities/gke.yaml b/test/cluster_capabilities/gke.yaml index a1247788c..be5a56d79 100644 --- a/test/cluster_capabilities/gke.yaml +++ b/test/cluster_capabilities/gke.yaml @@ -1,4 +1,4 @@ -# Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +# Copyright 2020-2026 the Pinniped contributors. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # The name of the cluster type. @@ -13,9 +13,14 @@ capabilities: # Will the cluster successfully provision a load balancer if requested? hasExternalLoadBalancerProvider: true - # Does the cluster allow requests without authentication? - # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests - anonymousAuthenticationSupported: true - # Are LDAP ports on the Internet reachable without interference from network firewalls or proxies? canReachInternetLDAPPorts: true + + # Does the cluster allow requests without authentication? + # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests + # Google disabled anonymous authentication in GKE clusters by default starting with k8s 1.35. + anonymousAuthentication: + healthEndpoints: + allowed: true + otherEndpoints: + allowedIfK8sMinorVersionLessThan: 35 diff --git a/test/cluster_capabilities/kind.yaml b/test/cluster_capabilities/kind.yaml index 485ba5060..7a707fd02 100644 --- a/test/cluster_capabilities/kind.yaml +++ b/test/cluster_capabilities/kind.yaml @@ -1,4 +1,4 @@ -# Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +# Copyright 2020-2026 the Pinniped contributors. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # The name of the cluster type. @@ -13,9 +13,13 @@ capabilities: # Will the cluster successfully provision a load balancer if requested? hasExternalLoadBalancerProvider: false - # Does the cluster allow requests without authentication? - # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests - anonymousAuthenticationSupported: true - # Are LDAP ports on the Internet reachable without interference from network firewalls or proxies? canReachInternetLDAPPorts: true + + # Does the cluster allow requests without authentication? + # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests + anonymousAuthentication: + healthEndpoints: + allowed: true + otherEndpoints: + allowed: true diff --git a/test/cluster_capabilities/tkgs.yaml b/test/cluster_capabilities/tkgs.yaml index 86220291e..8361bea24 100644 --- a/test/cluster_capabilities/tkgs.yaml +++ b/test/cluster_capabilities/tkgs.yaml @@ -1,4 +1,4 @@ -# Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +# Copyright 2020-2026 the Pinniped contributors. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # The name of the cluster type. @@ -13,9 +13,13 @@ capabilities: # Will the cluster successfully provision a load balancer if requested? hasExternalLoadBalancerProvider: true - # Does the cluster allow requests without authentication? - # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests - anonymousAuthenticationSupported: true - # Are LDAP ports on the Internet reachable without interference from network firewalls or proxies? canReachInternetLDAPPorts: true + + # Does the cluster allow requests without authentication? + # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests + anonymousAuthentication: + healthEndpoints: + allowed: true + otherEndpoints: + allowed: true diff --git a/test/integration/concierge_credentialrequest_test.go b/test/integration/concierge_credentialrequest_test.go index 91a3a90b9..bbac52338 100644 --- a/test/integration/concierge_credentialrequest_test.go +++ b/test/integration/concierge_credentialrequest_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2026 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -218,7 +218,7 @@ func TestCredentialRequest_Browser(t *testing.T) { // for its localhost listener via --listen-port=env.CLIUpstreamOIDC.CallbackURL.Port() per oidcLoginCommand. // Since ports are global to the process, tests using oidcLoginCommand must be run serially. func TestCredentialRequest_JWTAuthenticatorRulesToDisallowLogin_Browser(t *testing.T) { - env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupported) + env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupportedForOtherEndpoints) basicSpec := &authenticationv1alpha1.JWTAuthenticatorSpec{ Issuer: env.CLIUpstreamOIDC.Issuer, @@ -321,7 +321,7 @@ func TestCredentialRequest_JWTAuthenticatorRulesToDisallowLogin_Browser(t *testi // TCRs are non-mutating and safe to run in parallel with serial tests, see main_test.go. func TestCredentialRequest_ShouldFailWhenTheAuthenticatorDoesNotExist_Parallel(t *testing.T) { - env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupported) + env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupportedForOtherEndpoints) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) t.Cleanup(cancel) @@ -344,7 +344,7 @@ func TestCredentialRequest_ShouldFailWhenTheAuthenticatorDoesNotExist_Parallel(t // TCRs are non-mutating and safe to run in parallel with serial tests, see main_test.go. func TestCredentialRequest_ShouldFailWhenTheRequestIsValidButTheTokenDoesNotAuthenticateTheUser_Parallel(t *testing.T) { - env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupported) + env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupportedForOtherEndpoints) // Create a testWebhook so we have a legitimate authenticator to pass to the TokenCredentialRequest API. ctx, cancel := context.WithTimeout(context.Background(), time.Minute) @@ -365,7 +365,7 @@ func TestCredentialRequest_ShouldFailWhenTheRequestIsValidButTheTokenDoesNotAuth // TCRs are non-mutating and safe to run in parallel with serial tests, see main_test.go. func TestCredentialRequest_ShouldFailWhenRequestDoesNotIncludeToken_Parallel(t *testing.T) { - env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupported) + env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupportedForOtherEndpoints) // Create a testWebhook so we have a legitimate authenticator to pass to the TokenCredentialRequest API. ctx, cancel := context.WithTimeout(context.Background(), time.Minute) diff --git a/test/integration/concierge_impersonation_proxy_test.go b/test/integration/concierge_impersonation_proxy_test.go index a4abb9e61..b4f6ab55e 100644 --- a/test/integration/concierge_impersonation_proxy_test.go +++ b/test/integration/concierge_impersonation_proxy_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2026 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -902,8 +902,8 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl whoAmI, err = impersonationProxyAnonymousPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests(). Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) - // we expect the impersonation proxy to match the behavior of KAS in regards to anonymous requests - if env.HasCapability(testlib.AnonymousAuthenticationSupported) { + // we expect the impersonation proxy to match the behavior of KAS in regard to anonymous requests + if env.HasCapability(testlib.AnonymousAuthenticationSupportedForOtherEndpoints) { require.NoError(t, err, testlib.Sdump(err)) require.Equal(t, expectedWhoAmIRequestResponse( @@ -1409,8 +1409,8 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl }) }) - t.Run("anonymous authentication enabled", func(t *testing.T) { - testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupported) + t.Run("anonymous authentication enabled for health checks", func(t *testing.T) { + testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupportedForHealthEndpoints) parallelIfNotEKS(t) // anonymous auth enabled @@ -1421,11 +1421,50 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl t.Run("non-resource request", func(t *testing.T) { parallelIfNotEKS(t) - healthz, errHealth := impersonationProxyAnonymousRestClient.Get().AbsPath("/healthz").DoRaw(ctx) - require.NoError(t, errHealth, testlib.Sdump(errHealth)) - require.Equal(t, "ok", string(healthz)) - }) + response, err := impersonationProxyAnonymousRestClient.Get().AbsPath("/healthz").DoRaw(ctx) + require.NoError(t, err, testlib.Sdump(err)) + require.Equal(t, "ok", string(response)) + response, err = impersonationProxyAnonymousRestClient.Get().AbsPath("/readyz").DoRaw(ctx) + require.NoError(t, err, testlib.Sdump(err)) + require.Equal(t, "ok", string(response)) + + response, err = impersonationProxyAnonymousRestClient.Get().AbsPath("/livez").DoRaw(ctx) + require.NoError(t, err, testlib.Sdump(err)) + require.Equal(t, "ok", string(response)) + }) + }) + + t.Run("anonymous authentication disabled for health checks", func(t *testing.T) { + testlib.IntegrationEnv(t).WithoutCapability(testlib.AnonymousAuthenticationSupportedForHealthEndpoints) + parallelIfNotEKS(t) + + // anonymous auth disabled + // - hit the healthz endpoint (non-resource endpoint) + // - through the impersonation proxy + // - should fail Unauthorized + t.Run("non-resource request", func(t *testing.T) { + parallelIfNotEKS(t) + + expectedResponse := `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}` + "\n" + + response, err := impersonationProxyAnonymousRestClient.Get().AbsPath("/healthz").DoRaw(ctx) + require.True(t, apierrors.IsUnauthorized(err), testlib.Sdump(err)) + require.Equal(t, expectedResponse, string(response)) + + response, err = impersonationProxyAnonymousRestClient.Get().AbsPath("/readyz").DoRaw(ctx) + require.True(t, apierrors.IsUnauthorized(err), testlib.Sdump(err)) + require.Equal(t, expectedResponse, string(response)) + + response, err = impersonationProxyAnonymousRestClient.Get().AbsPath("/livez").DoRaw(ctx) + require.True(t, apierrors.IsUnauthorized(err), testlib.Sdump(err)) + require.Equal(t, expectedResponse, string(response)) + }) + }) + + t.Run("anonymous authentication enabled for other endpoints", func(t *testing.T) { + testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupportedForOtherEndpoints) + parallelIfNotEKS(t) // - hit the pods endpoint (a resource endpoint) // - through the impersonation proxy // - should fail forbidden @@ -1462,22 +1501,10 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl }) }) - t.Run("anonymous authentication disabled", func(t *testing.T) { - testlib.IntegrationEnv(t).WithoutCapability(testlib.AnonymousAuthenticationSupported) + t.Run("anonymous authentication disabled for other endpoints", func(t *testing.T) { + testlib.IntegrationEnv(t).WithoutCapability(testlib.AnonymousAuthenticationSupportedForOtherEndpoints) parallelIfNotEKS(t) - // - hit the healthz endpoint (non-resource endpoint) - // - through the impersonation proxy - // - should fail unauthorized - // - kube api server should reject it - t.Run("non-resource request", func(t *testing.T) { - parallelIfNotEKS(t) - - healthz, err := impersonationProxyAnonymousRestClient.Get().AbsPath("/healthz").DoRaw(ctx) - require.True(t, apierrors.IsUnauthorized(err), testlib.Sdump(err)) - require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(healthz)) - }) - // - hit the pods endpoint (a resource endpoint) // - through the impersonation proxy // - should fail unauthorized @@ -1491,7 +1518,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl require.Equal(t, &corev1.Pod{}, pod) }) - // - request to whoami (pinniped resource endpoing) + // - request to whoami (pinniped resource endpoint) // - through the impersonation proxy // - should fail unauthorized // - kube api server should reject it @@ -1982,7 +2009,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl // include an unsuccessful impersonation strategy saying that it was manually configured to be disabled. requireDisabledStrategy(ctx, t, env, adminConciergeClient) - if !env.HasCapability(testlib.ClusterSigningKeyIsAvailable) && env.HasCapability(testlib.AnonymousAuthenticationSupported) { + if !env.HasCapability(testlib.ClusterSigningKeyIsAvailable) && env.HasCapability(testlib.AnonymousAuthenticationSupportedForOtherEndpoints) { // This cluster does not support the cluster signing key strategy, so now that we've manually disabled the // impersonation strategy, we should be left with no working strategies. // Given that there are no working strategies, a TokenCredentialRequest which would otherwise work should now diff --git a/test/integration/concierge_whoami_test.go b/test/integration/concierge_whoami_test.go index af80f0852..fa22cb6ad 100644 --- a/test/integration/concierge_whoami_test.go +++ b/test/integration/concierge_whoami_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2026 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -394,7 +394,7 @@ func maybeNeedsExtraWithSHA256( // whoami requests are non-mutating and safe to run in parallel with serial tests, see main_test.go. func TestWhoAmI_Anonymous_Parallel(t *testing.T) { - _ = testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupported) + _ = testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupportedForOtherEndpoints) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() diff --git a/test/integration/supervisor_oidcclientsecret_test.go b/test/integration/supervisor_oidcclientsecret_test.go index 610a8b12a..139becd1c 100644 --- a/test/integration/supervisor_oidcclientsecret_test.go +++ b/test/integration/supervisor_oidcclientsecret_test.go @@ -1,4 +1,4 @@ -// Copyright 2022-2025 the Pinniped contributors. All Rights Reserved. +// Copyright 2022-2026 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -1040,11 +1040,11 @@ func TestOIDCClientSecretRequestUnauthenticated_Parallel(t *testing.T) { }, metav1.CreateOptions{}) require.Error(t, err) - if env.KubernetesDistribution == testlib.AKSDistro { - // On AKS the error just says "Unauthorized". - require.Contains(t, err.Error(), "Unauthorized") - } else { + if env.HasCapability(testlib.AnonymousAuthenticationSupportedForOtherEndpoints) { // Clusters which allow anonymous auth will give a more detailed error. require.Contains(t, err.Error(), `User "system:anonymous" cannot create resource "oidcclientsecretrequests"`) + } else { + // On AKS and any other cluster which disallows anonymous auth, the error just says "Unauthorized". + require.Contains(t, err.Error(), "Unauthorized") } } diff --git a/test/testlib/env.go b/test/testlib/env.go index bb55ff06a..0c4ea0310 100644 --- a/test/testlib/env.go +++ b/test/testlib/env.go @@ -1,4 +1,4 @@ -// Copyright 2020-2025 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2026 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package testlib @@ -9,6 +9,7 @@ import ( "net/url" "os" "sort" + "strconv" "strings" "sync" "testing" @@ -17,16 +18,18 @@ import ( "sigs.k8s.io/yaml" authenticationv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" + "go.pinniped.dev/internal/testutil" ) type Capability string type KubeDistro string const ( - ClusterSigningKeyIsAvailable Capability = "clusterSigningKeyIsAvailable" - AnonymousAuthenticationSupported Capability = "anonymousAuthenticationSupported" - HasExternalLoadBalancerProvider Capability = "hasExternalLoadBalancerProvider" - CanReachInternetLDAPPorts Capability = "canReachInternetLDAPPorts" + ClusterSigningKeyIsAvailable Capability = "clusterSigningKeyIsAvailable" + HasExternalLoadBalancerProvider Capability = "hasExternalLoadBalancerProvider" + CanReachInternetLDAPPorts Capability = "canReachInternetLDAPPorts" + AnonymousAuthenticationSupportedForHealthEndpoints Capability = "anonymousAuthenticationSupportedForHealthEndpoints" + AnonymousAuthenticationSupportedForOtherEndpoints Capability = "anonymousAuthenticationSupportedForOtherEndpoints" KindDistro KubeDistro = "Kind" GKEDistro KubeDistro = "GKE" @@ -35,6 +38,24 @@ const ( TKGSDistro KubeDistro = "TKGS" ) +type Capabilities struct { + ClusterSigningKeyIsAvailable bool `json:"clusterSigningKeyIsAvailable"` + HasExternalLoadBalancerProvider bool `json:"hasExternalLoadBalancerProvider"` + CanReachInternetLDAPPorts bool `json:"canReachInternetLDAPPorts"` + + AnonymousAuthentication AnonymousAuthenticationCapabilities `json:"anonymousAuthentication"` +} + +type AnonymousAuthenticationCapabilities struct { + HealthEndpoints AnonymousAuthenticationEndpointCapability `json:"healthEndpoints"` + OtherEndpoints AnonymousAuthenticationEndpointCapability `json:"otherEndpoints"` +} + +type AnonymousAuthenticationEndpointCapability struct { + Allowed bool `json:"allowed"` + AllowedIfK8sMinorVersionLessThan string `json:"allowedIfK8sMinorVersionLessThan"` +} + // TestEnv captures all the external parameters consumed by our integration tests. type TestEnv struct { t *testing.T @@ -50,7 +71,7 @@ type TestEnv struct { SupervisorCustomLabels map[string]string `json:"supervisorCustomLabels"` ConciergeCustomLabels map[string]string `json:"conciergeCustomLabels"` KubernetesDistribution KubeDistro `json:"kubernetesDistribution"` - Capabilities map[Capability]bool `json:"capabilities"` + Capabilities Capabilities `json:"capabilities"` TestWebhook authenticationv1alpha1.WebhookAuthenticatorSpec `json:"testWebhook"` SupervisorHTTPSAddress string `json:"supervisorHttpsAddress"` SupervisorHTTPSIngressAddress string `json:"supervisorHttpsIngressAddress"` @@ -400,9 +421,34 @@ func loadEnvVars(t *testing.T, result *TestEnv) { func (e *TestEnv) HasCapability(cap Capability) bool { e.t.Helper() - isCapable, capabilityWasDescribed := e.Capabilities[cap] - require.Truef(e.t, capabilityWasDescribed, "the %q capability of the test environment was not described", cap) - return isCapable + + switch cap { + case AnonymousAuthenticationSupportedForHealthEndpoints: + versionLessThan := e.Capabilities.AnonymousAuthentication.HealthEndpoints.AllowedIfK8sMinorVersionLessThan + if versionLessThan == "" { + return e.Capabilities.AnonymousAuthentication.HealthEndpoints.Allowed + } + minorVersionNumber, err := strconv.Atoi(versionLessThan) + require.NoError(e.t, err, "could not parse minor version number from AnonymousAuthentication HealthEndpoints capability as int") + return !testutil.KubeServerMinorVersionAtLeastInclusive(e.t, NewKubernetesClientset(e.t).Discovery(), minorVersionNumber) + case AnonymousAuthenticationSupportedForOtherEndpoints: + versionLessThan := e.Capabilities.AnonymousAuthentication.OtherEndpoints.AllowedIfK8sMinorVersionLessThan + if versionLessThan == "" { + return e.Capabilities.AnonymousAuthentication.OtherEndpoints.Allowed + } + minorVersionNumber, err := strconv.Atoi(versionLessThan) + require.NoError(e.t, err, "could not parse minor version number from AnonymousAuthentication OtherEndpoints capability as int") + return !testutil.KubeServerMinorVersionAtLeastInclusive(e.t, NewKubernetesClientset(e.t).Discovery(), minorVersionNumber) + case ClusterSigningKeyIsAvailable: + return e.Capabilities.ClusterSigningKeyIsAvailable + case HasExternalLoadBalancerProvider: + return e.Capabilities.HasExternalLoadBalancerProvider + case CanReachInternetLDAPPorts: + return e.Capabilities.CanReachInternetLDAPPorts + default: + require.Failf(e.t, "unknown capability type", "name: %s", cap) + return false + } } func (e *TestEnv) WithCapability(cap Capability) *TestEnv {