internal/concierge/impersonator: handle custom login API group

Signed-off-by: Andrew Keesler <akeesler@vmware.com>
This commit is contained in:
Andrew Keesler 2021-02-15 18:00:10 -05:00
parent 25bc8dd8a9
commit fdd8ef5835
No known key found for this signature in database
GPG Key ID: 27CE0444346F9413
10 changed files with 529 additions and 353 deletions

View File

@ -267,6 +267,7 @@ func execCredentialForImpersonationProxy(
tokenExpiry *metav1.Time, tokenExpiry *metav1.Time,
) (*clientauthv1beta1.ExecCredential, error) { ) (*clientauthv1beta1.ExecCredential, error) {
// TODO maybe de-dup this with conciergeclient.go // TODO maybe de-dup this with conciergeclient.go
// TODO reuse code from internal/testutil/impersonationtoken here to create token
var kind string var kind string
switch strings.ToLower(conciergeAuthenticatorType) { switch strings.ToLower(conciergeAuthenticatorType) {
case "webhook": case "webhook":

View File

@ -5,7 +5,6 @@ package impersonator
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
@ -13,12 +12,12 @@ import (
"strings" "strings"
"github.com/go-logr/logr" "github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/transport" "k8s.io/client-go/transport"
"go.pinniped.dev/generated/1.20/apis/concierge/login" "go.pinniped.dev/generated/1.20/apis/concierge/login"
loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1"
"go.pinniped.dev/internal/controller/authenticator/authncache" "go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/kubeclient" "go.pinniped.dev/internal/kubeclient"
) )
@ -35,12 +34,13 @@ var allowedHeaders = []string{
type proxy struct { type proxy struct {
cache *authncache.Cache cache *authncache.Cache
jsonDecoder runtime.Decoder
proxy *httputil.ReverseProxy proxy *httputil.ReverseProxy
log logr.Logger log logr.Logger
} }
func New(cache *authncache.Cache, log logr.Logger) (http.Handler, error) { func New(cache *authncache.Cache, jsonDecoder runtime.Decoder, log logr.Logger) (http.Handler, error) {
return newInternal(cache, log, func() (*rest.Config, error) { return newInternal(cache, jsonDecoder, log, func() (*rest.Config, error) {
client, err := kubeclient.New() client, err := kubeclient.New()
if err != nil { if err != nil {
return nil, err return nil, err
@ -49,7 +49,7 @@ func New(cache *authncache.Cache, log logr.Logger) (http.Handler, error) {
}) })
} }
func newInternal(cache *authncache.Cache, log logr.Logger, getConfig func() (*rest.Config, error)) (*proxy, error) { func newInternal(cache *authncache.Cache, jsonDecoder runtime.Decoder, log logr.Logger, getConfig func() (*rest.Config, error)) (*proxy, error) {
kubeconfig, err := getConfig() kubeconfig, err := getConfig()
if err != nil { if err != nil {
return nil, fmt.Errorf("could not get in-cluster config: %w", err) return nil, fmt.Errorf("could not get in-cluster config: %w", err)
@ -76,6 +76,7 @@ func newInternal(cache *authncache.Cache, log logr.Logger, getConfig func() (*re
return &proxy{ return &proxy{
cache: cache, cache: cache,
jsonDecoder: jsonDecoder,
proxy: reverseProxy, proxy: reverseProxy,
log: log, log: log,
}, nil }, nil
@ -87,7 +88,7 @@ func (p *proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
"method", r.Method, "method", r.Method,
) )
tokenCredentialReq, err := extractToken(r) tokenCredentialReq, err := extractToken(r, p.jsonDecoder)
if err != nil { if err != nil {
log.Error(err, "invalid token encoding") log.Error(err, "invalid token encoding")
http.Error(w, "invalid token encoding", http.StatusBadRequest) http.Error(w, "invalid token encoding", http.StatusBadRequest)
@ -134,7 +135,7 @@ func getProxyHeaders(userInfo user.Info, requestHeaders http.Header) http.Header
return newHeaders return newHeaders
} }
func extractToken(req *http.Request) (*login.TokenCredentialRequest, error) { func extractToken(req *http.Request, jsonDecoder runtime.Decoder) (*login.TokenCredentialRequest, error) {
authHeader := req.Header.Get("Authorization") authHeader := req.Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
return nil, fmt.Errorf("missing authorization header") return nil, fmt.Errorf("missing authorization header")
@ -148,13 +149,14 @@ func extractToken(req *http.Request) (*login.TokenCredentialRequest, error) {
return nil, fmt.Errorf("invalid base64 in encoded bearer token: %w", err) return nil, fmt.Errorf("invalid base64 in encoded bearer token: %w", err)
} }
var v1alpha1Req loginv1alpha1.TokenCredentialRequest obj, err := runtime.Decode(jsonDecoder, tokenCredentialRequestJSON)
if err := json.Unmarshal(tokenCredentialRequestJSON, &v1alpha1Req); err != nil { if err != nil {
return nil, fmt.Errorf("invalid TokenCredentialRequest encoded in bearer token: %w", err) return nil, fmt.Errorf("invalid object encoded in bearer token: %w", err)
} }
var internalReq login.TokenCredentialRequest tokenCredentialRequest, ok := obj.(*login.TokenCredentialRequest)
if err := loginv1alpha1.Convert_v1alpha1_TokenCredentialRequest_To_login_TokenCredentialRequest(&v1alpha1Req, &internalReq, nil); err != nil { if !ok {
return nil, fmt.Errorf("failed to convert v1alpha1 TokenCredentialRequest to internal version: %w", err) return nil, fmt.Errorf("invalid TokenCredentialRequest encoded in bearer token: got %T", obj)
} }
return &internalReq, nil
return tokenCredentialRequest, nil
} }

View File

@ -5,8 +5,6 @@ package impersonator
import ( import (
"context" "context"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -16,20 +14,31 @@ import (
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd/api" "k8s.io/client-go/tools/clientcmd/api"
loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1" authenticationv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/authentication/v1alpha1"
"go.pinniped.dev/generated/1.20/apis/concierge/login"
conciergescheme "go.pinniped.dev/internal/concierge/scheme"
"go.pinniped.dev/internal/controller/authenticator/authncache" "go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/groupsuffix"
"go.pinniped.dev/internal/mocks/mocktokenauthenticator" "go.pinniped.dev/internal/mocks/mocktokenauthenticator"
"go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil"
"go.pinniped.dev/internal/testutil/impersonationtoken"
"go.pinniped.dev/internal/testutil/testlogger" "go.pinniped.dev/internal/testutil/testlogger"
) )
func TestImpersonator(t *testing.T) { func TestImpersonator(t *testing.T) {
const (
defaultAPIGroup = "pinniped.dev"
customAPIGroup = "walrus.tld"
)
validURL, _ := url.Parse("http://pinniped.dev/blah") validURL, _ := url.Parse("http://pinniped.dev/blah")
testServerCA, testServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { testServerCA, testServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
// Expect that the request is authenticated based on the kubeconfig credential. // Expect that the request is authenticated based on the kubeconfig credential.
@ -61,8 +70,18 @@ func TestImpersonator(t *testing.T) {
return r return r
} }
goodAuthenticator := corev1.TypedLocalObjectReference{
Name: "authenticator-one",
APIGroup: stringPtr(authenticationv1alpha1.GroupName),
}
badAuthenticator := corev1.TypedLocalObjectReference{
Name: "",
APIGroup: stringPtr(authenticationv1alpha1.GroupName),
}
tests := []struct { tests := []struct {
name string name string
apiGroupOverride string
getKubeconfig func() (*rest.Config, error) getKubeconfig func() (*rest.Config, error)
wantCreationErr string wantCreationErr string
request *http.Request request *http.Request
@ -119,7 +138,7 @@ func TestImpersonator(t *testing.T) {
{ {
name: "authorization header missing bearer prefix", name: "authorization header missing bearer prefix",
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil }, getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
request: newRequest(map[string][]string{"Authorization": {makeTestTokenRequest("foo", "authenticator-one", "test-token")}}), request: newRequest(map[string][]string{"Authorization": {impersonationtoken.Make(t, "test-token", &goodAuthenticator, defaultAPIGroup)}}),
wantHTTPBody: "invalid token encoding\n", wantHTTPBody: "invalid token encoding\n",
wantHTTPStatus: http.StatusBadRequest, wantHTTPStatus: http.StatusBadRequest,
wantLogs: []string{"\"error\"=\"authorization header must be of type Bearer\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""}, wantLogs: []string{"\"error\"=\"authorization header must be of type Bearer\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
@ -138,33 +157,50 @@ func TestImpersonator(t *testing.T) {
request: newRequest(map[string][]string{"Authorization": {"Bearer abc"}}), request: newRequest(map[string][]string{"Authorization": {"Bearer abc"}}),
wantHTTPBody: "invalid token encoding\n", wantHTTPBody: "invalid token encoding\n",
wantHTTPStatus: http.StatusBadRequest, wantHTTPStatus: http.StatusBadRequest,
wantLogs: []string{"\"error\"=\"invalid TokenCredentialRequest encoded in bearer token: invalid character 'i' looking for beginning of value\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""}, wantLogs: []string{"\"error\"=\"invalid object encoded in bearer token: couldn't get version/kind; json parse error: invalid character 'i' looking for beginning of value\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
},
{
name: "base64 encoded token is encoded with default api group but we are expecting custom api group",
apiGroupOverride: customAPIGroup,
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
request: newRequest(map[string][]string{"Authorization": {"Bearer " + impersonationtoken.Make(t, "test-token", &goodAuthenticator, defaultAPIGroup)}}),
wantHTTPBody: "invalid token encoding\n",
wantHTTPStatus: http.StatusBadRequest,
wantLogs: []string{"\"error\"=\"invalid object encoded in bearer token: no kind \\\"TokenCredentialRequest\\\" is registered for version \\\"login.concierge.pinniped.dev/v1alpha1\\\" in scheme \\\"pkg/runtime/scheme.go:100\\\"\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
},
{
name: "base64 encoded token is encoded with custom api group but we are expecting default api group",
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
request: newRequest(map[string][]string{"Authorization": {"Bearer " + impersonationtoken.Make(t, "test-token", &goodAuthenticator, customAPIGroup)}}),
wantHTTPBody: "invalid token encoding\n",
wantHTTPStatus: http.StatusBadRequest,
wantLogs: []string{"\"error\"=\"invalid object encoded in bearer token: no kind \\\"TokenCredentialRequest\\\" is registered for version \\\"login.concierge.walrus.tld/v1alpha1\\\" in scheme \\\"pkg/runtime/scheme.go:100\\\"\" \"msg\"=\"invalid token encoding\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
}, },
{ {
name: "token could not be authenticated", name: "token could not be authenticated",
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil }, getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
request: newRequest(map[string][]string{"Authorization": {"Bearer " + makeTestTokenRequest("", "", "")}}), request: newRequest(map[string][]string{"Authorization": {"Bearer " + impersonationtoken.Make(t, "", &badAuthenticator, defaultAPIGroup)}}),
wantHTTPBody: "invalid token\n", wantHTTPBody: "invalid token\n",
wantHTTPStatus: http.StatusUnauthorized, wantHTTPStatus: http.StatusUnauthorized,
wantLogs: []string{"\"error\"=\"no such authenticator\" \"msg\"=\"received invalid token\" \"authenticator\"={\"apiGroup\":null,\"kind\":\"\",\"name\":\"\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""}, wantLogs: []string{"\"error\"=\"no such authenticator\" \"msg\"=\"received invalid token\" \"authenticator\"={\"apiGroup\":\"authentication.concierge.pinniped.dev\",\"kind\":\"\",\"name\":\"\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
}, },
{ {
name: "token authenticates as nil", name: "token authenticates as nil",
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil }, getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
request: newRequest(map[string][]string{"Authorization": {"Bearer " + makeTestTokenRequest("foo", "authenticator-one", "test-token")}}), request: newRequest(map[string][]string{"Authorization": {"Bearer " + impersonationtoken.Make(t, "test-token", &goodAuthenticator, defaultAPIGroup)}}),
expectMockToken: func(t *testing.T, recorder *mocktokenauthenticator.MockTokenMockRecorder) { expectMockToken: func(t *testing.T, recorder *mocktokenauthenticator.MockTokenMockRecorder) {
recorder.AuthenticateToken(gomock.Any(), "test-token").Return(nil, false, nil) recorder.AuthenticateToken(gomock.Any(), "test-token").Return(nil, false, nil)
}, },
wantHTTPBody: "not authenticated\n", wantHTTPBody: "not authenticated\n",
wantHTTPStatus: http.StatusUnauthorized, wantHTTPStatus: http.StatusUnauthorized,
wantLogs: []string{"\"level\"=0 \"msg\"=\"received token that did not authenticate\" \"authenticator\"={\"apiGroup\":null,\"kind\":\"\",\"name\":\"authenticator-one\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""}, wantLogs: []string{"\"level\"=0 \"msg\"=\"received token that did not authenticate\" \"authenticator\"={\"apiGroup\":\"authentication.concierge.pinniped.dev\",\"kind\":\"\",\"name\":\"authenticator-one\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
}, },
// happy path // happy path
{ {
name: "token validates", name: "token validates",
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil }, getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
request: newRequest(map[string][]string{ request: newRequest(map[string][]string{
"Authorization": {"Bearer " + makeTestTokenRequest("foo", "authenticator-one", "test-token")}, "Authorization": {"Bearer " + impersonationtoken.Make(t, "test-token", &goodAuthenticator, defaultAPIGroup)},
"Malicious-Header": {"test-header-value-1"}, "Malicious-Header": {"test-header-value-1"},
"User-Agent": {"test-user-agent"}, "User-Agent": {"test-user-agent"},
}), }),
@ -179,7 +215,29 @@ func TestImpersonator(t *testing.T) {
}, },
wantHTTPBody: "successful proxied response", wantHTTPBody: "successful proxied response",
wantHTTPStatus: http.StatusOK, wantHTTPStatus: http.StatusOK,
wantLogs: []string{"\"level\"=0 \"msg\"=\"proxying authenticated request\" \"authenticator\"={\"apiGroup\":null,\"kind\":\"\",\"name\":\"authenticator-one\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\" \"userID\"=\"test-uid\""}, wantLogs: []string{"\"level\"=0 \"msg\"=\"proxying authenticated request\" \"authenticator\"={\"apiGroup\":\"authentication.concierge.pinniped.dev\",\"kind\":\"\",\"name\":\"authenticator-one\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\" \"userID\"=\"test-uid\""},
},
{
name: "token validates with custom api group",
apiGroupOverride: customAPIGroup,
getKubeconfig: func() (*rest.Config, error) { return &testServerKubeconfig, nil },
request: newRequest(map[string][]string{
"Authorization": {"Bearer " + impersonationtoken.Make(t, "test-token", &goodAuthenticator, customAPIGroup)},
"Malicious-Header": {"test-header-value-1"},
"User-Agent": {"test-user-agent"},
}),
expectMockToken: func(t *testing.T, recorder *mocktokenauthenticator.MockTokenMockRecorder) {
userInfo := user.DefaultInfo{
Name: "test-user",
Groups: []string{"test-group-1", "test-group-2"},
UID: "test-uid",
}
response := &authenticator.Response{User: &userInfo}
recorder.AuthenticateToken(gomock.Any(), "test-token").Return(response, true, nil)
},
wantHTTPBody: "successful proxied response",
wantHTTPStatus: http.StatusOK,
wantLogs: []string{"\"level\"=0 \"msg\"=\"proxying authenticated request\" \"authenticator\"={\"apiGroup\":\"authentication.concierge.pinniped.dev\",\"kind\":\"\",\"name\":\"authenticator-one\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\" \"userID\"=\"test-uid\""},
}, },
} }
@ -187,11 +245,19 @@ func TestImpersonator(t *testing.T) {
tt := tt tt := tt
testLog := testlogger.New(t) testLog := testlogger.New(t)
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
defer func() {
if t.Failed() {
for i, line := range testLog.Lines() {
t.Logf("testLog line %d: %q", i+1, line)
}
}
}()
// stole this from cache_test, hopefully it is sufficient // stole this from cache_test, hopefully it is sufficient
cacheWithMockAuthenticator := authncache.New() cacheWithMockAuthenticator := authncache.New()
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
defer ctrl.Finish() defer ctrl.Finish()
key := authncache.Key{Name: "authenticator-one"} key := authncache.Key{Name: "authenticator-one", APIGroup: *goodAuthenticator.APIGroup}
mockToken := mocktokenauthenticator.NewMockToken(ctrl) mockToken := mocktokenauthenticator.NewMockToken(ctrl)
cacheWithMockAuthenticator.Store(key, mockToken) cacheWithMockAuthenticator.Store(key, mockToken)
@ -199,7 +265,12 @@ func TestImpersonator(t *testing.T) {
tt.expectMockToken(t, mockToken.EXPECT()) tt.expectMockToken(t, mockToken.EXPECT())
} }
proxy, err := newInternal(cacheWithMockAuthenticator, testLog, tt.getKubeconfig) apiGroup := defaultAPIGroup
if tt.apiGroupOverride != "" {
apiGroup = tt.apiGroupOverride
}
proxy, err := newInternal(cacheWithMockAuthenticator, makeDecoder(t, apiGroup), testLog, tt.getKubeconfig)
if tt.wantCreationErr != "" { if tt.wantCreationErr != "" {
require.EqualError(t, err, tt.wantCreationErr) require.EqualError(t, err, tt.wantCreationErr)
return return
@ -223,22 +294,21 @@ func TestImpersonator(t *testing.T) {
} }
} }
func makeTestTokenRequest(namespace string, name string, token string) string { func stringPtr(s string) *string { return &s }
reqJSON, err := json.Marshal(&loginv1alpha1.TokenCredentialRequest{
ObjectMeta: metav1.ObjectMeta{ func makeDecoder(t *testing.T, apiGroupSuffix string) runtime.Decoder {
Namespace: namespace, t.Helper()
},
TypeMeta: metav1.TypeMeta{ loginConciergeGroupName, ok := groupsuffix.Replace(login.GroupName, apiGroupSuffix)
Kind: "TokenCredentialRequest", require.True(t, ok, "couldn't replace suffix of %q with %q", login.GroupName, apiGroupSuffix)
APIVersion: loginv1alpha1.GroupName + "/v1alpha1",
}, scheme := conciergescheme.New(loginConciergeGroupName, apiGroupSuffix)
Spec: loginv1alpha1.TokenCredentialRequestSpec{ codecs := serializer.NewCodecFactory(scheme)
Token: token, respInfo, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), runtime.ContentTypeJSON)
Authenticator: corev1.TypedLocalObjectReference{Name: name}, require.True(t, ok, "couldn't find serializer info for media type")
},
return codecs.DecoderToVersion(respInfo.Serializer, schema.GroupVersion{
Group: loginConciergeGroupName,
Version: login.SchemeGroupVersion.Version,
}) })
if err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(reqJSON)
} }

View File

@ -0,0 +1,113 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package scheme contains code to construct a proper runtime.Scheme for the Concierge aggregated
// API.
package scheme
import (
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
loginapi "go.pinniped.dev/generated/1.20/apis/concierge/login"
loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1"
"go.pinniped.dev/internal/groupsuffix"
"go.pinniped.dev/internal/plog"
)
// New returns a runtime.Scheme for use by the Concierge aggregated API. The provided
// loginConciergeAPIGroup should be the API group that the Concierge is serving (e.g.,
// login.concierge.pinniped.dev, login.concierge.walrus.tld, etc.). The provided apiGroupSuffix is
// the API group suffix of the provided loginConciergeAPIGroup (e.g., pinniped.dev, walrus.tld,
// etc.).
func New(loginConciergeAPIGroup, apiGroupSuffix string) *runtime.Scheme {
// standard set up of the server side scheme
scheme := runtime.NewScheme()
// add the options to empty v1
metav1.AddToGroupVersion(scheme, metav1.Unversioned)
// nothing fancy is required if using the standard group
if loginConciergeAPIGroup == loginv1alpha1.GroupName {
utilruntime.Must(loginv1alpha1.AddToScheme(scheme))
utilruntime.Must(loginapi.AddToScheme(scheme))
return scheme
}
// we need a temporary place to register our types to avoid double registering them
tmpScheme := runtime.NewScheme()
utilruntime.Must(loginv1alpha1.AddToScheme(tmpScheme))
utilruntime.Must(loginapi.AddToScheme(tmpScheme))
for gvk := range tmpScheme.AllKnownTypes() {
if gvk.GroupVersion() == metav1.Unversioned {
continue // metav1.AddToGroupVersion registers types outside of our aggregated API group that we need to ignore
}
if gvk.Group != loginv1alpha1.GroupName {
panic("tmp scheme has types not in the aggregated API group: " + gvk.Group) // programmer error
}
obj, err := tmpScheme.New(gvk)
if err != nil {
panic(err) // programmer error, scheme internal code is broken
}
newGVK := schema.GroupVersionKind{
Group: loginConciergeAPIGroup,
Version: gvk.Version,
Kind: gvk.Kind,
}
// register the existing type but with the new group in the correct scheme
scheme.AddKnownTypeWithName(newGVK, obj)
}
// manually register conversions and defaulting into the correct scheme since we cannot directly call loginv1alpha1.AddToScheme
utilruntime.Must(loginv1alpha1.RegisterConversions(scheme))
utilruntime.Must(loginv1alpha1.RegisterDefaults(scheme))
// we do not want to return errors from the scheme and instead would prefer to defer
// to the REST storage layer for consistency. The simplest way to do this is to force
// a cache miss from the authenticator cache. Kube API groups are validated via the
// IsDNS1123Subdomain func thus we can easily create a group that is guaranteed never
// to be in the authenticator cache. Add a timestamp just to be extra sure.
const authenticatorCacheMissPrefix = "_INVALID_API_GROUP_"
authenticatorCacheMiss := authenticatorCacheMissPrefix + time.Now().UTC().String()
// we do not have any defaulting functions for *loginv1alpha1.TokenCredentialRequest
// today, but we may have some in the future. Calling AddTypeDefaultingFunc overwrites
// any previously registered defaulting function. Thus to make sure that we catch
// a situation where we add a defaulting func, we attempt to call it here with a nil
// *loginv1alpha1.TokenCredentialRequest. This will do nothing when there is no
// defaulting func registered, but it will almost certainly panic if one is added.
scheme.Default((*loginv1alpha1.TokenCredentialRequest)(nil))
// on incoming requests, restore the authenticator API group to the standard group
// note that we are responsible for duplicating this logic for every external API version
scheme.AddTypeDefaultingFunc(&loginv1alpha1.TokenCredentialRequest{}, func(obj interface{}) {
credentialRequest := obj.(*loginv1alpha1.TokenCredentialRequest)
if credentialRequest.Spec.Authenticator.APIGroup == nil {
// force a cache miss because this is an invalid request
plog.Debug("invalid token credential request, nil group", "authenticator", credentialRequest.Spec.Authenticator)
credentialRequest.Spec.Authenticator.APIGroup = &authenticatorCacheMiss
return
}
restoredGroup, ok := groupsuffix.Unreplace(*credentialRequest.Spec.Authenticator.APIGroup, apiGroupSuffix)
if !ok {
// force a cache miss because this is an invalid request
plog.Debug("invalid token credential request, wrong group", "authenticator", credentialRequest.Spec.Authenticator)
credentialRequest.Spec.Authenticator.APIGroup = &authenticatorCacheMiss
return
}
credentialRequest.Spec.Authenticator.APIGroup = &restoredGroup
})
return scheme
}

View File

@ -0,0 +1,184 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package scheme
import (
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
loginapi "go.pinniped.dev/generated/1.20/apis/concierge/login"
loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1"
"go.pinniped.dev/internal/groupsuffix"
)
func TestNew(t *testing.T) {
// the standard group
regularGV := schema.GroupVersion{
Group: "login.concierge.pinniped.dev",
Version: "v1alpha1",
}
regularGVInternal := schema.GroupVersion{
Group: "login.concierge.pinniped.dev",
Version: runtime.APIVersionInternal,
}
// the canonical other group
otherGV := schema.GroupVersion{
Group: "login.concierge.walrus.tld",
Version: "v1alpha1",
}
otherGVInternal := schema.GroupVersion{
Group: "login.concierge.walrus.tld",
Version: runtime.APIVersionInternal,
}
// kube's core internal
internalGV := schema.GroupVersion{
Group: "",
Version: runtime.APIVersionInternal,
}
tests := []struct {
name string
apiGroupSuffix string
want map[schema.GroupVersionKind]reflect.Type
}{
{
name: "regular api group",
apiGroupSuffix: "pinniped.dev",
want: map[schema.GroupVersionKind]reflect.Type{
// all the types that are in the aggregated API group
regularGV.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequest{}).Elem(),
regularGV.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequestList{}).Elem(),
regularGVInternal.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginapi.TokenCredentialRequest{}).Elem(),
regularGVInternal.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginapi.TokenCredentialRequestList{}).Elem(),
regularGV.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(),
regularGV.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(),
regularGV.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(),
regularGV.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(),
regularGV.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(),
regularGV.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(),
regularGV.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(),
regularGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(),
regularGVInternal.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(),
// the types below this line do not really matter to us because they are in the core group
internalGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(),
metav1.Unversioned.WithKind("APIGroup"): reflect.TypeOf(&metav1.APIGroup{}).Elem(),
metav1.Unversioned.WithKind("APIGroupList"): reflect.TypeOf(&metav1.APIGroupList{}).Elem(),
metav1.Unversioned.WithKind("APIResourceList"): reflect.TypeOf(&metav1.APIResourceList{}).Elem(),
metav1.Unversioned.WithKind("APIVersions"): reflect.TypeOf(&metav1.APIVersions{}).Elem(),
metav1.Unversioned.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(),
metav1.Unversioned.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(),
metav1.Unversioned.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(),
metav1.Unversioned.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(),
metav1.Unversioned.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(),
metav1.Unversioned.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(),
metav1.Unversioned.WithKind("Status"): reflect.TypeOf(&metav1.Status{}).Elem(),
metav1.Unversioned.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(),
metav1.Unversioned.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(),
},
},
{
name: "other api group",
apiGroupSuffix: "walrus.tld",
want: map[schema.GroupVersionKind]reflect.Type{
// all the types that are in the aggregated API group
otherGV.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequest{}).Elem(),
otherGV.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequestList{}).Elem(),
otherGVInternal.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginapi.TokenCredentialRequest{}).Elem(),
otherGVInternal.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginapi.TokenCredentialRequestList{}).Elem(),
otherGV.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(),
otherGV.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(),
otherGV.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(),
otherGV.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(),
otherGV.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(),
otherGV.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(),
otherGV.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(),
otherGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(),
otherGVInternal.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(),
// the types below this line do not really matter to us because they are in the core group
internalGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(),
metav1.Unversioned.WithKind("APIGroup"): reflect.TypeOf(&metav1.APIGroup{}).Elem(),
metav1.Unversioned.WithKind("APIGroupList"): reflect.TypeOf(&metav1.APIGroupList{}).Elem(),
metav1.Unversioned.WithKind("APIResourceList"): reflect.TypeOf(&metav1.APIResourceList{}).Elem(),
metav1.Unversioned.WithKind("APIVersions"): reflect.TypeOf(&metav1.APIVersions{}).Elem(),
metav1.Unversioned.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(),
metav1.Unversioned.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(),
metav1.Unversioned.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(),
metav1.Unversioned.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(),
metav1.Unversioned.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(),
metav1.Unversioned.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(),
metav1.Unversioned.WithKind("Status"): reflect.TypeOf(&metav1.Status{}).Elem(),
metav1.Unversioned.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(),
metav1.Unversioned.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(),
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
loginConciergeAPIGroup, ok := groupsuffix.Replace("login.concierge.pinniped.dev", tt.apiGroupSuffix)
require.True(t, ok)
scheme := New(loginConciergeAPIGroup, tt.apiGroupSuffix)
require.Equal(t, tt.want, scheme.AllKnownTypes())
// make a credential request like a client would send
authenticationConciergeAPIGroup := "authentication.concierge." + tt.apiGroupSuffix
credentialRequest := &loginv1alpha1.TokenCredentialRequest{
Spec: loginv1alpha1.TokenCredentialRequestSpec{
Authenticator: corev1.TypedLocalObjectReference{
APIGroup: &authenticationConciergeAPIGroup,
},
},
}
// run defaulting on it
scheme.Default(credentialRequest)
// make sure the group is restored if needed
require.Equal(t, "authentication.concierge.pinniped.dev", *credentialRequest.Spec.Authenticator.APIGroup)
// make a credential request in the standard group
defaultAuthenticationConciergeAPIGroup := "authentication.concierge.pinniped.dev"
defaultCredentialRequest := &loginv1alpha1.TokenCredentialRequest{
Spec: loginv1alpha1.TokenCredentialRequestSpec{
Authenticator: corev1.TypedLocalObjectReference{
APIGroup: &defaultAuthenticationConciergeAPIGroup,
},
},
}
// run defaulting on it
scheme.Default(defaultCredentialRequest)
if tt.apiGroupSuffix == "pinniped.dev" { // when using the standard group, this should just work
require.Equal(t, "authentication.concierge.pinniped.dev", *defaultCredentialRequest.Spec.Authenticator.APIGroup)
} else { // when using any other group, this should always be a cache miss
require.True(t, strings.HasPrefix(*defaultCredentialRequest.Spec.Authenticator.APIGroup, "_INVALID_API_GROUP_2"))
}
})
}
}

View File

@ -11,18 +11,17 @@ import (
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/runtime/serializer"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
genericapiserver "k8s.io/apiserver/pkg/server" genericapiserver "k8s.io/apiserver/pkg/server"
genericoptions "k8s.io/apiserver/pkg/server/options" genericoptions "k8s.io/apiserver/pkg/server/options"
loginapi "go.pinniped.dev/generated/1.20/apis/concierge/login" "go.pinniped.dev/generated/1.20/apis/concierge/login"
loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1" loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1"
"go.pinniped.dev/internal/certauthority/dynamiccertauthority" "go.pinniped.dev/internal/certauthority/dynamiccertauthority"
"go.pinniped.dev/internal/concierge/apiserver" "go.pinniped.dev/internal/concierge/apiserver"
conciergescheme "go.pinniped.dev/internal/concierge/scheme"
"go.pinniped.dev/internal/config/concierge" "go.pinniped.dev/internal/config/concierge"
"go.pinniped.dev/internal/controller/authenticator/authncache" "go.pinniped.dev/internal/controller/authenticator/authncache"
"go.pinniped.dev/internal/controllermanager" "go.pinniped.dev/internal/controllermanager"
@ -123,6 +122,14 @@ func (a *App) runServer(ctx context.Context) error {
// cert issuer used to issue certs to Pinniped clients wishing to login. // cert issuer used to issue certs to Pinniped clients wishing to login.
dynamicSigningCertProvider := dynamiccert.New() dynamicSigningCertProvider := dynamiccert.New()
// Get the "real" name of the login concierge API group (i.e., the API group name with the
// injected suffix).
loginConciergeAPIGroup, ok := groupsuffix.Replace(loginv1alpha1.GroupName, *cfg.APIGroupSuffix)
if !ok {
return fmt.Errorf("cannot make api group from %s/%s", loginv1alpha1.GroupName, *cfg.APIGroupSuffix)
}
loginConciergeScheme := conciergescheme.New(loginConciergeAPIGroup, *cfg.APIGroupSuffix)
// Prepare to start the controllers, but defer actually starting them until the // Prepare to start the controllers, but defer actually starting them until the
// post start hook of the aggregated API server. // post start hook of the aggregated API server.
startControllersFunc, err := controllermanager.PrepareControllers( startControllersFunc, err := controllermanager.PrepareControllers(
@ -138,6 +145,7 @@ func (a *App) runServer(ctx context.Context) error {
ServingCertDuration: time.Duration(*cfg.APIConfig.ServingCertificateConfig.DurationSeconds) * time.Second, ServingCertDuration: time.Duration(*cfg.APIConfig.ServingCertificateConfig.DurationSeconds) * time.Second,
ServingCertRenewBefore: time.Duration(*cfg.APIConfig.ServingCertificateConfig.RenewBeforeSeconds) * time.Second, ServingCertRenewBefore: time.Duration(*cfg.APIConfig.ServingCertificateConfig.RenewBeforeSeconds) * time.Second,
AuthenticatorCache: authenticators, AuthenticatorCache: authenticators,
LoginJSONDecoder: getLoginJSONDecoder(loginConciergeAPIGroup, loginConciergeScheme),
}, },
) )
if err != nil { if err != nil {
@ -150,7 +158,8 @@ func (a *App) runServer(ctx context.Context) error {
authenticators, authenticators,
dynamiccertauthority.New(dynamicSigningCertProvider), dynamiccertauthority.New(dynamicSigningCertProvider),
startControllersFunc, startControllersFunc,
*cfg.APIGroupSuffix, loginConciergeAPIGroup,
loginConciergeScheme,
) )
if err != nil { if err != nil {
return fmt.Errorf("could not configure aggregated API server: %w", err) return fmt.Errorf("could not configure aggregated API server: %w", err)
@ -172,14 +181,10 @@ func getAggregatedAPIServerConfig(
authenticator credentialrequest.TokenCredentialRequestAuthenticator, authenticator credentialrequest.TokenCredentialRequestAuthenticator,
issuer credentialrequest.CertIssuer, issuer credentialrequest.CertIssuer,
startControllersPostStartHook func(context.Context), startControllersPostStartHook func(context.Context),
apiGroupSuffix string, loginConciergeAPIGroup string,
loginConciergeScheme *runtime.Scheme,
) (*apiserver.Config, error) { ) (*apiserver.Config, error) {
loginConciergeAPIGroup, ok := groupsuffix.Replace(loginv1alpha1.GroupName, apiGroupSuffix) scheme := loginConciergeScheme
if !ok {
return nil, fmt.Errorf("cannot make api group from %s/%s", loginv1alpha1.GroupName, apiGroupSuffix)
}
scheme := getAggregatedAPIServerScheme(loginConciergeAPIGroup, apiGroupSuffix)
codecs := serializer.NewCodecFactory(scheme) codecs := serializer.NewCodecFactory(scheme)
defaultEtcdPathPrefix := fmt.Sprintf("/registry/%s", loginConciergeAPIGroup) defaultEtcdPathPrefix := fmt.Sprintf("/registry/%s", loginConciergeAPIGroup)
@ -224,90 +229,15 @@ func getAggregatedAPIServerConfig(
return apiServerConfig, nil return apiServerConfig, nil
} }
func getAggregatedAPIServerScheme(loginConciergeAPIGroup, apiGroupSuffix string) *runtime.Scheme { func getLoginJSONDecoder(loginConciergeAPIGroup string, loginConciergeScheme *runtime.Scheme) runtime.Decoder {
// standard set up of the server side scheme scheme := loginConciergeScheme
scheme := runtime.NewScheme() codecs := serializer.NewCodecFactory(scheme)
respInfo, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), runtime.ContentTypeJSON)
// add the options to empty v1
metav1.AddToGroupVersion(scheme, metav1.Unversioned)
// nothing fancy is required if using the standard group
if loginConciergeAPIGroup == loginv1alpha1.GroupName {
utilruntime.Must(loginv1alpha1.AddToScheme(scheme))
utilruntime.Must(loginapi.AddToScheme(scheme))
return scheme
}
// we need a temporary place to register our types to avoid double registering them
tmpScheme := runtime.NewScheme()
utilruntime.Must(loginv1alpha1.AddToScheme(tmpScheme))
utilruntime.Must(loginapi.AddToScheme(tmpScheme))
for gvk := range tmpScheme.AllKnownTypes() {
if gvk.GroupVersion() == metav1.Unversioned {
continue // metav1.AddToGroupVersion registers types outside of our aggregated API group that we need to ignore
}
if gvk.Group != loginv1alpha1.GroupName {
panic("tmp scheme has types not in the aggregated API group: " + gvk.Group) // programmer error
}
obj, err := tmpScheme.New(gvk)
if err != nil {
panic(err) // programmer error, scheme internal code is broken
}
newGVK := schema.GroupVersionKind{
Group: loginConciergeAPIGroup,
Version: gvk.Version,
Kind: gvk.Kind,
}
// register the existing type but with the new group in the correct scheme
scheme.AddKnownTypeWithName(newGVK, obj)
}
// manually register conversions and defaulting into the correct scheme since we cannot directly call loginv1alpha1.AddToScheme
utilruntime.Must(loginv1alpha1.RegisterConversions(scheme))
utilruntime.Must(loginv1alpha1.RegisterDefaults(scheme))
// we do not want to return errors from the scheme and instead would prefer to defer
// to the REST storage layer for consistency. The simplest way to do this is to force
// a cache miss from the authenticator cache. Kube API groups are validated via the
// IsDNS1123Subdomain func thus we can easily create a group that is guaranteed never
// to be in the authenticator cache. Add a timestamp just to be extra sure.
const authenticatorCacheMissPrefix = "_INVALID_API_GROUP_"
authenticatorCacheMiss := authenticatorCacheMissPrefix + time.Now().UTC().String()
// we do not have any defaulting functions for *loginv1alpha1.TokenCredentialRequest
// today, but we may have some in the future. Calling AddTypeDefaultingFunc overwrites
// any previously registered defaulting function. Thus to make sure that we catch
// a situation where we add a defaulting func, we attempt to call it here with a nil
// *loginv1alpha1.TokenCredentialRequest. This will do nothing when there is no
// defaulting func registered, but it will almost certainly panic if one is added.
scheme.Default((*loginv1alpha1.TokenCredentialRequest)(nil))
// on incoming requests, restore the authenticator API group to the standard group
// note that we are responsible for duplicating this logic for every external API version
scheme.AddTypeDefaultingFunc(&loginv1alpha1.TokenCredentialRequest{}, func(obj interface{}) {
credentialRequest := obj.(*loginv1alpha1.TokenCredentialRequest)
if credentialRequest.Spec.Authenticator.APIGroup == nil {
// force a cache miss because this is an invalid request
plog.Debug("invalid token credential request, nil group", "authenticator", credentialRequest.Spec.Authenticator)
credentialRequest.Spec.Authenticator.APIGroup = &authenticatorCacheMiss
return
}
restoredGroup, ok := groupsuffix.Unreplace(*credentialRequest.Spec.Authenticator.APIGroup, apiGroupSuffix)
if !ok { if !ok {
// force a cache miss because this is an invalid request panic(fmt.Errorf("unknown content type: %s ", runtime.ContentTypeJSON)) // static input, programmer error
plog.Debug("invalid token credential request, wrong group", "authenticator", credentialRequest.Spec.Authenticator)
credentialRequest.Spec.Authenticator.APIGroup = &authenticatorCacheMiss
return
} }
return codecs.DecoderToVersion(respInfo.Serializer, schema.GroupVersion{
credentialRequest.Spec.Authenticator.APIGroup = &restoredGroup Group: loginConciergeAPIGroup,
Version: login.SchemeGroupVersion.Version,
}) })
return scheme
} }

View File

@ -6,21 +6,12 @@ package server
import ( import (
"bytes" "bytes"
"context" "context"
"reflect"
"strings" "strings"
"testing" "testing"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
loginapi "go.pinniped.dev/generated/1.20/apis/concierge/login"
loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1"
"go.pinniped.dev/internal/groupsuffix"
) )
const knownGoodUsage = ` const knownGoodUsage = `
@ -97,167 +88,3 @@ func TestCommand(t *testing.T) {
}) })
} }
} }
func Test_getAggregatedAPIServerScheme(t *testing.T) {
// the standard group
regularGV := schema.GroupVersion{
Group: "login.concierge.pinniped.dev",
Version: "v1alpha1",
}
regularGVInternal := schema.GroupVersion{
Group: "login.concierge.pinniped.dev",
Version: runtime.APIVersionInternal,
}
// the canonical other group
otherGV := schema.GroupVersion{
Group: "login.concierge.walrus.tld",
Version: "v1alpha1",
}
otherGVInternal := schema.GroupVersion{
Group: "login.concierge.walrus.tld",
Version: runtime.APIVersionInternal,
}
// kube's core internal
internalGV := schema.GroupVersion{
Group: "",
Version: runtime.APIVersionInternal,
}
tests := []struct {
name string
apiGroupSuffix string
want map[schema.GroupVersionKind]reflect.Type
}{
{
name: "regular api group",
apiGroupSuffix: "pinniped.dev",
want: map[schema.GroupVersionKind]reflect.Type{
// all the types that are in the aggregated API group
regularGV.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequest{}).Elem(),
regularGV.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequestList{}).Elem(),
regularGVInternal.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginapi.TokenCredentialRequest{}).Elem(),
regularGVInternal.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginapi.TokenCredentialRequestList{}).Elem(),
regularGV.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(),
regularGV.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(),
regularGV.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(),
regularGV.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(),
regularGV.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(),
regularGV.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(),
regularGV.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(),
regularGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(),
regularGVInternal.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(),
// the types below this line do not really matter to us because they are in the core group
internalGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(),
metav1.Unversioned.WithKind("APIGroup"): reflect.TypeOf(&metav1.APIGroup{}).Elem(),
metav1.Unversioned.WithKind("APIGroupList"): reflect.TypeOf(&metav1.APIGroupList{}).Elem(),
metav1.Unversioned.WithKind("APIResourceList"): reflect.TypeOf(&metav1.APIResourceList{}).Elem(),
metav1.Unversioned.WithKind("APIVersions"): reflect.TypeOf(&metav1.APIVersions{}).Elem(),
metav1.Unversioned.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(),
metav1.Unversioned.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(),
metav1.Unversioned.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(),
metav1.Unversioned.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(),
metav1.Unversioned.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(),
metav1.Unversioned.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(),
metav1.Unversioned.WithKind("Status"): reflect.TypeOf(&metav1.Status{}).Elem(),
metav1.Unversioned.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(),
metav1.Unversioned.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(),
},
},
{
name: "other api group",
apiGroupSuffix: "walrus.tld",
want: map[schema.GroupVersionKind]reflect.Type{
// all the types that are in the aggregated API group
otherGV.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequest{}).Elem(),
otherGV.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginv1alpha1.TokenCredentialRequestList{}).Elem(),
otherGVInternal.WithKind("TokenCredentialRequest"): reflect.TypeOf(&loginapi.TokenCredentialRequest{}).Elem(),
otherGVInternal.WithKind("TokenCredentialRequestList"): reflect.TypeOf(&loginapi.TokenCredentialRequestList{}).Elem(),
otherGV.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(),
otherGV.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(),
otherGV.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(),
otherGV.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(),
otherGV.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(),
otherGV.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(),
otherGV.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(),
otherGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(),
otherGVInternal.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(),
// the types below this line do not really matter to us because they are in the core group
internalGV.WithKind("WatchEvent"): reflect.TypeOf(&metav1.InternalEvent{}).Elem(),
metav1.Unversioned.WithKind("APIGroup"): reflect.TypeOf(&metav1.APIGroup{}).Elem(),
metav1.Unversioned.WithKind("APIGroupList"): reflect.TypeOf(&metav1.APIGroupList{}).Elem(),
metav1.Unversioned.WithKind("APIResourceList"): reflect.TypeOf(&metav1.APIResourceList{}).Elem(),
metav1.Unversioned.WithKind("APIVersions"): reflect.TypeOf(&metav1.APIVersions{}).Elem(),
metav1.Unversioned.WithKind("CreateOptions"): reflect.TypeOf(&metav1.CreateOptions{}).Elem(),
metav1.Unversioned.WithKind("DeleteOptions"): reflect.TypeOf(&metav1.DeleteOptions{}).Elem(),
metav1.Unversioned.WithKind("ExportOptions"): reflect.TypeOf(&metav1.ExportOptions{}).Elem(),
metav1.Unversioned.WithKind("GetOptions"): reflect.TypeOf(&metav1.GetOptions{}).Elem(),
metav1.Unversioned.WithKind("ListOptions"): reflect.TypeOf(&metav1.ListOptions{}).Elem(),
metav1.Unversioned.WithKind("PatchOptions"): reflect.TypeOf(&metav1.PatchOptions{}).Elem(),
metav1.Unversioned.WithKind("Status"): reflect.TypeOf(&metav1.Status{}).Elem(),
metav1.Unversioned.WithKind("UpdateOptions"): reflect.TypeOf(&metav1.UpdateOptions{}).Elem(),
metav1.Unversioned.WithKind("WatchEvent"): reflect.TypeOf(&metav1.WatchEvent{}).Elem(),
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
loginConciergeAPIGroup, ok := groupsuffix.Replace("login.concierge.pinniped.dev", tt.apiGroupSuffix)
require.True(t, ok)
scheme := getAggregatedAPIServerScheme(loginConciergeAPIGroup, tt.apiGroupSuffix)
require.Equal(t, tt.want, scheme.AllKnownTypes())
// make a credential request like a client would send
authenticationConciergeAPIGroup := "authentication.concierge." + tt.apiGroupSuffix
credentialRequest := &loginv1alpha1.TokenCredentialRequest{
Spec: loginv1alpha1.TokenCredentialRequestSpec{
Authenticator: corev1.TypedLocalObjectReference{
APIGroup: &authenticationConciergeAPIGroup,
},
},
}
// run defaulting on it
scheme.Default(credentialRequest)
// make sure the group is restored if needed
require.Equal(t, "authentication.concierge.pinniped.dev", *credentialRequest.Spec.Authenticator.APIGroup)
// make a credential request in the standard group
defaultAuthenticationConciergeAPIGroup := "authentication.concierge.pinniped.dev"
defaultCredentialRequest := &loginv1alpha1.TokenCredentialRequest{
Spec: loginv1alpha1.TokenCredentialRequestSpec{
Authenticator: corev1.TypedLocalObjectReference{
APIGroup: &defaultAuthenticationConciergeAPIGroup,
},
},
}
// run defaulting on it
scheme.Default(defaultCredentialRequest)
if tt.apiGroupSuffix == "pinniped.dev" { // when using the standard group, this should just work
require.Equal(t, "authentication.concierge.pinniped.dev", *defaultCredentialRequest.Spec.Authenticator.APIGroup)
} else { // when using any other group, this should always be a cache miss
require.True(t, strings.HasPrefix(*defaultCredentialRequest.Spec.Authenticator.APIGroup, "_INVALID_API_GROUP_2"))
}
})
}
}

View File

@ -12,6 +12,7 @@ import (
"net/http" "net/http"
"time" "time"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/clock" "k8s.io/apimachinery/pkg/util/clock"
k8sinformers "k8s.io/client-go/informers" k8sinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
@ -82,6 +83,10 @@ type Config struct {
// AuthenticatorCache is a cache of authenticators shared amongst various authenticated-related controllers. // AuthenticatorCache is a cache of authenticators shared amongst various authenticated-related controllers.
AuthenticatorCache *authncache.Cache AuthenticatorCache *authncache.Cache
// LoginJSONDecoder can decode login.concierge.pinniped.dev types (e.g., TokenCredentialRequest)
// into their internal representation.
LoginJSONDecoder runtime.Decoder
// Labels are labels that should be added to any resources created by the controllers. // Labels are labels that should be added to any resources created by the controllers.
Labels map[string]string Labels map[string]string
} }
@ -289,7 +294,7 @@ func PrepareControllers(c *Config) (func(ctx context.Context), error) {
"pinniped-concierge-impersonation-proxy-load-balancer", // TODO this string should come from `c.NamesConfig` "pinniped-concierge-impersonation-proxy-load-balancer", // TODO this string should come from `c.NamesConfig`
tls.Listen, tls.Listen,
func() (http.Handler, error) { func() (http.Handler, error) {
impersonationProxyHandler, err := impersonator.New(c.AuthenticatorCache, klogr.New().WithName("impersonation-proxy")) impersonationProxyHandler, err := impersonator.New(c.AuthenticatorCache, c.LoginJSONDecoder, klogr.New().WithName("impersonation-proxy"))
if err != nil { if err != nil {
return nil, fmt.Errorf("could not create impersonation proxy: %w", err) return nil, fmt.Errorf("could not create impersonation proxy: %w", err)
} }

View File

@ -0,0 +1,67 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package impersonationtoken contains a test utility to generate a token to be used against our
// impersonation proxy.
//
// It is its own package to fix import cycles involving concierge/scheme, testutil, and groupsuffix.
package impersonationtoken
import (
"encoding/base64"
"testing"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1"
conciergescheme "go.pinniped.dev/internal/concierge/scheme"
"go.pinniped.dev/internal/groupsuffix"
)
func Make(
t *testing.T,
token string,
authenticator *corev1.TypedLocalObjectReference,
apiGroupSuffix string,
) string {
t.Helper()
// The impersonation test token should be a base64-encoded TokenCredentialRequest object. The API
// group of the TokenCredentialRequest object, and its Spec.Authenticator, should match whatever
// is installed on the cluster. This API group is usually replaced by the kubeclient middleware,
// but this object is not touched by the middleware since it is in a HTTP header. Therefore, we
// need to make a manual edit here.
loginConciergeGroupName, ok := groupsuffix.Replace(loginv1alpha1.GroupName, apiGroupSuffix)
require.True(t, ok, "couldn't replace suffix of %q with %q", loginv1alpha1.GroupName, apiGroupSuffix)
tokenCredentialRequest := loginv1alpha1.TokenCredentialRequest{
TypeMeta: metav1.TypeMeta{
Kind: "TokenCredentialRequest",
APIVersion: loginConciergeGroupName + "/v1alpha1",
},
Spec: loginv1alpha1.TokenCredentialRequestSpec{
Token: token,
Authenticator: *authenticator.DeepCopy(),
},
}
// It is assumed that the provided authenticator uses the default pinniped.dev API group, since
// this is usually replaced by the kubeclient middleware. Since we are not going through the
// kubeclient middleware, we need to make this replacement ourselves.
require.NotNil(t, tokenCredentialRequest.Spec.Authenticator.APIGroup, "expected authenticator to have non-nil API group")
authenticatorAPIGroup, ok := groupsuffix.Replace(*tokenCredentialRequest.Spec.Authenticator.APIGroup, apiGroupSuffix)
require.True(t, ok, "couldn't replace suffix of %q with %q", *tokenCredentialRequest.Spec.Authenticator.APIGroup, apiGroupSuffix)
tokenCredentialRequest.Spec.Authenticator.APIGroup = &authenticatorAPIGroup
scheme := conciergescheme.New(loginConciergeGroupName, apiGroupSuffix)
codecs := serializer.NewCodecFactory(scheme)
respInfo, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), runtime.ContentTypeJSON)
require.True(t, ok, "couldn't find serializer info for media type")
reqJSON, err := runtime.Encode(respInfo.PrettySerializer, &tokenCredentialRequest)
require.NoError(t, err)
return base64.RawURLEncoding.EncodeToString(reqJSON)
}

View File

@ -5,8 +5,6 @@ package integration
import ( import (
"context" "context"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -20,8 +18,8 @@ import (
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
loginv1alpha1 "go.pinniped.dev/generated/1.20/apis/concierge/login/v1alpha1"
"go.pinniped.dev/internal/concierge/impersonator" "go.pinniped.dev/internal/concierge/impersonator"
"go.pinniped.dev/internal/testutil/impersonationtoken"
"go.pinniped.dev/test/library" "go.pinniped.dev/test/library"
) )
@ -49,7 +47,7 @@ func TestImpersonationProxy(t *testing.T) {
kubeconfig := &rest.Config{ kubeconfig := &rest.Config{
Host: proxyServiceURL, Host: proxyServiceURL,
TLSClientConfig: rest.TLSClientConfig{Insecure: true}, TLSClientConfig: rest.TLSClientConfig{Insecure: true},
BearerToken: makeImpersonationTestToken(t, authenticator), BearerToken: impersonationtoken.Make(t, env.TestUser.Token, &authenticator, env.APIGroupSuffix),
Proxy: func(req *http.Request) (*url.URL, error) { Proxy: func(req *http.Request) (*url.URL, error) {
proxyURL, err := url.Parse(env.Proxy) proxyURL, err := url.Parse(env.Proxy)
require.NoError(t, err) require.NoError(t, err)
@ -143,24 +141,3 @@ func hasLoadBalancerService(ctx context.Context, t *testing.T, client kubernetes
} }
return false return false
} }
func makeImpersonationTestToken(t *testing.T, authenticator corev1.TypedLocalObjectReference) string {
t.Helper()
env := library.IntegrationEnv(t)
reqJSON, err := json.Marshal(&loginv1alpha1.TokenCredentialRequest{
ObjectMeta: metav1.ObjectMeta{
Namespace: env.ConciergeNamespace,
},
TypeMeta: metav1.TypeMeta{
Kind: "TokenCredentialRequest",
APIVersion: loginv1alpha1.GroupName + "/v1alpha1",
},
Spec: loginv1alpha1.TokenCredentialRequestSpec{
Token: env.TestUser.Token,
Authenticator: authenticator,
},
})
require.NoError(t, err)
return base64.RawURLEncoding.EncodeToString(reqJSON)
}