Merge pull request #645 from enj/enj/f/anon_impersonation_proxy
impersonator: honor anonymous authentication being disabled
This commit is contained in:
commit
3a47060256
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user