diff --git a/internal/concierge/impersonator/doc.go b/internal/concierge/impersonator/doc.go index d15c6715..3ca70d69 100644 --- a/internal/concierge/impersonator/doc.go +++ b/internal/concierge/impersonator/doc.go @@ -19,6 +19,12 @@ also honor client certs from a CA that is specific to the impersonation proxy. This approach allows clients to use the Token Credential Request API even when we do not have the cluster's signing key. +The proxy will honor cluster configuration in regards to anonymous authentication. +When disabled, the proxy will not authenticate these requests. There is one caveat +in that Pinniped itself provides the Token Credential Request API which is used +specifically by anonymous users to retrieve credentials. This API is the single +API that will remain available even when anonymous authentication is disabled. + In terms of authorization, we rely mostly on the Kubernetes API server. Since we impersonate the user, the proxied request will be authorized against that user. Thus for all regular REST verbs, we perform no authorization checks. diff --git a/internal/concierge/impersonator/impersonator.go b/internal/concierge/impersonator/impersonator.go index 2eeaf25d..d2aa0280 100644 --- a/internal/concierge/impersonator/impersonator.go +++ b/internal/concierge/impersonator/impersonator.go @@ -19,6 +19,7 @@ import ( apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructuredscheme" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" @@ -198,9 +199,59 @@ func newInternal( //nolint:funlen // yeah, it's kind of long. serverConfig.AuditPolicyChecker = policy.FakeChecker(auditinternal.LevelMetadata, nil) serverConfig.AuditBackend = &auditfake.Backend{} + // Probe the API server to figure out if anonymous auth is enabled. + anonymousAuthEnabled, err := isAnonymousAuthEnabled(kubeClient.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) + // if we ever start unioning a TCR bearer token authenticator with serverConfig.Authenticator // then we will need to update the related assumption in tokenPassthroughRoundTripper + delegatingAuthenticator := serverConfig.Authentication.Authenticator + blockAnonymousAuthenticator := &comparableAuthenticator{ + 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 { + return resp, ok, err + } + + // authentication failed + if err != nil || !ok { + return resp, ok, err + } + + // any other user than anonymous is irrelevant + if resp.User.GetName() != user.Anonymous { + return resp, ok, err + } + + reqInfo, ok := genericapirequest.RequestInfoFrom(req.Context()) + if !ok { + return nil, false, constable.Error("no RequestInfo found in the context") + } + + // a TKR is a resource, any request that is not for a resource should not be authenticated + if !reqInfo.IsResourceRequest { + return nil, false, nil + } + + // any resource besides TKR should not be authenticated + if !isTokenCredReq(reqInfo) { + return nil, false, nil + } + + // anonymous authentication is disabled, but we must let an anonymous request + // to TKR authenticate as this is the only method to retrieve credentials + return resp, ok, err + }, + } + // Set our custom authenticator before calling Compete(), which will use it. + serverConfig.Authentication.Authenticator = blockAnonymousAuthenticator + delegatingAuthorizer := serverConfig.Authorization.Authorizer nestedImpersonationAuthorizer := &comparableAuthorizer{ authorizerFunc: func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { @@ -230,16 +281,22 @@ func newInternal( //nolint:funlen // yeah, it's kind of long. // Set our custom authorizer before calling Compete(), which will use it. serverConfig.Authorization.Authorizer = nestedImpersonationAuthorizer - impersonationProxyServer, err := serverConfig.Complete().New("impersonation-proxy", genericapiserver.NewEmptyDelegate()) + completedConfig := serverConfig.Complete() + impersonationProxyServer, err := completedConfig.New("impersonation-proxy", genericapiserver.NewEmptyDelegate()) if err != nil { return nil, err } preparedRun := impersonationProxyServer.PrepareRun() + // Sanity check. Make sure that our custom authenticator is still in place and did not get changed or wrapped. + if completedConfig.Authentication.Authenticator != blockAnonymousAuthenticator { + return nil, fmt.Errorf("invalid mutation of anonymous authenticator detected: %#v", completedConfig.Authentication.Authenticator) + } + // Sanity check. Make sure that our custom authorizer is still in place and did not get changed or wrapped. if preparedRun.Authorizer != nestedImpersonationAuthorizer { - return nil, constable.Error("invalid mutation of impersonation authorizer detected") + return nil, fmt.Errorf("invalid mutation of impersonation authorizer detected: %#v", preparedRun.Authorizer) } // Sanity check. Assert that we have a functioning token file to use and no bearer token. @@ -262,6 +319,59 @@ func newInternal( //nolint:funlen // yeah, it's kind of long. return result, nil } +func isAnonymousAuthEnabled(config *rest.Config) (bool, error) { + anonymousConfig := rest.AnonymousClientConfig(config) + + // we do not need either of these but RESTClientFor complains if they are not set + anonymousConfig.GroupVersion = &schema.GroupVersion{} + anonymousConfig.NegotiatedSerializer = unstructuredscheme.NewUnstructuredNegotiatedSerializer() + + // 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) + if err != nil { + return false, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, errHealthz := rc.Get().AbsPath("/healthz").DoRaw(ctx) + + switch { + // 200 ok on healthz clearly indicates authentication success + case errHealthz == nil: + return true, nil + + // we are authenticated but not authorized. anonymous authentication is enabled + case apierrors.IsForbidden(errHealthz): + return true, nil + + // failure to authenticate will return unauthorized (http misnomer) + case apierrors.IsUnauthorized(errHealthz): + return false, nil + + // any other error is unexpected + default: + return false, errHealthz + } +} + +func isTokenCredReq(reqInfo *genericapirequest.RequestInfo) bool { + if reqInfo.Resource != "tokencredentialrequests" { + return false + } + + // pinniped components allow for the group suffix to be customized + // rather than wiring in the current configured suffix, checking the prefix is sufficient + if !strings.HasPrefix(reqInfo.APIGroup, "login.concierge.") { + return false + } + + return true +} + func deleteKnownImpersonationHeaders(delegate http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // remove known impersonation headers while avoiding mutation of input request @@ -290,6 +400,11 @@ func deleteKnownImpersonationHeaders(delegate http.Handler) http.Handler { }) } +// No-op wrapping around RequestFunc to allow for comparisons. +type comparableAuthenticator struct { + authenticator.RequestFunc +} + // No-op wrapping around AuthorizerFunc to allow for comparisons. type comparableAuthorizer struct { authorizerFunc diff --git a/internal/concierge/impersonator/impersonator_test.go b/internal/concierge/impersonator/impersonator_test.go index 78fd1759..f30615d3 100644 --- a/internal/concierge/impersonator/impersonator_test.go +++ b/internal/concierge/impersonator/impersonator_test.go @@ -5,6 +5,7 @@ package impersonator import ( "context" + "fmt" "math/rand" "net" "net/http" @@ -17,8 +18,11 @@ import ( "github.com/stretchr/testify/require" authenticationv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructuredscheme" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/httpstream" auditinternal "k8s.io/apiserver/pkg/apis/audit" @@ -33,10 +37,13 @@ import ( "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd/api" featuregatetesting "k8s.io/component-base/featuregate/testing" + "k8s.io/utils/pointer" + loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/dynamiccert" + "go.pinniped.dev/internal/groupsuffix" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/httputil/roundtripper" "go.pinniped.dev/internal/kubeclient" @@ -72,6 +79,8 @@ func TestImpersonator(t *testing.T) { clientNextProtos []string kubeAPIServerClientBearerTokenFile string kubeAPIServerStatusCode int + kubeAPIServerHealthz http.Handler + anonymousAuthDisabled bool wantKubeAPIServerRequestHeaders http.Header wantError string wantConstructionError string @@ -90,6 +99,43 @@ func TestImpersonator(t *testing.T) { "X-Forwarded-For": {"127.0.0.1"}, }, }, + { + name: "happy path with forbidden healthz", + clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}), + kubeAPIServerClientBearerTokenFile: "required-to-be-set", + kubeAPIServerHealthz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("no healthz for you")) + }), + wantKubeAPIServerRequestHeaders: 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"}, + }, + }, + { + name: "happy path with unauthorized healthz", + clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}), + kubeAPIServerClientBearerTokenFile: "required-to-be-set", + kubeAPIServerHealthz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("no healthz for you")) + }), + anonymousAuthDisabled: true, + wantKubeAPIServerRequestHeaders: 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"}, + }, + }, { name: "happy path with upgrade", clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}), @@ -333,6 +379,14 @@ func TestImpersonator(t *testing.T) { name: "no bearer token file in Kube API server client config", wantConstructionError: "invalid impersonator loopback rest config has wrong bearer token semantics", }, + { + name: "unexpected healthz response", + kubeAPIServerHealthz: 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: an error on the server ("broken") has prevented the request from succeeding`, + }, { name: "header canonicalization user header", clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}), @@ -367,6 +421,9 @@ func TestImpersonator(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + t.Cleanup(cancel) + // we need to create this listener ourselves because the API server // code treats (port == 0 && listener == nil) to mean "do nothing" listener, port, err := genericoptions.CreateListener("", "127.0.0.1:0", net.ListenConfig{}) @@ -384,22 +441,28 @@ func TestImpersonator(t *testing.T) { testKubeAPIServerWasCalled := false var testKubeAPIServerSawHeaders http.Header testKubeAPIServerCA, testKubeAPIServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodGet, r.Method) switch r.URL.Path { case "/api/v1/namespaces/kube-system/configmaps": + require.Equal(t, http.MethodGet, r.Method) + // The production code uses NewDynamicCAFromConfigMapController which fetches a ConfigMap, // so treat that differently. It wants to read the Kube API server CA from that ConfigMap // to use it to validate client certs. We don't need it for this test, so return NotFound. http.NotFound(w, r) return + case "/api/v1/namespaces": + require.Equal(t, http.MethodGet, r.Method) + testKubeAPIServerWasCalled = true testKubeAPIServerSawHeaders = r.Header if tt.kubeAPIServerStatusCode != http.StatusOK { w.WriteHeader(tt.kubeAPIServerStatusCode) - } else { - w.Header().Add("Content-Type", "application/json; charset=UTF-8") - _, _ = w.Write([]byte(here.Doc(` + return + } + + w.Header().Add("Content-Type", "application/json; charset=UTF-8") + _, _ = w.Write([]byte(here.Doc(` { "kind": "NamespaceList", "apiVersion":"v1", @@ -409,9 +472,61 @@ func TestImpersonator(t *testing.T) { ] } `))) + return + + case "/probe": + require.Equal(t, http.MethodGet, r.Method) + + _, _ = fmt.Fprint(w, "probed") + return + + case "/healthz": + 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.kubeAPIServerHealthz != nil { + tt.kubeAPIServerHealthz.ServeHTTP(w, r) + return } + + // by default just match the KAS /healthz endpoint + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + _, _ = fmt.Fprint(w, "ok") + return + + case "/apis/login.concierge.pinniped.dev/v1alpha1/tokencredentialrequests": + require.Equal(t, http.MethodPost, r.Method) + + w.Header().Add("Content-Type", "application/json; charset=UTF-8") + _, _ = w.Write([]byte(`{}`)) + return + + case "/apis/login.concierge.walrus.tld/v1alpha1/tokencredentialrequests": + require.Equal(t, http.MethodPost, r.Method) + + w.Header().Add("Content-Type", "application/json; charset=UTF-8") + _, _ = w.Write([]byte(`{}`)) + return + + case "/apis/not-concierge.walrus.tld/v1/tokencredentialrequests": + require.Equal(t, http.MethodGet, r.Method) + + w.Header().Add("Content-Type", "application/json; charset=UTF-8") + _, _ = w.Write([]byte(`{"hello": "quack"}`)) + return + + case "/apis/not-concierge.walrus.tld/v1/ducks": + require.Equal(t, http.MethodGet, r.Method) + + w.Header().Add("Content-Type", "application/json; charset=UTF-8") + _, _ = w.Write([]byte(`{"hello": "birds"}`)) + return + default: - require.Fail(t, "fake Kube API server got an unexpected request") + require.Fail(t, "fake Kube API server got an unexpected request", "path: %s", r.URL.Path) + return } }) @@ -485,7 +600,7 @@ func TestImpersonator(t *testing.T) { // The fake Kube API server knows how to to list namespaces, so make that request using the client // through the impersonator. - listResponse, err := client.Kubernetes.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{}) + listResponse, err := client.Kubernetes.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) if len(tt.wantError) > 0 { require.EqualError(t, err, tt.wantError) require.Equal(t, &corev1.NamespaceList{}, listResponse) @@ -505,6 +620,81 @@ func TestImpersonator(t *testing.T) { // of the original request mutated by the impersonator. Otherwise the headers should be nil. require.Equal(t, tt.wantKubeAPIServerRequestHeaders, testKubeAPIServerSawHeaders) + // anonymous TCR should always work + + tcrRegGroup, err := kubeclient.New(kubeclient.WithConfig(rest.AnonymousClientConfig(clientKubeconfig))) + require.NoError(t, err) + + tcrOtherGroup, err := kubeclient.New(kubeclient.WithConfig(rest.AnonymousClientConfig(clientKubeconfig)), + kubeclient.WithMiddleware(groupsuffix.New("walrus.tld"))) + require.NoError(t, err) + + _, errTCR := tcrRegGroup.PinnipedConcierge.LoginV1alpha1().TokenCredentialRequests().Create(ctx, &loginv1alpha1.TokenCredentialRequest{}, metav1.CreateOptions{}) + require.NoError(t, errTCR) + + _, errTCROtherGroup := tcrOtherGroup.PinnipedConcierge.LoginV1alpha1().TokenCredentialRequests().Create(ctx, + &loginv1alpha1.TokenCredentialRequest{ + Spec: loginv1alpha1.TokenCredentialRequestSpec{ + Authenticator: corev1.TypedLocalObjectReference{ + APIGroup: pointer.String("anything.pinniped.dev"), + }, + }, + }, metav1.CreateOptions{}) + require.NoError(t, errTCROtherGroup) + + // these calls should only work when anonymous auth is enabled + + anonymousConfig := rest.AnonymousClientConfig(clientKubeconfig) + anonymousConfig.GroupVersion = &schema.GroupVersion{ + Group: "not-concierge.walrus.tld", + Version: "v1", + } + anonymousConfig.APIPath = "/apis" + anonymousConfig.NegotiatedSerializer = unstructuredscheme.NewUnstructuredNegotiatedSerializer() + rc, err := rest.RESTClientFor(anonymousConfig) + require.NoError(t, err) + + probeBody, errProbe := rc.Get().AbsPath("/probe").DoRaw(ctx) + if tt.anonymousAuthDisabled { + require.True(t, errors.IsUnauthorized(errProbe), errProbe) + require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(probeBody)) + } else { + require.NoError(t, errProbe) + require.Equal(t, "probed", string(probeBody)) + } + + notTCRBody, errNotTCR := rc.Get().Resource("tokencredentialrequests").DoRaw(ctx) + if tt.anonymousAuthDisabled { + require.True(t, errors.IsUnauthorized(errNotTCR), errNotTCR) + require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(notTCRBody)) + } else { + require.NoError(t, errNotTCR) + require.Equal(t, `{"hello": "quack"}`, string(notTCRBody)) + } + + ducksBody, errDucks := rc.Get().Resource("ducks").DoRaw(ctx) + if tt.anonymousAuthDisabled { + require.True(t, errors.IsUnauthorized(errDucks), errDucks) + require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Unauthorized","reason":"Unauthorized","code":401}`+"\n", string(ducksBody)) + } else { + require.NoError(t, errDucks) + require.Equal(t, `{"hello": "birds"}`, string(ducksBody)) + } + + // this should always fail as unauthorized (even for TCR) because the cert is not valid + + badCertConfig := rest.AnonymousClientConfig(clientKubeconfig) + badCert := newClientCert(t, unrelatedCA, "bad-user", []string{"bad-group"}) + badCertConfig.TLSClientConfig.CertData = badCert.certPEM + badCertConfig.TLSClientConfig.KeyData = badCert.keyPEM + + tcrBadCert, err := kubeclient.New(kubeclient.WithConfig(badCertConfig)) + require.NoError(t, err) + + _, errBadCert := tcrBadCert.PinnipedConcierge.LoginV1alpha1().TokenCredentialRequests().Create(ctx, &loginv1alpha1.TokenCredentialRequest{}, metav1.CreateOptions{}) + require.True(t, errors.IsUnauthorized(errBadCert), errBadCert) + require.EqualError(t, errBadCert, "Unauthorized") + // Stop the impersonator server. close(stopCh) exitErr := <-errCh diff --git a/test/integration/concierge_impersonation_proxy_test.go b/test/integration/concierge_impersonation_proxy_test.go index 98b44dcf..714d7b74 100644 --- a/test/integration/concierge_impersonation_proxy_test.go +++ b/test/integration/concierge_impersonation_proxy_test.go @@ -39,12 +39,15 @@ import ( "k8s.io/apimachinery/pkg/api/equality" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructuredscheme" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/watch" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/request/bearertoken" "k8s.io/apiserver/pkg/authentication/serviceaccount" + "k8s.io/apiserver/pkg/authentication/user" k8sinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" @@ -53,6 +56,7 @@ import ( "k8s.io/client-go/util/certificate/csr" "k8s.io/client-go/util/keyutil" "k8s.io/client-go/util/retry" + "k8s.io/utils/pointer" conciergev1alpha "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1" @@ -800,15 +804,21 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl ).PinnipedConcierge whoAmI, err = impersonationProxyAnonymousPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests(). Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) - require.NoError(t, err) - require.Equal(t, - expectedWhoAmIRequestResponse( - "system:anonymous", - []string{"system:unauthenticated"}, - nil, - ), - whoAmI, - ) + + // we expect the impersonation proxy to match the behavior of KAS in regards to anonymous requests + if env.HasCapability(library.AnonymousAuthenticationSupported) { + require.NoError(t, err) + require.Equal(t, + expectedWhoAmIRequestResponse( + "system:anonymous", + []string{"system:unauthenticated"}, + nil, + ), + whoAmI, + ) + } else { + require.True(t, k8serrors.IsUnauthorized(err), library.Sdump(err)) + } // Test using a service account token. namespaceName := createTestNamespace(t, adminClient) @@ -1193,6 +1203,173 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl actualConfigMap.TypeMeta = metav1.TypeMeta{} // This isn't filled out in the wantConfigMap we got back from create. require.Equal(t, *wantConfigMap, actualConfigMap) }) + + t.Run("honors anonymous authentication of KAS", func(t *testing.T) { + t.Parallel() + + impersonationProxyAnonymousClient := newAnonymousImpersonationProxyClient( + t, impersonationProxyURL, impersonationProxyCACertPEM, nil, + ) + + copyConfig := rest.CopyConfig(impersonationProxyAnonymousClient.JSONConfig) + copyConfig.GroupVersion = &schema.GroupVersion{} + copyConfig.NegotiatedSerializer = unstructuredscheme.NewUnstructuredNegotiatedSerializer() + impersonationProxyAnonymousRestClient, err := rest.RESTClientFor(copyConfig) + require.NoError(t, err) + + adminClientRestConfig := library.NewClientConfig(t) + clusterAdminCredentials := getCredForConfig(t, adminClientRestConfig) + impersonationProxyAdminClientAsAnonymousConfig := newImpersonationProxyClientWithCredentials(t, + clusterAdminCredentials, + impersonationProxyURL, impersonationProxyCACertPEM, + &rest.ImpersonationConfig{UserName: user.Anonymous}). + JSONConfig + impersonationProxyAdminClientAsAnonymousConfigCopy := rest.CopyConfig(impersonationProxyAdminClientAsAnonymousConfig) + impersonationProxyAdminClientAsAnonymousConfigCopy.GroupVersion = &schema.GroupVersion{} + impersonationProxyAdminClientAsAnonymousConfigCopy.NegotiatedSerializer = unstructuredscheme.NewUnstructuredNegotiatedSerializer() + impersonationProxyAdminRestClientAsAnonymous, err := rest.RESTClientFor(impersonationProxyAdminClientAsAnonymousConfigCopy) + require.NoError(t, err) + + t.Run("anonymous authentication irrelevant", func(t *testing.T) { + t.Parallel() + + // - hit the token credential request endpoint with an empty body + // - through the impersonation proxy + // - should succeed as an invalid request whether anonymous authentication is enabled or disabled + // - should not reject as unauthorized + t.Run("token credential request", func(t *testing.T) { + t.Parallel() + + tkr, err := impersonationProxyAnonymousClient.PinnipedConcierge.LoginV1alpha1().TokenCredentialRequests(). + Create(ctx, &loginv1alpha1.TokenCredentialRequest{ + Spec: loginv1alpha1.TokenCredentialRequestSpec{ + Authenticator: corev1.TypedLocalObjectReference{APIGroup: pointer.String("anything.pinniped.dev")}, + }, + }, metav1.CreateOptions{}) + require.True(t, k8serrors.IsInvalid(err), library.Sdump(err)) + require.Equal(t, `.login.concierge.pinniped.dev "" is invalid: spec.token.value: Required value: token must be supplied`, err.Error()) + require.Equal(t, &loginv1alpha1.TokenCredentialRequest{}, tkr) + }) + + // - hit the healthz endpoint (non-resource endpoint) + // - through the impersonation proxy + // - as cluster admin, impersonating anonymous user + // - should succeed, authentication happens as cluster-admin + // - whoami should confirm we are using impersonation + // - healthz should succeed, anonymous users can request this endpoint + // - healthz/log should fail, forbidden anonymous + t.Run("non-resource request while impersonating anonymous - nested impersonation", func(t *testing.T) { + t.Parallel() + + whoami, errWho := impersonationProxyAdminRestClientAsAnonymous.Post().Body([]byte(`{}`)).AbsPath("/apis/identity.concierge." + env.APIGroupSuffix + "/v1alpha1/whoamirequests").DoRaw(ctx) + require.NoError(t, errWho, library.Sdump(errWho)) + require.True(t, strings.HasPrefix(string(whoami), `{"kind":"WhoAmIRequest","apiVersion":"identity.concierge.`+env.APIGroupSuffix+`/v1alpha1","metadata":{"creationTimestamp":null},"spec":{},"status":{"kubernetesUserInfo":{"user":{"username":"system:anonymous","groups":["system:unauthenticated"],"extra":{"original-user-info.impersonation-proxy.concierge.pinniped.dev":["{\"username\":`), string(whoami)) + + healthz, errHealth := impersonationProxyAdminRestClientAsAnonymous.Get().AbsPath("/healthz").DoRaw(ctx) + require.NoError(t, errHealth, library.Sdump(errHealth)) + require.Equal(t, "ok", string(healthz)) + + healthzLog, errHealthzLog := impersonationProxyAdminRestClientAsAnonymous.Get().AbsPath("/healthz/log").DoRaw(ctx) + require.True(t, k8serrors.IsForbidden(errHealthzLog), "%s\n%s", library.Sdump(errHealthzLog), string(healthzLog)) + require.Equal(t, `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"forbidden: User \"system:anonymous\" cannot get path \"/healthz/log\"","reason":"Forbidden","details":{},"code":403}`+"\n", string(healthzLog)) + }) + }) + + t.Run("anonymous authentication enabled", func(t *testing.T) { + library.IntegrationEnv(t).WithCapability(library.AnonymousAuthenticationSupported) + t.Parallel() + + // anonymous auth enabled + // - hit the healthz endpoint (non-resource endpoint) + // - through the impersonation proxy + // - should succeed 200 + // - should respond "ok" + t.Run("non-resource request", func(t *testing.T) { + t.Parallel() + + healthz, errHealth := impersonationProxyAnonymousRestClient.Get().AbsPath("/healthz").DoRaw(ctx) + require.NoError(t, errHealth, library.Sdump(errHealth)) + require.Equal(t, "ok", string(healthz)) + }) + + // - hit the pods endpoint (a resource endpoint) + // - through the impersonation proxy + // - should fail forbidden + // - system:anonymous cannot get pods + t.Run("resource", func(t *testing.T) { + t.Parallel() + + pod, err := impersonationProxyAnonymousClient.Kubernetes.CoreV1().Pods(metav1.NamespaceSystem). + Get(ctx, "does-not-matter", metav1.GetOptions{}) + require.True(t, k8serrors.IsForbidden(err), library.Sdump(err)) + require.EqualError(t, err, `pods "does-not-matter" is forbidden: User "system:anonymous" cannot get resource "pods" in API group "" in the namespace "kube-system"`, library.Sdump(err)) + require.Equal(t, &corev1.Pod{}, pod) + }) + + // - request to whoami (pinniped resource endpoing) + // - through the impersonation proxy + // - should succeed 200 + // - should respond "you are system:anonymous" + t.Run("pinniped resource request", func(t *testing.T) { + t.Parallel() + + whoAmI, err := impersonationProxyAnonymousClient.PinnipedConcierge.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.NoError(t, err) + require.Equal(t, + expectedWhoAmIRequestResponse( + "system:anonymous", + []string{"system:unauthenticated"}, + nil, + ), + whoAmI, + ) + }) + }) + + t.Run("anonymous authentication disabled", func(t *testing.T) { + library.IntegrationEnv(t).WithoutCapability(library.AnonymousAuthenticationSupported) + t.Parallel() + + // - 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) { + t.Parallel() + + healthz, err := impersonationProxyAnonymousRestClient.Get().AbsPath("/healthz").DoRaw(ctx) + require.True(t, k8serrors.IsUnauthorized(err), library.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 + // - kube api server should reject it + t.Run("resource", func(t *testing.T) { + t.Parallel() + + pod, err := impersonationProxyAnonymousClient.Kubernetes.CoreV1().Pods(metav1.NamespaceSystem). + Get(ctx, "does-not-matter", metav1.GetOptions{}) + require.True(t, k8serrors.IsUnauthorized(err), library.Sdump(err)) + require.Equal(t, &corev1.Pod{}, pod) + }) + + // - request to whoami (pinniped resource endpoing) + // - through the impersonation proxy + // - should fail unauthorized + // - kube api server should reject it + t.Run("pinniped resource request", func(t *testing.T) { + t.Parallel() + + whoAmI, err := impersonationProxyAnonymousClient.PinnipedConcierge.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.True(t, k8serrors.IsUnauthorized(err), library.Sdump(err)) + require.Equal(t, &identityv1alpha1.WhoAmIRequest{}, whoAmI) + }) + }) + }) }) t.Run("adding an annotation reconciles the LoadBalancer service", func(t *testing.T) {