impersonator: honor anonymous authentication being disabled

When anonymous authentication is disabled, the impersonation proxy
will no longer authenticate anonymous requests other than calls to
the token credential request API (this API is used to retrieve
credentials and thus must be accessed anonymously).

Signed-off-by: Benjamin A. Petersen <ben@benjaminapetersen.me>
Signed-off-by: Monis Khan <mok@vmware.com>
This commit is contained in:
Benjamin A. Petersen 2021-05-27 10:17:19 -04:00 committed by Monis Khan
parent 02335e2ade
commit 492f6cfddf
No known key found for this signature in database
GPG Key ID: 52C90ADA01B269B8
4 changed files with 505 additions and 17 deletions

View File

@ -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 This approach allows clients to use the Token Credential Request API even when
we do not have the cluster's signing key. 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 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. impersonate the user, the proxied request will be authorized against that user.
Thus for all regular REST verbs, we perform no authorization checks. Thus for all regular REST verbs, we perform no authorization checks.

View File

@ -19,6 +19,7 @@ import (
apiequality "k8s.io/apimachinery/pkg/api/equality" apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer" "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.AuditPolicyChecker = policy.FakeChecker(auditinternal.LevelMetadata, nil)
serverConfig.AuditBackend = &auditfake.Backend{} 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 // if we ever start unioning a TCR bearer token authenticator with serverConfig.Authenticator
// then we will need to update the related assumption in tokenPassthroughRoundTripper // 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 delegatingAuthorizer := serverConfig.Authorization.Authorizer
nestedImpersonationAuthorizer := &comparableAuthorizer{ nestedImpersonationAuthorizer := &comparableAuthorizer{
authorizerFunc: func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { 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. // Set our custom authorizer before calling Compete(), which will use it.
serverConfig.Authorization.Authorizer = nestedImpersonationAuthorizer 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 { if err != nil {
return nil, err return nil, err
} }
preparedRun := impersonationProxyServer.PrepareRun() 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. // Sanity check. Make sure that our custom authorizer is still in place and did not get changed or wrapped.
if preparedRun.Authorizer != nestedImpersonationAuthorizer { 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. // 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 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 { func deleteKnownImpersonationHeaders(delegate http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// remove known impersonation headers while avoiding mutation of input 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. // No-op wrapping around AuthorizerFunc to allow for comparisons.
type comparableAuthorizer struct { type comparableAuthorizer struct {
authorizerFunc authorizerFunc

View File

@ -5,6 +5,7 @@ package impersonator
import ( import (
"context" "context"
"fmt"
"math/rand" "math/rand"
"net" "net"
"net/http" "net/http"
@ -17,8 +18,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
authenticationv1 "k8s.io/api/authentication/v1" authenticationv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/util/httpstream" "k8s.io/apimachinery/pkg/util/httpstream"
auditinternal "k8s.io/apiserver/pkg/apis/audit" auditinternal "k8s.io/apiserver/pkg/apis/audit"
@ -33,10 +37,13 @@ import (
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd/api" "k8s.io/client-go/tools/clientcmd/api"
featuregatetesting "k8s.io/component-base/featuregate/testing" 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/certauthority"
"go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/dynamiccert" "go.pinniped.dev/internal/dynamiccert"
"go.pinniped.dev/internal/groupsuffix"
"go.pinniped.dev/internal/here" "go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/httputil/roundtripper" "go.pinniped.dev/internal/httputil/roundtripper"
"go.pinniped.dev/internal/kubeclient" "go.pinniped.dev/internal/kubeclient"
@ -72,6 +79,8 @@ func TestImpersonator(t *testing.T) {
clientNextProtos []string clientNextProtos []string
kubeAPIServerClientBearerTokenFile string kubeAPIServerClientBearerTokenFile string
kubeAPIServerStatusCode int kubeAPIServerStatusCode int
kubeAPIServerHealthz http.Handler
anonymousAuthDisabled bool
wantKubeAPIServerRequestHeaders http.Header wantKubeAPIServerRequestHeaders http.Header
wantError string wantError string
wantConstructionError string wantConstructionError string
@ -90,6 +99,43 @@ func TestImpersonator(t *testing.T) {
"X-Forwarded-For": {"127.0.0.1"}, "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", name: "happy path with upgrade",
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}), 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", name: "no bearer token file in Kube API server client config",
wantConstructionError: "invalid impersonator loopback rest config has wrong bearer token semantics", 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", name: "header canonicalization user header",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}), 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.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
t.Cleanup(cancel)
// we need to create this listener ourselves because the API server // we need to create this listener ourselves because the API server
// code treats (port == 0 && listener == nil) to mean "do nothing" // code treats (port == 0 && listener == nil) to mean "do nothing"
listener, port, err := genericoptions.CreateListener("", "127.0.0.1:0", net.ListenConfig{}) listener, port, err := genericoptions.CreateListener("", "127.0.0.1:0", net.ListenConfig{})
@ -384,22 +441,28 @@ func TestImpersonator(t *testing.T) {
testKubeAPIServerWasCalled := false testKubeAPIServerWasCalled := false
var testKubeAPIServerSawHeaders http.Header var testKubeAPIServerSawHeaders http.Header
testKubeAPIServerCA, testKubeAPIServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { testKubeAPIServerCA, testKubeAPIServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
switch r.URL.Path { switch r.URL.Path {
case "/api/v1/namespaces/kube-system/configmaps": case "/api/v1/namespaces/kube-system/configmaps":
require.Equal(t, http.MethodGet, r.Method)
// The production code uses NewDynamicCAFromConfigMapController which fetches a ConfigMap, // 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 // 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. // to use it to validate client certs. We don't need it for this test, so return NotFound.
http.NotFound(w, r) http.NotFound(w, r)
return return
case "/api/v1/namespaces": case "/api/v1/namespaces":
require.Equal(t, http.MethodGet, r.Method)
testKubeAPIServerWasCalled = true testKubeAPIServerWasCalled = true
testKubeAPIServerSawHeaders = r.Header testKubeAPIServerSawHeaders = r.Header
if tt.kubeAPIServerStatusCode != http.StatusOK { if tt.kubeAPIServerStatusCode != http.StatusOK {
w.WriteHeader(tt.kubeAPIServerStatusCode) w.WriteHeader(tt.kubeAPIServerStatusCode)
} else { return
w.Header().Add("Content-Type", "application/json; charset=UTF-8") }
_, _ = w.Write([]byte(here.Doc(`
w.Header().Add("Content-Type", "application/json; charset=UTF-8")
_, _ = w.Write([]byte(here.Doc(`
{ {
"kind": "NamespaceList", "kind": "NamespaceList",
"apiVersion":"v1", "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: 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 // The fake Kube API server knows how to to list namespaces, so make that request using the client
// through the impersonator. // 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 { if len(tt.wantError) > 0 {
require.EqualError(t, err, tt.wantError) require.EqualError(t, err, tt.wantError)
require.Equal(t, &corev1.NamespaceList{}, listResponse) 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. // of the original request mutated by the impersonator. Otherwise the headers should be nil.
require.Equal(t, tt.wantKubeAPIServerRequestHeaders, testKubeAPIServerSawHeaders) 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. // Stop the impersonator server.
close(stopCh) close(stopCh)
exitErr := <-errCh exitErr := <-errCh

View File

@ -39,12 +39,15 @@ import (
"k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/equality"
k8serrors "k8s.io/apimachinery/pkg/api/errors" k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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/labels"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/watch" "k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/request/bearertoken" "k8s.io/apiserver/pkg/authentication/request/bearertoken"
"k8s.io/apiserver/pkg/authentication/serviceaccount" "k8s.io/apiserver/pkg/authentication/serviceaccount"
"k8s.io/apiserver/pkg/authentication/user"
k8sinformers "k8s.io/client-go/informers" k8sinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
@ -53,6 +56,7 @@ import (
"k8s.io/client-go/util/certificate/csr" "k8s.io/client-go/util/certificate/csr"
"k8s.io/client-go/util/keyutil" "k8s.io/client-go/util/keyutil"
"k8s.io/client-go/util/retry" "k8s.io/client-go/util/retry"
"k8s.io/utils/pointer"
conciergev1alpha "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" conciergev1alpha "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1"
identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/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 ).PinnipedConcierge
whoAmI, err = impersonationProxyAnonymousPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests(). whoAmI, err = impersonationProxyAnonymousPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests().
Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{})
require.NoError(t, err)
require.Equal(t, // we expect the impersonation proxy to match the behavior of KAS in regards to anonymous requests
expectedWhoAmIRequestResponse( if env.HasCapability(library.AnonymousAuthenticationSupported) {
"system:anonymous", require.NoError(t, err)
[]string{"system:unauthenticated"}, require.Equal(t,
nil, expectedWhoAmIRequestResponse(
), "system:anonymous",
whoAmI, []string{"system:unauthenticated"},
) nil,
),
whoAmI,
)
} else {
require.True(t, k8serrors.IsUnauthorized(err), library.Sdump(err))
}
// Test using a service account token. // Test using a service account token.
namespaceName := createTestNamespace(t, adminClient) 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. actualConfigMap.TypeMeta = metav1.TypeMeta{} // This isn't filled out in the wantConfigMap we got back from create.
require.Equal(t, *wantConfigMap, actualConfigMap) 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) { t.Run("adding an annotation reconciles the LoadBalancer service", func(t *testing.T) {