521adffb17
This change updates the impersonator logic to use the delegated authorizer for all non-rest verbs such as impersonate. This allows it to correctly perform authorization checks for incoming requests that set impersonation headers while not performing unnecessary checks that are already handled by KAS. The audit layer is enabled to track the original user who made the request. This information is then included in a reserved extra field original-user-info.impersonation-proxy.concierge.pinniped.dev as a JSON blob. Signed-off-by: Monis Khan <mok@vmware.com>
1265 lines
55 KiB
Go
1265 lines
55 KiB
Go
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package impersonator
|
|
|
|
import (
|
|
"context"
|
|
"math/rand"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
authenticationv1 "k8s.io/api/authentication/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/serializer"
|
|
"k8s.io/apimachinery/pkg/util/httpstream"
|
|
auditinternal "k8s.io/apiserver/pkg/apis/audit"
|
|
"k8s.io/apiserver/pkg/authentication/user"
|
|
"k8s.io/apiserver/pkg/endpoints/request"
|
|
"k8s.io/apiserver/pkg/features"
|
|
genericapiserver "k8s.io/apiserver/pkg/server"
|
|
genericoptions "k8s.io/apiserver/pkg/server/options"
|
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/clientcmd/api"
|
|
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
|
|
|
"go.pinniped.dev/internal/certauthority"
|
|
"go.pinniped.dev/internal/dynamiccert"
|
|
"go.pinniped.dev/internal/here"
|
|
"go.pinniped.dev/internal/httputil/roundtripper"
|
|
"go.pinniped.dev/internal/kubeclient"
|
|
"go.pinniped.dev/internal/testutil"
|
|
)
|
|
|
|
func TestImpersonator(t *testing.T) {
|
|
ca, err := certauthority.New("ca", time.Hour)
|
|
require.NoError(t, err)
|
|
caKey, err := ca.PrivateKeyToPEM()
|
|
require.NoError(t, err)
|
|
caContent := dynamiccert.NewCA("ca")
|
|
err = caContent.SetCertKeyContent(ca.Bundle(), caKey)
|
|
require.NoError(t, err)
|
|
|
|
cert, key, err := ca.IssueServerCertPEM(nil, []net.IP{net.ParseIP("127.0.0.1")}, time.Hour)
|
|
require.NoError(t, err)
|
|
certKeyContent := dynamiccert.NewServingCert("cert-key")
|
|
err = certKeyContent.SetCertKeyContent(cert, key)
|
|
require.NoError(t, err)
|
|
|
|
unrelatedCA, err := certauthority.New("ca", time.Hour)
|
|
require.NoError(t, err)
|
|
|
|
// turn off this code path for all tests because it does not handle the config we remove correctly
|
|
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.APIPriorityAndFairness, false)()
|
|
|
|
tests := []struct {
|
|
name string
|
|
clientCert *clientCert
|
|
clientImpersonateUser rest.ImpersonationConfig
|
|
clientMutateHeaders func(http.Header)
|
|
clientNextProtos []string
|
|
kubeAPIServerClientBearerTokenFile string
|
|
kubeAPIServerStatusCode int
|
|
wantKubeAPIServerRequestHeaders http.Header
|
|
wantError string
|
|
wantConstructionError string
|
|
}{
|
|
{
|
|
name: "happy path",
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
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"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header.Add("Connection", "Upgrade")
|
|
header.Add("Upgrade", "spdy/3.1")
|
|
|
|
if ok := httpstream.IsUpgradeRequest(&http.Request{Header: header}); !ok {
|
|
panic("request must be upgrade in this test")
|
|
}
|
|
},
|
|
clientNextProtos: []string{"http/1.1"}, // we need to use http1 as http2 does not support upgrades, see http2checkConnHeaders
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"test-username2"},
|
|
"Impersonate-Group": {"test-group3", "test-group4", "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"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"spdy/3.1"},
|
|
},
|
|
},
|
|
{
|
|
name: "happy path ignores forwarded header",
|
|
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header.Add("X-Forwarded-For", "example.com")
|
|
},
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"test-username2"},
|
|
"Impersonate-Group": {"test-group3", "test-group4", "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 ignores forwarded header canonicalization",
|
|
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["x-FORWARDED-for"] = append(header["x-FORWARDED-for"], "example.com")
|
|
},
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"test-username2"},
|
|
"Impersonate-Group": {"test-group3", "test-group4", "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: "user is authenticated but the kube API request returns an error",
|
|
kubeAPIServerStatusCode: http.StatusNotFound,
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: `the server could not find the requested resource (get namespaces)`,
|
|
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: "when there is no client cert on request, it is an anonymous request",
|
|
clientCert: &clientCert{},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"system:anonymous"},
|
|
"Impersonate-Group": {"system:unauthenticated"},
|
|
"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: "failed client cert authentication",
|
|
clientCert: newClientCert(t, unrelatedCA, "test-username", []string{"test-group1"}),
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Unauthorized",
|
|
},
|
|
{
|
|
name: "nested impersonation by regular users calls delegating authorizer",
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{UserName: "some-other-username"},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
// this fails because the delegating authorizer in this test only allows system:masters and fails everything else
|
|
wantError: `users "some-other-username" is forbidden: User "test-username" ` +
|
|
`cannot impersonate resource "users" in API group "" at the cluster scope`,
|
|
},
|
|
{
|
|
name: "nested impersonation by admin users calls delegating authorizer",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{
|
|
UserName: "fire",
|
|
Groups: []string{"elements"},
|
|
Extra: map[string][]string{
|
|
"colors": {"red", "orange", "blue"},
|
|
|
|
// gke
|
|
"iam.gke.io/user-assertion": {"good", "stuff"},
|
|
"user-assertion.cloud.google.com": {"smaller", "things"},
|
|
|
|
// openshift
|
|
"scopes.authorization.openshift.io": {"user:info", "user:full", "user:check-access"},
|
|
|
|
// openstack
|
|
"alpha.kubernetes.io/identity/roles": {"a-role1", "a-role2"},
|
|
"alpha.kubernetes.io/identity/project/id": {"a-project-id"},
|
|
"alpha.kubernetes.io/identity/project/name": {"a-project-name"},
|
|
"alpha.kubernetes.io/identity/user/domain/id": {"a-domain-id"},
|
|
"alpha.kubernetes.io/identity/user/domain/name": {"a-domain-name"},
|
|
},
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"fire"},
|
|
"Impersonate-Group": {"elements", "system:authenticated"},
|
|
"Impersonate-Extra-Colors": {"red", "orange", "blue"},
|
|
"Impersonate-Extra-Iam.gke.io%2fuser-Assertion": {"good", "stuff"},
|
|
"Impersonate-Extra-User-Assertion.cloud.google.com": {"smaller", "things"},
|
|
"Impersonate-Extra-Scopes.authorization.openshift.io": {"user:info", "user:full", "user:check-access"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2froles": {"a-role1", "a-role2"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fproject%2fid": {"a-project-id"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fproject%2fname": {"a-project-name"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fuser%2fdomain%2fid": {"a-domain-id"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fuser%2fdomain%2fname": {"a-domain-name"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"test-admin","groups":["test-group2","system:masters","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: "nested impersonation by admin users cannot impersonate UID",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{UserName: "some-other-username"},
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["Impersonate-Uid"] = []string{"root"}
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Internal error occurred: invalid impersonation",
|
|
},
|
|
{
|
|
name: "nested impersonation by admin users cannot impersonate UID header canonicalization",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{UserName: "some-other-username"},
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["imPerSoNaTE-uid"] = []string{"magic"}
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Internal error occurred: invalid impersonation",
|
|
},
|
|
{
|
|
name: "nested impersonation by admin users cannot use reserved key",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{
|
|
UserName: "other-user-to-impersonate",
|
|
Groups: []string{"other-peeps"},
|
|
Extra: map[string][]string{
|
|
"key": {"good"},
|
|
"something.impersonation-proxy.concierge.pinniped.dev": {"bad data"},
|
|
},
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
|
|
},
|
|
{
|
|
name: "nested impersonation by admin users cannot use invalid key",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{
|
|
UserName: "panda",
|
|
Groups: []string{"other-peeps"},
|
|
Extra: map[string][]string{
|
|
"party~~time": {"danger"},
|
|
},
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
|
|
},
|
|
{
|
|
name: "nested impersonation by admin users can use uppercase key because impersonation is lossy",
|
|
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
|
|
clientImpersonateUser: rest.ImpersonationConfig{
|
|
UserName: "panda",
|
|
Groups: []string{"other-peeps"},
|
|
Extra: map[string][]string{
|
|
"ROAR": {"tiger"}, // by the time our code sees this key, it is lowercased to "roar"
|
|
},
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantKubeAPIServerRequestHeaders: http.Header{
|
|
"Impersonate-User": {"panda"},
|
|
"Impersonate-Group": {"other-peeps", "system:authenticated"},
|
|
"Impersonate-Extra-Roar": {"tiger"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"test-admin","groups":["test-group2","system:masters","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: "no bearer token file in Kube API server client config",
|
|
wantConstructionError: "invalid impersonator loopback rest config has wrong bearer token semantics",
|
|
},
|
|
{
|
|
name: "header canonicalization user header",
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["imPerSonaTE-USer"] = []string{"PANDA"}
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: `users "PANDA" is forbidden: User "test-username" ` +
|
|
`cannot impersonate resource "users" in API group "" at the cluster scope`,
|
|
},
|
|
{
|
|
name: "header canonicalization future UID header",
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["imPerSonaTE-uid"] = []string{"007"}
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Internal error occurred: invalid impersonation",
|
|
},
|
|
{
|
|
name: "future UID header",
|
|
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
|
|
clientMutateHeaders: func(header http.Header) {
|
|
header["Impersonate-Uid"] = []string{"008"}
|
|
},
|
|
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
|
|
wantError: "Internal error occurred: invalid impersonation",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// 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{})
|
|
require.NoError(t, err)
|
|
|
|
// After failing to start and after shutdown, the impersonator port should be available again.
|
|
defer requireCanBindToPort(t, port)
|
|
|
|
if tt.kubeAPIServerStatusCode == 0 {
|
|
tt.kubeAPIServerStatusCode = http.StatusOK
|
|
}
|
|
|
|
// Set up a fake Kube API server which will stand in for the real one. The impersonator
|
|
// will proxy incoming calls to this fake server.
|
|
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":
|
|
// 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":
|
|
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(`
|
|
{
|
|
"kind": "NamespaceList",
|
|
"apiVersion":"v1",
|
|
"items": [
|
|
{"metadata":{"name": "namespace1"}},
|
|
{"metadata":{"name": "namespace2"}}
|
|
]
|
|
}
|
|
`)))
|
|
}
|
|
default:
|
|
require.Fail(t, "fake Kube API server got an unexpected request")
|
|
}
|
|
})
|
|
|
|
// Create the client config that the impersonation server should use to talk to the Kube API server.
|
|
testKubeAPIServerKubeconfig := rest.Config{
|
|
Host: testKubeAPIServerURL,
|
|
BearerToken: "some-service-account-token",
|
|
TLSClientConfig: rest.TLSClientConfig{CAData: []byte(testKubeAPIServerCA)},
|
|
BearerTokenFile: tt.kubeAPIServerClientBearerTokenFile,
|
|
}
|
|
clientOpts := []kubeclient.Option{kubeclient.WithConfig(&testKubeAPIServerKubeconfig)}
|
|
|
|
// Punch out just enough stuff to make New actually run without error.
|
|
recOpts := func(options *genericoptions.RecommendedOptions) {
|
|
options.Authentication.RemoteKubeConfigFileOptional = true
|
|
options.Authorization.RemoteKubeConfigFileOptional = true
|
|
options.CoreAPI = nil
|
|
options.Admission = nil
|
|
options.SecureServing.Listener = listener // use our listener with the dynamic port
|
|
}
|
|
|
|
// Create an impersonator. Use an invalid port number to make sure our listener override works.
|
|
runner, constructionErr := newInternal(-1000, certKeyContent, caContent, clientOpts, recOpts)
|
|
if len(tt.wantConstructionError) > 0 {
|
|
require.EqualError(t, constructionErr, tt.wantConstructionError)
|
|
require.Nil(t, runner)
|
|
// The rest of the test doesn't make sense when you expect a construction error, so stop here.
|
|
return
|
|
}
|
|
require.NoError(t, constructionErr)
|
|
require.NotNil(t, runner)
|
|
|
|
// Start the impersonator.
|
|
stopCh := make(chan struct{})
|
|
errCh := make(chan error)
|
|
go func() {
|
|
stopErr := runner(stopCh)
|
|
errCh <- stopErr
|
|
}()
|
|
|
|
// Create a kubeconfig to talk to the impersonator as a client.
|
|
clientKubeconfig := &rest.Config{
|
|
Host: "https://127.0.0.1:" + strconv.Itoa(port),
|
|
TLSClientConfig: rest.TLSClientConfig{
|
|
CAData: ca.Bundle(),
|
|
CertData: tt.clientCert.certPEM,
|
|
KeyData: tt.clientCert.keyPEM,
|
|
NextProtos: tt.clientNextProtos,
|
|
},
|
|
UserAgent: "test-agent",
|
|
// BearerToken should be ignored during auth when there are valid client certs,
|
|
// and it should not passed into the impersonator handler func as an authorization header.
|
|
BearerToken: "must-be-ignored",
|
|
Impersonate: tt.clientImpersonateUser,
|
|
WrapTransport: func(rt http.RoundTripper) http.RoundTripper {
|
|
if tt.clientMutateHeaders == nil {
|
|
return rt
|
|
}
|
|
|
|
return roundtripper.Func(func(req *http.Request) (*http.Response, error) {
|
|
req = req.Clone(req.Context())
|
|
tt.clientMutateHeaders(req.Header)
|
|
return rt.RoundTrip(req)
|
|
})
|
|
},
|
|
}
|
|
|
|
// Create a real Kube client to make API requests to the impersonator.
|
|
client, err := kubeclient.New(kubeclient.WithConfig(clientKubeconfig))
|
|
require.NoError(t, err)
|
|
|
|
// 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{})
|
|
if len(tt.wantError) > 0 {
|
|
require.EqualError(t, err, tt.wantError)
|
|
require.Equal(t, &corev1.NamespaceList{}, listResponse)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, &corev1.NamespaceList{
|
|
Items: []corev1.Namespace{
|
|
{ObjectMeta: metav1.ObjectMeta{Name: "namespace1"}},
|
|
{ObjectMeta: metav1.ObjectMeta{Name: "namespace2"}},
|
|
},
|
|
}, listResponse)
|
|
}
|
|
|
|
// If we expect to see some headers, then the fake KAS should have been called.
|
|
require.Equal(t, len(tt.wantKubeAPIServerRequestHeaders) != 0, testKubeAPIServerWasCalled)
|
|
// If the impersonator proxied the request to the fake Kube API server, we should see the headers
|
|
// of the original request mutated by the impersonator. Otherwise the headers should be nil.
|
|
require.Equal(t, tt.wantKubeAPIServerRequestHeaders, testKubeAPIServerSawHeaders)
|
|
|
|
// Stop the impersonator server.
|
|
close(stopCh)
|
|
exitErr := <-errCh
|
|
require.NoError(t, exitErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImpersonatorHTTPHandler(t *testing.T) {
|
|
const testUser = "test-user"
|
|
|
|
testGroups := []string{"test-group-1", "test-group-2"}
|
|
testExtra := map[string][]string{
|
|
"extra-1": {"some", "extra", "stuff"},
|
|
"extra-2": {"some", "more", "extra", "stuff"},
|
|
}
|
|
|
|
validURL, _ := url.Parse("http://pinniped.dev/blah")
|
|
newRequest := func(h http.Header, userInfo user.Info, event *auditinternal.Event) *http.Request {
|
|
ctx := context.Background()
|
|
|
|
if userInfo != nil {
|
|
ctx = request.WithUser(ctx, userInfo)
|
|
}
|
|
|
|
ae := &auditinternal.Event{Level: auditinternal.LevelMetadata}
|
|
if event != nil {
|
|
ae = event
|
|
}
|
|
ctx = request.WithAuditEvent(ctx, ae)
|
|
|
|
reqInfo := &request.RequestInfo{
|
|
IsResourceRequest: false,
|
|
Path: validURL.Path,
|
|
Verb: "get",
|
|
}
|
|
ctx = request.WithRequestInfo(ctx, reqInfo)
|
|
|
|
r, err := http.NewRequestWithContext(ctx, http.MethodGet, validURL.String(), nil)
|
|
require.NoError(t, err)
|
|
r.Header = h
|
|
|
|
return r
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
restConfig *rest.Config
|
|
wantCreationErr string
|
|
request *http.Request
|
|
wantHTTPBody string
|
|
wantHTTPStatus int
|
|
wantKubeAPIServerRequestHeaders http.Header
|
|
kubeAPIServerStatusCode int
|
|
}{
|
|
{
|
|
name: "invalid kubeconfig host",
|
|
restConfig: &rest.Config{Host: ":"},
|
|
wantCreationErr: "could not parse host URL from in-cluster config: parse \":\": missing protocol scheme",
|
|
},
|
|
{
|
|
name: "invalid transport config",
|
|
restConfig: &rest.Config{
|
|
Host: "pinniped.dev/blah",
|
|
ExecProvider: &api.ExecConfig{},
|
|
AuthProvider: &api.AuthProviderConfig{},
|
|
},
|
|
wantCreationErr: "could not get http/1.1 round tripper: could not get in-cluster transport config: execProvider and authProvider cannot be used in combination",
|
|
},
|
|
{
|
|
name: "fail to get transport from config",
|
|
restConfig: &rest.Config{
|
|
Host: "pinniped.dev/blah",
|
|
BearerToken: "test-bearer-token",
|
|
Transport: http.DefaultTransport,
|
|
TLSClientConfig: rest.TLSClientConfig{Insecure: true},
|
|
},
|
|
wantCreationErr: "could not get http/1.1 round tripper: using a custom transport with TLS certificate options or the insecure flag is not allowed",
|
|
},
|
|
{
|
|
name: "Impersonate-User header already in request",
|
|
request: newRequest(map[string][]string{"Impersonate-User": {"some-user"}}, nil, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "Impersonate-Group header already in request",
|
|
request: newRequest(map[string][]string{"Impersonate-Group": {"some-group"}}, nil, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "Impersonate-Extra header already in request",
|
|
request: newRequest(map[string][]string{"Impersonate-Extra-something": {"something"}}, nil, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "Impersonate-* header already in request",
|
|
request: newRequest(map[string][]string{"Impersonate-Something": {"some-newfangled-impersonate-header"}}, nil, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid impersonation","reason":"InternalError","details":{"causes":[{"message":"invalid impersonation"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "unexpected authorization header",
|
|
request: newRequest(map[string][]string{"Authorization": {"panda"}}, nil, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid authorization header","reason":"InternalError","details":{"causes":[{"message":"invalid authorization header"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "missing user",
|
|
request: newRequest(map[string][]string{}, nil, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid user","reason":"InternalError","details":{"causes":[{"message":"invalid user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "unexpected UID",
|
|
request: newRequest(map[string][]string{}, &user.DefaultInfo{UID: "007"}, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user but missing audit event",
|
|
request: func() *http.Request {
|
|
req := newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: testExtra,
|
|
}, nil)
|
|
ctx := request.WithAuditEvent(req.Context(), nil)
|
|
req = req.WithContext(ctx)
|
|
return req
|
|
}(),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: invalid audit event","reason":"InternalError","details":{"causes":[{"message":"invalid audit event"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user with upper case extra",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: map[string][]string{
|
|
"valid-key": {"valid-value"},
|
|
"Invalid-key": {"still-valid-value"},
|
|
},
|
|
}, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user with upper case extra across multiple lines",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: map[string][]string{
|
|
"valid-key": {"valid-value"},
|
|
"valid-data\nInvalid-key": {"still-valid-value"},
|
|
},
|
|
}, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
{
|
|
name: "authenticated user with reserved extra key",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: map[string][]string{
|
|
"valid-key": {"valid-value"},
|
|
"foo.impersonation-proxy.concierge.pinniped.dev": {"still-valid-value"},
|
|
},
|
|
}, nil),
|
|
wantHTTPBody: `{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Internal error occurred: unimplemented functionality - unable to act as current user","reason":"InternalError","details":{"causes":[{"message":"unimplemented functionality - unable to act as current user"}]},"code":500}` + "\n",
|
|
wantHTTPStatus: http.StatusInternalServerError,
|
|
},
|
|
// happy path
|
|
{
|
|
name: "authenticated user",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: testExtra,
|
|
}, nil),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
|
|
"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
|
|
"Impersonate-Group": {"test-group-1", "test-group-2"},
|
|
"Impersonate-User": {"test-user"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated gke user",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: "username@company.com",
|
|
Groups: []string{"system:authenticated"},
|
|
Extra: map[string][]string{
|
|
// make sure we can handle these keys
|
|
"iam.gke.io/user-assertion": {"ABC"},
|
|
"user-assertion.cloud.google.com": {"XYZ"},
|
|
},
|
|
}, nil),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Iam.gke.io%2fuser-Assertion": {"ABC"},
|
|
"Impersonate-Extra-User-Assertion.cloud.google.com": {"XYZ"},
|
|
"Impersonate-Group": {"system:authenticated"},
|
|
"Impersonate-User": {"username@company.com"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated openshift/openstack user",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: "kube:admin",
|
|
// both of these auth stacks set UID but we cannot handle it today
|
|
// UID: "user-id",
|
|
Groups: []string{"system:cluster-admins", "system:authenticated"},
|
|
Extra: map[string][]string{
|
|
// openshift
|
|
"scopes.authorization.openshift.io": {"user:info", "user:full"},
|
|
|
|
// openstack
|
|
"alpha.kubernetes.io/identity/roles": {"role1", "role2"},
|
|
"alpha.kubernetes.io/identity/project/id": {"project-id"},
|
|
"alpha.kubernetes.io/identity/project/name": {"project-name"},
|
|
"alpha.kubernetes.io/identity/user/domain/id": {"domain-id"},
|
|
"alpha.kubernetes.io/identity/user/domain/name": {"domain-name"},
|
|
},
|
|
}, nil),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Scopes.authorization.openshift.io": {"user:info", "user:full"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2froles": {"role1", "role2"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fproject%2fid": {"project-id"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fproject%2fname": {"project-name"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fuser%2fdomain%2fid": {"domain-id"},
|
|
"Impersonate-Extra-Alpha.kubernetes.io%2fidentity%2fuser%2fdomain%2fname": {"domain-name"},
|
|
"Impersonate-Group": {"system:cluster-admins", "system:authenticated"},
|
|
"Impersonate-User": {"kube:admin"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated user with almost reserved key",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: "username@company.com",
|
|
Groups: []string{"system:authenticated"},
|
|
Extra: map[string][]string{
|
|
"foo.iimpersonation-proxy.concierge.pinniped.dev": {"still-valid-value"},
|
|
},
|
|
}, nil),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Foo.iimpersonation-Proxy.concierge.pinniped.dev": {"still-valid-value"},
|
|
"Impersonate-Group": {"system:authenticated"},
|
|
"Impersonate-User": {"username@company.com"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated user with almost reserved key and nested impersonation",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: "username@company.com",
|
|
Groups: []string{"system:authenticated"},
|
|
Extra: map[string][]string{
|
|
"original-user-info.impersonation-proxyy.concierge.pinniped.dev": {"log confusion stuff here"},
|
|
},
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "panda",
|
|
UID: "0x001",
|
|
Groups: []string{"bears", "friends"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"original-user-info.impersonation-proxy.concierge.pinniped.dev": {"this is allowed"},
|
|
},
|
|
},
|
|
ImpersonatedUser: &authenticationv1.UserInfo{},
|
|
},
|
|
),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxyy.concierge.pinniped.dev": {"log confusion stuff here"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"panda","uid":"0x001","groups":["bears","friends"],"extra":{"original-user-info.impersonation-proxy.concierge.pinniped.dev":["this is allowed"]}}`},
|
|
"Impersonate-Group": {"system:authenticated"},
|
|
"Impersonate-User": {"username@company.com"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated user with nested impersonation",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: testExtra,
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "panda",
|
|
UID: "0x001",
|
|
Groups: []string{"bears", "friends"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"assertion": {"sha", "md5"},
|
|
"req-id": {"0123"},
|
|
},
|
|
},
|
|
ImpersonatedUser: &authenticationv1.UserInfo{},
|
|
},
|
|
),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
|
|
"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
|
|
"Impersonate-Group": {"test-group-1", "test-group-2"},
|
|
"Impersonate-User": {"test-user"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"panda","uid":"0x001","groups":["bears","friends"],"extra":{"assertion":["sha","md5"],"req-id":["0123"]}}`},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated gke user with nested impersonation",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: testExtra,
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "username@company.com",
|
|
Groups: []string{"system:authenticated"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
// make sure we can handle these keys
|
|
"iam.gke.io/user-assertion": {"ABC"},
|
|
"user-assertion.cloud.google.com": {"999"},
|
|
},
|
|
},
|
|
ImpersonatedUser: &authenticationv1.UserInfo{},
|
|
},
|
|
),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
|
|
"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
|
|
"Impersonate-Group": {"test-group-1", "test-group-2"},
|
|
"Impersonate-User": {"test-user"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"username@company.com","groups":["system:authenticated"],"extra":{"iam.gke.io/user-assertion":["ABC"],"user-assertion.cloud.google.com":["999"]}}`},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "authenticated user with nested impersonation of gke user",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Content-Length": {"some-length"},
|
|
"Other-Header": {"test-header-value-1"}, // this header will be passed through
|
|
}, &user.DefaultInfo{
|
|
Name: "username@company.com",
|
|
Groups: []string{"system:authenticated"},
|
|
Extra: map[string][]string{
|
|
// make sure we can handle these keys
|
|
"iam.gke.io/user-assertion": {"DEF"},
|
|
"user-assertion.cloud.google.com": {"XYZ"},
|
|
},
|
|
},
|
|
&auditinternal.Event{
|
|
User: authenticationv1.UserInfo{
|
|
Username: "panda",
|
|
UID: "0x001",
|
|
Groups: []string{"bears", "friends"},
|
|
Extra: map[string]authenticationv1.ExtraValue{
|
|
"assertion": {"sha", "md5"},
|
|
"req-id": {"0123"},
|
|
},
|
|
},
|
|
ImpersonatedUser: &authenticationv1.UserInfo{},
|
|
},
|
|
),
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Iam.gke.io%2fuser-Assertion": {"DEF"},
|
|
"Impersonate-Extra-User-Assertion.cloud.google.com": {"XYZ"},
|
|
"Impersonate-Group": {"system:authenticated"},
|
|
"Impersonate-User": {"username@company.com"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Accept": {"some-accepted-format"},
|
|
"Accept-Encoding": {"some-accepted-encoding"},
|
|
"Connection": {"Upgrade"},
|
|
"Upgrade": {"some-upgrade"},
|
|
"Content-Type": {"some-type"},
|
|
"Other-Header": {"test-header-value-1"},
|
|
"Impersonate-Extra-Original-User-Info.impersonation-Proxy.concierge.pinniped.dev": {`{"username":"panda","uid":"0x001","groups":["bears","friends"],"extra":{"assertion":["sha","md5"],"req-id":["0123"]}}`},
|
|
},
|
|
wantHTTPBody: "successful proxied response",
|
|
wantHTTPStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "user is authenticated but the kube API request returns an error",
|
|
request: newRequest(map[string][]string{
|
|
"User-Agent": {"test-user-agent"},
|
|
}, &user.DefaultInfo{
|
|
Name: testUser,
|
|
Groups: testGroups,
|
|
Extra: testExtra,
|
|
}, nil),
|
|
kubeAPIServerStatusCode: http.StatusNotFound,
|
|
wantKubeAPIServerRequestHeaders: map[string][]string{
|
|
"Accept-Encoding": {"gzip"}, // because the rest client used in this test does not disable compression
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
|
|
"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
|
|
"Impersonate-Group": {"test-group-1", "test-group-2"},
|
|
"Impersonate-User": {"test-user"},
|
|
"User-Agent": {"test-user-agent"},
|
|
},
|
|
wantHTTPStatus: http.StatusNotFound,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if tt.kubeAPIServerStatusCode == 0 {
|
|
tt.kubeAPIServerStatusCode = http.StatusOK
|
|
}
|
|
|
|
testKubeAPIServerWasCalled := false
|
|
testKubeAPIServerSawHeaders := http.Header{}
|
|
testKubeAPIServerCA, testKubeAPIServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
testKubeAPIServerWasCalled = true
|
|
testKubeAPIServerSawHeaders = r.Header
|
|
if tt.kubeAPIServerStatusCode != http.StatusOK {
|
|
w.WriteHeader(tt.kubeAPIServerStatusCode)
|
|
} else {
|
|
_, _ = w.Write([]byte("successful proxied response"))
|
|
}
|
|
})
|
|
testKubeAPIServerKubeconfig := rest.Config{
|
|
Host: testKubeAPIServerURL,
|
|
BearerToken: "some-service-account-token",
|
|
TLSClientConfig: rest.TLSClientConfig{CAData: []byte(testKubeAPIServerCA)},
|
|
}
|
|
if tt.restConfig == nil {
|
|
tt.restConfig = &testKubeAPIServerKubeconfig
|
|
}
|
|
|
|
impersonatorHTTPHandlerFunc, err := newImpersonationReverseProxyFunc(tt.restConfig)
|
|
if tt.wantCreationErr != "" {
|
|
require.EqualError(t, err, tt.wantCreationErr)
|
|
require.Nil(t, impersonatorHTTPHandlerFunc)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
require.NotNil(t, impersonatorHTTPHandlerFunc)
|
|
|
|
// this is not a valid way to get a server config, but it is good enough for a unit test
|
|
scheme := runtime.NewScheme()
|
|
metav1.AddToGroupVersion(scheme, metav1.Unversioned)
|
|
codecs := serializer.NewCodecFactory(scheme)
|
|
serverConfig := genericapiserver.NewRecommendedConfig(codecs)
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
r := tt.request
|
|
wantKubeAPIServerRequestHeaders := tt.wantKubeAPIServerRequestHeaders
|
|
|
|
// take the isUpgradeRequest branch randomly to make sure we exercise both branches
|
|
forceUpgradeRequest := rand.Int()%2 == 0 //nolint:gosec // we do not care if this is cryptographically secure
|
|
if forceUpgradeRequest && len(r.Header.Get("Upgrade")) == 0 {
|
|
r = r.Clone(r.Context())
|
|
r.Header.Add("Connection", "Upgrade")
|
|
r.Header.Add("Upgrade", "spdy/3.1")
|
|
|
|
wantKubeAPIServerRequestHeaders = wantKubeAPIServerRequestHeaders.Clone()
|
|
if wantKubeAPIServerRequestHeaders == nil {
|
|
wantKubeAPIServerRequestHeaders = http.Header{}
|
|
}
|
|
wantKubeAPIServerRequestHeaders.Add("Connection", "Upgrade")
|
|
wantKubeAPIServerRequestHeaders.Add("Upgrade", "spdy/3.1")
|
|
}
|
|
|
|
requestBeforeServe := r.Clone(r.Context())
|
|
impersonatorHTTPHandlerFunc(&serverConfig.Config).ServeHTTP(w, r)
|
|
|
|
require.Equal(t, requestBeforeServe, r, "ServeHTTP() mutated the request, and it should not per http.Handler docs")
|
|
if tt.wantHTTPStatus != 0 {
|
|
require.Equalf(t, tt.wantHTTPStatus, w.Code, "fyi, response body was %q", w.Body.String())
|
|
}
|
|
if tt.wantHTTPBody != "" {
|
|
require.Equal(t, tt.wantHTTPBody, w.Body.String())
|
|
}
|
|
|
|
if tt.wantHTTPStatus == http.StatusOK || tt.kubeAPIServerStatusCode != http.StatusOK {
|
|
require.True(t, testKubeAPIServerWasCalled, "Should have proxied the request to the Kube API server, but didn't")
|
|
require.Equal(t, wantKubeAPIServerRequestHeaders, testKubeAPIServerSawHeaders)
|
|
} else {
|
|
require.False(t, testKubeAPIServerWasCalled, "Should not have proxied the request to the Kube API server, but did")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type clientCert struct {
|
|
certPEM, keyPEM []byte
|
|
}
|
|
|
|
func newClientCert(t *testing.T, ca *certauthority.CA, username string, groups []string) *clientCert {
|
|
t.Helper()
|
|
certPEM, keyPEM, err := ca.IssueClientCertPEM(username, groups, time.Hour)
|
|
require.NoError(t, err)
|
|
return &clientCert{
|
|
certPEM: certPEM,
|
|
keyPEM: keyPEM,
|
|
}
|
|
}
|
|
|
|
func requireCanBindToPort(t *testing.T, port int) {
|
|
t.Helper()
|
|
ln, _, listenErr := genericoptions.CreateListener("", "0.0.0.0:"+strconv.Itoa(port), net.ListenConfig{})
|
|
require.NoError(t, listenErr)
|
|
require.NoError(t, ln.Close())
|
|
}
|
|
|
|
func Test_deleteKnownImpersonationHeaders(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
headers, want http.Header
|
|
}{
|
|
{
|
|
name: "no impersonation",
|
|
headers: map[string][]string{
|
|
"a": {"b"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"User-Agent": {"test-user-agent"},
|
|
},
|
|
want: map[string][]string{
|
|
"a": {"b"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"User-Agent": {"test-user-agent"},
|
|
},
|
|
},
|
|
{
|
|
name: "impersonate user header is dropped",
|
|
headers: map[string][]string{
|
|
"a": {"b"},
|
|
"Impersonate-User": {"panda"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"User-Agent": {"test-user-agent"},
|
|
},
|
|
want: map[string][]string{
|
|
"a": {"b"},
|
|
"Accept-Encoding": {"gzip"},
|
|
"User-Agent": {"test-user-agent"},
|
|
},
|
|
},
|
|
{
|
|
name: "all known impersonate headers are dropped",
|
|
headers: map[string][]string{
|
|
"Accept-Encoding": {"gzip"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
|
|
"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
|
|
"Impersonate-Group": {"test-group-1", "test-group-2"},
|
|
"Impersonate-User": {"test-user"},
|
|
"User-Agent": {"test-user-agent"},
|
|
},
|
|
want: map[string][]string{
|
|
"Accept-Encoding": {"gzip"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-user-agent"},
|
|
},
|
|
},
|
|
{
|
|
name: "future UID header is not dropped",
|
|
headers: map[string][]string{
|
|
"Accept-Encoding": {"gzip"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
|
|
"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
|
|
"Impersonate-Group": {"test-group-1", "test-group-2"},
|
|
"Impersonate-User": {"test-user"},
|
|
"Impersonate-Uid": {"008"},
|
|
"User-Agent": {"test-user-agent"},
|
|
},
|
|
want: map[string][]string{
|
|
"Accept-Encoding": {"gzip"},
|
|
"Authorization": {"Bearer some-service-account-token"},
|
|
"User-Agent": {"test-user-agent"},
|
|
"Impersonate-Uid": {"008"},
|
|
},
|
|
},
|
|
{
|
|
name: "future UID header is not dropped, no other headers",
|
|
headers: map[string][]string{
|
|
"Impersonate-Uid": {"009"},
|
|
},
|
|
want: map[string][]string{
|
|
"Impersonate-Uid": {"009"},
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
inputReq := (&http.Request{Header: tt.headers}).WithContext(context.Background())
|
|
inputReqCopy := inputReq.Clone(inputReq.Context())
|
|
|
|
delegate := http.HandlerFunc(func(w http.ResponseWriter, outputReq *http.Request) {
|
|
require.Nil(t, w)
|
|
|
|
// assert only headers mutated
|
|
outputReqCopy := outputReq.Clone(outputReq.Context())
|
|
outputReqCopy.Header = tt.headers
|
|
require.Equal(t, inputReqCopy, outputReqCopy)
|
|
|
|
require.Equal(t, tt.want, outputReq.Header)
|
|
|
|
if ensureNoImpersonationHeaders(inputReq) == nil {
|
|
require.True(t, inputReq == outputReq, "expect req to passed through when no modification needed")
|
|
}
|
|
})
|
|
|
|
deleteKnownImpersonationHeaders(delegate).ServeHTTP(nil, inputReq)
|
|
require.Equal(t, inputReqCopy, inputReq) // assert no mutation occurred
|
|
})
|
|
}
|
|
}
|