diff --git a/internal/concierge/impersonator/doc.go b/internal/concierge/impersonator/doc.go index d8504603..d15c6715 100644 --- a/internal/concierge/impersonator/doc.go +++ b/internal/concierge/impersonator/doc.go @@ -35,6 +35,13 @@ 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. +One final wrinkle is that impersonation cannot impersonate UIDs (yet). This is +problematic because service account tokens always assert a UID. To handle this +case without losing authentication information, when we see an identity with a +UID that was asserted via a bearer token, we simply pass the request through +with the original bearer token and no impersonation headers set (as if the user +had made the request directly against the Kubernetes API server). + 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. diff --git a/internal/concierge/impersonator/impersonator.go b/internal/concierge/impersonator/impersonator.go index 087329dd..5778b795 100644 --- a/internal/concierge/impersonator/impersonator.go +++ b/internal/concierge/impersonator/impersonator.go @@ -15,6 +15,8 @@ import ( "strings" "time" + authenticationv1 "k8s.io/api/authentication/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -26,6 +28,8 @@ import ( "k8s.io/apimachinery/pkg/util/sets" auditinternal "k8s.io/apiserver/pkg/apis/audit" "k8s.io/apiserver/pkg/audit/policy" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/request/bearertoken" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/endpoints/filterlatency" @@ -45,6 +49,7 @@ import ( "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/kubeclient" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/valuelesscontext" ) // FactoryFunc is a function which can create an impersonator server. @@ -176,6 +181,11 @@ func newInternal( //nolint:funlen // yeah, it's kind of long. // See the genericapiserver.DefaultBuildHandlerChain func for details. handler = defaultBuildHandlerChainFunc(handler, c) + // we need to grab the bearer token before WithAuthentication deletes it. + handler = filterlatency.TrackCompleted(handler) + handler = withBearerTokenPreservation(handler) + handler = filterlatency.TrackStarted(handler, "bearertokenpreservation") + // Always set security headers so browsers do the right thing. handler = filterlatency.TrackCompleted(handler) handler = securityheader.Wrap(handler) @@ -189,6 +199,9 @@ func newInternal( //nolint:funlen // yeah, it's kind of long. serverConfig.AuditPolicyChecker = policy.FakeChecker(auditinternal.LevelMetadata, nil) serverConfig.AuditBackend = &auditfake.Backend{} + // if we ever start unioning a TCR bearer token authenticator with serverConfig.Authenticator + // then we will need to update the related assumption in tokenPassthroughRoundTripper + delegatingAuthorizer := serverConfig.Authorization.Authorizer nestedImpersonationAuthorizer := &comparableAuthorizer{ authorizerFunc: func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { @@ -290,6 +303,35 @@ func (f authorizerFunc) Authorize(ctx context.Context, a authorizer.Attributes) return f(ctx, a) } +func withBearerTokenPreservation(delegate http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // this looks a bit hacky but lets us avoid writing any logic for parsing out the bearer token + var reqToken string + _, _, _ = bearertoken.New(authenticator.TokenFunc(func(_ context.Context, token string) (*authenticator.Response, bool, error) { + reqToken = token + return nil, false, nil + })).AuthenticateRequest(r) + + // smuggle the token through the context. this does mean that we need to avoid logging the context. + if len(reqToken) != 0 { + ctx := context.WithValue(r.Context(), tokenKey, reqToken) + r = r.WithContext(ctx) + } + + delegate.ServeHTTP(w, r) + }) +} + +func tokenFrom(ctx context.Context) string { + token, _ := ctx.Value(tokenKey).(string) + return token +} + +// contextKey type is unexported to prevent collisions. +type contextKey int + +const tokenKey contextKey = iota + func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapiserver.Config) http.Handler, error) { serverURL, err := url.Parse(restConfig.Host) if err != nil { @@ -300,11 +342,19 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi if err != nil { return nil, fmt.Errorf("could not get http/1.1 round tripper: %w", err) } + http1RoundTripperAnonymous, err := getTransportForProtocol(rest.AnonymousClientConfig(restConfig), "http/1.1") + if err != nil { + return nil, fmt.Errorf("could not get http/1.1 anonymous round tripper: %w", err) + } http2RoundTripper, err := getTransportForProtocol(restConfig, "h2") if err != nil { return nil, fmt.Errorf("could not get http/2.0 round tripper: %w", err) } + http2RoundTripperAnonymous, err := getTransportForProtocol(rest.AnonymousClientConfig(restConfig), "h2") + if err != nil { + return nil, fmt.Errorf("could not get http/2.0 anonymous round tripper: %w", err) + } return func(c *genericapiserver.Config) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -347,15 +397,18 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi return } + // grab the request's bearer token if present. this is optional and does not fail the request if missing. + token := tokenFrom(r.Context()) + // 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 + baseRT, baseRTAnonymous := http2RoundTripper, http2RoundTripperAnonymous isUpgradeRequest := httpstream.IsUpgradeRequest(r) if isUpgradeRequest { - baseRT = http1RoundTripper + baseRT, baseRTAnonymous = http1RoundTripper, http1RoundTripperAnonymous } - rt, err := getTransportForUser(userInfo, baseRT, ae) + rt, err := getTransportForUser(r.Context(), userInfo, baseRT, baseRTAnonymous, ae, token, c.Authentication.Authenticator) if err != nil { plog.WarningErr("rejecting request as we cannot act as the current user", err, "url", r.URL.String(), @@ -413,34 +466,117 @@ func ensureNoImpersonationHeaders(r *http.Request) error { return nil } -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: extra, - } - // transport.NewImpersonatingRoundTripper clones the request before setting headers - // thus it will not accidentally mutate the input request (see http.Handler docs) - return transport.NewImpersonatingRoundTripper(impersonateConfig, delegate), nil +func getTransportForUser(ctx context.Context, userInfo user.Info, delegate, delegateAnonymous http.RoundTripper, ae *auditinternal.Event, token string, authenticator authenticator.Request) (http.RoundTripper, error) { + if canImpersonateFully(userInfo) { + return standardImpersonationRoundTripper(userInfo, ae, delegate) } - // 0. in the case of a request that is not attempting to do nested impersonation - // 1. if we make the assumption that the TCR API does not issue tokens (or pass the TCR API bearer token - // authenticator into this func - we need to know the authentication cred is something KAS would honor) - // 2. then if preserve the incoming authorization header into the request's context - // 3. we could reauthenticate it here (it would be a free cache hit) - // 4. confirm that it matches the passed in user info (i.e. it was actually the cred used to authenticate and not a client cert) - // 5. then we could issue a reverse proxy request using an anonymous rest config and the bearer token - // 6. thus instead of impersonating the user, we would just be passing their request through - // 7. this would preserve the UID info and thus allow us to safely support all token based auth - // 8. the above would be safe even if in the future Kube started supporting UIDs asserted by client certs - return nil, constable.Error("unexpected uid") + return tokenPassthroughRoundTripper(ctx, delegateAnonymous, ae, token, authenticator) +} + +func canImpersonateFully(userInfo user.Info) bool { + // nolint: gosimple // this structure is on purpose because we plan to expand this function + if len(userInfo.GetUID()) == 0 { + return true + } + + // once kube supports UID impersonation, add logic to detect if the KAS is + // new enough to have this functionality and return true in that case as well + return false +} + +func standardImpersonationRoundTripper(userInfo user.Info, ae *auditinternal.Event, delegate http.RoundTripper) (http.RoundTripper, error) { + extra, err := buildExtra(userInfo.GetExtra(), ae) + if err != nil { + return nil, err + } + + impersonateConfig := transport.ImpersonationConfig{ + UserName: userInfo.GetName(), + Groups: userInfo.GetGroups(), + Extra: extra, + } + // transport.NewImpersonatingRoundTripper clones the request before setting headers + // thus it will not accidentally mutate the input request (see http.Handler docs) + return transport.NewImpersonatingRoundTripper(impersonateConfig, delegate), nil +} + +func tokenPassthroughRoundTripper(ctx context.Context, delegateAnonymous http.RoundTripper, ae *auditinternal.Event, token string, authenticator authenticator.Request) (http.RoundTripper, error) { + // all code below assumes KAS does not support UID impersonation because that case is handled in the standard path + + // it also assumes that the TCR API does not issue tokens - if this assumption changes, we will need + // some way to distinguish a token that is only valid against this impersonation proxy and not against KAS. + // this code will fail closed because said TCR token would not work against KAS and the request would fail. + + // if we get here we know the final user info had a UID + // if the original user is also performing a nested impersonation, it means that said nested + // impersonation is trying to impersonate a UID since final user info == ae.ImpersonatedUser + // we know this KAS does not support UID impersonation so this request must be rejected + if ae.ImpersonatedUser != nil { + return nil, constable.Error("unable to impersonate uid") + } + + // see what KAS thinks this token translates into + // this is important because certs have precedence over tokens and we want + // to make sure that we do not get confused and pass along the wrong token + tokenUser, err := tokenReview(ctx, token, authenticator) + if err != nil { + return nil, err + } + + // we want to compare the result of the token authentication with the original user that made the request + // if the user who made the request and the token do not match, we cannot go any further at this point + if !apiequality.Semantic.DeepEqual(ae.User, tokenUser) { + // this info leak seems fine for trace level logs + plog.Trace("failed to passthrough token due to user mismatch", + "original-username", ae.User.Username, + "original-uid", ae.User.UID, + "token-username", tokenUser.Username, + "token-uid", tokenUser.UID, + ) + return nil, constable.Error("token authenticated as a different user") + } + + // now we know that if we send this token to KAS, it will authenticate correctly + return transport.NewBearerAuthRoundTripper(token, delegateAnonymous), nil +} + +func tokenReview(ctx context.Context, token string, authenticator authenticator.Request) (authenticationv1.UserInfo, error) { + if len(token) == 0 { + return authenticationv1.UserInfo{}, constable.Error("no token on request") + } + + // create a header that contains nothing but the token + // an astute observer may ask "but what about the token's audience?" + // in this case, we want to leave audiences unset per the token review docs: + // > If no audiences are provided, the audience will default to the audience of the Kubernetes apiserver. + // i.e. we want to make sure that the given token is valid against KAS + fakeReq := &http.Request{Header: http.Header{}} + fakeReq.Header.Set("Authorization", "Bearer "+token) + + // propagate cancellation of parent context (without any values such as audience) + fakeReq = fakeReq.WithContext(valuelesscontext.New(ctx)) + + // this will almost always be a free call that hits our 10 second cache TTL + resp, ok, err := authenticator.AuthenticateRequest(fakeReq) + if err != nil { + return authenticationv1.UserInfo{}, err + } + if !ok { + return authenticationv1.UserInfo{}, constable.Error("token failed to authenticate") + } + + tokenUser := authenticationv1.UserInfo{ + Username: resp.User.GetName(), + UID: resp.User.GetUID(), + Groups: resp.User.GetGroups(), + Extra: make(map[string]authenticationv1.ExtraValue, len(resp.User.GetExtra())), + } + for k, v := range resp.User.GetExtra() { + tokenUser.Extra[k] = v + } + + return tokenUser, nil } func buildExtra(extra map[string][]string, ae *auditinternal.Event) (map[string][]string, error) { diff --git a/internal/concierge/impersonator/impersonator_test.go b/internal/concierge/impersonator/impersonator_test.go index dfd765c3..78fd1759 100644 --- a/internal/concierge/impersonator/impersonator_test.go +++ b/internal/concierge/impersonator/impersonator_test.go @@ -22,6 +22,8 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/httpstream" auditinternal "k8s.io/apiserver/pkg/apis/audit" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/request/bearertoken" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/features" @@ -33,6 +35,7 @@ import ( featuregatetesting "k8s.io/component-base/featuregate/testing" "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/dynamiccert" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/httputil/roundtripper" @@ -176,6 +179,26 @@ func TestImpersonator(t *testing.T) { "X-Forwarded-For": {"127.0.0.1"}, }, }, + { + name: "when there is no client cert on request but it has basic auth, it is still an anonymous request", + clientCert: &clientCert{}, + clientMutateHeaders: func(header http.Header) { + header.Set("Test", "val") + req := &http.Request{Header: header} + req.SetBasicAuth("foo", "bar") + }, + 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"}, + "Test": {"val"}, + }, + }, { name: "failed client cert authentication", clientCert: newClientCert(t, unrelatedCA, "test-username", []string{"test-group1"}), @@ -499,39 +522,12 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "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 + authenticator authenticator.Request wantHTTPBody string wantHTTPStatus int wantKubeAPIServerRequestHeaders http.Header @@ -563,50 +559,50 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "Impersonate-User header already in request", - request: newRequest(map[string][]string{"Impersonate-User": {"some-user"}}, nil, nil), + request: newRequest(t, 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), + request: newRequest(t, 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), + request: newRequest(t, 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), + request: newRequest(t, 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), + request: newRequest(t, 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), + request: newRequest(t, 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), + request: newRequest(t, 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{ + req := newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Connection": {"Upgrade"}, "Upgrade": {"some-upgrade"}, @@ -615,7 +611,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { Name: testUser, Groups: testGroups, Extra: testExtra, - }, nil) + }, nil, "") ctx := request.WithAuditEvent(req.Context(), nil) req = req.WithContext(ctx) return req @@ -625,7 +621,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated user with upper case extra", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Connection": {"Upgrade"}, "Upgrade": {"some-upgrade"}, @@ -639,13 +635,13 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "valid-key": {"valid-value"}, "Invalid-key": {"still-valid-value"}, }, - }, nil), + }, 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{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Connection": {"Upgrade"}, "Upgrade": {"some-upgrade"}, @@ -659,13 +655,13 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "valid-key": {"valid-value"}, "valid-data\nInvalid-key": {"still-valid-value"}, }, - }, nil), + }, 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{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Connection": {"Upgrade"}, "Upgrade": {"some-upgrade"}, @@ -679,14 +675,164 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "valid-key": {"valid-value"}, "foo.impersonation-proxy.concierge.pinniped.dev": {"still-valid-value"}, }, - }, nil), + }, 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 UID but no bearer token", + request: newRequest(t, 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{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: testUser, + UID: "fancy-uid", + Groups: testGroups, + Extra: map[string]authenticationv1.ExtraValue{ + "extra-1": {"some", "extra", "stuff"}, + "extra-2": {"some", "more", "extra", "stuff"}, + }, + }, + ImpersonatedUser: nil, + }, + "", + ), + authenticator: 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 UID and bearer token and nested impersonation", + request: newRequest(t, 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{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: "dude", + UID: "--1--", + Groups: []string{"--a--", "--b--"}, + Extra: map[string]authenticationv1.ExtraValue{ + "--c--": {"--d--"}, + "--e--": {"--f--"}, + }, + }, + ImpersonatedUser: &authenticationv1.UserInfo{}, + }, + "token-from-user-nested", + ), + authenticator: 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 UID and bearer token results in error", + request: newRequest(t, 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{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: "dude", + UID: "--1--", + Groups: []string{"--a--", "--b--"}, + Extra: map[string]authenticationv1.ExtraValue{ + "--c--": {"--d--"}, + "--e--": {"--f--"}, + }, + }, + ImpersonatedUser: nil, + }, + "some-non-empty-token", + ), + authenticator: testTokenAuthenticator(t, "", nil, constable.Error("some err")), + 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 UID and bearer token does not authenticate", + request: newRequest(t, 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{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: "dude", + UID: "--1--", + Groups: []string{"--a--", "--b--"}, + Extra: map[string]authenticationv1.ExtraValue{ + "--c--": {"--d--"}, + "--e--": {"--f--"}, + }, + }, + ImpersonatedUser: nil, + }, + "this-token-does-not-work", + ), + authenticator: testTokenAuthenticator(t, "some-other-token-works", nil, 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 UID and bearer token authenticates as different user", + request: newRequest(t, 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{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: "dude", + UID: "--1--", + Groups: []string{"--a--", "--b--"}, + Extra: map[string]authenticationv1.ExtraValue{ + "--c--": {"--d--"}, + "--e--": {"--f--"}, + }, + }, + ImpersonatedUser: nil, + }, + "this-token-does-work", + ), + authenticator: testTokenAuthenticator(t, "this-token-does-work", &user.DefaultInfo{Name: "someone-else"}, 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{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -699,7 +845,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { Name: testUser, Groups: testGroups, Extra: testExtra, - }, nil), + }, nil, ""), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, "Impersonate-Extra-Extra-1": {"some", "extra", "stuff"}, @@ -717,9 +863,61 @@ func TestImpersonatorHTTPHandler(t *testing.T) { wantHTTPBody: "successful proxied response", wantHTTPStatus: http.StatusOK, }, + { + name: "authenticated user with UID and bearer token", + request: newRequest(t, map[string][]string{ + "User-Agent": {"test-user-agent"}, + "Accept": {"some-accepted-format"}, + "Accept-Encoding": {"some-accepted-encoding"}, + "Connection": {"Upgrade"}, + "Upgrade": {"some-upgrade"}, + "Content-Type": {"some-type"}, + "Content-Length": {"some-length"}, + "Other-Header": {"test-header-value-1"}, + }, &user.DefaultInfo{ + UID: "-", // anything non-empty, rest of the fields get ignored in this code path + }, + &auditinternal.Event{ + User: authenticationv1.UserInfo{ + Username: testUser, + UID: "fancy-uid", + Groups: testGroups, + Extra: map[string]authenticationv1.ExtraValue{ + "extra-1": {"some", "extra", "stuff"}, + "extra-2": {"some", "more", "extra", "stuff"}, + }, + }, + ImpersonatedUser: nil, + }, + "token-from-user", + ), + authenticator: testTokenAuthenticator( + t, + "token-from-user", + &user.DefaultInfo{ + Name: testUser, + UID: "fancy-uid", + Groups: testGroups, + Extra: testExtra, + }, + nil, + ), + wantKubeAPIServerRequestHeaders: map[string][]string{ + "Authorization": {"Bearer token-from-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{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -736,7 +934,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "iam.gke.io/user-assertion": {"ABC"}, "user-assertion.cloud.google.com": {"XYZ"}, }, - }, nil), + }, nil, ""), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, "Impersonate-Extra-Iam.gke.io%2fuser-Assertion": {"ABC"}, @@ -756,7 +954,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated openshift/openstack user", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -781,7 +979,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { "alpha.kubernetes.io/identity/user/domain/id": {"domain-id"}, "alpha.kubernetes.io/identity/user/domain/name": {"domain-name"}, }, - }, nil), + }, nil, ""), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, "Impersonate-Extra-Scopes.authorization.openshift.io": {"user:info", "user:full"}, @@ -805,7 +1003,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated user with almost reserved key", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -820,7 +1018,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { Extra: map[string][]string{ "foo.iimpersonation-proxy.concierge.pinniped.dev": {"still-valid-value"}, }, - }, nil), + }, nil, ""), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, "Impersonate-Extra-Foo.iimpersonation-Proxy.concierge.pinniped.dev": {"still-valid-value"}, @@ -839,7 +1037,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated user with almost reserved key and nested impersonation", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -866,6 +1064,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, ImpersonatedUser: &authenticationv1.UserInfo{}, }, + "", ), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, @@ -886,7 +1085,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated user with nested impersonation", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -912,6 +1111,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, ImpersonatedUser: &authenticationv1.UserInfo{}, }, + "", ), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, @@ -933,7 +1133,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated gke user with nested impersonation", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -959,6 +1159,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, ImpersonatedUser: &authenticationv1.UserInfo{}, }, + "", ), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, @@ -980,7 +1181,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "authenticated user with nested impersonation of gke user", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, "Accept": {"some-accepted-format"}, "Accept-Encoding": {"some-accepted-encoding"}, @@ -1010,6 +1211,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, ImpersonatedUser: &authenticationv1.UserInfo{}, }, + "", ), wantKubeAPIServerRequestHeaders: map[string][]string{ "Authorization": {"Bearer some-service-account-token"}, @@ -1031,13 +1233,13 @@ func TestImpersonatorHTTPHandler(t *testing.T) { }, { name: "user is authenticated but the kube API request returns an error", - request: newRequest(map[string][]string{ + request: newRequest(t, map[string][]string{ "User-Agent": {"test-user-agent"}, }, &user.DefaultInfo{ Name: testUser, Groups: testGroups, Extra: testExtra, - }, nil), + }, nil, ""), kubeAPIServerStatusCode: http.StatusNotFound, wantKubeAPIServerRequestHeaders: map[string][]string{ "Accept-Encoding": {"gzip"}, // because the rest client used in this test does not disable compression @@ -1095,6 +1297,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) { metav1.AddToGroupVersion(scheme, metav1.Unversioned) codecs := serializer.NewCodecFactory(scheme) serverConfig := genericapiserver.NewRecommendedConfig(codecs) + serverConfig.Authentication.Authenticator = tt.authenticator w := httptest.NewRecorder() @@ -1137,6 +1340,83 @@ func TestImpersonatorHTTPHandler(t *testing.T) { } } +func newRequest(t *testing.T, h http.Header, userInfo user.Info, event *auditinternal.Event, token string) *http.Request { + t.Helper() + + validURL, err := url.Parse("http://pinniped.dev/blah") + require.NoError(t, err) + + 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) + + ctx = authenticator.WithAudiences(ctx, authenticator.Audiences{"must-be-ignored"}) + + if len(token) != 0 { + ctx = context.WithValue(ctx, tokenKey, token) + } + + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, time.Now().Add(time.Hour)) + t.Cleanup(cancel) + + r, err := http.NewRequestWithContext(ctx, http.MethodGet, validURL.String(), nil) + require.NoError(t, err) + + r.Header = h + + return r +} + +func testTokenAuthenticator(t *testing.T, token string, userInfo user.Info, err error) authenticator.Request { + t.Helper() + + return authenticator.RequestFunc(func(r *http.Request) (*authenticator.Response, bool, error) { + if auds, ok := authenticator.AudiencesFrom(r.Context()); ok || len(auds) != 0 { + t.Errorf("unexpected audiences on request: %v", auds) + } + + if ctxToken := tokenFrom(r.Context()); len(ctxToken) != 0 { + t.Errorf("unexpected token on request: %v", ctxToken) + } + + if _, ok := r.Context().Deadline(); !ok { + t.Error("request should always have deadline") + } + + if err != nil { + return nil, false, err + } + + var reqToken string + _, _, _ = bearertoken.New(authenticator.TokenFunc(func(_ context.Context, token string) (*authenticator.Response, bool, error) { + reqToken = token + return nil, false, nil + })).AuthenticateRequest(r) + + if reqToken != token { + return nil, false, nil + } + + return &authenticator.Response{User: userInfo}, true, nil + }) +} + type clientCert struct { certPEM, keyPEM []byte } @@ -1242,7 +1522,9 @@ func Test_deleteKnownImpersonationHeaders(t *testing.T) { inputReq := (&http.Request{Header: tt.headers}).WithContext(context.Background()) inputReqCopy := inputReq.Clone(inputReq.Context()) + var called bool delegate := http.HandlerFunc(func(w http.ResponseWriter, outputReq *http.Request) { + called = true require.Nil(t, w) // assert only headers mutated @@ -1259,6 +1541,85 @@ func Test_deleteKnownImpersonationHeaders(t *testing.T) { deleteKnownImpersonationHeaders(delegate).ServeHTTP(nil, inputReq) require.Equal(t, inputReqCopy, inputReq) // assert no mutation occurred + require.True(t, called) + }) + } +} + +func Test_withBearerTokenPreservation(t *testing.T) { + tests := []struct { + name string + headers http.Header + want string + }{ + { + name: "has bearer token", + headers: map[string][]string{ + "Authorization": {"Bearer thingy"}, + }, + want: "thingy", + }, + { + name: "has bearer token but too many preceding spaces", + headers: map[string][]string{ + "Authorization": {"Bearer 1"}, + }, + want: "", + }, + { + name: "has bearer token with space, only keeps first part", + headers: map[string][]string{ + "Authorization": {"Bearer panda man"}, + }, + want: "panda", + }, + { + name: "has bearer token with surrounding whitespace", + headers: map[string][]string{ + "Authorization": {" Bearer cool beans "}, + }, + want: "cool", + }, + { + name: "has multiple bearer tokens", + headers: map[string][]string{ + "Authorization": {"Bearer this thing", "what does this mean?"}, + }, + want: "this", + }, + { + name: "no bearer token", + headers: map[string][]string{ + "Not-Authorization": {"Bearer not a token"}, + }, + want: "", + }, + } + 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()) + + var called bool + delegate := http.HandlerFunc(func(w http.ResponseWriter, outputReq *http.Request) { + called = true + require.Nil(t, w) + + // assert only context is mutated + outputReqCopy := outputReq.Clone(inputReq.Context()) + require.Equal(t, inputReqCopy, outputReqCopy) + + require.Equal(t, tt.want, tokenFrom(outputReq.Context())) + + if len(tt.want) == 0 { + require.True(t, inputReq == outputReq, "expect req to passed through when no token expected") + } + }) + + withBearerTokenPreservation(delegate).ServeHTTP(nil, inputReq) + require.Equal(t, inputReqCopy, inputReq) // assert no mutation occurred + require.True(t, called) }) } } diff --git a/internal/controller/authenticator/authncache/cache.go b/internal/controller/authenticator/authncache/cache.go index 5aa31004..14366c39 100644 --- a/internal/controller/authenticator/authncache/cache.go +++ b/internal/controller/authenticator/authncache/cache.go @@ -16,6 +16,7 @@ import ( loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/valuelesscontext" ) // ErrNoSuchAuthenticator is returned by Cache.AuthenticateTokenCredentialRequest() when the requested authenticator is not configured. @@ -101,7 +102,7 @@ func (c *Cache) AuthenticateTokenCredentialRequest(ctx context.Context, req *log // The incoming context could have an audience. Since we do not want to handle audiences right now, do not pass it // through directly to the authentication webhook. - ctx = valuelessContext{ctx} + ctx = valuelesscontext.New(ctx) // Call the selected authenticator. resp, authenticated, err := val.AuthenticateToken(ctx, req.Spec.Token) @@ -119,7 +120,3 @@ func (c *Cache) AuthenticateTokenCredentialRequest(ctx context.Context, req *log } return respUser, nil } - -type valuelessContext struct{ context.Context } - -func (valuelessContext) Value(interface{}) interface{} { return nil } diff --git a/internal/valuelesscontext/valuelesscontext.go b/internal/valuelesscontext/valuelesscontext.go new file mode 100644 index 00000000..93c90a5e --- /dev/null +++ b/internal/valuelesscontext/valuelesscontext.go @@ -0,0 +1,14 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package valuelesscontext + +import "context" + +func New(ctx context.Context) context.Context { + return valuelessContext{Context: ctx} +} + +type valuelessContext struct{ context.Context } + +func (valuelessContext) Value(interface{}) interface{} { return nil } diff --git a/test/integration/concierge_impersonation_proxy_test.go b/test/integration/concierge_impersonation_proxy_test.go index 97a328ac..390ae1f1 100644 --- a/test/integration/concierge_impersonation_proxy_test.go +++ b/test/integration/concierge_impersonation_proxy_test.go @@ -6,7 +6,11 @@ package integration import ( "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/x509" + "crypto/x509/pkix" "encoding/base64" "encoding/json" "encoding/pem" @@ -28,6 +32,8 @@ import ( "golang.org/x/net/http2" authenticationv1 "k8s.io/api/authentication/v1" authorizationv1 "k8s.io/api/authorization/v1" + certificatesv1 "k8s.io/api/certificates/v1" + certificatesv1beta1 "k8s.io/api/certificates/v1beta1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -42,6 +48,8 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/transport" + "k8s.io/client-go/util/cert" + "k8s.io/client-go/util/certificate/csr" "k8s.io/client-go/util/keyutil" "sigs.k8s.io/yaml" @@ -780,32 +788,148 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl whoAmI, ) - // Test using a service account token. Authenticating as Service Accounts through the impersonation - // proxy is not supported, so it should fail. + // Test using a service account token. namespaceName := createTestNamespace(t, adminClient) - _, saToken, _ := createServiceAccountToken(ctx, t, adminClient, namespaceName) + saName, saToken, _ := createServiceAccountToken(ctx, t, adminClient, namespaceName) impersonationProxyServiceAccountPinnipedConciergeClient := newImpersonationProxyClientWithCredentials(t, &loginv1alpha1.ClusterCredential{Token: saToken}, impersonationProxyURL, impersonationProxyCACertPEM, nil).PinnipedConcierge - _, err = impersonationProxyServiceAccountPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests(). + whoAmI, err = impersonationProxyServiceAccountPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests(). Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) - require.EqualError(t, err, "Internal error occurred: unimplemented functionality - unable to act as current user") - require.True(t, k8serrors.IsInternalError(err), err) - require.Equal(t, &k8serrors.StatusError{ - ErrStatus: metav1.Status{ - Status: metav1.StatusFailure, - Code: http.StatusInternalServerError, - Reason: metav1.StatusReasonInternalError, - Details: &metav1.StatusDetails{ - Causes: []metav1.StatusCause{ - { - Message: "unimplemented functionality - unable to act as current user", - }, + require.NoError(t, err) + require.Equal(t, + expectedWhoAmIRequestResponse( + serviceaccount.MakeUsername(namespaceName, saName), + []string{"system:serviceaccounts", "system:serviceaccounts:" + namespaceName, "system:authenticated"}, + nil, + ), + whoAmI, + ) + }) + + t.Run("WhoAmIRequests and SA token request", func(t *testing.T) { + namespaceName := createTestNamespace(t, adminClient) + kubeClient := adminClient.CoreV1() + saName, _, saUID := createServiceAccountToken(ctx, t, adminClient, namespaceName) + + _, tokenRequestProbeErr := kubeClient.ServiceAccounts(namespaceName).CreateToken(ctx, saName, &authenticationv1.TokenRequest{}, metav1.CreateOptions{}) + if k8serrors.IsNotFound(tokenRequestProbeErr) && tokenRequestProbeErr.Error() == "the server could not find the requested resource" { + return // stop test early since the token request API is not enabled on this cluster - other errors are caught below + } + + pod, err := kubeClient.Pods(namespaceName).Create(ctx, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-impersonation-proxy-", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "ignored-but-required", + Image: "does-not-matter", }, }, - Message: "Internal error occurred: unimplemented functionality - unable to act as current user", + ServiceAccountName: saName, }, - }, err) + }, metav1.CreateOptions{}) + require.NoError(t, err) + + tokenRequestBadAudience, err := kubeClient.ServiceAccounts(namespaceName).CreateToken(ctx, saName, &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{"should-fail-because-wrong-audience"}, // anything that is not an API server audience + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Pod", + APIVersion: "", + Name: pod.Name, + UID: pod.UID, + }, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + impersonationProxySABadAudPinnipedConciergeClient := newImpersonationProxyClientWithCredentials(t, + &loginv1alpha1.ClusterCredential{Token: tokenRequestBadAudience.Status.Token}, + impersonationProxyURL, impersonationProxyCACertPEM, nil).PinnipedConcierge + + _, badAudErr := impersonationProxySABadAudPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.True(t, k8serrors.IsUnauthorized(badAudErr), library.Sdump(badAudErr)) + + tokenRequest, err := kubeClient.ServiceAccounts(namespaceName).CreateToken(ctx, saName, &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + Audiences: []string{}, + BoundObjectRef: &authenticationv1.BoundObjectReference{ + Kind: "Pod", + APIVersion: "", + Name: pod.Name, + UID: pod.UID, + }, + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + impersonationProxySAClient := newImpersonationProxyClientWithCredentials(t, + &loginv1alpha1.ClusterCredential{Token: tokenRequest.Status.Token}, + impersonationProxyURL, impersonationProxyCACertPEM, nil) + + whoAmITokenReq, err := impersonationProxySAClient.PinnipedConcierge.IdentityV1alpha1().WhoAmIRequests(). + Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) + require.NoError(t, err) + + // new service account tokens include the pod info in the extra fields + require.Equal(t, + expectedWhoAmIRequestResponse( + serviceaccount.MakeUsername(namespaceName, saName), + []string{"system:serviceaccounts", "system:serviceaccounts:" + namespaceName, "system:authenticated"}, + map[string]identityv1alpha1.ExtraValue{ + "authentication.kubernetes.io/pod-name": {pod.Name}, + "authentication.kubernetes.io/pod-uid": {string(pod.UID)}, + }, + ), + whoAmITokenReq, + ) + + // allow the test SA to create CSRs + library.CreateTestClusterRoleBinding(t, + rbacv1.Subject{Kind: rbacv1.ServiceAccountKind, Name: saName, Namespace: namespaceName}, + rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "system:node-bootstrapper"}, + ) + library.WaitForUserToHaveAccess(t, serviceaccount.MakeUsername(namespaceName, saName), []string{}, &authorizationv1.ResourceAttributes{ + Verb: "create", Group: certificatesv1.GroupName, Version: "*", Resource: "certificatesigningrequests", + }) + + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + csrPEM, err := cert.MakeCSR(privateKey, &pkix.Name{ + CommonName: "panda-man", + Organization: []string{"living-the-dream", "need-more-sleep"}, + }, nil, nil) + require.NoError(t, err) + + csrName, _, err := csr.RequestCertificate( + impersonationProxySAClient.Kubernetes, + csrPEM, + "", + certificatesv1.KubeAPIServerClientSignerName, + []certificatesv1.KeyUsage{certificatesv1.UsageClientAuth}, + privateKey, + ) + require.NoError(t, err) + + saCSR, err := impersonationProxySAClient.Kubernetes.CertificatesV1beta1().CertificateSigningRequests().Get(ctx, csrName, metav1.GetOptions{}) + require.NoError(t, err) + + err = adminClient.CertificatesV1beta1().CertificateSigningRequests().Delete(ctx, csrName, metav1.DeleteOptions{}) + require.NoError(t, err) + + // make sure the user info that the CSR captured matches the SA, including the UID + require.Equal(t, serviceaccount.MakeUsername(namespaceName, saName), saCSR.Spec.Username) + require.Equal(t, string(saUID), saCSR.Spec.UID) + require.Equal(t, []string{"system:serviceaccounts", "system:serviceaccounts:" + namespaceName, "system:authenticated"}, saCSR.Spec.Groups) + require.Equal(t, map[string]certificatesv1beta1.ExtraValue{ + "authentication.kubernetes.io/pod-name": {pod.Name}, + "authentication.kubernetes.io/pod-uid": {string(pod.UID)}, + }, saCSR.Spec.Extra) }) t.Run("kubectl as a client", func(t *testing.T) {