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:
commit
1efa4da80c
@ -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.
|
||||
|
@ -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,8 +466,26 @@ func ensureNoImpersonationHeaders(r *http.Request) error {
|
||||
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 canImpersonateFully(userInfo) {
|
||||
return standardImpersonationRoundTripper(userInfo, ae, delegate)
|
||||
}
|
||||
|
||||
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
|
||||
@ -428,19 +499,84 @@ func getTransportForUser(userInfo user.Info, delegate http.RoundTripper, ae *aud
|
||||
// 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")
|
||||
}
|
||||
|
||||
// 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")
|
||||
// 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) {
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
|
14
internal/valuelesscontext/valuelesscontext.go
Normal file
14
internal/valuelesscontext/valuelesscontext.go
Normal 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 }
|
@ -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{
|
||||
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{
|
||||
{
|
||||
Message: "unimplemented functionality - unable to act as current user",
|
||||
Name: "ignored-but-required",
|
||||
Image: "does-not-matter",
|
||||
},
|
||||
},
|
||||
ServiceAccountName: saName,
|
||||
},
|
||||
Message: "Internal error occurred: unimplemented functionality - unable to act as current user",
|
||||
}, 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,
|
||||
},
|
||||
}, err)
|
||||
},
|
||||
}, 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) {
|
||||
|
Loading…
Reference in New Issue
Block a user