impersonation proxy: add nested impersonation support

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>
This commit is contained in:
Monis Khan
2021-04-09 17:52:53 -04:00
parent 70d607d87e
commit 521adffb17
4 changed files with 1228 additions and 161 deletions

View File

@@ -0,0 +1,42 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
/*
Package impersonator implements an HTTP server that reverse proxies all requests
to the Kubernetes API server with impersonation headers set to match the calling
user. Since impersonation cannot be disabled, this allows us to dynamically
configure authentication on any cluster, even the cloud hosted ones.
The specifics of how it is implemented are of interest. The most novel detail
about the implementation is that we use the "front-end" of the aggregated API
server logic, mainly the DefaultBuildHandlerChain func, to handle how incoming
requests are authenticated, authorized, etc. The "back-end" of the proxy is a
reverse proxy that impersonates the user (instead of serving REST APIs).
In terms of authentication, we aim to handle every type of authentication that
the Kubernetes API server supports by delegating most of the checks to it. We
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.
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.
Nested impersonation is handled by performing the same authorization checks the
Kubernetes API server would (we get this mostly for free by using the aggregated
API server code). We preserve the original user in the reserved extra key
original-user-info.impersonation-proxy.concierge.pinniped.dev as a JSON blob of
the authenticationv1.UserInfo struct. This is necessary to make sure that the
Kubernetes audit log contains all three identities (original user, impersonated
user and the impersonation proxy's service account). Capturing the original
user information requires that we enable the auditing stack (WithImpersonation
only shares this information with the audit stack). To keep things simple,
we use the fake audit backend at the Metadata level for all requests. This
guarantees that we always have an audit event on every request.
For all normal requests, we only use http/2.0 when proxying to the API server.
For upgrade requests, we only use http/1.1 since these always go from http/1.1
to either websockets or SPDY.
*/
package impersonator

View File

@@ -4,11 +4,14 @@
package impersonator
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"regexp"
"strings"
"time"
@@ -21,6 +24,8 @@ import (
"k8s.io/apimachinery/pkg/util/httpstream"
utilnet "k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/sets"
auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/audit/policy"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/filterlatency"
@@ -31,6 +36,7 @@ import (
"k8s.io/apiserver/pkg/server/dynamiccertificates"
"k8s.io/apiserver/pkg/server/filters"
genericoptions "k8s.io/apiserver/pkg/server/options"
auditfake "k8s.io/apiserver/plugin/pkg/audit/fake"
"k8s.io/client-go/rest"
"k8s.io/client-go/transport"
@@ -100,7 +106,6 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
if err != nil {
return nil, err
}
recommendedOptions.Authentication.ClientCert.ClientCA = "---irrelevant-but-needs-to-be-non-empty---" // drop when we pick up https://github.com/kubernetes/kubernetes/pull/100055
recommendedOptions.Authentication.ClientCert.CAContentProvider = dynamiccertificates.NewUnionCAContentProvider(
impersonationProxySignerCA, kubeClientCA,
)
@@ -163,35 +168,55 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
}))
handler = filterlatency.TrackStarted(handler, "impersonationproxy")
handler = filterlatency.TrackCompleted(handler)
handler = deleteKnownImpersonationHeaders(handler)
handler = filterlatency.TrackStarted(handler, "deleteimpersonationheaders")
// The standard Kube handler chain (authn, authz, impersonation, audit, etc).
// See the genericapiserver.DefaultBuildHandlerChain func for details.
handler = defaultBuildHandlerChainFunc(handler, c)
// Always set security headers so browsers do the right thing.
handler = filterlatency.TrackCompleted(handler)
handler = securityheader.Wrap(handler)
handler = filterlatency.TrackStarted(handler, "securityheaders")
return handler
}
// Overwrite the delegating authorizer with one that only cares about impersonation.
// Empty string is disallowed because request info has had bugs in the past where it would leave it empty.
disallowedVerbs := sets.NewString("", "impersonate")
noImpersonationAuthorizer := &comparableAuthorizer{
AuthorizerFunc: func(a authorizer.Attributes) (authorizer.Decision, string, error) {
// Supporting impersonation is not hard, it would just require a bunch of testing
// and configuring the audit layer (to preserve the caller) which we can do later.
// We would also want to delete the incoming impersonation headers
// instead of overwriting the delegating authorizer, we would
// actually use it to make the impersonation authorization checks.
if disallowedVerbs.Has(a.GetVerb()) {
return authorizer.DecisionDeny, "impersonation is not allowed or invalid verb", nil
}
// wire up a fake audit backend at the metadata level so we can preserve the original user during nested impersonation
// TODO: wire up the real std out logging audit backend based on plog log level
serverConfig.AuditPolicyChecker = policy.FakeChecker(auditinternal.LevelMetadata, nil)
serverConfig.AuditBackend = &auditfake.Backend{}
return authorizer.DecisionAllow, "deferring authorization to kube API server", nil
delegatingAuthorizer := serverConfig.Authorization.Authorizer
nestedImpersonationAuthorizer := &comparableAuthorizer{
authorizerFunc: func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
switch a.GetVerb() {
case "":
// Empty string is disallowed because request info has had bugs in the past where it would leave it empty.
return authorizer.DecisionDeny, "invalid verb", nil
case "create",
"update",
"delete",
"deletecollection",
"get",
"list",
"watch",
"patch",
"proxy":
// we know these verbs are from the request info parsing which is safe to delegate to KAS
return authorizer.DecisionAllow, "deferring standard verb authorization to kube API server", nil
default:
// assume everything else is internal SAR checks that we need to run against the requesting user
// because when KAS does the check, it may run the check against our service account and not the
// requesting user. This also handles the impersonate verb to allow for nested impersonation.
return delegatingAuthorizer.Authorize(ctx, a)
}
},
}
// Set our custom authorizer before calling Compete(), which will use it.
serverConfig.Authorization.Authorizer = noImpersonationAuthorizer
serverConfig.Authorization.Authorizer = nestedImpersonationAuthorizer
impersonationProxyServer, err := serverConfig.Complete().New("impersonation-proxy", genericapiserver.NewEmptyDelegate())
if err != nil {
@@ -201,7 +226,7 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
preparedRun := impersonationProxyServer.PrepareRun()
// Sanity check. Make sure that our custom authorizer is still in place and did not get changed or wrapped.
if preparedRun.Authorizer != noImpersonationAuthorizer {
if preparedRun.Authorizer != nestedImpersonationAuthorizer {
return nil, constable.Error("invalid mutation of impersonation authorizer detected")
}
@@ -225,9 +250,44 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
return result, nil
}
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
// unknown future impersonation headers will still get caught by our later checks
if ensureNoImpersonationHeaders(r) != nil {
r = r.Clone(r.Context())
impersonationHeaders := []string{
transport.ImpersonateUserHeader,
transport.ImpersonateGroupHeader,
}
for k := range r.Header {
if !strings.HasPrefix(k, transport.ImpersonateUserExtraHeaderPrefix) {
continue
}
impersonationHeaders = append(impersonationHeaders, k)
}
for _, header := range impersonationHeaders {
r.Header.Del(header) // delay mutation until the end when we are done iterating over the map
}
}
delegate.ServeHTTP(w, r)
})
}
// No-op wrapping around AuthorizerFunc to allow for comparisons.
type comparableAuthorizer struct {
authorizer.AuthorizerFunc
authorizerFunc
}
// TODO: delete when we pick up https://github.com/kubernetes/kubernetes/pull/100963
type authorizerFunc func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error)
func (f authorizerFunc) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
return f(ctx, a)
}
func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapiserver.Config) http.Handler, error) {
@@ -258,7 +318,7 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
}
if err := ensureNoImpersonationHeaders(r); err != nil {
plog.Error("noImpersonationAuthorizer logic did not prevent nested impersonation but it is always supposed to do so",
plog.Error("unknown impersonation header seen",
err,
"url", r.URL.String(),
"method", r.Method,
@@ -277,6 +337,16 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
return
}
ae := request.AuditEventFrom(r.Context())
if ae == nil {
plog.Warning("aggregated API server logic did not set audit event but it is always supposed to do so",
"url", r.URL.String(),
"method", r.Method,
)
newInternalErrResponse(w, r, c.Serializer, "invalid audit event")
return
}
// KAS only supports upgrades via http/1.1 to websockets/SPDY (upgrades never use http/2.0)
// Thus we default to using http/2.0 when the request is not an upgrade, otherwise we use http/1.1
baseRT := http2RoundTripper
@@ -285,7 +355,7 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
baseRT = http1RoundTripper
}
rt, err := getTransportForUser(userInfo, baseRT)
rt, err := getTransportForUser(userInfo, baseRT, ae)
if err != nil {
plog.WarningErr("rejecting request as we cannot act as the current user", err,
"url", r.URL.String(),
@@ -332,6 +402,9 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
func ensureNoImpersonationHeaders(r *http.Request) error {
for key := range r.Header {
// even though we have unit tests that try to cover this case, it is hard to tell if Go does
// client side canonicalization on encode, server side canonicalization on decode, or both
key := http.CanonicalHeaderKey(key)
if strings.HasPrefix(key, "Impersonate") {
return fmt.Errorf("%q header already exists", key)
}
@@ -340,12 +413,17 @@ func ensureNoImpersonationHeaders(r *http.Request) error {
return nil
}
func getTransportForUser(userInfo user.Info, delegate http.RoundTripper) (http.RoundTripper, error) {
func getTransportForUser(userInfo user.Info, delegate http.RoundTripper, ae *auditinternal.Event) (http.RoundTripper, error) {
if len(userInfo.GetUID()) == 0 {
extra, err := buildExtra(userInfo.GetExtra(), ae)
if err != nil {
return nil, err
}
impersonateConfig := transport.ImpersonationConfig{
UserName: userInfo.GetName(),
Groups: userInfo.GetGroups(),
Extra: userInfo.GetExtra(),
Extra: extra,
}
// transport.NewImpersonatingRoundTripper clones the request before setting headers
// thus it will not accidentally mutate the input request (see http.Handler docs)
@@ -365,6 +443,44 @@ func getTransportForUser(userInfo user.Info, delegate http.RoundTripper) (http.R
return nil, constable.Error("unexpected uid")
}
func buildExtra(extra map[string][]string, ae *auditinternal.Event) (map[string][]string, error) {
const reservedImpersonationProxySuffix = ".impersonation-proxy.concierge.pinniped.dev"
// always validate that the extra is something we support irregardless of nested impersonation
for k := range extra {
if !extraKeyRegexp.MatchString(k) {
return nil, fmt.Errorf("disallowed extra key seen: %s", k)
}
if strings.HasSuffix(k, reservedImpersonationProxySuffix) {
return nil, fmt.Errorf("disallowed extra key with reserved prefix seen: %s", k)
}
}
if ae.ImpersonatedUser == nil {
return extra, nil // just return the given extra since nested impersonation is not being used
}
// avoid mutating input map, preallocate new map to store original user info
out := make(map[string][]string, len(extra)+1)
for k, v := range extra {
out[k] = v // shallow copy of slice since we are not going to mutate it
}
origUserInfoJSON, err := json.Marshal(ae.User)
if err != nil {
return nil, err
}
out["original-user-info"+reservedImpersonationProxySuffix] = []string{string(origUserInfoJSON)}
return out, nil
}
// extraKeyRegexp is a very conservative regex to handle impersonation's extra key fidelity limitations such as casing and escaping.
var extraKeyRegexp = regexp.MustCompile(`^[a-z0-9/\-._]+$`)
func newInternalErrResponse(w http.ResponseWriter, r *http.Request, s runtime.NegotiatedSerializer, msg string) {
newStatusErrResponse(w, r, s, apierrors.NewInternalError(constable.Error(msg)))
}

View File

@@ -15,11 +15,13 @@ import (
"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"
@@ -39,8 +41,6 @@ import (
)
func TestImpersonator(t *testing.T) {
const port = 9444
ca, err := certauthority.New("ca", time.Hour)
require.NoError(t, err)
caKey, err := ca.PrivateKeyToPEM()
@@ -58,13 +58,7 @@ func TestImpersonator(t *testing.T) {
unrelatedCA, err := certauthority.New("ca", time.Hour)
require.NoError(t, err)
// 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
}
// 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 {
@@ -140,7 +134,7 @@ func TestImpersonator(t *testing.T) {
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")
header["x-FORWARDED-for"] = append(header["x-FORWARDED-for"], "example.com")
},
wantKubeAPIServerRequestHeaders: http.Header{
"Impersonate-User": {"test-username2"},
@@ -189,20 +183,128 @@ func TestImpersonator(t *testing.T) {
wantError: "Unauthorized",
},
{
name: "double impersonation is not allowed by regular users",
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: impersonation is not allowed or invalid verb`,
`cannot impersonate resource "users" in API group "" at the cluster scope`,
},
{
name: "double impersonation is not allowed by admin users",
clientCert: newClientCert(t, ca, "test-admin", []string{"system:masters", "test-group2"}),
clientImpersonateUser: rest.ImpersonationConfig{UserName: "some-other-username"},
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",
wantError: `users "some-other-username" is forbidden: User "test-admin" ` +
`cannot impersonate resource "users" in API group "" at the cluster scope: impersonation is not allowed or invalid verb`,
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",
@@ -212,17 +314,17 @@ func TestImpersonator(t *testing.T) {
name: "header canonicalization user header",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
clientMutateHeaders: func(header http.Header) {
header.Set("imPerSonaTE-USer", "PANDA")
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: impersonation is not allowed or invalid verb`,
`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.Set("imPerSonaTE-uid", "007")
header["imPerSonaTE-uid"] = []string{"007"}
},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantError: "Internal error occurred: invalid impersonation",
@@ -231,7 +333,7 @@ func TestImpersonator(t *testing.T) {
name: "future UID header",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
clientMutateHeaders: func(header http.Header) {
header.Set("Impersonate-Uid", "008")
header["Impersonate-Uid"] = []string{"008"}
},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantError: "Internal error occurred: invalid impersonation",
@@ -239,8 +341,14 @@ func TestImpersonator(t *testing.T) {
}
for _, tt := range tests {
tt := tt
// This is a serial test because the production code binds to the port.
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)
@@ -293,8 +401,17 @@ func TestImpersonator(t *testing.T) {
}
clientOpts := []kubeclient.Option{kubeclient.WithConfig(&testKubeAPIServerKubeconfig)}
// Create an impersonator.
runner, constructionErr := newInternal(port, certKeyContent, caContent, clientOpts, recOpts)
// 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)
@@ -383,20 +500,30 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
}
validURL, _ := url.Parse("http://pinniped.dev/blah")
newRequest := func(h http.Header, userInfo user.Info) *http.Request {
newRequest := func(h http.Header, userInfo user.Info, event *auditinternal.Event) *http.Request {
ctx := context.Background()
if userInfo != nil {
ctx = request.WithUser(ctx, userInfo)
}
r, err := http.NewRequestWithContext(ctx, http.MethodGet, validURL.String(), nil)
require.NoError(t, err)
r.Header = h
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",
}
r = r.WithContext(request.WithRequestInfo(ctx, reqInfo))
ctx = request.WithRequestInfo(ctx, reqInfo)
r, err := http.NewRequestWithContext(ctx, http.MethodGet, validURL.String(), nil)
require.NoError(t, err)
r.Header = h
return r
}
@@ -436,43 +563,123 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
},
{
name: "Impersonate-User header already in request",
request: newRequest(map[string][]string{"Impersonate-User": {"some-user"}}, nil),
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),
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),
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),
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),
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),
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"}),
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,
},
@@ -492,7 +699,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
Name: testUser,
Groups: testGroups,
Extra: testExtra,
}),
}, nil),
wantKubeAPIServerRequestHeaders: map[string][]string{
"Authorization": {"Bearer some-service-account-token"},
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
@@ -510,6 +717,318 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
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{
@@ -518,7 +1037,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
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
@@ -623,6 +1142,7 @@ type clientCert struct {
}
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{
@@ -632,7 +1152,113 @@ func newClientCert(t *testing.T, ca *certauthority.CA, username string, groups [
}
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
})
}
}