Merge branch 'main' into initial_ldap

This commit is contained in:
Ryan Richard
2021-05-11 11:09:37 -07:00
37 changed files with 1949 additions and 231 deletions
+7
View File
@@ -35,6 +35,13 @@ only shares this information with the audit stack). To keep things simple,
we use the fake audit backend at the Metadata level for all requests. This
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.
+165 -29
View File
@@ -15,6 +15,8 @@ import (
"strings"
"time"
authenticationv1 "k8s.io/api/authentication/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -26,6 +28,8 @@ import (
"k8s.io/apimachinery/pkg/util/sets"
auditinternal "k8s.io/apiserver/pkg/apis/audit"
"k8s.io/apiserver/pkg/audit/policy"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/request/bearertoken"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/endpoints/filterlatency"
@@ -45,6 +49,7 @@ import (
"go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/kubeclient"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/valuelesscontext"
)
// FactoryFunc is a function which can create an impersonator server.
@@ -176,6 +181,11 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
// See the genericapiserver.DefaultBuildHandlerChain func for details.
handler = defaultBuildHandlerChainFunc(handler, c)
// we need to grab the bearer token before WithAuthentication deletes it.
handler = filterlatency.TrackCompleted(handler)
handler = withBearerTokenPreservation(handler)
handler = filterlatency.TrackStarted(handler, "bearertokenpreservation")
// Always set security headers so browsers do the right thing.
handler = filterlatency.TrackCompleted(handler)
handler = securityheader.Wrap(handler)
@@ -189,6 +199,9 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
serverConfig.AuditPolicyChecker = policy.FakeChecker(auditinternal.LevelMetadata, nil)
serverConfig.AuditBackend = &auditfake.Backend{}
// if we ever start unioning a TCR bearer token authenticator with serverConfig.Authenticator
// then we will need to update the related assumption in tokenPassthroughRoundTripper
delegatingAuthorizer := serverConfig.Authorization.Authorizer
nestedImpersonationAuthorizer := &comparableAuthorizer{
authorizerFunc: func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
@@ -290,6 +303,35 @@ func (f authorizerFunc) Authorize(ctx context.Context, a authorizer.Attributes)
return f(ctx, a)
}
func withBearerTokenPreservation(delegate http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// this looks a bit hacky but lets us avoid writing any logic for parsing out the bearer token
var reqToken string
_, _, _ = bearertoken.New(authenticator.TokenFunc(func(_ context.Context, token string) (*authenticator.Response, bool, error) {
reqToken = token
return nil, false, nil
})).AuthenticateRequest(r)
// smuggle the token through the context. this does mean that we need to avoid logging the context.
if len(reqToken) != 0 {
ctx := context.WithValue(r.Context(), tokenKey, reqToken)
r = r.WithContext(ctx)
}
delegate.ServeHTTP(w, r)
})
}
func tokenFrom(ctx context.Context) string {
token, _ := ctx.Value(tokenKey).(string)
return token
}
// contextKey type is unexported to prevent collisions.
type contextKey int
const tokenKey contextKey = iota
func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapiserver.Config) http.Handler, error) {
serverURL, err := url.Parse(restConfig.Host)
if err != nil {
@@ -300,11 +342,19 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
if err != nil {
return nil, fmt.Errorf("could not get http/1.1 round tripper: %w", err)
}
http1RoundTripperAnonymous, err := getTransportForProtocol(rest.AnonymousClientConfig(restConfig), "http/1.1")
if err != nil {
return nil, fmt.Errorf("could not get http/1.1 anonymous round tripper: %w", err)
}
http2RoundTripper, err := getTransportForProtocol(restConfig, "h2")
if err != nil {
return nil, fmt.Errorf("could not get http/2.0 round tripper: %w", err)
}
http2RoundTripperAnonymous, err := getTransportForProtocol(rest.AnonymousClientConfig(restConfig), "h2")
if err != nil {
return nil, fmt.Errorf("could not get http/2.0 anonymous round tripper: %w", err)
}
return func(c *genericapiserver.Config) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -347,15 +397,18 @@ func newImpersonationReverseProxyFunc(restConfig *rest.Config) (func(*genericapi
return
}
// grab the request's bearer token if present. this is optional and does not fail the request if missing.
token := tokenFrom(r.Context())
// KAS only supports upgrades via http/1.1 to websockets/SPDY (upgrades never use http/2.0)
// Thus we default to using http/2.0 when the request is not an upgrade, otherwise we use http/1.1
baseRT := http2RoundTripper
baseRT, baseRTAnonymous := http2RoundTripper, http2RoundTripperAnonymous
isUpgradeRequest := httpstream.IsUpgradeRequest(r)
if isUpgradeRequest {
baseRT = http1RoundTripper
baseRT, baseRTAnonymous = http1RoundTripper, http1RoundTripperAnonymous
}
rt, err := getTransportForUser(userInfo, baseRT, ae)
rt, err := getTransportForUser(r.Context(), userInfo, baseRT, baseRTAnonymous, ae, token, c.Authentication.Authenticator)
if err != nil {
plog.WarningErr("rejecting request as we cannot act as the current user", err,
"url", r.URL.String(),
@@ -413,34 +466,117 @@ func ensureNoImpersonationHeaders(r *http.Request) error {
return nil
}
func getTransportForUser(userInfo user.Info, delegate http.RoundTripper, ae *auditinternal.Event) (http.RoundTripper, error) {
if len(userInfo.GetUID()) == 0 {
extra, err := buildExtra(userInfo.GetExtra(), ae)
if err != nil {
return nil, err
}
impersonateConfig := transport.ImpersonationConfig{
UserName: userInfo.GetName(),
Groups: userInfo.GetGroups(),
Extra: extra,
}
// transport.NewImpersonatingRoundTripper clones the request before setting headers
// thus it will not accidentally mutate the input request (see http.Handler docs)
return transport.NewImpersonatingRoundTripper(impersonateConfig, delegate), nil
func getTransportForUser(ctx context.Context, userInfo user.Info, delegate, delegateAnonymous http.RoundTripper, ae *auditinternal.Event, token string, authenticator authenticator.Request) (http.RoundTripper, error) {
if canImpersonateFully(userInfo) {
return standardImpersonationRoundTripper(userInfo, ae, delegate)
}
// 0. in the case of a request that is not attempting to do nested impersonation
// 1. if we make the assumption that the TCR API does not issue tokens (or pass the TCR API bearer token
// authenticator into this func - we need to know the authentication cred is something KAS would honor)
// 2. then if preserve the incoming authorization header into the request's context
// 3. we could reauthenticate it here (it would be a free cache hit)
// 4. confirm that it matches the passed in user info (i.e. it was actually the cred used to authenticate and not a client cert)
// 5. then we could issue a reverse proxy request using an anonymous rest config and the bearer token
// 6. thus instead of impersonating the user, we would just be passing their request through
// 7. this would preserve the UID info and thus allow us to safely support all token based auth
// 8. the above would be safe even if in the future Kube started supporting UIDs asserted by client certs
return nil, constable.Error("unexpected uid")
return tokenPassthroughRoundTripper(ctx, delegateAnonymous, ae, token, authenticator)
}
func canImpersonateFully(userInfo user.Info) bool {
// nolint: gosimple // this structure is on purpose because we plan to expand this function
if len(userInfo.GetUID()) == 0 {
return true
}
// once kube supports UID impersonation, add logic to detect if the KAS is
// new enough to have this functionality and return true in that case as well
return false
}
func standardImpersonationRoundTripper(userInfo user.Info, ae *auditinternal.Event, delegate http.RoundTripper) (http.RoundTripper, error) {
extra, err := buildExtra(userInfo.GetExtra(), ae)
if err != nil {
return nil, err
}
impersonateConfig := transport.ImpersonationConfig{
UserName: userInfo.GetName(),
Groups: userInfo.GetGroups(),
Extra: extra,
}
// transport.NewImpersonatingRoundTripper clones the request before setting headers
// thus it will not accidentally mutate the input request (see http.Handler docs)
return transport.NewImpersonatingRoundTripper(impersonateConfig, delegate), nil
}
func tokenPassthroughRoundTripper(ctx context.Context, delegateAnonymous http.RoundTripper, ae *auditinternal.Event, token string, authenticator authenticator.Request) (http.RoundTripper, error) {
// all code below assumes KAS does not support UID impersonation because that case is handled in the standard path
// it also assumes that the TCR API does not issue tokens - if this assumption changes, we will need
// some way to distinguish a token that is only valid against this impersonation proxy and not against KAS.
// this code will fail closed because said TCR token would not work against KAS and the request would fail.
// if we get here we know the final user info had a UID
// if the original user is also performing a nested impersonation, it means that said nested
// impersonation is trying to impersonate a UID since final user info == ae.ImpersonatedUser
// we know this KAS does not support UID impersonation so this request must be rejected
if ae.ImpersonatedUser != nil {
return nil, constable.Error("unable to impersonate uid")
}
// see what KAS thinks this token translates into
// this is important because certs have precedence over tokens and we want
// to make sure that we do not get confused and pass along the wrong token
tokenUser, err := tokenReview(ctx, token, authenticator)
if err != nil {
return nil, err
}
// we want to compare the result of the token authentication with the original user that made the request
// if the user who made the request and the token do not match, we cannot go any further at this point
if !apiequality.Semantic.DeepEqual(ae.User, tokenUser) {
// this info leak seems fine for trace level logs
plog.Trace("failed to passthrough token due to user mismatch",
"original-username", ae.User.Username,
"original-uid", ae.User.UID,
"token-username", tokenUser.Username,
"token-uid", tokenUser.UID,
)
return nil, constable.Error("token authenticated as a different user")
}
// now we know that if we send this token to KAS, it will authenticate correctly
return transport.NewBearerAuthRoundTripper(token, delegateAnonymous), nil
}
func tokenReview(ctx context.Context, token string, authenticator authenticator.Request) (authenticationv1.UserInfo, error) {
if len(token) == 0 {
return authenticationv1.UserInfo{}, constable.Error("no token on request")
}
// create a header that contains nothing but the token
// an astute observer may ask "but what about the token's audience?"
// in this case, we want to leave audiences unset per the token review docs:
// > If no audiences are provided, the audience will default to the audience of the Kubernetes apiserver.
// i.e. we want to make sure that the given token is valid against KAS
fakeReq := &http.Request{Header: http.Header{}}
fakeReq.Header.Set("Authorization", "Bearer "+token)
// propagate cancellation of parent context (without any values such as audience)
fakeReq = fakeReq.WithContext(valuelesscontext.New(ctx))
// this will almost always be a free call that hits our 10 second cache TTL
resp, ok, err := authenticator.AuthenticateRequest(fakeReq)
if err != nil {
return authenticationv1.UserInfo{}, err
}
if !ok {
return authenticationv1.UserInfo{}, constable.Error("token failed to authenticate")
}
tokenUser := authenticationv1.UserInfo{
Username: resp.User.GetName(),
UID: resp.User.GetUID(),
Groups: resp.User.GetGroups(),
Extra: make(map[string]authenticationv1.ExtraValue, len(resp.User.GetExtra())),
}
for k, v := range resp.User.GetExtra() {
tokenUser.Extra[k] = v
}
return tokenUser, nil
}
func buildExtra(extra map[string][]string, ae *auditinternal.Event) (map[string][]string, error) {
@@ -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)
})
}
}
+3
View File
@@ -122,6 +122,9 @@ func validateNames(names *NamesConfigSpec) error {
if names.ImpersonationSignerSecret == "" {
missingNames = append(missingNames, "impersonationSignerSecret")
}
if names.AgentServiceAccount == "" {
missingNames = append(missingNames, "agentServiceAccount")
}
if len(missingNames) > 0 {
return constable.Error("missing required names: " + strings.Join(missingNames, ", "))
}
+14 -1
View File
@@ -43,6 +43,7 @@ func TestFromPath(t *testing.T) {
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
labels:
myLabelKey1: myLabelValue1
myLabelKey2: myLabelValue2
@@ -72,6 +73,7 @@ func TestFromPath(t *testing.T) {
ImpersonationTLSCertificateSecret: "impersonationTLSCertificateSecret-value",
ImpersonationCACertificateSecret: "impersonationCACertificateSecret-value",
ImpersonationSignerSecret: "impersonationSignerSecret-value",
AgentServiceAccount: "agentServiceAccount-value",
},
Labels: map[string]string{
"myLabelKey1": "myLabelValue1",
@@ -98,6 +100,7 @@ func TestFromPath(t *testing.T) {
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
`),
wantConfig: &Config{
DiscoveryInfo: DiscoveryInfoSpec{
@@ -119,6 +122,7 @@ func TestFromPath(t *testing.T) {
ImpersonationTLSCertificateSecret: "impersonationTLSCertificateSecret-value",
ImpersonationCACertificateSecret: "impersonationCACertificateSecret-value",
ImpersonationSignerSecret: "impersonationSignerSecret-value",
AgentServiceAccount: "agentServiceAccount-value",
},
Labels: map[string]string{},
KubeCertAgentConfig: KubeCertAgentSpec{
@@ -133,7 +137,7 @@ func TestFromPath(t *testing.T) {
wantError: "validate names: missing required names: servingCertificateSecret, credentialIssuer, " +
"apiService, impersonationConfigMap, impersonationLoadBalancerService, " +
"impersonationTLSCertificateSecret, impersonationCACertificateSecret, " +
"impersonationSignerSecret",
"impersonationSignerSecret, agentServiceAccount",
},
{
name: "Missing apiService name",
@@ -147,6 +151,7 @@ func TestFromPath(t *testing.T) {
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
`),
wantError: "validate names: missing required names: apiService",
},
@@ -162,6 +167,7 @@ func TestFromPath(t *testing.T) {
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
`),
wantError: "validate names: missing required names: credentialIssuer",
},
@@ -177,6 +183,7 @@ func TestFromPath(t *testing.T) {
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
`),
wantError: "validate names: missing required names: servingCertificateSecret",
},
@@ -192,6 +199,7 @@ func TestFromPath(t *testing.T) {
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
`),
wantError: "validate names: missing required names: impersonationConfigMap",
},
@@ -207,6 +215,7 @@ func TestFromPath(t *testing.T) {
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
`),
wantError: "validate names: missing required names: impersonationLoadBalancerService",
},
@@ -222,6 +231,7 @@ func TestFromPath(t *testing.T) {
impersonationLoadBalancerService: impersonationLoadBalancerService-value
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
`),
wantError: "validate names: missing required names: impersonationTLSCertificateSecret",
},
@@ -237,6 +247,7 @@ func TestFromPath(t *testing.T) {
impersonationLoadBalancerService: impersonationLoadBalancerService-value
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
`),
wantError: "validate names: missing required names: impersonationCACertificateSecret",
},
@@ -252,6 +263,7 @@ func TestFromPath(t *testing.T) {
impersonationLoadBalancerService: impersonationLoadBalancerService-value
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationCACertificateSecret: impersonationCACertificateSecret-value
agentServiceAccount: agentServiceAccount-value
`),
wantError: "validate names: missing required names: impersonationSignerSecret",
},
@@ -265,6 +277,7 @@ func TestFromPath(t *testing.T) {
apiService: pinniped-api
impersonationLoadBalancerService: impersonationLoadBalancerService-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
`),
wantError: "validate names: missing required names: impersonationConfigMap, " +
"impersonationTLSCertificateSecret, impersonationCACertificateSecret",
+1
View File
@@ -41,6 +41,7 @@ type NamesConfigSpec struct {
ImpersonationTLSCertificateSecret string `json:"impersonationTLSCertificateSecret"`
ImpersonationCACertificateSecret string `json:"impersonationCACertificateSecret"`
ImpersonationSignerSecret string `json:"impersonationSignerSecret"`
AgentServiceAccount string `json:"agentServiceAccount"`
}
// ServingCertificateConfigSpec contains the configuration knobs for the API's
@@ -5,7 +5,9 @@
package authenticator
import (
"crypto/x509"
"encoding/base64"
"fmt"
auth1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1"
)
@@ -22,8 +24,18 @@ type Closer interface {
// nil CA bundle will be returned. If the provided spec contains a CA bundle that is not properly
// encoded, an error will be returned.
func CABundle(spec *auth1alpha1.TLSSpec) ([]byte, error) {
if spec == nil {
if spec == nil || len(spec.CertificateAuthorityData) == 0 {
return nil, nil
}
return base64.StdEncoding.DecodeString(spec.CertificateAuthorityData)
pem, err := base64.StdEncoding.DecodeString(spec.CertificateAuthorityData)
if err != nil {
return nil, err
}
if ok := x509.NewCertPool().AppendCertsFromPEM(pem); !ok {
return nil, fmt.Errorf("certificateAuthorityData is not valid PEM")
}
return pem, nil
}
@@ -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 }
@@ -135,6 +135,15 @@ func TestNewWebhookAuthenticator(t *testing.T) {
require.EqualError(t, err, "invalid TLS configuration: illegal base64 data at input byte 7")
})
t.Run("invalid pem data", func(t *testing.T) {
res, err := newWebhookAuthenticator(&auth1alpha1.WebhookAuthenticatorSpec{
Endpoint: "https://example.com",
TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte("bad data"))},
}, ioutil.TempFile, clientcmd.WriteToFile)
require.Nil(t, res)
require.EqualError(t, err, "invalid TLS configuration: certificateAuthorityData is not valid PEM")
})
t.Run("valid config with no TLS spec", func(t *testing.T) {
res, err := newWebhookAuthenticator(&auth1alpha1.WebhookAuthenticatorSpec{
Endpoint: "https://example.com",
@@ -64,6 +64,9 @@ type AgentConfig struct {
// NamePrefix will be prefixed to all agent pod names.
NamePrefix string
// ServiceAccountName is the service account under which to run the agent pods.
ServiceAccountName string
// ContainerImagePullSecrets is a list of names of Kubernetes Secret objects that will be used as
// ImagePullSecrets on the kube-cert-agent pods.
ContainerImagePullSecrets []string
@@ -472,6 +475,7 @@ func (c *agentController) newAgentDeployment(controllerManagerPod *corev1.Pod) *
RestartPolicy: corev1.RestartPolicyAlways,
NodeSelector: controllerManagerPod.Spec.NodeSelector,
AutomountServiceAccountToken: pointer.BoolPtr(false),
ServiceAccountName: c.cfg.ServiceAccountName,
NodeName: controllerManagerPod.Spec.NodeName,
Tolerations: controllerManagerPod.Spec.Tolerations,
// We need to run the agent pod as root since the file permissions
@@ -123,6 +123,7 @@ func TestAgentController(t *testing.T) {
}},
RestartPolicy: corev1.RestartPolicyAlways,
TerminationGracePeriodSeconds: pointer.Int64Ptr(0),
ServiceAccountName: "test-service-account-name",
AutomountServiceAccountToken: pointer.BoolPtr(false),
SecurityContext: &corev1.PodSecurityContext{
RunAsUser: pointer.Int64Ptr(0),
@@ -672,6 +673,7 @@ func TestAgentController(t *testing.T) {
AgentConfig{
Namespace: "concierge",
ContainerImage: "pinniped-server-image",
ServiceAccountName: "test-service-account-name",
NamePrefix: "pinniped-concierge-kube-cert-agent-",
ContainerImagePullSecrets: []string{"pinniped-image-pull-secret"},
CredentialIssuerName: "pinniped-concierge-config",
@@ -13,6 +13,7 @@ import (
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
@@ -269,11 +270,17 @@ func (c *oidcWatcherController) validateIssuer(ctx context.Context, upstream *v1
discoveredProvider, err = oidc.NewProvider(oidc.ClientContext(ctx, httpClient), upstream.Spec.Issuer)
if err != nil {
const klogLevelTrace = 6
c.log.V(klogLevelTrace).WithValues(
"namespace", upstream.Namespace,
"name", upstream.Name,
"issuer", upstream.Spec.Issuer,
).Error(err, "failed to perform OIDC discovery")
return &v1alpha1.Condition{
Type: typeOIDCDiscoverySucceeded,
Status: v1alpha1.ConditionFalse,
Reason: reasonUnreachable,
Message: fmt.Sprintf("failed to perform OIDC discovery against %q", upstream.Spec.Issuer),
Message: fmt.Sprintf("failed to perform OIDC discovery against %q:\n%s", upstream.Spec.Issuer, truncateNonOIDCErr(err)),
}
}
@@ -428,3 +435,14 @@ func (*oidcWatcherController) computeScopes(additionalScopes []string) []string
sort.Strings(scopes)
return scopes
}
func truncateNonOIDCErr(err error) string {
const max = 100
msg := err.Error()
if len(msg) <= max || strings.HasPrefix(msg, "oidc:") {
return msg
}
return msg[:max] + fmt.Sprintf(" [truncated %d chars]", len(msg)-max)
}
@@ -370,7 +370,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: "invalid-url",
Issuer: "invalid-url-that-is-really-really-long",
Client: v1alpha1.OIDCClient{SecretName: testSecretName},
AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes},
},
@@ -382,9 +382,10 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="Get \"invalid-url-that-is-really-really-long/.well-known/openid-configuration\": unsupported protocol scheme \"\"" "issuer"="invalid-url-that-is-really-really-long" "name"="test-name" "namespace"="test-namespace"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"invalid-url\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"invalid-url\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"invalid-url-that-is-really-really-long\":\nGet \"invalid-url-that-is-really-really-long/.well-known/openid-configuration\": unsupported protocol [truncated 9 chars]" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"invalid-url-that-is-really-really-long\":\nGet \"invalid-url-that-is-really-really-long/.well-known/openid-configuration\": unsupported protocol [truncated 9 chars]" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
},
wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
@@ -404,7 +405,8 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
Status: "False",
LastTransitionTime: now,
Reason: "Unreachable",
Message: `failed to perform OIDC discovery against "invalid-url"`,
Message: `failed to perform OIDC discovery against "invalid-url-that-is-really-really-long":
Get "invalid-url-that-is-really-really-long/.well-known/openid-configuration": unsupported protocol [truncated 9 chars]`,
},
},
},
@@ -600,6 +602,151 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) {
},
}},
},
{
name: "existing valid upstream with trailing slash",
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234},
Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: testIssuerURL + "/ends-with-slash/",
TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64},
Client: v1alpha1.OIDCClient{SecretName: testSecretName},
AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes},
Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim},
},
Status: v1alpha1.OIDCIdentityProviderStatus{
Phase: "Ready",
Conditions: []v1alpha1.Condition{
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration"},
},
},
}},
inputSecrets: []runtime.Object{&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testSecretName},
Type: "secrets.pinniped.dev/oidc-client",
Data: testValidSecretData,
}},
wantLogs: []string{
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`,
},
wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{
&oidctestutil.TestUpstreamOIDCIdentityProvider{
Name: testName,
ClientID: testClientID,
AuthorizationURL: *testIssuerAuthorizeURL,
Scopes: testExpectedScopes,
UsernameClaim: testUsernameClaim,
GroupsClaim: testGroupsClaim,
},
},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234},
Status: v1alpha1.OIDCIdentityProviderStatus{
Phase: "Ready",
Conditions: []v1alpha1.Condition{
{Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234},
{Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234},
},
},
}},
},
{
name: "issuer is invalid URL, missing trailing slash",
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: testIssuerURL + "/ends-with-slash",
TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64},
Client: v1alpha1.OIDCClient{SecretName: testSecretName},
AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes},
},
}},
inputSecrets: []runtime.Object{&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testSecretName},
Type: "secrets.pinniped.dev/oidc-client",
Data: testValidSecretData,
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="oidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "issuer"="` + testIssuerURL + `/ends-with-slash" "name"="test-name" "namespace"="test-namespace"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/ends-with-slash\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/ends-with-slash\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
},
wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Status: v1alpha1.OIDCIdentityProviderStatus{
Phase: "Error",
Conditions: []v1alpha1.Condition{
{
Type: "ClientCredentialsValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
Message: "loaded client credentials",
},
{
Type: "OIDCDiscoverySucceeded",
Status: "False",
LastTransitionTime: now,
Reason: "Unreachable",
Message: `failed to perform OIDC discovery against "` + testIssuerURL + `/ends-with-slash":
oidc: issuer did not match the issuer returned by provider, expected "` + testIssuerURL + `/ends-with-slash" got "` + testIssuerURL + `/ends-with-slash/"`,
},
},
},
}},
},
{
name: "issuer is invalid URL, extra trailing slash",
inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Spec: v1alpha1.OIDCIdentityProviderSpec{
Issuer: testIssuerURL + "/",
TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64},
Client: v1alpha1.OIDCClient{SecretName: testSecretName},
AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes},
},
}},
inputSecrets: []runtime.Object{&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testSecretName},
Type: "secrets.pinniped.dev/oidc-client",
Data: testValidSecretData,
}},
wantErr: controllerlib.ErrSyntheticRequeue.Error(),
wantLogs: []string{
`oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="oidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "issuer"="` + testIssuerURL + `/" "name"="test-name" "namespace"="test-namespace"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`,
`oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`,
`oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`,
},
wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{},
wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{
ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName},
Status: v1alpha1.OIDCIdentityProviderStatus{
Phase: "Error",
Conditions: []v1alpha1.Condition{
{
Type: "ClientCredentialsValid",
Status: "True",
LastTransitionTime: now,
Reason: "Success",
Message: "loaded client credentials",
},
{
Type: "OIDCDiscoverySucceeded",
Status: "False",
LastTransitionTime: now,
Reason: "Unreachable",
Message: `failed to perform OIDC discovery against "` + testIssuerURL + `/":
oidc: issuer did not match the issuer returned by provider, expected "` + testIssuerURL + `/" got "` + testIssuerURL + `"`,
},
},
},
}},
},
}
for _, tt := range tests {
tt := tt
@@ -728,5 +875,25 @@ func newTestIssuer(t *testing.T) (string, string) {
})
})
// handle the four issuer with trailing slash configs
// valid case in= out=
// handled above at the root of testURL
// valid case in=/ out=/
mux.HandleFunc("/ends-with-slash/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&providerJSON{
Issuer: testURL + "/ends-with-slash/",
AuthURL: "https://example.com/authorize",
})
})
// invalid case in= out=/
// can be tested using /ends-with-slash/ endpoint
// invalid case in=/ out=
// can be tested using root endpoint
return caBundlePEM, testURL
}
@@ -1,4 +1,4 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package supervisorstorage
@@ -59,7 +59,7 @@ func GarbageCollectorController(
return isSecretWithGCAnnotation(oldObj) || isSecretWithGCAnnotation(newObj)
},
DeleteFunc: func(obj metav1.Object) bool { return false }, // ignore all deletes
ParentFunc: nil,
ParentFunc: pinnipedcontroller.SingletonQueue(),
},
controllerlib.InformerOption{},
),
@@ -67,16 +67,20 @@ func GarbageCollectorController(
}
func (c *garbageCollectorController) Sync(ctx controllerlib.Context) error {
// make sure we have a consistent, static meaning for the current time during the sync loop
frozenClock := clock.NewFakeClock(c.clock.Now())
// The Sync method is triggered upon any change to any Secret, which would make this
// controller too chatty, so it rate limits itself to a more reasonable interval.
// Note that even during a period when no secrets are changing, it will still run
// at the informer's full-resync interval (as long as there are some secrets).
if c.clock.Now().Sub(c.timeOfMostRecentSweep) < minimumRepeatInterval {
if since := frozenClock.Since(c.timeOfMostRecentSweep); since < minimumRepeatInterval {
ctx.Queue.AddAfter(ctx.Key, minimumRepeatInterval-since)
return nil
}
plog.Info("starting storage garbage collection sweep")
c.timeOfMostRecentSweep = c.clock.Now()
c.timeOfMostRecentSweep = frozenClock.Now()
listOfSecrets, err := c.secretInformer.Lister().List(labels.Everything())
if err != nil {
@@ -97,7 +101,7 @@ func (c *garbageCollectorController) Sync(ctx controllerlib.Context) error {
continue
}
if garbageCollectAfterTime.Before(c.clock.Now()) {
if garbageCollectAfterTime.Before(frozenClock.Now()) {
err = c.kubeClient.CoreV1().Secrets(secret.Namespace).Delete(ctx.Context, secret.Name, metav1.DeleteOptions{})
if err != nil {
plog.WarningErr("failed to garbage collect resource", err, logKV(secret))
@@ -66,6 +66,10 @@ func TestGarbageCollectorControllerInformerFilters(t *testing.T) {
r.True(subject.Update(secretWithAnnotation, otherSecret))
r.True(subject.Update(otherSecret, secretWithAnnotation))
})
it("returns the same singleton key", func() {
r.Equal(controllerlib.Key{}, subject.Parent(secretWithAnnotation))
})
})
when("any Secret with the required annotation is deleted", func() {
@@ -136,9 +140,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
Context: cancelContext,
Name: subject.Name(),
Key: controllerlib.Key{
Namespace: "",
Name: "",
Namespace: "foo",
Name: "bar",
},
Queue: &testQueue{t: t},
}
// Must start informers before calling TestRunSynchronously()
@@ -262,16 +267,23 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
// Run sync once with the current time set to frozenTime.
r.NoError(controllerlib.TestSync(t, subject, *syncContext))
require.Empty(t, kubeClient.Actions())
r.False(syncContext.Queue.(*testQueue).called)
// Run sync again when not enough time has passed since the most recent run, so no delete
// operations should happen even though there is a expired secret now.
fakeClock.Step(29 * time.Second)
r.NoError(controllerlib.TestSync(t, subject, *syncContext))
require.Empty(t, kubeClient.Actions())
r.True(syncContext.Queue.(*testQueue).called)
r.Equal(controllerlib.Key{Namespace: "foo", Name: "bar"}, syncContext.Queue.(*testQueue).key) // assert key is passed through
r.Equal(time.Second, syncContext.Queue.(*testQueue).duration) // assert that we get the exact requeue time
syncContext.Queue = &testQueue{t: t} // reset the queue for the next sync
// Step to the exact threshold and run Sync again. Now we are past the rate limiting period.
fakeClock.Step(1*time.Second + 1*time.Millisecond)
fakeClock.Step(time.Second)
r.NoError(controllerlib.TestSync(t, subject, *syncContext))
r.False(syncContext.Queue.(*testQueue).called)
// It should have deleted the expired secret.
r.ElementsMatch(
@@ -381,3 +393,23 @@ func TestGarbageCollectorControllerSync(t *testing.T) {
})
}, spec.Parallel(), spec.Report(report.Terminal{}))
}
type testQueue struct {
t *testing.T
called bool
key controllerlib.Key
duration time.Duration
controllerlib.Queue // panic if any other methods called
}
func (q *testQueue) AddAfter(key controllerlib.Key, duration time.Duration) {
q.t.Helper()
require.False(q.t, q.called, "AddAfter should only be called once")
q.called = true
q.key = key
q.duration = duration
}
@@ -121,6 +121,7 @@ func PrepareControllers(c *Config) (func(ctx context.Context), error) {
agentConfig := kubecertagent.AgentConfig{
Namespace: c.ServerInstallationInfo.Namespace,
ServiceAccountName: c.NamesConfig.AgentServiceAccount,
ContainerImage: *c.KubeCertAgentConfig.Image,
NamePrefix: *c.KubeCertAgentConfig.NamePrefix,
ContainerImagePullSecrets: c.KubeCertAgentConfig.ImagePullSecrets,
+4 -9
View File
@@ -164,13 +164,8 @@ type TimeoutsConfiguration struct {
OIDCSessionStorageLifetime time.Duration
// AccessTokenSessionStorageLifetime is the length of time after which an access token's session data is allowed
// to be garbage collected from storage. These must exist in storage for as long as the refresh token is valid.
// Therefore, this can be just slightly longer than the AccessTokenLifespan. Access tokens are handed back to
// the token endpoint for the token exchange use case. During a token exchange, if the access token is expired
// and still exists in storage, then the endpoint will be able to give a slightly more specific error message,
// rather than a more generic error that is returned when the token does not exist. If this is desirable, then
// the AccessTokenSessionStorageLifetime can be made to be significantly larger than AccessTokenLifespan, at the
// cost of slower cleanup.
// to be garbage collected from storage. These must exist in storage for as long as the refresh token is valid
// or else the refresh flow will not work properly. So this must be longer than RefreshTokenLifespan.
AccessTokenSessionStorageLifetime time.Duration
// RefreshTokenSessionStorageLifetime is the length of time after which a refresh token's session data is allowed
@@ -186,7 +181,7 @@ type TimeoutsConfiguration struct {
// Get the defaults for the Supervisor server.
func DefaultOIDCTimeoutsConfiguration() TimeoutsConfiguration {
accessTokenLifespan := 15 * time.Minute
accessTokenLifespan := 2 * time.Minute
authorizationCodeLifespan := 10 * time.Minute
refreshTokenLifespan := 9 * time.Hour
@@ -199,7 +194,7 @@ func DefaultOIDCTimeoutsConfiguration() TimeoutsConfiguration {
AuthorizationCodeSessionStorageLifetime: authorizationCodeLifespan + refreshTokenLifespan,
PKCESessionStorageLifetime: authorizationCodeLifespan + (1 * time.Minute),
OIDCSessionStorageLifetime: authorizationCodeLifespan + (1 * time.Minute),
AccessTokenSessionStorageLifetime: accessTokenLifespan + (1 * time.Minute),
AccessTokenSessionStorageLifetime: refreshTokenLifespan + accessTokenLifespan,
RefreshTokenSessionStorageLifetime: refreshTokenLifespan + accessTokenLifespan,
}
}
+2 -2
View File
@@ -61,8 +61,8 @@ const (
hmacSecret = "this needs to be at least 32 characters to meet entropy requirements"
authCodeExpirationSeconds = 10 * 60 // Current, we set our auth code expiration to 10 minutes
accessTokenExpirationSeconds = 15 * 60 // Currently, we set our access token expiration to 15 minutes
idTokenExpirationSeconds = 15 * 60 // Currently, we set our ID token expiration to 15 minutes
accessTokenExpirationSeconds = 2 * 60 // Currently, we set our access token expiration to 2 minutes
idTokenExpirationSeconds = 2 * 60 // Currently, we set our ID token expiration to 2 minutes
timeComparisonFudgeSeconds = 15
)
+116 -15
View File
@@ -1,4 +1,4 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package plog implements a thin layer over klog to help enforce pinniped's logging convention.
@@ -26,56 +26,157 @@
// act of desperation to determine why the system is broken.
package plog
import "k8s.io/klog/v2"
import (
"k8s.io/klog/v2"
)
const errorKey = "error"
// Use Error to log an unexpected system error.
func Error(msg string, err error, keysAndValues ...interface{}) {
klog.ErrorS(err, msg, keysAndValues...)
type _ interface {
Error(msg string, err error, keysAndValues ...interface{})
Warning(msg string, keysAndValues ...interface{})
WarningErr(msg string, err error, keysAndValues ...interface{})
Info(msg string, keysAndValues ...interface{})
InfoErr(msg string, err error, keysAndValues ...interface{})
Debug(msg string, keysAndValues ...interface{})
DebugErr(msg string, err error, keysAndValues ...interface{})
Trace(msg string, keysAndValues ...interface{})
TraceErr(msg string, err error, keysAndValues ...interface{})
All(msg string, keysAndValues ...interface{})
}
func Warning(msg string, keysAndValues ...interface{}) {
type PLogger struct {
prefix string
depth int
}
func New(prefix string) PLogger {
return PLogger{
depth: 0,
prefix: prefix,
}
}
func (p *PLogger) Error(msg string, err error, keysAndValues ...interface{}) {
klog.ErrorSDepth(p.depth+1, err, p.prefix+msg, keysAndValues...)
}
func (p *PLogger) warningDepth(msg string, depth int, keysAndValues ...interface{}) {
// klog's structured logging has no concept of a warning (i.e. no WarningS function)
// Thus we use info at log level zero as a proxy
// klog's info logs have an I prefix and its warning logs have a W prefix
// Since we lose the W prefix by using InfoS, just add a key to make these easier to find
keysAndValues = append([]interface{}{"warning", "true"}, keysAndValues...)
klog.V(klogLevelWarning).InfoS(msg, keysAndValues...)
if klog.V(klogLevelWarning).Enabled() {
klog.InfoSDepth(depth+1, p.prefix+msg, keysAndValues...)
}
}
func (p *PLogger) Warning(msg string, keysAndValues ...interface{}) {
p.warningDepth(msg, p.depth+1, keysAndValues...)
}
// Use WarningErr to issue a Warning message with an error object as part of the message.
func (p *PLogger) WarningErr(msg string, err error, keysAndValues ...interface{}) {
p.warningDepth(msg, p.depth+1, append([]interface{}{errorKey, err}, keysAndValues...)...)
}
func (p *PLogger) infoDepth(msg string, depth int, keysAndValues ...interface{}) {
if klog.V(klogLevelInfo).Enabled() {
klog.InfoSDepth(depth+1, p.prefix+msg, keysAndValues...)
}
}
func (p *PLogger) Info(msg string, keysAndValues ...interface{}) {
p.infoDepth(msg, p.depth+1, keysAndValues...)
}
// Use InfoErr to log an expected error, e.g. validation failure of an http parameter.
func (p *PLogger) InfoErr(msg string, err error, keysAndValues ...interface{}) {
p.infoDepth(msg, p.depth+1, append([]interface{}{errorKey, err}, keysAndValues...)...)
}
func (p *PLogger) debugDepth(msg string, depth int, keysAndValues ...interface{}) {
if klog.V(klogLevelDebug).Enabled() {
klog.InfoSDepth(depth+1, p.prefix+msg, keysAndValues...)
}
}
func (p *PLogger) Debug(msg string, keysAndValues ...interface{}) {
p.debugDepth(msg, p.depth+1, keysAndValues...)
}
// Use DebugErr to issue a Debug message with an error object as part of the message.
func (p *PLogger) DebugErr(msg string, err error, keysAndValues ...interface{}) {
p.debugDepth(msg, p.depth+1, append([]interface{}{errorKey, err}, keysAndValues...)...)
}
func (p *PLogger) traceDepth(msg string, depth int, keysAndValues ...interface{}) {
if klog.V(klogLevelTrace).Enabled() {
klog.InfoSDepth(depth+1, p.prefix+msg, keysAndValues...)
}
}
func (p *PLogger) Trace(msg string, keysAndValues ...interface{}) {
p.traceDepth(msg, p.depth+1, keysAndValues...)
}
// Use TraceErr to issue a Trace message with an error object as part of the message.
func (p *PLogger) TraceErr(msg string, err error, keysAndValues ...interface{}) {
p.traceDepth(msg, p.depth+1, append([]interface{}{errorKey, err}, keysAndValues...)...)
}
func (p *PLogger) All(msg string, keysAndValues ...interface{}) {
if klog.V(klogLevelAll).Enabled() {
klog.InfoSDepth(p.depth+1, p.prefix+msg, keysAndValues...)
}
}
var pLogger = PLogger{ //nolint:gochecknoglobals
depth: 1,
}
// Use Error to log an unexpected system error.
func Error(msg string, err error, keysAndValues ...interface{}) {
pLogger.Error(msg, err, keysAndValues...)
}
func Warning(msg string, keysAndValues ...interface{}) {
pLogger.Warning(msg, keysAndValues...)
}
// Use WarningErr to issue a Warning message with an error object as part of the message.
func WarningErr(msg string, err error, keysAndValues ...interface{}) {
Warning(msg, append([]interface{}{errorKey, err}, keysAndValues...)...)
pLogger.WarningErr(msg, err, keysAndValues...)
}
func Info(msg string, keysAndValues ...interface{}) {
klog.V(klogLevelInfo).InfoS(msg, keysAndValues...)
pLogger.Info(msg, keysAndValues...)
}
// Use InfoErr to log an expected error, e.g. validation failure of an http parameter.
func InfoErr(msg string, err error, keysAndValues ...interface{}) {
Info(msg, append([]interface{}{errorKey, err}, keysAndValues...)...)
pLogger.InfoErr(msg, err, keysAndValues...)
}
func Debug(msg string, keysAndValues ...interface{}) {
klog.V(klogLevelDebug).InfoS(msg, keysAndValues...)
pLogger.Debug(msg, keysAndValues...)
}
// Use DebugErr to issue a Debug message with an error object as part of the message.
func DebugErr(msg string, err error, keysAndValues ...interface{}) {
Debug(msg, append([]interface{}{errorKey, err}, keysAndValues...)...)
pLogger.DebugErr(msg, err, keysAndValues...)
}
func Trace(msg string, keysAndValues ...interface{}) {
klog.V(klogLevelTrace).InfoS(msg, keysAndValues...)
pLogger.Trace(msg, keysAndValues...)
}
// Use TraceErr to issue a Trace message with an error object as part of the message.
func TraceErr(msg string, err error, keysAndValues ...interface{}) {
Trace(msg, append([]interface{}{errorKey, err}, keysAndValues...)...)
pLogger.TraceErr(msg, err, keysAndValues...)
}
func All(msg string, keysAndValues ...interface{}) {
klog.V(klogLevelAll).InfoS(msg, keysAndValues...)
pLogger.All(msg, keysAndValues...)
}
@@ -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 }
@@ -0,0 +1,242 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package valuelesscontext
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"k8s.io/apiserver/pkg/authentication/authenticator"
)
func TestNew(t *testing.T) {
t.Parallel()
type contextKey int
tests := []struct {
name string
f func(*testing.T, context.Context) context.Context
wantReg, wantNew, wantBoth func(*testing.T, context.Context)
}{
{
name: "empty context",
f: func(t *testing.T, ctx context.Context) context.Context {
return ctx
},
wantReg: func(t *testing.T, ctx context.Context) {},
wantNew: func(t *testing.T, ctx context.Context) {},
wantBoth: func(t *testing.T, ctx context.Context) {
auds, ok := authenticator.AudiencesFrom(ctx)
require.False(t, ok)
require.Nil(t, auds)
val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string)
require.False(t, ok)
require.Zero(t, val)
deadline, ok := ctx.Deadline()
require.False(t, ok)
require.Zero(t, deadline)
require.Nil(t, ctx.Done())
require.NoError(t, ctx.Err())
},
},
{
name: "context with audience",
f: func(t *testing.T, ctx context.Context) context.Context {
return authenticator.WithAudiences(ctx, authenticator.Audiences{"1", "2"})
},
wantReg: func(t *testing.T, ctx context.Context) {
auds, ok := authenticator.AudiencesFrom(ctx)
require.True(t, ok)
require.Equal(t, authenticator.Audiences{"1", "2"}, auds)
},
wantNew: func(t *testing.T, ctx context.Context) {
auds, ok := authenticator.AudiencesFrom(ctx)
require.False(t, ok)
require.Nil(t, auds)
},
wantBoth: func(t *testing.T, ctx context.Context) {
val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string)
require.False(t, ok)
require.Zero(t, val)
deadline, ok := ctx.Deadline()
require.False(t, ok)
require.Zero(t, deadline)
require.Nil(t, ctx.Done())
require.NoError(t, ctx.Err())
},
},
{
name: "context with audience and past deadline",
f: func(t *testing.T, ctx context.Context) context.Context {
ctx = authenticator.WithAudiences(ctx, authenticator.Audiences{"3", "4"})
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, time.Now().Add(-time.Hour))
t.Cleanup(cancel)
return ctx
},
wantReg: func(t *testing.T, ctx context.Context) {
auds, ok := authenticator.AudiencesFrom(ctx)
require.True(t, ok)
require.Equal(t, authenticator.Audiences{"3", "4"}, auds)
},
wantNew: func(t *testing.T, ctx context.Context) {
auds, ok := authenticator.AudiencesFrom(ctx)
require.False(t, ok)
require.Nil(t, auds)
},
wantBoth: func(t *testing.T, ctx context.Context) {
val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string)
require.False(t, ok)
require.Zero(t, val)
deadline, ok := ctx.Deadline()
require.True(t, ok)
require.NotZero(t, deadline)
require.True(t, deadline.Before(time.Now()))
ch := ctx.Done()
require.NotNil(t, ch)
select {
case <-ch:
case <-time.After(10 * time.Second):
t.Error("expected closed done channel")
}
require.Equal(t, context.DeadlineExceeded, ctx.Err())
},
},
{
name: "context with audience and custom value and past deadline",
f: func(t *testing.T, ctx context.Context) context.Context {
ctx = authenticator.WithAudiences(ctx, authenticator.Audiences{"3", "4"})
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, time.Now().Add(-time.Hour))
t.Cleanup(cancel)
ctx = context.WithValue(ctx, contextKey(0xDEADBEEF), "mooo")
return ctx
},
wantReg: func(t *testing.T, ctx context.Context) {
auds, ok := authenticator.AudiencesFrom(ctx)
require.True(t, ok)
require.Equal(t, authenticator.Audiences{"3", "4"}, auds)
val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string)
require.True(t, ok)
require.Equal(t, "mooo", val)
},
wantNew: func(t *testing.T, ctx context.Context) {
auds, ok := authenticator.AudiencesFrom(ctx)
require.False(t, ok)
require.Nil(t, auds)
val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string)
require.False(t, ok)
require.Zero(t, val)
},
wantBoth: func(t *testing.T, ctx context.Context) {
deadline, ok := ctx.Deadline()
require.True(t, ok)
require.NotZero(t, deadline)
require.True(t, deadline.Before(time.Now()))
ch := ctx.Done()
require.NotNil(t, ch)
select {
case <-ch:
case <-time.After(10 * time.Second):
t.Error("expected closed done channel")
}
require.Equal(t, context.DeadlineExceeded, ctx.Err())
},
},
{
name: "context with audience and custom value and future deadline",
f: func(t *testing.T, ctx context.Context) context.Context {
ctx = authenticator.WithAudiences(ctx, authenticator.Audiences{"3", "4"})
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, time.Now().Add(time.Hour))
t.Cleanup(cancel)
ctx = context.WithValue(ctx, contextKey(0xDEADBEEF), "mooo")
return ctx
},
wantReg: func(t *testing.T, ctx context.Context) {
auds, ok := authenticator.AudiencesFrom(ctx)
require.True(t, ok)
require.Equal(t, authenticator.Audiences{"3", "4"}, auds)
val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string)
require.True(t, ok)
require.Equal(t, "mooo", val)
},
wantNew: func(t *testing.T, ctx context.Context) {
auds, ok := authenticator.AudiencesFrom(ctx)
require.False(t, ok)
require.Nil(t, auds)
val, ok := ctx.Value(contextKey(0xDEADBEEF)).(string)
require.False(t, ok)
require.Zero(t, val)
},
wantBoth: func(t *testing.T, ctx context.Context) {
deadline, ok := ctx.Deadline()
require.True(t, ok)
require.NotZero(t, deadline)
require.True(t, deadline.After(time.Now()))
ch := ctx.Done()
require.NotNil(t, ch)
select {
case <-ch:
t.Error("expected not closed done channel")
case <-time.After(3 * time.Second):
}
require.NoError(t, ctx.Err())
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := tt.f(t, context.Background())
t.Run("reg", func(t *testing.T) {
t.Parallel()
tt.wantReg(t, ctx)
})
t.Run("reg-both", func(t *testing.T) {
t.Parallel()
tt.wantBoth(t, ctx)
})
t.Run("new", func(t *testing.T) {
t.Parallel()
tt.wantNew(t, New(ctx))
})
t.Run("new-both", func(t *testing.T) {
t.Parallel()
tt.wantBoth(t, New(ctx))
})
})
}
}