Merge pull request #590 from enj/enj/f/sa_authn_impersonation_proxy

impersonator: add support for service account token authentication
This commit is contained in:
Mo Khan 2021-04-29 17:53:27 -04:00 committed by GitHub
commit 1efa4da80c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 748 additions and 109 deletions

View File

@ -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 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. 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 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 For upgrade requests, we only use http/1.1 since these always go from http/1.1
to either websockets or SPDY. to either websockets or SPDY.

View File

@ -15,6 +15,8 @@ import (
"strings" "strings"
"time" "time"
authenticationv1 "k8s.io/api/authentication/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
@ -26,6 +28,8 @@ import (
"k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/sets"
auditinternal "k8s.io/apiserver/pkg/apis/audit" auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/audit/policy" "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/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/filterlatency" "k8s.io/apiserver/pkg/endpoints/filterlatency"
@ -45,6 +49,7 @@ import (
"go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/kubeclient" "go.pinniped.dev/internal/kubeclient"
"go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/valuelesscontext"
) )
// FactoryFunc is a function which can create an impersonator server. // 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. // See the genericapiserver.DefaultBuildHandlerChain func for details.
handler = defaultBuildHandlerChainFunc(handler, c) 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. // Always set security headers so browsers do the right thing.
handler = filterlatency.TrackCompleted(handler) handler = filterlatency.TrackCompleted(handler)
handler = securityheader.Wrap(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.AuditPolicyChecker = policy.FakeChecker(auditinternal.LevelMetadata, nil)
serverConfig.AuditBackend = &auditfake.Backend{} 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 delegatingAuthorizer := serverConfig.Authorization.Authorizer
nestedImpersonationAuthorizer := &comparableAuthorizer{ nestedImpersonationAuthorizer := &comparableAuthorizer{
authorizerFunc: func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { authorizerFunc: func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
@ -290,6 +303,35 @@ func (f authorizerFunc) Authorize(ctx context.Context, a authorizer.Attributes)
return f(ctx, a) 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) { func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapiserver.Config) http.Handler, error) {
serverURL, err := url.Parse(restConfig.Host) serverURL, err := url.Parse(restConfig.Host)
if err != nil { if err != nil {
@ -300,11 +342,19 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
if err != nil { if err != nil {
return nil, fmt.Errorf("could not get http/1.1 round tripper: %w", err) 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") http2RoundTripper, err := getTransportForProtocol(restConfig, "h2")
if err != nil { if err != nil {
return nil, fmt.Errorf("could not get http/2.0 round tripper: %w", err) 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 func(c *genericapiserver.Config) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -347,15 +397,18 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
return 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) // 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 // 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) isUpgradeRequest := httpstream.IsUpgradeRequest(r)
if isUpgradeRequest { 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 { if err != nil {
plog.WarningErr("rejecting request as we cannot act as the current user", err, plog.WarningErr("rejecting request as we cannot act as the current user", err,
"url", r.URL.String(), "url", r.URL.String(),
@ -413,34 +466,117 @@ func ensureNoImpersonationHeaders(r *http.Request) error {
return nil return nil
} }
func getTransportForUser(userInfo user.Info, delegate http.RoundTripper, ae *auditinternal.Event) (http.RoundTripper, error) { func getTransportForUser(ctx context.Context, userInfo user.Info, delegate, delegateAnonymous http.RoundTripper, ae *auditinternal.Event, token string, authenticator authenticator.Request) (http.RoundTripper, error) {
if len(userInfo.GetUID()) == 0 { if canImpersonateFully(userInfo) {
extra, err := buildExtra(userInfo.GetExtra(), ae) return standardImpersonationRoundTripper(userInfo, ae, delegate)
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
} }
// 0. in the case of a request that is not attempting to do nested impersonation return tokenPassthroughRoundTripper(ctx, delegateAnonymous, ae, token, authenticator)
// 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 func canImpersonateFully(userInfo user.Info) bool {
// 3. we could reauthenticate it here (it would be a free cache hit) // nolint: gosimple // this structure is on purpose because we plan to expand this function
// 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) if len(userInfo.GetUID()) == 0 {
// 5. then we could issue a reverse proxy request using an anonymous rest config and the bearer token return true
// 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 // once kube supports UID impersonation, add logic to detect if the KAS is
return nil, constable.Error("unexpected uid") // 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) { func buildExtra(extra map[string][]string, ae *auditinternal.Event) (map[string][]string, error) {

View File

@ -22,6 +22,8 @@ import (
"k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/util/httpstream" "k8s.io/apimachinery/pkg/util/httpstream"
auditinternal "k8s.io/apiserver/pkg/apis/audit" auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/request/bearertoken"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/features" "k8s.io/apiserver/pkg/features"
@ -33,6 +35,7 @@ import (
featuregatetesting "k8s.io/component-base/featuregate/testing" featuregatetesting "k8s.io/component-base/featuregate/testing"
"go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/certauthority"
"go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/dynamiccert" "go.pinniped.dev/internal/dynamiccert"
"go.pinniped.dev/internal/here" "go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/httputil/roundtripper" "go.pinniped.dev/internal/httputil/roundtripper"
@ -176,6 +179,26 @@ func TestImpersonator(t *testing.T) {
"X-Forwarded-For": {"127.0.0.1"}, "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", name: "failed client cert authentication",
clientCert: newClientCert(t, unrelatedCA, "test-username", []string{"test-group1"}), clientCert: newClientCert(t, unrelatedCA, "test-username", []string{"test-group1"}),
@ -499,39 +522,12 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
"extra-2": {"some", "more", "extra", "stuff"}, "extra-2": {"some", "more", "extra", "stuff"},
} }
validURL, _ := url.Parse("http://pinniped.dev/blah")
newRequest := func(h http.Header, userInfo user.Info, event *auditinternal.Event) *http.Request {
ctx := context.Background()
if userInfo != nil {
ctx = request.WithUser(ctx, userInfo)
}
ae := &auditinternal.Event{Level: auditinternal.LevelMetadata}
if event != nil {
ae = event
}
ctx = request.WithAuditEvent(ctx, ae)
reqInfo := &request.RequestInfo{
IsResourceRequest: false,
Path: validURL.Path,
Verb: "get",
}
ctx = request.WithRequestInfo(ctx, reqInfo)
r, err := http.NewRequestWithContext(ctx, http.MethodGet, validURL.String(), nil)
require.NoError(t, err)
r.Header = h
return r
}
tests := []struct { tests := []struct {
name string name string
restConfig *rest.Config restConfig *rest.Config
wantCreationErr string wantCreationErr string
request *http.Request request *http.Request
authenticator authenticator.Request
wantHTTPBody string wantHTTPBody string
wantHTTPStatus int wantHTTPStatus int
wantKubeAPIServerRequestHeaders http.Header wantKubeAPIServerRequestHeaders http.Header
@ -563,50 +559,50 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
}, },
{ {
name: "Impersonate-User header already in request", 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", 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, wantHTTPStatus: http.StatusInternalServerError,
}, },
{ {
name: "Impersonate-Group header already in request", 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", 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, wantHTTPStatus: http.StatusInternalServerError,
}, },
{ {
name: "Impersonate-Extra header already in request", 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", 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, wantHTTPStatus: http.StatusInternalServerError,
}, },
{ {
name: "Impersonate-* header already in request", 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", 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, wantHTTPStatus: http.StatusInternalServerError,
}, },
{ {
name: "unexpected authorization header", 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", 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, wantHTTPStatus: http.StatusInternalServerError,
}, },
{ {
name: "missing user", 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", 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, wantHTTPStatus: http.StatusInternalServerError,
}, },
{ {
name: "unexpected UID", 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", 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, wantHTTPStatus: http.StatusInternalServerError,
}, },
{ {
name: "authenticated user but missing audit event", name: "authenticated user but missing audit event",
request: func() *http.Request { request: func() *http.Request {
req := newRequest(map[string][]string{ req := newRequest(t, map[string][]string{
"User-Agent": {"test-user-agent"}, "User-Agent": {"test-user-agent"},
"Connection": {"Upgrade"}, "Connection": {"Upgrade"},
"Upgrade": {"some-upgrade"}, "Upgrade": {"some-upgrade"},
@ -615,7 +611,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
Name: testUser, Name: testUser,
Groups: testGroups, Groups: testGroups,
Extra: testExtra, Extra: testExtra,
}, nil) }, nil, "")
ctx := request.WithAuditEvent(req.Context(), nil) ctx := request.WithAuditEvent(req.Context(), nil)
req = req.WithContext(ctx) req = req.WithContext(ctx)
return req return req
@ -625,7 +621,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
}, },
{ {
name: "authenticated user with upper case extra", name: "authenticated user with upper case extra",
request: newRequest(map[string][]string{ request: newRequest(t, map[string][]string{
"User-Agent": {"test-user-agent"}, "User-Agent": {"test-user-agent"},
"Connection": {"Upgrade"}, "Connection": {"Upgrade"},
"Upgrade": {"some-upgrade"}, "Upgrade": {"some-upgrade"},
@ -639,13 +635,13 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
"valid-key": {"valid-value"}, "valid-key": {"valid-value"},
"Invalid-key": {"still-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", 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, wantHTTPStatus: http.StatusInternalServerError,
}, },
{ {
name: "authenticated user with upper case extra across multiple lines", 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"}, "User-Agent": {"test-user-agent"},
"Connection": {"Upgrade"}, "Connection": {"Upgrade"},
"Upgrade": {"some-upgrade"}, "Upgrade": {"some-upgrade"},
@ -659,13 +655,13 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
"valid-key": {"valid-value"}, "valid-key": {"valid-value"},
"valid-data\nInvalid-key": {"still-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", 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, wantHTTPStatus: http.StatusInternalServerError,
}, },
{ {
name: "authenticated user with reserved extra key", name: "authenticated user with reserved extra key",
request: newRequest(map[string][]string{ request: newRequest(t, map[string][]string{
"User-Agent": {"test-user-agent"}, "User-Agent": {"test-user-agent"},
"Connection": {"Upgrade"}, "Connection": {"Upgrade"},
"Upgrade": {"some-upgrade"}, "Upgrade": {"some-upgrade"},
@ -679,14 +675,164 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
"valid-key": {"valid-value"}, "valid-key": {"valid-value"},
"foo.impersonation-proxy.concierge.pinniped.dev": {"still-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", 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, wantHTTPStatus: http.StatusInternalServerError,
}, },
// happy path // happy path
{ {
name: "authenticated user", name: "authenticated user",
request: newRequest(map[string][]string{ request: newRequest(t, map[string][]string{
"User-Agent": {"test-user-agent"}, "User-Agent": {"test-user-agent"},
"Accept": {"some-accepted-format"}, "Accept": {"some-accepted-format"},
"Accept-Encoding": {"some-accepted-encoding"}, "Accept-Encoding": {"some-accepted-encoding"},
@ -699,7 +845,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
Name: testUser, Name: testUser,
Groups: testGroups, Groups: testGroups,
Extra: testExtra, Extra: testExtra,
}, nil), }, nil, ""),
wantKubeAPIServerRequestHeaders: map[string][]string{ wantKubeAPIServerRequestHeaders: map[string][]string{
"Authorization": {"Bearer some-service-account-token"}, "Authorization": {"Bearer some-service-account-token"},
"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"}, "Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
@ -717,9 +863,61 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
wantHTTPBody: "successful proxied response", wantHTTPBody: "successful proxied response",
wantHTTPStatus: http.StatusOK, 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", name: "authenticated gke user",
request: newRequest(map[string][]string{ request: newRequest(t, map[string][]string{
"User-Agent": {"test-user-agent"}, "User-Agent": {"test-user-agent"},
"Accept": {"some-accepted-format"}, "Accept": {"some-accepted-format"},
"Accept-Encoding": {"some-accepted-encoding"}, "Accept-Encoding": {"some-accepted-encoding"},
@ -736,7 +934,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
"iam.gke.io/user-assertion": {"ABC"}, "iam.gke.io/user-assertion": {"ABC"},
"user-assertion.cloud.google.com": {"XYZ"}, "user-assertion.cloud.google.com": {"XYZ"},
}, },
}, nil), }, nil, ""),
wantKubeAPIServerRequestHeaders: map[string][]string{ wantKubeAPIServerRequestHeaders: map[string][]string{
"Authorization": {"Bearer some-service-account-token"}, "Authorization": {"Bearer some-service-account-token"},
"Impersonate-Extra-Iam.gke.io%2fuser-Assertion": {"ABC"}, "Impersonate-Extra-Iam.gke.io%2fuser-Assertion": {"ABC"},
@ -756,7 +954,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
}, },
{ {
name: "authenticated openshift/openstack user", name: "authenticated openshift/openstack user",
request: newRequest(map[string][]string{ request: newRequest(t, map[string][]string{
"User-Agent": {"test-user-agent"}, "User-Agent": {"test-user-agent"},
"Accept": {"some-accepted-format"}, "Accept": {"some-accepted-format"},
"Accept-Encoding": {"some-accepted-encoding"}, "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/id": {"domain-id"},
"alpha.kubernetes.io/identity/user/domain/name": {"domain-name"}, "alpha.kubernetes.io/identity/user/domain/name": {"domain-name"},
}, },
}, nil), }, nil, ""),
wantKubeAPIServerRequestHeaders: map[string][]string{ wantKubeAPIServerRequestHeaders: map[string][]string{
"Authorization": {"Bearer some-service-account-token"}, "Authorization": {"Bearer some-service-account-token"},
"Impersonate-Extra-Scopes.authorization.openshift.io": {"user:info", "user:full"}, "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", name: "authenticated user with almost reserved key",
request: newRequest(map[string][]string{ request: newRequest(t, map[string][]string{
"User-Agent": {"test-user-agent"}, "User-Agent": {"test-user-agent"},
"Accept": {"some-accepted-format"}, "Accept": {"some-accepted-format"},
"Accept-Encoding": {"some-accepted-encoding"}, "Accept-Encoding": {"some-accepted-encoding"},
@ -820,7 +1018,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
Extra: map[string][]string{ Extra: map[string][]string{
"foo.iimpersonation-proxy.concierge.pinniped.dev": {"still-valid-value"}, "foo.iimpersonation-proxy.concierge.pinniped.dev": {"still-valid-value"},
}, },
}, nil), }, nil, ""),
wantKubeAPIServerRequestHeaders: map[string][]string{ wantKubeAPIServerRequestHeaders: map[string][]string{
"Authorization": {"Bearer some-service-account-token"}, "Authorization": {"Bearer some-service-account-token"},
"Impersonate-Extra-Foo.iimpersonation-Proxy.concierge.pinniped.dev": {"still-valid-value"}, "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", 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"}, "User-Agent": {"test-user-agent"},
"Accept": {"some-accepted-format"}, "Accept": {"some-accepted-format"},
"Accept-Encoding": {"some-accepted-encoding"}, "Accept-Encoding": {"some-accepted-encoding"},
@ -866,6 +1064,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
}, },
ImpersonatedUser: &authenticationv1.UserInfo{}, ImpersonatedUser: &authenticationv1.UserInfo{},
}, },
"",
), ),
wantKubeAPIServerRequestHeaders: map[string][]string{ wantKubeAPIServerRequestHeaders: map[string][]string{
"Authorization": {"Bearer some-service-account-token"}, "Authorization": {"Bearer some-service-account-token"},
@ -886,7 +1085,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
}, },
{ {
name: "authenticated user with nested impersonation", name: "authenticated user with nested impersonation",
request: newRequest(map[string][]string{ request: newRequest(t, map[string][]string{
"User-Agent": {"test-user-agent"}, "User-Agent": {"test-user-agent"},
"Accept": {"some-accepted-format"}, "Accept": {"some-accepted-format"},
"Accept-Encoding": {"some-accepted-encoding"}, "Accept-Encoding": {"some-accepted-encoding"},
@ -912,6 +1111,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
}, },
ImpersonatedUser: &authenticationv1.UserInfo{}, ImpersonatedUser: &authenticationv1.UserInfo{},
}, },
"",
), ),
wantKubeAPIServerRequestHeaders: map[string][]string{ wantKubeAPIServerRequestHeaders: map[string][]string{
"Authorization": {"Bearer some-service-account-token"}, "Authorization": {"Bearer some-service-account-token"},
@ -933,7 +1133,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
}, },
{ {
name: "authenticated gke user with nested impersonation", name: "authenticated gke user with nested impersonation",
request: newRequest(map[string][]string{ request: newRequest(t, map[string][]string{
"User-Agent": {"test-user-agent"}, "User-Agent": {"test-user-agent"},
"Accept": {"some-accepted-format"}, "Accept": {"some-accepted-format"},
"Accept-Encoding": {"some-accepted-encoding"}, "Accept-Encoding": {"some-accepted-encoding"},
@ -959,6 +1159,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
}, },
ImpersonatedUser: &authenticationv1.UserInfo{}, ImpersonatedUser: &authenticationv1.UserInfo{},
}, },
"",
), ),
wantKubeAPIServerRequestHeaders: map[string][]string{ wantKubeAPIServerRequestHeaders: map[string][]string{
"Authorization": {"Bearer some-service-account-token"}, "Authorization": {"Bearer some-service-account-token"},
@ -980,7 +1181,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
}, },
{ {
name: "authenticated user with nested impersonation of gke user", 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"}, "User-Agent": {"test-user-agent"},
"Accept": {"some-accepted-format"}, "Accept": {"some-accepted-format"},
"Accept-Encoding": {"some-accepted-encoding"}, "Accept-Encoding": {"some-accepted-encoding"},
@ -1010,6 +1211,7 @@ func TestImpersonatorHTTPHandler(t *testing.T) {
}, },
ImpersonatedUser: &authenticationv1.UserInfo{}, ImpersonatedUser: &authenticationv1.UserInfo{},
}, },
"",
), ),
wantKubeAPIServerRequestHeaders: map[string][]string{ wantKubeAPIServerRequestHeaders: map[string][]string{
"Authorization": {"Bearer some-service-account-token"}, "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", 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-Agent": {"test-user-agent"},
}, &user.DefaultInfo{ }, &user.DefaultInfo{
Name: testUser, Name: testUser,
Groups: testGroups, Groups: testGroups,
Extra: testExtra, Extra: testExtra,
}, nil), }, nil, ""),
kubeAPIServerStatusCode: http.StatusNotFound, kubeAPIServerStatusCode: http.StatusNotFound,
wantKubeAPIServerRequestHeaders: map[string][]string{ wantKubeAPIServerRequestHeaders: map[string][]string{
"Accept-Encoding": {"gzip"}, // because the rest client used in this test does not disable compression "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) metav1.AddToGroupVersion(scheme, metav1.Unversioned)
codecs := serializer.NewCodecFactory(scheme) codecs := serializer.NewCodecFactory(scheme)
serverConfig := genericapiserver.NewRecommendedConfig(codecs) serverConfig := genericapiserver.NewRecommendedConfig(codecs)
serverConfig.Authentication.Authenticator = tt.authenticator
w := httptest.NewRecorder() 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 { type clientCert struct {
certPEM, keyPEM []byte certPEM, keyPEM []byte
} }
@ -1242,7 +1522,9 @@ func Test_deleteKnownImpersonationHeaders(t *testing.T) {
inputReq := (&http.Request{Header: tt.headers}).WithContext(context.Background()) inputReq := (&http.Request{Header: tt.headers}).WithContext(context.Background())
inputReqCopy := inputReq.Clone(inputReq.Context()) inputReqCopy := inputReq.Clone(inputReq.Context())
var called bool
delegate := http.HandlerFunc(func(w http.ResponseWriter, outputReq *http.Request) { delegate := http.HandlerFunc(func(w http.ResponseWriter, outputReq *http.Request) {
called = true
require.Nil(t, w) require.Nil(t, w)
// assert only headers mutated // assert only headers mutated
@ -1259,6 +1541,85 @@ func Test_deleteKnownImpersonationHeaders(t *testing.T) {
deleteKnownImpersonationHeaders(delegate).ServeHTTP(nil, inputReq) deleteKnownImpersonationHeaders(delegate).ServeHTTP(nil, inputReq)
require.Equal(t, inputReqCopy, inputReq) // assert no mutation occurred 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)
}) })
} }
} }

View File

@ -16,6 +16,7 @@ import (
loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login"
"go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/valuelesscontext"
) )
// ErrNoSuchAuthenticator is returned by Cache.AuthenticateTokenCredentialRequest() when the requested authenticator is not configured. // 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 // 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. // through directly to the authentication webhook.
ctx = valuelessContext{ctx} ctx = valuelesscontext.New(ctx)
// Call the selected authenticator. // Call the selected authenticator.
resp, authenticated, err := val.AuthenticateToken(ctx, req.Spec.Token) 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 return respUser, nil
} }
type valuelessContext struct{ context.Context }
func (valuelessContext) Value(interface{}) interface{} { return nil }

View File

@ -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 }

View File

@ -6,7 +6,11 @@ package integration
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
@ -28,6 +32,8 @@ import (
"golang.org/x/net/http2" "golang.org/x/net/http2"
authenticationv1 "k8s.io/api/authentication/v1" authenticationv1 "k8s.io/api/authentication/v1"
authorizationv1 "k8s.io/api/authorization/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" corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1" rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors" k8serrors "k8s.io/apimachinery/pkg/api/errors"
@ -42,6 +48,8 @@ import (
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/transport" "k8s.io/client-go/transport"
"k8s.io/client-go/util/cert"
"k8s.io/client-go/util/certificate/csr"
"k8s.io/client-go/util/keyutil" "k8s.io/client-go/util/keyutil"
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
@ -780,32 +788,148 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl
whoAmI, whoAmI,
) )
// Test using a service account token. Authenticating as Service Accounts through the impersonation // Test using a service account token.
// proxy is not supported, so it should fail.
namespaceName := createTestNamespace(t, adminClient) namespaceName := createTestNamespace(t, adminClient)
_, saToken, _ := createServiceAccountToken(ctx, t, adminClient, namespaceName) saName, saToken, _ := createServiceAccountToken(ctx, t, adminClient, namespaceName)
impersonationProxyServiceAccountPinnipedConciergeClient := newImpersonationProxyClientWithCredentials(t, impersonationProxyServiceAccountPinnipedConciergeClient := newImpersonationProxyClientWithCredentials(t,
&loginv1alpha1.ClusterCredential{Token: saToken}, &loginv1alpha1.ClusterCredential{Token: saToken},
impersonationProxyURL, impersonationProxyCACertPEM, nil).PinnipedConcierge impersonationProxyURL, impersonationProxyCACertPEM, nil).PinnipedConcierge
_, err = impersonationProxyServiceAccountPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests(). whoAmI, err = impersonationProxyServiceAccountPinnipedConciergeClient.IdentityV1alpha1().WhoAmIRequests().
Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{}) Create(ctx, &identityv1alpha1.WhoAmIRequest{}, metav1.CreateOptions{})
require.EqualError(t, err, "Internal error occurred: unimplemented functionality - unable to act as current user") require.NoError(t, err)
require.True(t, k8serrors.IsInternalError(err), err) require.Equal(t,
require.Equal(t, &k8serrors.StatusError{ expectedWhoAmIRequestResponse(
ErrStatus: metav1.Status{ serviceaccount.MakeUsername(namespaceName, saName),
Status: metav1.StatusFailure, []string{"system:serviceaccounts", "system:serviceaccounts:" + namespaceName, "system:authenticated"},
Code: http.StatusInternalServerError, nil,
Reason: metav1.StatusReasonInternalError, ),
Details: &metav1.StatusDetails{ whoAmI,
Causes: []metav1.StatusCause{ )
{ })
Message: "unimplemented functionality - unable to act as current user",
}, 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) { t.Run("kubectl as a client", func(t *testing.T) {