Merge pull request #1733 from vmware-tanzu/jtc/issue-1700/impersonation-proxy-token-request-api

The Impersonation Proxy should use a short-lived token from the `TokenRequest` API
This commit is contained in:
Ryan Richard
2023-12-01 11:48:52 -08:00
committed by GitHub
25 changed files with 1717 additions and 144 deletions

View File

@@ -45,8 +45,9 @@ metadata:
annotations:
#! we need to create this service account before we create the secret
kapp.k14s.io/change-group: "impersonation-proxy.concierge.pinniped.dev/serviceaccount"
secrets: #! make sure the token controller does not create any other secrets
- name: #@ defaultResourceNameWithSuffix("impersonation-proxy")
kubernetes.io/enforce-mountable-secrets: "true"
secrets: [] #! make sure the token controller does not create any secrets
automountServiceAccountToken: false
---
apiVersion: v1
kind: ConfigMap
@@ -77,6 +78,8 @@ data:
impersonationCACertificateSecret: (@= defaultResourceNameWithSuffix("impersonation-proxy-ca-certificate") @)
impersonationSignerSecret: (@= defaultResourceNameWithSuffix("impersonation-proxy-signer-ca-certificate") @)
agentServiceAccount: (@= defaultResourceNameWithSuffix("kube-cert-agent") @)
impersonationProxyServiceAccount: (@= defaultResourceNameWithSuffix("impersonation-proxy") @)
impersonationProxyLegacySecret: (@= defaultResourceNameWithSuffix("impersonation-proxy") @)
labels: (@= json.encode(labels()).rstrip() @)
kubeCertAgent:
namePrefix: (@= defaultResourceNameWithSuffix("kube-cert-agent-") @)
@@ -182,9 +185,6 @@ spec:
- name: podinfo
mountPath: /etc/podinfo
readOnly: true
- name: impersonation-proxy
mountPath: /var/run/secrets/impersonation-proxy.concierge.pinniped.dev/serviceaccount
readOnly: true
env:
#@ if data.values.https_proxy:
- name: HTTPS_PROXY
@@ -220,12 +220,6 @@ spec:
- name: config-volume
configMap:
name: #@ defaultResourceNameWithSuffix("config")
- name: impersonation-proxy
secret:
secretName: #@ defaultResourceNameWithSuffix("impersonation-proxy")
items: #! make sure our pod does not start until the token controller has a chance to populate the secret
- key: token
path: token
- name: podinfo
downwardAPI:
items:
@@ -353,16 +347,3 @@ spec:
#@ else:
annotations: #@ data.values.impersonation_proxy_spec.service.annotations
#@ end
---
apiVersion: v1
kind: Secret
metadata:
name: #@ defaultResourceNameWithSuffix("impersonation-proxy")
namespace: #@ namespace()
labels: #@ labels()
annotations:
#! wait until the SA exists to create this secret so that the token controller does not delete it
#! we have this secret at the end so that kubectl will create the service account first
kapp.k14s.io/change-rule: "upsert after upserting impersonation-proxy.concierge.pinniped.dev/serviceaccount"
kubernetes.io/service-account.name: #@ defaultResourceNameWithSuffix("impersonation-proxy")
type: kubernetes.io/service-account-token

View File

@@ -1,4 +1,4 @@
#! Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
#! Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
#! SPDX-License-Identifier: Apache-2.0
#@ load("@ytt:data", "data")
@@ -156,6 +156,13 @@ rules:
- apiGroups: [ coordination.k8s.io ]
resources: [ leases ]
verbs: [ create, get, update ]
#! We need to be able to get service accounts and create serviceaccounts/tokens so that we can create short-lived tokens for the impersonation proxy
- apiGroups: [""]
resources: [ serviceaccounts ]
verbs: [ get ]
- apiGroups: [""]
resources: [ serviceaccounts/token ]
verbs: [ create ]
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1

View File

@@ -0,0 +1,43 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package backoff
import (
"math"
"time"
)
type InfiniteBackoff struct {
// The initial duration.
Duration time.Duration
// Factor is used to scale up the Duration until it reaches MaxDuration.
// Should be at least 1.0.
Factor float64
// A limit on step size. Once reached, this value will be used as the interval.
MaxDuration time.Duration
hasStepped bool
}
// Step returns the next duration in the backoff sequence.
// It modifies the receiver and is not thread-safe.
func (b *InfiniteBackoff) Step() time.Duration {
if !b.hasStepped {
b.hasStepped = true
return b.Duration
}
var next time.Duration
b.Factor = math.Max(1, b.Factor)
// Grow by the factor (which could be 1).
next = time.Duration(float64(b.Duration) * b.Factor)
// Stop growing the intervals once we exceed the max duration.
if b.MaxDuration > 0 && next > b.MaxDuration {
next = b.MaxDuration
}
b.Duration = next
return next
}

View File

@@ -0,0 +1,94 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package backoff
import (
"math"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestInfiniteBackoff(t *testing.T) {
tests := []struct {
name string
stepper Stepper
expectedSequence []time.Duration
}{
{
name: "zero initialization results in 0ns steps",
stepper: &InfiniteBackoff{},
expectedSequence: func() []time.Duration {
results := make([]time.Duration, 1000)
for i := 0; i < 1000; i++ {
results[i] = time.Duration(0)
}
return results
}(),
},
{
name: "double 5 ns forever",
stepper: &InfiniteBackoff{
Duration: 5 * time.Nanosecond,
Factor: 2,
},
expectedSequence: func() []time.Duration {
// limit to 60 to prevent int64 overflow
results := make([]time.Duration, 60)
results[0] = 5 * time.Nanosecond
for i := 1; i < 60; i++ {
results[i] = 2 * results[i-1]
}
return results
}(),
},
{
name: "grows slowly until limit",
stepper: &InfiniteBackoff{
Duration: 20 * time.Nanosecond,
Factor: 1.1,
MaxDuration: 40 * time.Nanosecond,
},
expectedSequence: func() []time.Duration {
results := make([]time.Duration, 1000)
results[0] = 20 * time.Nanosecond
for i := 1; i < 1000; i++ {
nanoseconds := 1.1 * float64(results[i-1])
results[i] = time.Duration(math.Min(nanoseconds, 40))
}
return results
}(),
},
{
name: "factor less than 1.0 is replaced with 1.0",
stepper: &InfiniteBackoff{
Duration: 20 * time.Nanosecond,
Factor: 0.9,
},
expectedSequence: func() []time.Duration {
results := make([]time.Duration, 1000)
for i := 0; i < 1000; i++ {
results[i] = 20 * time.Nanosecond
}
return results
}(),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
require.NotEmpty(t, tt.expectedSequence)
for i, expected := range tt.expectedSequence {
actual := tt.stepper.Step()
require.Equalf(t, expected, actual, "incorrect result on step #%d, previous steps are %v", i, tt.expectedSequence[:i])
}
backoff, ok := tt.stepper.(*InfiniteBackoff)
require.True(t, ok)
require.NotNil(t, backoff)
require.GreaterOrEqual(t, backoff.Factor, 1.0)
})
}
}

View File

@@ -0,0 +1,56 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package backoff
import (
"context"
"time"
"k8s.io/apimachinery/pkg/util/wait"
)
type Stepper interface {
Step() time.Duration
}
func wrapConditionWithNoPanics(ctx context.Context, condition wait.ConditionWithContextFunc) (done bool, err error) {
defer func() {
if r := recover(); r != nil {
if err2, ok := r.(error); ok {
err = err2
return
}
}
}()
return condition(ctx)
}
func WithContext(ctx context.Context, backoff Stepper, condition wait.ConditionWithContextFunc) error {
// Loop forever, unless we reach one of the return statements below.
for {
// Stop if the context is done.
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Stop trying unless the condition function returns false.
// Allow cancellation during the attempt if the condition function respects the ctx.
if ok, err := wrapConditionWithNoPanics(ctx, condition); err != nil || ok {
return err
}
// Calculate how long to wait before the next step.
waitBeforeRetry := backoff.Step()
// Wait before running again, allowing cancellation during the wait.
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(waitBeforeRetry):
}
}
}

View File

@@ -0,0 +1,124 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package backoff
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/wait"
)
type MockStepper struct {
steps []time.Duration
currentStep int
}
func (m *MockStepper) Step() time.Duration {
result := m.steps[m.currentStep]
m.currentStep++
return result
}
func TestWithContext(t *testing.T) {
tests := []struct {
name string
steps []time.Duration
finalCondition wait.ConditionWithContextFunc
expectedErr error
}{
{
name: "cancelling results in cancellation error",
steps: []time.Duration{
time.Duration(0),
time.Duration(0),
time.Duration(0),
},
finalCondition: func(ctx context.Context) (done bool, err error) {
return false, nil
},
expectedErr: context.Canceled,
},
{
name: "when condition is done, exit early",
steps: []time.Duration{
time.Duration(0),
time.Duration(0),
time.Duration(0),
},
finalCondition: func(ctx context.Context) (done bool, err error) {
return true, nil
},
expectedErr: nil,
},
{
name: "when condition returns error, exit early",
steps: []time.Duration{
time.Duration(0),
time.Duration(0),
time.Duration(0),
time.Duration(0),
time.Duration(0),
},
finalCondition: func(ctx context.Context) (done bool, err error) {
return false, errors.New("error from condition")
},
expectedErr: errors.New("error from condition"),
},
{
name: "when condition panics, cover and exit",
steps: []time.Duration{
time.Duration(0),
},
finalCondition: func(ctx context.Context) (done bool, err error) {
panic(errors.New("panic error"))
},
expectedErr: errors.New("panic error"),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
testContext, cancel := context.WithCancel(context.Background())
backoff := &MockStepper{
steps: tt.steps,
}
actualConditionCalls := 0
err := WithContext(testContext, backoff, func(ctx context.Context) (done bool, err error) {
actualConditionCalls++
if backoff.currentStep >= (len(backoff.steps) - 1) {
cancel()
return tt.finalCondition(ctx)
}
return false, nil
})
require.Equal(t, tt.expectedErr, err)
require.Equal(t, len(backoff.steps), actualConditionCalls)
})
}
t.Run("does not invoke any functions when run with a cancelled context", func(t *testing.T) {
testContext, cancel := context.WithCancel(context.Background())
cancel()
stepper := &MockStepper{}
conditionCalls := 0
condition := func(context.Context) (done bool, err error) {
conditionCalls++
return false, nil
}
err := WithContext(testContext, stepper, condition)
require.Equal(t, context.Canceled, err)
require.Equal(t, 0, conditionCalls)
require.Equal(t, 0, stepper.currentStep)
})
}

View File

@@ -21,6 +21,7 @@ import (
"go.pinniped.dev/internal/pversion"
"go.pinniped.dev/internal/registry/credentialrequest"
"go.pinniped.dev/internal/registry/whoamirequest"
"go.pinniped.dev/internal/tokenclient"
)
type Config struct {
@@ -36,6 +37,7 @@ type ExtraConfig struct {
NegotiatedSerializer runtime.NegotiatedSerializer
LoginConciergeGroupVersion schema.GroupVersion
IdentityConciergeGroupVersion schema.GroupVersion
TokenClient *tokenclient.TokenClient
}
type PinnipedServer struct {
@@ -134,6 +136,24 @@ func (c completedConfig) New() (*PinnipedServer, error) {
},
)
s.GenericAPIServer.AddPostStartHookOrDie("fetch-impersonation-proxy-tokens",
func(postStartContext genericapiserver.PostStartHookContext) error {
plog.Debug("fetch-impersonation-proxy-tokens start hook starting")
defer plog.Debug("fetch-impersonation-proxy-tokens start hook completed")
controllersShutdownWaitGroup.Add(1)
go func() {
defer controllersShutdownWaitGroup.Done()
// Start the token client
c.ExtraConfig.TokenClient.Start(controllersCtx)
plog.Debug("fetch-impersonation-proxy-tokens start hook's background goroutine has finished")
}()
return nil
},
)
s.GenericAPIServer.AddPreShutdownHookOrDie("stop-controllers",
func() error {
plog.Debug("stop-controllers pre shutdown hook starting")

View File

@@ -12,7 +12,6 @@ import (
"net/http"
"net/http/httputil"
"net/url"
"os"
"reflect"
"regexp"
"strings"
@@ -56,6 +55,7 @@ import (
"go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/kubeclient"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/tokenclient"
"go.pinniped.dev/internal/valuelesscontext"
)
@@ -68,26 +68,38 @@ type FactoryFunc func(
port int,
dynamicCertProvider dynamiccert.Private,
impersonationProxySignerCA dynamiccert.Public,
impersonationProxyTokenCache tokenclient.ExpiringSingletonTokenCacheGet,
) (func(stopCh <-chan struct{}) error, error)
func New(
port int,
dynamicCertProvider dynamiccert.Private,
impersonationProxySignerCA dynamiccert.Public,
impersonationProxyTokenCache tokenclient.ExpiringSingletonTokenCacheGet,
) (func(stopCh <-chan struct{}) error, error) {
return newInternal(port, dynamicCertProvider, impersonationProxySignerCA, kubeclient.Secure, nil, nil, nil)
return newInternal(port, dynamicCertProvider, impersonationProxySignerCA, kubeclient.Secure, impersonationProxyTokenCache, nil, nil, nil)
}
func newInternal( //nolint:funlen // yeah, it's kind of long.
//nolint:funlen // It is definitely too complicated. New calls newInternal, which makes another function.
func newInternal(
port int,
dynamicCertProvider dynamiccert.Private,
impersonationProxySignerCA dynamiccert.Public,
restConfigFunc ptls.RestConfigFunc, // for unit testing, should always be kubeclient.Secure in production
clientOpts []kubeclient.Option, // for unit testing, should always be nil in production
cache tokenclient.ExpiringSingletonTokenCacheGet,
baseConfig *rest.Config, // for unit testing, should always be nil in production
recOpts func(*genericoptions.RecommendedOptions), // for unit testing, should always be nil in production
recConfig func(*genericapiserver.RecommendedConfig), // for unit testing, should always be nil in production
) (func(stopCh <-chan struct{}) error, error) {
var listener net.Listener
var err error
if baseConfig == nil {
baseConfig, err = rest.InClusterConfig()
if err != nil {
return nil, err
}
}
constructServer := func() (func(stopCh <-chan struct{}) error, error) {
// Bare minimum server side scheme to allow for status messages to be encoded.
@@ -117,7 +129,7 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
// along with the Kube API server's CA.
// Note: any changes to the Authentication stack need to be kept in sync with any assumptions made
// by getTransportForUser, especially if we ever update the TCR API to start returning bearer tokens.
kubeClientUnsafeForProxying, err := kubeclient.New(clientOpts...)
kubeClientUnsafeForProxying, err := kubeclient.New(kubeclient.WithConfig(baseConfig))
if err != nil {
return nil, err
}
@@ -168,7 +180,8 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
)
// use the custom impersonation proxy service account credentials when reverse proxying to the API server
kubeClientForProxy, err := getReverseProxyClient(clientOpts)
kubeClientForProxy, err := getReverseProxyClient(baseConfig, cache)
if err != nil {
return nil, fmt.Errorf("failed to build reverse proxy client: %w", err)
}
@@ -321,11 +334,6 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
return nil, fmt.Errorf("invalid mutation of impersonation authorizer detected: %#v", preparedRun.Authorizer)
}
// Sanity check. Assert that we have a functioning token file to use and no bearer token.
if len(preparedRun.LoopbackClientConfig.BearerToken) != 0 || len(preparedRun.LoopbackClientConfig.BearerTokenFile) == 0 {
return nil, constable.Error("invalid impersonator loopback rest config has wrong bearer token semantics")
}
return preparedRun.Run, nil
}
@@ -341,28 +349,16 @@ func newInternal( //nolint:funlen // yeah, it's kind of long.
return result, nil
}
func getReverseProxyClient(clientOpts []kubeclient.Option) (*kubeclient.Client, error) {
// just use the overrides given during unit tests
if len(clientOpts) != 0 {
return kubeclient.New(clientOpts...)
func getReverseProxyClient(baseConfig *rest.Config, cache tokenclient.ExpiringSingletonTokenCacheGet) (*kubeclient.Client, error) {
impersonationProxyRestConfig := kubeclient.SecureAnonymousClientConfig(baseConfig)
authRoundTripper := func(base http.RoundTripper) http.RoundTripper {
return &authorizationRoundTripper{
cache: cache,
base: base,
}
}
// this is the magic path where the impersonation proxy SA token is mounted
const tokenFile = "/var/run/secrets/impersonation-proxy.concierge.pinniped.dev/serviceaccount/token" //nolint:gosec // this is not a credential
// make sure the token file we need exists before trying to use it
if _, err := os.Stat(tokenFile); err != nil {
return nil, err
}
// build an in cluster config that uses the impersonation proxy token file
impersonationProxyRestConfig, err := rest.InClusterConfig()
if err != nil {
return nil, err
}
impersonationProxyRestConfig = kubeclient.SecureAnonymousClientConfig(impersonationProxyRestConfig)
impersonationProxyRestConfig.BearerTokenFile = tokenFile
impersonationProxyRestConfig.Wrap(authRoundTripper)
return kubeclient.New(kubeclient.WithConfig(impersonationProxyRestConfig))
}

View File

@@ -55,6 +55,7 @@ import (
"go.pinniped.dev/internal/httputil/roundtripper"
"go.pinniped.dev/internal/kubeclient"
"go.pinniped.dev/internal/testutil/tlsserver"
"go.pinniped.dev/internal/tokenclient"
)
func TestImpersonator(t *testing.T) {
@@ -84,24 +85,23 @@ func TestImpersonator(t *testing.T) {
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.APIPriorityAndFairness, false)()
tests := []struct {
name string
clientCert *clientCert
clientImpersonateUser rest.ImpersonationConfig
clientMutateHeaders func(http.Header)
clientNextProtos []string
kubeAPIServerClientBearerTokenFile string
kubeAPIServerStatusCode int
kubeAPIServerHealthz http.Handler
anonymousAuthDisabled bool
wantKubeAPIServerRequestHeaders http.Header
wantError string
wantConstructionError string
wantAuthorizerAttributes []authorizer.AttributesRecord
name string
clientCert *clientCert
clientImpersonateUser rest.ImpersonationConfig
clientMutateHeaders func(http.Header)
clientNextProtos []string
kubeAPIServerStatusCode int
kubeAPIServerHealthz http.Handler
anonymousAuthDisabled bool
noServiceAcctTokenInCache bool // when true, no available service account token for the impersonator to use
wantKubeAPIServerRequestHeaders http.Header
wantError string
wantConstructionError string
wantAuthorizerAttributes []authorizer.AttributesRecord
}{
{
name: "happy path",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
name: "happy path",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
wantKubeAPIServerRequestHeaders: http.Header{
"Impersonate-User": {"test-username"},
"Impersonate-Group": {"test-group1", "test-group2", "system:authenticated"},
@@ -119,9 +119,8 @@ func TestImpersonator(t *testing.T) {
},
},
{
name: "happy path with forbidden healthz",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
name: "happy path with forbidden healthz",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
kubeAPIServerHealthz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("no healthz for you"))
@@ -143,9 +142,8 @@ func TestImpersonator(t *testing.T) {
},
},
{
name: "happy path with unauthorized healthz",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
name: "happy path with unauthorized healthz",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
kubeAPIServerHealthz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("no healthz for you"))
@@ -168,9 +166,8 @@ func TestImpersonator(t *testing.T) {
},
},
{
name: "happy path with upgrade",
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}),
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
name: "happy path with upgrade",
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}),
clientMutateHeaders: func(header http.Header) {
header.Add("Connection", "Upgrade")
header.Add("Upgrade", "spdy/3.1")
@@ -199,9 +196,8 @@ func TestImpersonator(t *testing.T) {
},
},
{
name: "happy path ignores forwarded header",
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}),
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
name: "happy path ignores forwarded header",
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}),
clientMutateHeaders: func(header http.Header) {
header.Add("X-Forwarded-For", "example.com")
},
@@ -222,9 +218,8 @@ func TestImpersonator(t *testing.T) {
},
},
{
name: "happy path ignores forwarded header canonicalization",
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}),
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
name: "happy path ignores forwarded header canonicalization",
clientCert: newClientCert(t, ca, "test-username2", []string{"test-group3", "test-group4"}),
clientMutateHeaders: func(header http.Header) {
header["x-FORWARDED-for"] = append(header["x-FORWARDED-for"], "example.com")
},
@@ -245,11 +240,10 @@ func TestImpersonator(t *testing.T) {
},
},
{
name: "user is authenticated but the kube API request returns an error",
kubeAPIServerStatusCode: http.StatusNotFound,
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantError: `the server could not find the requested resource (get namespaces)`,
name: "user is authenticated but the kube API request returns an error",
kubeAPIServerStatusCode: http.StatusNotFound,
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
wantError: `the server could not find the requested resource (get namespaces)`,
wantKubeAPIServerRequestHeaders: http.Header{
"Impersonate-User": {"test-username"},
"Impersonate-Group": {"test-group1", "test-group2", "system:authenticated"},
@@ -267,9 +261,8 @@ func TestImpersonator(t *testing.T) {
},
},
{
name: "when there is no client cert on request, it is an anonymous request",
clientCert: &clientCert{},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
name: "when there is no client cert on request, it is an anonymous request",
clientCert: &clientCert{},
wantKubeAPIServerRequestHeaders: http.Header{
"Impersonate-User": {"system:anonymous"},
"Impersonate-Group": {"system:unauthenticated"},
@@ -294,7 +287,6 @@ func TestImpersonator(t *testing.T) {
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"},
@@ -313,17 +305,15 @@ func TestImpersonator(t *testing.T) {
},
},
{
name: "failed client cert authentication",
clientCert: newClientCert(t, unrelatedCA, "test-username", []string{"test-group1"}),
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantError: "Unauthorized",
wantAuthorizerAttributes: nil,
name: "failed client cert authentication",
clientCert: newClientCert(t, unrelatedCA, "test-username", []string{"test-group1"}),
wantError: "Unauthorized",
wantAuthorizerAttributes: nil,
},
{
name: "nested impersonation by regular users calls delegating authorizer",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
clientImpersonateUser: rest.ImpersonationConfig{UserName: "some-other-username"},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
name: "nested impersonation by regular users calls delegating authorizer",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
clientImpersonateUser: rest.ImpersonationConfig{UserName: "some-other-username"},
// this fails because the delegating authorizer in this test only allows system:masters and fails everything else
wantError: `users "some-other-username" is forbidden: User "test-username" ` +
`cannot impersonate resource "users" in API group "" at the cluster scope: ` +
@@ -359,7 +349,6 @@ func TestImpersonator(t *testing.T) {
"alpha.kubernetes.io/identity/user/domain/name": {"a-domain-name"},
},
},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantKubeAPIServerRequestHeaders: http.Header{
"Impersonate-User": {"fire"},
"Impersonate-Group": {"elements", "system:authenticated"},
@@ -477,8 +466,7 @@ func TestImpersonator(t *testing.T) {
clientMutateHeaders: func(header http.Header) {
header["Impersonate-Uid"] = []string{"root"}
},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
wantAuthorizerAttributes: []authorizer.AttributesRecord{
{
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
@@ -501,8 +489,7 @@ func TestImpersonator(t *testing.T) {
clientMutateHeaders: func(header http.Header) {
header["imPerSoNaTE-uid"] = []string{"magic"}
},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
wantAuthorizerAttributes: []authorizer.AttributesRecord{
{
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
@@ -529,8 +516,7 @@ func TestImpersonator(t *testing.T) {
"something.impersonation-proxy.concierge.pinniped.dev": {"bad data"},
},
},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
wantAuthorizerAttributes: []authorizer.AttributesRecord{
{
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
@@ -569,8 +555,7 @@ func TestImpersonator(t *testing.T) {
"party~~time": {"danger"},
},
},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
wantError: "Internal error occurred: unimplemented functionality - unable to act as current user",
wantAuthorizerAttributes: []authorizer.AttributesRecord{
{
User: &user.DefaultInfo{Name: "test-admin", UID: "", Groups: []string{"test-group2", "system:masters", "system:authenticated"}, Extra: nil},
@@ -600,7 +585,6 @@ func TestImpersonator(t *testing.T) {
"ROAR": {"tiger"}, // by the time our code sees this key, it is lowercased to "roar"
},
},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantKubeAPIServerRequestHeaders: http.Header{
"Impersonate-User": {"panda"},
"Impersonate-Group": {"other-peeps", "system:authenticated"},
@@ -631,11 +615,6 @@ func TestImpersonator(t *testing.T) {
},
},
},
{
name: "no bearer token file in Kube API server client config",
wantConstructionError: "invalid impersonator loopback rest config has wrong bearer token semantics",
wantAuthorizerAttributes: nil,
},
{
name: "unexpected healthz response",
kubeAPIServerHealthz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -651,7 +630,6 @@ func TestImpersonator(t *testing.T) {
clientMutateHeaders: func(header http.Header) {
header["imPerSonaTE-USer"] = []string{"PANDA"}
},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantError: `users "PANDA" is forbidden: User "test-username" ` +
`cannot impersonate resource "users" in API group "" at the cluster scope: ` +
`decision made by impersonation-proxy.concierge.pinniped.dev`,
@@ -668,9 +646,8 @@ func TestImpersonator(t *testing.T) {
clientMutateHeaders: func(header http.Header) {
header["imPerSonaTE-uid"] = []string{"007"}
},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantError: `an error on the server ("Internal Server Error: \"/api/v1/namespaces\": requested [{UID 007 authentication.k8s.io/v1 }] without impersonating a user") has prevented the request from succeeding (get namespaces)`,
wantAuthorizerAttributes: []authorizer.AttributesRecord{},
wantError: `an error on the server ("Internal Server Error: \"/api/v1/namespaces\": requested [{UID 007 authentication.k8s.io/v1 }] without impersonating a user") has prevented the request from succeeding (get namespaces)`,
wantAuthorizerAttributes: []authorizer.AttributesRecord{},
},
{
name: "future UID header", // no longer future as it exists in Kube v1.22
@@ -678,9 +655,21 @@ func TestImpersonator(t *testing.T) {
clientMutateHeaders: func(header http.Header) {
header["Impersonate-Uid"] = []string{"008"}
},
kubeAPIServerClientBearerTokenFile: "required-to-be-set",
wantError: `an error on the server ("Internal Server Error: \"/api/v1/namespaces\": requested [{UID 008 authentication.k8s.io/v1 }] without impersonating a user") has prevented the request from succeeding (get namespaces)`,
wantAuthorizerAttributes: []authorizer.AttributesRecord{},
wantError: `an error on the server ("Internal Server Error: \"/api/v1/namespaces\": requested [{UID 008 authentication.k8s.io/v1 }] without impersonating a user") has prevented the request from succeeding (get namespaces)`,
wantAuthorizerAttributes: []authorizer.AttributesRecord{},
},
{
name: "when there is no service account token cached for the impersonator to use to call the KAS",
clientCert: newClientCert(t, ca, "test-username", []string{"test-group1", "test-group2"}),
noServiceAcctTokenInCache: true,
wantKubeAPIServerRequestHeaders: nil, // no request should have been made to the KAS on behalf of the user
wantError: `an error on the server ("") has prevented the request from succeeding (get namespaces)`,
wantAuthorizerAttributes: []authorizer.AttributesRecord{
{
User: &user.DefaultInfo{Name: "test-username", UID: "", Groups: []string{"test-group1", "test-group2", "system:authenticated"}, Extra: nil},
Verb: "list", Namespace: "", APIGroup: "", APIVersion: "v1", Resource: "namespaces", Subresource: "", Name: "", ResourceRequest: true, Path: "/api/v1/namespaces",
},
},
},
}
for _, tt := range tests {
@@ -697,7 +686,9 @@ func TestImpersonator(t *testing.T) {
require.NoError(t, err)
// After failing to start and after shutdown, the impersonator port should be available again.
defer requireCanBindToPort(t, port)
t.Cleanup(func() {
requireCanBindToPort(t, port)
})
if tt.kubeAPIServerStatusCode == 0 {
tt.kubeAPIServerStatusCode = http.StatusOK
@@ -830,11 +821,8 @@ func TestImpersonator(t *testing.T) {
// Create the client config that the impersonation server should use to talk to the Kube API server.
testKubeAPIServerKubeconfig := rest.Config{
Host: testKubeAPIServer.URL,
BearerToken: "some-service-account-token",
TLSClientConfig: rest.TLSClientConfig{CAData: tlsserver.TLSTestServerCA(testKubeAPIServer)},
BearerTokenFile: tt.kubeAPIServerClientBearerTokenFile,
}
clientOpts := []kubeclient.Option{kubeclient.WithConfig(&testKubeAPIServerKubeconfig)}
// Punch out just enough stuff to make New actually run without error.
recOpts := func(options *genericoptions.RecommendedOptions) {
@@ -872,8 +860,13 @@ func TestImpersonator(t *testing.T) {
return kubeclient.Secure(config)
}
serviceTokenCache := tokenclient.NewExpiringSingletonTokenCache()
if !tt.noServiceAcctTokenInCache {
serviceTokenCache.Set("some-service-account-token", 1*time.Hour)
}
// Create an impersonator. Use an invalid port number to make sure our listener override works.
runner, constructionErr := newInternal(-1000, certKeyContent, caContent, restConfigFunc, clientOpts, recOpts, recConfig)
runner, constructionErr := newInternal(-1000, certKeyContent, caContent, restConfigFunc, serviceTokenCache, &testKubeAPIServerKubeconfig, recOpts, recConfig)
if len(tt.wantConstructionError) > 0 {
require.EqualError(t, constructionErr, tt.wantConstructionError)
require.Nil(t, runner)
@@ -891,6 +884,13 @@ func TestImpersonator(t *testing.T) {
errCh <- stopErr
}()
// Stop the impersonator server at the end of the test, even if it fails.
t.Cleanup(func() {
close(stopCh)
exitErr := <-errCh
require.NoError(t, exitErr)
})
// Create a kubeconfig to talk to the impersonator as a client.
clientKubeconfig := &rest.Config{
Host: "https://127.0.0.1:" + strconv.Itoa(port),
@@ -922,7 +922,7 @@ func TestImpersonator(t *testing.T) {
client, err := kubeclient.New(kubeclient.WithConfig(clientKubeconfig))
require.NoError(t, err)
// The fake Kube API server knows how to to list namespaces, so make that request using the client
// The fake Kube API server knows how to list namespaces, so make that request using the client
// through the impersonator.
listResponse, err := client.Kubernetes.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
if len(tt.wantError) > 0 {
@@ -944,6 +944,12 @@ func TestImpersonator(t *testing.T) {
// of the original request mutated by the impersonator. Otherwise the headers should be nil.
require.Equal(t, tt.wantKubeAPIServerRequestHeaders, testKubeAPIServerSawHeaders)
// The rest of the test doesn't make sense for when there is no service account token available in the cache.
// In this case, the impersonator cannot make any calls to the Kube API server on behalf of any user.
if tt.noServiceAcctTokenInCache {
return
}
// these authorization checks are caused by the anonymous auth checks below
tt.wantAuthorizerAttributes = append(tt.wantAuthorizerAttributes,
authorizer.AttributesRecord{
@@ -1046,11 +1052,6 @@ func TestImpersonator(t *testing.T) {
_, errBadCert := tcrBadCert.PinnipedConcierge.LoginV1alpha1().TokenCredentialRequests().Create(ctx, &loginv1alpha1.TokenCredentialRequest{}, metav1.CreateOptions{})
require.True(t, errors.IsUnauthorized(errBadCert), errBadCert)
require.EqualError(t, errBadCert, "Unauthorized")
// Stop the impersonator server.
close(stopCh)
exitErr := <-errCh
require.NoError(t, exitErr)
})
}
}

View File

@@ -0,0 +1,43 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package impersonator
import (
"errors"
"fmt"
"net/http"
utilnet "k8s.io/apimachinery/pkg/util/net"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/tokenclient"
)
type authorizationRoundTripper struct {
cache tokenclient.ExpiringSingletonTokenCacheGet
base http.RoundTripper
}
var _ utilnet.RoundTripperWrapper = (*authorizationRoundTripper)(nil)
func (rt *authorizationRoundTripper) WrappedRoundTripper() http.RoundTripper {
return rt.base
}
func (rt *authorizationRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req = utilnet.CloneRequest(req)
token := rt.cache.Get()
if token == "" {
plog.Error("could not RoundTrip impersonation proxy request to API server",
errors.New("no service account token available in in-memory cache"))
return nil, fmt.Errorf("no impersonator service account token available")
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
return rt.base.RoundTrip(req)
}

View File

@@ -0,0 +1,106 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package impersonator
import (
"context"
"errors"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"go.pinniped.dev/internal/tokenclient"
)
func TestWrappedRoundTripper(t *testing.T) {
var base = new(oauth2.Transport)
roundTripper := authorizationRoundTripper{
base: base,
}
require.Equal(t, base, roundTripper.WrappedRoundTripper())
}
type fakeRoundTripper struct {
request *http.Request
response *http.Response
err error
}
func (t *fakeRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
t.request = request
return t.response, t.err
}
var _ http.RoundTripper = (*fakeRoundTripper)(nil)
type fakeCache struct {
token string
}
func (c *fakeCache) Get() string {
return c.token
}
var _ tokenclient.ExpiringSingletonTokenCacheGet = (*fakeCache)(nil)
func TestRoundTrip(t *testing.T) {
fakeResponse := new(http.Response)
for _, tt := range []struct {
name string
token string
baseResponse *http.Response
baseError string
wantResponse *http.Response
wantError string
}{
{
name: "happy path - mutate the request and return whatever the base returns",
token: "token",
baseResponse: fakeResponse,
baseError: "error from base",
wantResponse: fakeResponse,
wantError: "error from base",
},
{
name: "no token available",
token: "", // since the cache always returns a non-pointer string, this indicates empty
wantError: "no impersonator service account token available",
},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
base := &fakeRoundTripper{
response: new(http.Response),
err: errors.New(tt.baseError),
}
cache := &fakeCache{
token: tt.token,
}
roundTripper := &authorizationRoundTripper{
cache: cache,
base: base,
}
request, err := http.NewRequestWithContext(context.Background(), "GET", "https://example.com", http.NoBody)
require.NoError(t, err)
//nolint:bodyclose // response.Body is nil so you can't call .Close() on it
response, err := roundTripper.RoundTrip(request)
require.Equal(t, tt.wantResponse, response)
require.ErrorContains(t, err, tt.wantError)
if tt.token != "" {
require.Equal(t, "Bearer "+tt.token, base.request.Header.Get("Authorization"))
} else {
require.Empty(t, base.request)
}
})
}
}

View File

@@ -38,6 +38,7 @@ import (
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/pversion"
"go.pinniped.dev/internal/registry/credentialrequest"
"go.pinniped.dev/internal/tokenclient"
)
// App is an object that represents the pinniped-concierge application.
@@ -136,6 +137,8 @@ func (a *App) runServer(ctx context.Context) error {
// injected suffix).
scheme, loginGV, identityGV := conciergescheme.New(*cfg.APIGroupSuffix)
impersonationProxyTokenCache := tokenclient.NewExpiringSingletonTokenCache()
// Prepare to start the controllers, but defer actually starting them until the
// post start hook of the aggregated API server.
buildControllers, err := controllermanager.PrepareControllers(
@@ -154,6 +157,7 @@ func (a *App) runServer(ctx context.Context) error {
AuthenticatorCache: authenticators,
// This port should be safe to cast because the config reader already validated it.
ImpersonationProxyServerPort: int(*cfg.ImpersonationProxyServerPort),
ImpersonationProxyTokenCache: impersonationProxyTokenCache,
},
)
if err != nil {
@@ -181,6 +185,23 @@ func (a *App) runServer(ctx context.Context) error {
return fmt.Errorf("could not configure aggregated API server: %w", err)
}
// Configure a token client that retrieves relatively short-lived tokens from the API server.
// It uses a k8s client without leader election because all pods need tokens.
// This k8s client should not be reused for other purposes.
// The token client will retrieve tokens in the background for the lifetime of the concierge process,
// whether the impersonation proxy is enabled or not.
oneDayInSeconds := int64(24 * 60 * 60)
k8sClient, err := kubeclient.New()
if err != nil {
return fmt.Errorf("could not create default kubernetes client: %w", err)
}
aggregatedAPIServerConfig.ExtraConfig.TokenClient = tokenclient.New(
cfg.NamesConfig.ImpersonationProxyServiceAccount,
k8sClient.Kubernetes.CoreV1().ServiceAccounts(podInfo.Namespace),
impersonationProxyTokenCache.Set,
plog.New(),
tokenclient.WithExpirationSeconds(oneDayInSeconds))
// Complete the aggregated API server config and make a server instance.
server, err := aggregatedAPIServerConfig.Complete().New()
if err != nil {

View File

@@ -161,6 +161,12 @@ func validateNames(names *NamesConfigSpec) error {
if names.AgentServiceAccount == "" {
missingNames = append(missingNames, "agentServiceAccount")
}
if names.ImpersonationProxyServiceAccount == "" {
missingNames = append(missingNames, "impersonationProxyServiceAccount")
}
if names.ImpersonationProxyLegacySecret == "" {
missingNames = append(missingNames, "impersonationProxyLegacySecret")
}
if len(missingNames) > 0 {
return constable.Error("missing required names: " + strings.Join(missingNames, ", "))
}

View File

@@ -47,6 +47,8 @@ func TestFromPath(t *testing.T) {
impersonationSignerSecret: impersonationSignerSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
extraName: extraName-value
labels:
myLabelKey1: myLabelValue1
@@ -80,6 +82,8 @@ func TestFromPath(t *testing.T) {
ImpersonationCACertificateSecret: "impersonationCACertificateSecret-value",
ImpersonationSignerSecret: "impersonationSignerSecret-value",
AgentServiceAccount: "agentServiceAccount-value",
ImpersonationProxyServiceAccount: "impersonationProxyServiceAccount-value",
ImpersonationProxyLegacySecret: "impersonationProxyLegacySecret-value",
},
Labels: map[string]string{
"myLabelKey1": "myLabelValue1",
@@ -121,6 +125,8 @@ func TestFromPath(t *testing.T) {
impersonationSignerSecret: impersonationSignerSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
extraName: extraName-value
labels:
myLabelKey1: myLabelValue1
@@ -156,6 +162,8 @@ func TestFromPath(t *testing.T) {
ImpersonationCACertificateSecret: "impersonationCACertificateSecret-value",
ImpersonationSignerSecret: "impersonationSignerSecret-value",
AgentServiceAccount: "agentServiceAccount-value",
ImpersonationProxyServiceAccount: "impersonationProxyServiceAccount-value",
ImpersonationProxyLegacySecret: "impersonationProxyLegacySecret-value",
},
Labels: map[string]string{
"myLabelKey1": "myLabelValue1",
@@ -197,6 +205,8 @@ func TestFromPath(t *testing.T) {
impersonationSignerSecret: impersonationSignerSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
extraName: extraName-value
labels:
myLabelKey1: myLabelValue1
@@ -233,6 +243,8 @@ func TestFromPath(t *testing.T) {
ImpersonationCACertificateSecret: "impersonationCACertificateSecret-value",
ImpersonationSignerSecret: "impersonationSignerSecret-value",
AgentServiceAccount: "agentServiceAccount-value",
ImpersonationProxyServiceAccount: "impersonationProxyServiceAccount-value",
ImpersonationProxyLegacySecret: "impersonationProxyLegacySecret-value",
},
Labels: map[string]string{
"myLabelKey1": "myLabelValue1",
@@ -264,6 +276,7 @@ func TestFromPath(t *testing.T) {
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
log:
level: all
format: snorlax
@@ -284,6 +297,8 @@ func TestFromPath(t *testing.T) {
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
`),
wantConfig: &Config{
DiscoveryInfo: DiscoveryInfoSpec{
@@ -308,6 +323,8 @@ func TestFromPath(t *testing.T) {
ImpersonationCACertificateSecret: "impersonationCACertificateSecret-value",
ImpersonationSignerSecret: "impersonationSignerSecret-value",
AgentServiceAccount: "agentServiceAccount-value",
ImpersonationProxyServiceAccount: "impersonationProxyServiceAccount-value",
ImpersonationProxyLegacySecret: "impersonationProxyLegacySecret-value",
},
Labels: map[string]string{},
KubeCertAgentConfig: KubeCertAgentSpec{
@@ -322,7 +339,7 @@ func TestFromPath(t *testing.T) {
wantError: "validate names: missing required names: servingCertificateSecret, credentialIssuer, " +
"apiService, impersonationLoadBalancerService, " +
"impersonationClusterIPService, impersonationTLSCertificateSecret, impersonationCACertificateSecret, " +
"impersonationSignerSecret, agentServiceAccount",
"impersonationSignerSecret, agentServiceAccount, impersonationProxyServiceAccount, impersonationProxyLegacySecret",
},
{
name: "Missing apiService name",
@@ -337,6 +354,8 @@ func TestFromPath(t *testing.T) {
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
`),
wantError: "validate names: missing required names: apiService",
},
@@ -353,6 +372,8 @@ func TestFromPath(t *testing.T) {
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
`),
wantError: "validate names: missing required names: credentialIssuer",
},
@@ -369,6 +390,8 @@ func TestFromPath(t *testing.T) {
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
`),
wantError: "validate names: missing required names: servingCertificateSecret",
},
@@ -385,6 +408,8 @@ func TestFromPath(t *testing.T) {
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
`),
wantError: "validate names: missing required names: impersonationLoadBalancerService",
},
@@ -401,6 +426,8 @@ func TestFromPath(t *testing.T) {
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
`),
wantError: "validate names: missing required names: impersonationClusterIPService",
},
@@ -417,6 +444,8 @@ func TestFromPath(t *testing.T) {
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
`),
wantError: "validate names: missing required names: impersonationTLSCertificateSecret",
},
@@ -433,6 +462,8 @@ func TestFromPath(t *testing.T) {
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
`),
wantError: "validate names: missing required names: impersonationCACertificateSecret",
},
@@ -449,9 +480,47 @@ func TestFromPath(t *testing.T) {
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationCACertificateSecret: impersonationCACertificateSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
`),
wantError: "validate names: missing required names: impersonationSignerSecret",
},
{
name: "Missing impersonationProxyServiceAccount name",
yaml: here.Doc(`
---
names:
servingCertificateSecret: pinniped-concierge-api-tls-serving-certificate
credentialIssuer: pinniped-config
apiService: pinniped-api
impersonationLoadBalancerService: impersonationLoadBalancerService-value
impersonationClusterIPService: impersonationClusterIPService-value
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
`),
wantError: "validate names: missing required names: impersonationProxyServiceAccount",
},
{
name: "Missing impersonationProxyLegacySecret name",
yaml: here.Doc(`
---
names:
servingCertificateSecret: pinniped-concierge-api-tls-serving-certificate
credentialIssuer: pinniped-config
apiService: pinniped-api
impersonationLoadBalancerService: impersonationLoadBalancerService-value
impersonationClusterIPService: impersonationClusterIPService-value
impersonationTLSCertificateSecret: impersonationTLSCertificateSecret-value
impersonationCACertificateSecret: impersonationCACertificateSecret-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
`),
wantError: "validate names: missing required names: impersonationProxyLegacySecret",
},
{
name: "Missing several required names",
yaml: here.Doc(`
@@ -464,6 +533,8 @@ func TestFromPath(t *testing.T) {
impersonationClusterIPService: impersonationClusterIPService-value
impersonationSignerSecret: impersonationSignerSecret-value
agentServiceAccount: agentServiceAccount-value
impersonationProxyServiceAccount: impersonationProxyServiceAccount-value
impersonationProxyLegacySecret: impersonationProxyLegacySecret-value
`),
wantError: "validate names: missing required names: " +
"impersonationTLSCertificateSecret, impersonationCACertificateSecret",

View File

@@ -45,6 +45,8 @@ type NamesConfigSpec struct {
ImpersonationCACertificateSecret string `json:"impersonationCACertificateSecret"`
ImpersonationSignerSecret string `json:"impersonationSignerSecret"`
AgentServiceAccount string `json:"agentServiceAccount"`
ImpersonationProxyServiceAccount string `json:"impersonationProxyServiceAccount"`
ImpersonationProxyLegacySecret string `json:"impersonationProxyLegacySecret"`
}
// ServingCertificateConfigSpec contains the configuration knobs for the API's

View File

@@ -46,6 +46,7 @@ import (
"go.pinniped.dev/internal/dynamiccert"
"go.pinniped.dev/internal/endpointaddr"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/tokenclient"
)
const (
@@ -86,6 +87,8 @@ type impersonatorConfigController struct {
tlsServingCertDynamicCertProvider dynamiccert.Private
infoLog logr.Logger
debugLog logr.Logger
impersonationProxyTokenCache tokenclient.ExpiringSingletonTokenCacheGet
}
func NewImpersonatorConfigController(
@@ -108,6 +111,7 @@ func NewImpersonatorConfigController(
impersonationSignerSecretName string,
impersonationSigningCertProvider dynamiccert.Provider,
log logr.Logger,
impersonationProxyTokenCache tokenclient.ExpiringSingletonTokenCacheGet,
) controllerlib.Controller {
secretNames := sets.NewString(tlsSecretName, caSecretName, impersonationSignerSecretName)
log = log.WithName("impersonator-config-controller")
@@ -135,6 +139,7 @@ func NewImpersonatorConfigController(
tlsServingCertDynamicCertProvider: dynamiccert.NewServingCert("impersonation-proxy-serving-cert"),
infoLog: log.V(plog.KlogLevelInfo),
debugLog: log.V(plog.KlogLevelDebug),
impersonationProxyTokenCache: impersonationProxyTokenCache,
},
},
withInformer(credentialIssuerInformer,
@@ -487,6 +492,7 @@ func (c *impersonatorConfigController) ensureImpersonatorIsStarted(syncCtx contr
c.impersonationProxyPort,
c.tlsServingCertDynamicCertProvider,
c.impersonationSigningCertProvider,
c.impersonationProxyTokenCache,
)
if err != nil {
return err

View File

@@ -45,6 +45,7 @@ import (
"go.pinniped.dev/internal/kubeclient"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/testutil"
"go.pinniped.dev/internal/tokenclient"
)
func TestImpersonatorConfigControllerOptions(t *testing.T) {
@@ -93,6 +94,7 @@ func TestImpersonatorConfigControllerOptions(t *testing.T) {
caSignerName,
nil,
plog.Logr(), //nolint:staticcheck // old test with no log assertions
nil,
)
credIssuerInformerFilter = observableWithInformerOption.GetFilterForInformer(credIssuerInformer)
servicesInformerFilter = observableWithInformerOption.GetFilterForInformer(servicesInformer)
@@ -272,6 +274,7 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
const httpsPort = ":443"
const fakeServerResponseBody = "hello, world!"
const externallyProvidedTLSSecretName = "external-tls-secret" //nolint:gosec // this is not a credential
var fakeExpiringSingletonTokenCacheGet = tokenclient.NewExpiringSingletonTokenCache()
var labels = map[string]string{"app": "app-name", "other-key": "other-value"}
var r *require.Assertions
@@ -308,11 +311,13 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
port int,
dynamicCertProvider dynamiccert.Private,
impersonationProxySignerCAProvider dynamiccert.Public,
expiringSingletonTokenCacheGet tokenclient.ExpiringSingletonTokenCacheGet,
) (func(stopCh <-chan struct{}) error, error) {
impersonatorFuncWasCalled++
r.Equal(8444, port)
r.NotNil(dynamicCertProvider)
r.NotNil(impersonationProxySignerCAProvider)
r.Equal(fakeExpiringSingletonTokenCacheGet, expiringSingletonTokenCacheGet)
if impersonatorFuncError != nil {
return nil, impersonatorFuncError
@@ -580,7 +585,8 @@ func TestImpersonatorConfigControllerSync(t *testing.T) {
impersonatorFunc,
mTLSClientCertCASecretName,
mTLSClientCertProvider,
plog.Logr(), //nolint:staticcheck // old test with no log assertions
plog.Logr(), //nolint:staticcheck // old test with no log assertions,
fakeExpiringSingletonTokenCacheGet,
)
controllerlib.TestWrap(t, subject, func(syncer controllerlib.Syncer) controllerlib.Syncer {
tlsServingCertDynamicCertProvider = syncer.(*impersonatorConfigController).tlsServingCertDynamicCertProvider

View File

@@ -0,0 +1,107 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package serviceaccounttokencleanup
import (
"fmt"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
corev1informers "k8s.io/client-go/informers/core/v1"
"k8s.io/client-go/kubernetes"
pinnipedcontroller "go.pinniped.dev/internal/controller"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/plog"
)
// NewLegacyServiceAccountTokenCleanupController creates a controller whose purpose is to delete a legacy Secret
// that was created by installation of older versions of the Pinniped Concierge which is no longer needed.
// This Secret was used to request and to hold a long-lived service account token which was used by the Concierge
// impersonation proxy. It has been replaced by a goroutine which requests short-lived service account tokens
// by making calls to the Kubernetes API server, without any need to read or write the tokens to a Secret.
// Since the old Secret contains a long-lived token, we try to delete it here. That Secret may not exist if the user
// never installed an old version of the Concierge, in which case this controller should do pretty much nothing.
func NewLegacyServiceAccountTokenCleanupController(
namespace string,
legacySecretName string,
k8sClient kubernetes.Interface,
secretInformer corev1informers.SecretInformer,
withInformer pinnipedcontroller.WithInformerOptionFunc,
logger plog.Logger,
) controllerlib.Controller {
name := "legacy-service-account-token-cleanup-controller"
return controllerlib.New(controllerlib.Config{
Name: name,
Syncer: &serviceAccountTokenCleanupController{
name: name,
namespace: namespace,
legacySecretName: legacySecretName,
k8sClient: k8sClient,
secretInformer: secretInformer,
logger: logger.WithName(name),
},
},
withInformer(
secretInformer,
pinnipedcontroller.SimpleFilterWithSingletonQueue(func(obj metav1.Object) bool {
secret, ok := obj.(*corev1.Secret)
return obj.GetNamespace() == namespace &&
obj.GetName() == legacySecretName &&
ok &&
secret.Type == corev1.SecretTypeServiceAccountToken
}),
controllerlib.InformerOption{},
))
}
type serviceAccountTokenCleanupController struct {
name string
namespace string
legacySecretName string
k8sClient kubernetes.Interface
secretInformer corev1informers.SecretInformer
logger plog.Logger
}
func (c serviceAccountTokenCleanupController) Sync(syncCtx controllerlib.Context) error {
secrets, err := c.secretInformer.Lister().Secrets(c.namespace).List(labels.Everything())
if err != nil {
return fmt.Errorf("unable to list all secrets in namespace %s: %w", c.namespace, err)
}
foundSecret := false
for _, secret := range secrets {
if secret.Name == c.legacySecretName && secret.Type == corev1.SecretTypeServiceAccountToken {
foundSecret = true
}
}
c.logger.Debug(
fmt.Sprintf("%s controller checked for legacy secret", c.name),
"secretName", c.legacySecretName,
"secretNamespace", c.namespace,
"secretType", corev1.SecretTypeServiceAccountToken,
"foundSecret", foundSecret,
)
if foundSecret {
err = c.k8sClient.CoreV1().Secrets(c.namespace).Delete(syncCtx.Context, c.legacySecretName, metav1.DeleteOptions{})
if err != nil {
return fmt.Errorf("unable to delete secret %s in namespace %s: %w", c.legacySecretName, c.namespace, err)
}
c.logger.Debug(
fmt.Sprintf("%s controller succcessfully deleted legacy secret", c.name),
"secretName", c.legacySecretName,
"secretNamespace", c.namespace,
"secretType", corev1.SecretTypeServiceAccountToken,
)
}
return nil
}

View File

@@ -0,0 +1,223 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package serviceaccounttokencleanup
import (
"bytes"
"context"
"errors"
"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"
kubeinformers "k8s.io/client-go/informers"
kubernetesfake "k8s.io/client-go/kubernetes/fake"
kubetesting "k8s.io/client-go/testing"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/testutil"
)
func TestNewServiceAccountTokenCleanupController(t *testing.T) {
namespace := "a-namespace"
legacySecretName := "a-secret"
observableWithInformerOption := testutil.NewObservableWithInformerOption()
secretsInformer := kubeinformers.NewSharedInformerFactory(nil, 0).Core().V1().Secrets()
var log bytes.Buffer
_ = NewLegacyServiceAccountTokenCleanupController(
namespace,
legacySecretName,
nil, // not needed for this test
secretsInformer,
observableWithInformerOption.WithInformer,
plog.TestLogger(t, &log),
)
secretsInformerFilter := observableWithInformerOption.GetFilterForInformer(secretsInformer)
legacySecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: legacySecretName, Namespace: namespace}, Type: corev1.SecretTypeServiceAccountToken}
wrongName := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "wrongName", Namespace: namespace}, Type: corev1.SecretTypeServiceAccountToken}
wrongType := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "wrongType", Namespace: namespace}, Type: "other-type"}
wrongNamespace := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "wrongNamespace", Namespace: "wrong-namespace"}, Type: corev1.SecretTypeServiceAccountToken}
wrongObject := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "config-map", Namespace: namespace}}
require.False(t, secretsInformerFilter.Add(wrongName))
require.False(t, secretsInformerFilter.Update(wrongName, wrongNamespace))
require.False(t, secretsInformerFilter.Update(wrongNamespace, wrongName))
require.False(t, secretsInformerFilter.Delete(wrongName))
require.False(t, secretsInformerFilter.Add(wrongObject))
require.False(t, secretsInformerFilter.Update(wrongObject, wrongNamespace))
require.False(t, secretsInformerFilter.Update(wrongNamespace, wrongObject))
require.False(t, secretsInformerFilter.Delete(wrongObject))
require.False(t, secretsInformerFilter.Add(wrongNamespace))
require.False(t, secretsInformerFilter.Update(wrongNamespace, wrongObject))
require.False(t, secretsInformerFilter.Update(wrongObject, wrongNamespace))
require.False(t, secretsInformerFilter.Delete(wrongNamespace))
require.False(t, secretsInformerFilter.Add(wrongType))
require.False(t, secretsInformerFilter.Update(wrongType, wrongNamespace))
require.False(t, secretsInformerFilter.Update(wrongNamespace, wrongType))
require.False(t, secretsInformerFilter.Delete(wrongType))
require.True(t, secretsInformerFilter.Add(legacySecret))
require.True(t, secretsInformerFilter.Update(legacySecret, wrongNamespace))
require.True(t, secretsInformerFilter.Update(wrongNamespace, legacySecret))
require.True(t, secretsInformerFilter.Delete(legacySecret))
}
func TestSync(t *testing.T) {
for _, tt := range []struct {
name string
secretNameToDelete string
namespace string
addReactors func(*kubernetesfake.Clientset)
expectedErrMessage string
expectedActions []kubetesting.Action
}{
{
name: "happy path",
secretNameToDelete: "secret-to-delete",
namespace: "some-namespace",
expectedActions: []kubetesting.Action{
kubetesting.NewDeleteAction(
schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "secrets",
},
"some-namespace",
"secret-to-delete",
),
},
},
{
name: "no secret to delete",
secretNameToDelete: "not-an-existing-secret",
expectedActions: []kubetesting.Action{},
},
{
name: "returns API errors",
secretNameToDelete: "secret-to-delete",
namespace: "other-namespace",
addReactors: func(clientset *kubernetesfake.Clientset) {
clientset.PrependReactor(
"delete",
"secrets",
func(a kubetesting.Action) (bool, runtime.Object, error) {
return true, nil, errors.New("error from API client")
},
)
},
expectedErrMessage: "unable to delete secret secret-to-delete in namespace other-namespace: error from API client",
expectedActions: []kubetesting.Action{
kubetesting.NewDeleteAction(
schema.GroupVersionResource{
Group: "",
Version: "v1",
Resource: "secrets",
},
"other-namespace",
"secret-to-delete",
),
},
},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
kubeAPIClient, kubeInformers := setupKubernetes(t, tt.namespace)
if tt.addReactors != nil {
tt.addReactors(kubeAPIClient)
}
var log bytes.Buffer
controller := NewLegacyServiceAccountTokenCleanupController(
tt.namespace,
tt.secretNameToDelete,
kubeAPIClient,
kubeInformers.Core().V1().Secrets(),
controllerlib.WithInformer,
plog.TestLogger(t, &log),
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Must start informers before calling TestRunSynchronously().
kubeInformers.Start(ctx.Done())
controllerlib.TestRunSynchronously(t, controller)
err := controllerlib.TestSync(t, controller, controllerlib.Context{
Context: ctx,
})
if tt.expectedErrMessage == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tt.expectedErrMessage)
}
actualActions := kubeAPIClient.Actions()
require.Equal(t, tt.expectedActions, actualActions)
})
}
}
func setupKubernetes(t *testing.T, namespace string) (*kubernetesfake.Clientset, kubeinformers.SharedInformerFactory) {
t.Helper()
kubeAPIClient := kubernetesfake.NewSimpleClientset()
kubeInformerClient := kubernetesfake.NewSimpleClientset()
kubeInformers := kubeinformers.NewSharedInformerFactory(
kubeInformerClient,
0,
)
secretToDelete := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "secret-to-delete",
Namespace: namespace,
},
Type: corev1.SecretTypeServiceAccountToken,
}
secretWithWrongName := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "wrong-name",
Namespace: namespace,
},
Type: corev1.SecretTypeServiceAccountToken,
}
secretWithWrongType := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "secret-to-leave-alone",
Namespace: namespace,
},
}
secretWithWrongNamespace := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "secret-to-delete",
Namespace: "other",
},
Type: corev1.SecretTypeServiceAccountToken,
}
require.NoError(t, kubeAPIClient.Tracker().Add(secretToDelete))
require.NoError(t, kubeInformerClient.Tracker().Add(secretToDelete))
require.NoError(t, kubeAPIClient.Tracker().Add(secretWithWrongName))
require.NoError(t, kubeInformerClient.Tracker().Add(secretWithWrongName))
require.NoError(t, kubeAPIClient.Tracker().Add(secretWithWrongType))
require.NoError(t, kubeInformerClient.Tracker().Add(secretWithWrongType))
require.NoError(t, kubeAPIClient.Tracker().Add(secretWithWrongNamespace))
require.NoError(t, kubeInformerClient.Tracker().Add(secretWithWrongNamespace))
return kubeAPIClient, kubeInformers
}

View File

@@ -25,6 +25,7 @@ import (
"go.pinniped.dev/internal/controller/authenticator/webhookcachefiller"
"go.pinniped.dev/internal/controller/impersonatorconfig"
"go.pinniped.dev/internal/controller/kubecertagent"
"go.pinniped.dev/internal/controller/serviceaccounttokencleanup"
"go.pinniped.dev/internal/controllerinit"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/deploymentref"
@@ -34,6 +35,7 @@ import (
"go.pinniped.dev/internal/kubeclient"
"go.pinniped.dev/internal/leaderelection"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/tokenclient"
)
const (
@@ -81,6 +83,9 @@ type Config struct {
// (Note that the impersonation proxy also accepts client certs signed by the Kube API server's cert.)
ImpersonationSigningCertProvider dynamiccert.Provider
// ImpersonationProxyTokenCache holds short-lived tokens for the impersonation proxy service account.
ImpersonationProxyTokenCache tokenclient.ExpiringSingletonTokenCacheGet
// ServingCertDuration is the validity period, in seconds, of the API serving certificate.
ServingCertDuration time.Duration
@@ -276,6 +281,7 @@ func PrepareControllers(c *Config) (controllerinit.RunnerBuilder, error) { //nol
c.NamesConfig.ImpersonationSignerSecret,
c.ImpersonationSigningCertProvider,
plog.Logr(), //nolint:staticcheck // old controller with lots of log statements
c.ImpersonationProxyTokenCache,
),
singletonWorker,
).
@@ -306,6 +312,17 @@ func PrepareControllers(c *Config) (controllerinit.RunnerBuilder, error) { //nol
plog.New(),
),
singletonWorker,
).
WithController(
serviceaccounttokencleanup.NewLegacyServiceAccountTokenCleanupController(
c.ServerInstallationInfo.Namespace,
c.NamesConfig.ImpersonationProxyLegacySecret,
client.Kubernetes,
informers.installationNamespaceK8s.Core().V1().Secrets(),
controllerlib.WithInformer,
plog.New(),
),
singletonWorker,
)
return controllerinit.Prepare(controllerManager.Start, leaderElector,

View File

@@ -0,0 +1,50 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package tokenclient
import (
"time"
"k8s.io/apimachinery/pkg/util/cache"
)
const tokenCacheKey = "token"
type ExpiringSingletonTokenCacheGet interface {
Get() string
}
type ExpiringSingletonTokenCache interface {
ExpiringSingletonTokenCacheGet
Set(token string, ttl time.Duration)
}
type expiringCacheImpl struct {
cache *cache.Expiring
}
var _ ExpiringSingletonTokenCacheGet = &expiringCacheImpl{}
var _ ExpiringSingletonTokenCache = &expiringCacheImpl{}
func NewExpiringSingletonTokenCache() ExpiringSingletonTokenCache {
return &expiringCacheImpl{cache: cache.NewExpiring()}
}
func (e *expiringCacheImpl) Get() string {
maybeToken, ok := e.cache.Get(tokenCacheKey)
if !ok {
return ""
}
token, ok := maybeToken.(string)
if !ok {
return ""
}
return token
}
func (e *expiringCacheImpl) Set(token string, ttl time.Duration) {
e.cache.Set(tokenCacheKey, token, ttl)
}

View File

@@ -0,0 +1,35 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package tokenclient
import (
"testing"
"time"
"github.com/stretchr/testify/require"
k8sCache "k8s.io/apimachinery/pkg/util/cache"
)
func TestExpiringSingletonTokenCache(t *testing.T) {
cache := NewExpiringSingletonTokenCache()
require.NotNil(t, cache)
require.Empty(t, cache.Get())
cache.Set("i am a 12 hour token", 12*time.Hour)
require.Equal(t, "i am a 12 hour token", cache.Get())
cache.Set("i am a 0-TTL token", time.Duration(0))
time.Sleep(1 * time.Millisecond)
require.Empty(t, cache.Get())
cache.Set("i am a very short token", 1*time.Millisecond)
time.Sleep(2 * time.Millisecond)
require.Empty(t, cache.Get())
}
func TestExpiringSingletonTokenCache_WithNonString(t *testing.T) {
cache := &expiringCacheImpl{cache: k8sCache.NewExpiring()}
cache.cache.Set(tokenCacheKey, true, 1*time.Hour)
require.Empty(t, cache.Get())
}

View File

@@ -0,0 +1,135 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package tokenclient
import (
"context"
"time"
"github.com/pkg/errors"
authenticationv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/utils/clock"
"go.pinniped.dev/internal/backoff"
"go.pinniped.dev/internal/plog"
)
type WhatToDoWithTokenFunc func(token string, ttl time.Duration)
type TokenClient struct {
serviceAccountName string
serviceAccountClient corev1client.ServiceAccountInterface
whatToDoWithToken WhatToDoWithTokenFunc
expirationSeconds int64
clock clock.Clock
logger plog.Logger
}
type Opt func(client *TokenClient)
func WithExpirationSeconds(expirationSeconds int64) Opt {
return func(client *TokenClient) {
client.expirationSeconds = expirationSeconds
}
}
func New(
serviceAccountName string,
serviceAccountClient corev1client.ServiceAccountInterface,
whatToDoWithToken WhatToDoWithTokenFunc,
logger plog.Logger,
opts ...Opt,
) *TokenClient {
client := &TokenClient{
serviceAccountName: serviceAccountName,
serviceAccountClient: serviceAccountClient,
whatToDoWithToken: whatToDoWithToken,
expirationSeconds: 600,
clock: clock.RealClock{},
logger: logger,
}
for _, opt := range opts {
opt(client)
}
return client
}
func (tc TokenClient) Start(ctx context.Context) {
sleeper := make(chan time.Time, 1)
// Make sure that the <-sleeper below gets run once immediately.
sleeper <- time.Now()
for {
select {
case <-ctx.Done():
tc.logger.Info("TokenClient was cancelled and is stopping")
return
case <-sleeper:
var tokenTTL time.Duration
err := backoff.WithContext(ctx, &backoff.InfiniteBackoff{
Duration: 10 * time.Millisecond,
MaxDuration: 10 * time.Second,
Factor: 2.0,
}, func(ctx context.Context) (bool, error) {
var (
err error
token string
)
token, tokenTTL, err = tc.fetchToken(ctx)
if err != nil {
// We got an error. Log it, swallow it, and ask for retry by returning false.
tc.logger.Error("TokenClient could not fetch short-lived service account token (will retry)", err,
"serviceAccountName", tc.serviceAccountName)
return false, nil
}
// We got a new token, so invoke the callback.
tc.whatToDoWithToken(token, tokenTTL)
// Stop backing off.
return true, nil
})
if err != nil {
// We were cancelled during our WithContext. We know it was not due to some other
// error because our last argument to WithContext above never returns any errors.
return
}
// Schedule ourselves to wake up in the future.
time.AfterFunc(tokenTTL*4/5, func() {
sleeper <- time.Now()
})
}
}
}
func (tc TokenClient) fetchToken(ctx context.Context) (token string, ttl time.Duration, _ error) {
tc.logger.Debug("TokenClient calling CreateToken to fetch a short-lived service account token")
tokenResponse, err := tc.serviceAccountClient.CreateToken(ctx,
tc.serviceAccountName,
&authenticationv1.TokenRequest{
Spec: authenticationv1.TokenRequestSpec{
ExpirationSeconds: &tc.expirationSeconds,
},
},
metav1.CreateOptions{},
)
if err != nil {
return "", 0, errors.Wrap(err, "error creating token")
}
if tokenResponse == nil {
return "", 0, errors.New("got nil CreateToken response")
}
return tokenResponse.Status.Token,
tokenResponse.Status.ExpirationTimestamp.Sub(tc.clock.Now()),
nil
}

View File

@@ -0,0 +1,404 @@
// Copyright 2023 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package tokenclient
import (
"bytes"
"context"
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
authenticationv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes/fake"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
coretesting "k8s.io/client-go/testing"
"k8s.io/utils/clock"
clocktesting "k8s.io/utils/clock/testing"
"go.pinniped.dev/internal/plog"
)
const (
verb = "create"
resource = "serviceaccounts/token"
)
func TestNew(t *testing.T) {
mockWhatToDoWithTokenFunc := *new(WhatToDoWithTokenFunc)
mockClient := fake.NewSimpleClientset().CoreV1().ServiceAccounts("")
mockTime := time.Now()
mockClock := clocktesting.NewFakeClock(mockTime)
var log bytes.Buffer
testLogger := plog.TestLogger(t, &log)
type args struct {
serviceAccountName string
serviceAccountClient corev1client.ServiceAccountInterface
whatToDoWithToken WhatToDoWithTokenFunc
logger plog.Logger
opts []Opt
}
tests := []struct {
name string
args args
expected *TokenClient
}{
{
name: "defaults",
args: args{
serviceAccountName: "serviceAccountName",
serviceAccountClient: mockClient,
whatToDoWithToken: mockWhatToDoWithTokenFunc,
logger: testLogger,
},
expected: &TokenClient{
serviceAccountName: "serviceAccountName",
serviceAccountClient: mockClient,
whatToDoWithToken: mockWhatToDoWithTokenFunc,
expirationSeconds: 600,
clock: clock.RealClock{},
logger: testLogger,
},
},
{
name: "with all opts",
args: args{
serviceAccountName: "custom-serviceAccountName",
serviceAccountClient: mockClient,
whatToDoWithToken: mockWhatToDoWithTokenFunc,
logger: testLogger,
opts: []Opt{
WithExpirationSeconds(777),
withClock(mockClock),
},
},
expected: &TokenClient{
serviceAccountName: "custom-serviceAccountName",
serviceAccountClient: mockClient,
whatToDoWithToken: mockWhatToDoWithTokenFunc,
expirationSeconds: 777,
clock: mockClock,
logger: testLogger,
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
actual := New(
tt.args.serviceAccountName,
tt.args.serviceAccountClient,
tt.args.whatToDoWithToken,
tt.args.logger,
tt.args.opts...,
)
require.Equal(t, tt.expected, actual)
})
}
}
// withClock should only be used for testing.
func withClock(clock clock.Clock) Opt {
return func(client *TokenClient) {
client.clock = clock
}
}
func TestFetchToken(t *testing.T) {
mockTime := metav1.Now()
type expected struct {
token string
ttl time.Duration
errMessage string
}
tests := []struct {
name string
expirationSeconds int64
serviceAccountName string
tokenResponseValue *authenticationv1.TokenRequest
tokenResponseError error
expected expected
}{
{
name: "happy path",
expirationSeconds: 555,
serviceAccountName: "happy-path-service-account-name",
tokenResponseValue: &authenticationv1.TokenRequest{
Status: authenticationv1.TokenRequestStatus{
Token: "token value",
ExpirationTimestamp: metav1.NewTime(mockTime.Add(25 * time.Minute)),
},
},
expected: expected{
token: "token value",
ttl: 25 * time.Minute,
},
},
{
name: "returns errors from howToFetchTokenFromAPIServer",
expirationSeconds: 444,
serviceAccountName: "service-account-name",
tokenResponseError: errors.New("has an error"),
expected: expected{
errMessage: "error creating token: has an error",
},
},
{
name: "errors when howToFetchTokenFromAPIServer returns nil",
expirationSeconds: 333,
serviceAccountName: "service-account-name",
expected: expected{
errMessage: "got nil CreateToken response",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mockClock := clocktesting.NewFakeClock(mockTime.Time)
var log bytes.Buffer
require.NotEmpty(t, tt.serviceAccountName)
mockClient := fake.NewSimpleClientset()
tokenClient := New(
tt.serviceAccountName,
mockClient.CoreV1().ServiceAccounts("any-namespace-works"),
nil,
plog.TestLogger(t, &log),
WithExpirationSeconds(tt.expirationSeconds),
)
tokenClient.clock = mockClock
mockClient.PrependReactor(verb, resource, func(action coretesting.Action) (handled bool, ret runtime.Object, err error) {
require.Equal(t, tt.serviceAccountName, action.(coretesting.CreateActionImpl).Name)
tokenRequest := action.(coretesting.CreateAction).GetObject().(*authenticationv1.TokenRequest)
require.NotNil(t, tokenRequest)
require.Equal(t, tt.expirationSeconds, *tokenRequest.Spec.ExpirationSeconds)
require.Empty(t, tokenRequest.Spec.Audiences)
require.Empty(t, tokenRequest.Spec.BoundObjectRef)
return true, tt.tokenResponseValue, tt.tokenResponseError
})
token, ttl, err := tokenClient.fetchToken(context.Background())
if tt.expected.errMessage != "" {
require.ErrorContains(t, err, tt.expected.errMessage)
} else {
require.Equal(t, tt.expected.token, token)
require.Equal(t, tt.expected.ttl, ttl)
}
})
}
}
func TestStart(t *testing.T) {
type apiResponse struct {
token string
ttl time.Duration // how much in the future from the time of token response to set the expiration date
err error
}
type receivedToken struct {
token string
ttl time.Duration // expected ttl, within a fudge factor
}
type wanted struct {
receivedTokens []receivedToken
timeFudgeFactor time.Duration
approxTimesBetweenAPIInvocations []time.Duration
}
tests := []struct {
name string
apiResponses []apiResponse
want *wanted
}{
{
name: "several successful token requests",
apiResponses: []apiResponse{
{token: "t1", ttl: 200 * time.Millisecond},
{token: "t2", ttl: 400 * time.Millisecond},
{token: "t3", ttl: 300 * time.Millisecond},
{token: "t4", ttl: time.Hour},
},
want: &wanted{
timeFudgeFactor: 30 * time.Millisecond, // lots of fudge for busy CI workers
receivedTokens: []receivedToken{
{token: "t1", ttl: 200 * time.Millisecond},
{token: "t2", ttl: 400 * time.Millisecond},
{token: "t3", ttl: 300 * time.Millisecond},
{token: "t4", ttl: time.Hour},
},
approxTimesBetweenAPIInvocations: []time.Duration{
160 * time.Millisecond, // time between getting t1 and t2 (80% of t1's ttl)
320 * time.Millisecond, // time between getting t2 and t3 (80% of t2's ttl)
240 * time.Millisecond, // time between getting t4 and t4 (80% of t3's ttl)
},
},
},
{
name: "some errors in the middle",
apiResponses: []apiResponse{
{token: "t1", ttl: 100 * time.Millisecond},
{token: "t2", ttl: 200 * time.Millisecond},
{err: errors.New("err1")},
{err: errors.New("err2")},
{err: errors.New("err3")},
{err: errors.New("err4")},
{err: errors.New("err5")},
{err: errors.New("err6")},
{err: errors.New("err7")},
{token: "t3", ttl: 100 * time.Millisecond},
{token: "t4", ttl: time.Hour},
},
want: &wanted{
timeFudgeFactor: 30 * time.Millisecond, // lots of fudge for busy CI workers
receivedTokens: []receivedToken{
{token: "t1", ttl: 100 * time.Millisecond},
{token: "t2", ttl: 200 * time.Millisecond},
{token: "t3", ttl: 100 * time.Millisecond},
{token: "t4", ttl: time.Hour},
},
approxTimesBetweenAPIInvocations: []time.Duration{
80 * time.Millisecond, // time between getting t1 and t2 (80% of t1's ttl)
160 * time.Millisecond, // time between getting t2 and err1 (80% of t2's ttl)
10 * time.Millisecond, // time between getting err1 and err2 (1st step of exponential backoff)
20 * time.Millisecond, // time between getting err2 and err3 (2nd step of exponential backoff)
40 * time.Millisecond, // time between getting err3 and err4 (3rd step of exponential backoff)
80 * time.Millisecond, // time between getting err4 and err5 (4th step of exponential backoff)
160 * time.Millisecond, // time between getting err5 and err6 (5th step of exponential backoff)
320 * time.Millisecond, // time between getting err6 and err7 (6th step of exponential backoff)
640 * time.Millisecond, // time between getting err7 and t3 (7th step of exponential backoff)
80 * time.Millisecond, // time between getting t3 and t4 (80% of t3's ttl)
},
},
},
{
name: "getting errors before successfully fetching the first token",
apiResponses: []apiResponse{
{err: errors.New("err1")},
{err: errors.New("err2")},
{err: errors.New("err3")},
{err: errors.New("err4")},
{token: "t1", ttl: 100 * time.Millisecond},
{token: "t2", ttl: 200 * time.Millisecond},
{token: "t3", ttl: time.Hour},
},
want: &wanted{
timeFudgeFactor: 30 * time.Millisecond, // lots of fudge for busy CI workers
receivedTokens: []receivedToken{
{token: "t1", ttl: 100 * time.Millisecond},
{token: "t2", ttl: 200 * time.Millisecond},
{token: "t3", ttl: time.Hour},
},
approxTimesBetweenAPIInvocations: []time.Duration{
10 * time.Millisecond, // time between getting err1 and err2 (1st step of exponential backoff)
20 * time.Millisecond, // time between getting err2 and err3 (2nd step of exponential backoff)
40 * time.Millisecond, // time between getting err3 and err4 (3rd step of exponential backoff)
80 * time.Millisecond, // time between getting err4 and t1 (4th step of exponential backoff)
80 * time.Millisecond, // time between getting t1 and t2 (80% of t1's ttl)
160 * time.Millisecond, // time between getting t2 and t3 (80% of t2's ttl)
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mockClient := fake.NewSimpleClientset()
var logs bytes.Buffer
var mutex sync.Mutex
// These variables are accessed by the reactor and by the callback function in the goroutine which is
// running Start() below. But they are also accessed by this test's main goroutine to make assertions later.
// Protect them with a mutex to make the data race detector happy.
var receivedTokens []receivedToken
var reactorCallTimestamps []time.Time
reactorCallCount := 0
subject := New(
"service-account-name",
mockClient.CoreV1().ServiceAccounts("any-namespace-works"),
func(token string, ttl time.Duration) {
mutex.Lock()
defer mutex.Unlock()
t.Logf("received token %q with ttl %q", token, ttl)
receivedTokens = append(receivedTokens, receivedToken{token: token, ttl: ttl})
},
plog.TestLogger(t, &logs),
)
mockClient.PrependReactor(verb, resource, func(action coretesting.Action) (handled bool, ret runtime.Object, err error) {
mutex.Lock()
defer mutex.Unlock()
require.Less(t, reactorCallCount, len(tt.apiResponses),
"more TokenRequests were made than fake reactor responses were prepared in the test setup")
response := &authenticationv1.TokenRequest{Status: authenticationv1.TokenRequestStatus{
Token: tt.apiResponses[reactorCallCount].token,
ExpirationTimestamp: metav1.NewTime(time.Now().Add(tt.apiResponses[reactorCallCount].ttl)),
}}
responseErr := tt.apiResponses[reactorCallCount].err
reactorCallCount++
reactorCallTimestamps = append(reactorCallTimestamps, time.Now())
t.Logf("fake CreateToken API returning response %q at time %s", response.Status, time.Now())
return true, response, responseErr
})
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(4*time.Second, cancel) // cancel the context after a few seconds
go subject.Start(ctx) // Start() should only return after the context is cancelled
<-ctx.Done()
mutex.Lock()
defer mutex.Unlock()
// Should have used up all the reactor responses from the test table.
require.Equal(t, reactorCallCount, len(tt.apiResponses))
// Should have got the expected callbacks for new tokens.
require.Equal(t, len(tt.want.receivedTokens), len(receivedTokens))
for i := range tt.want.receivedTokens {
require.Equal(t, tt.want.receivedTokens[i].token, receivedTokens[i].token)
require.InDelta(t,
float64(tt.want.receivedTokens[i].ttl), float64(receivedTokens[i].ttl),
float64(tt.want.timeFudgeFactor),
)
}
// Should have observed the appropriate amount of elapsed time in between each call to the CreateToken API.
require.Equal(t, reactorCallCount-1, len(tt.want.approxTimesBetweenAPIInvocations), "wrong number of expected time deltas in test setup")
for i := range reactorCallTimestamps {
if i == 0 {
continue
}
actualDelta := reactorCallTimestamps[i].Sub(reactorCallTimestamps[i-1])
require.InDeltaf(t,
tt.want.approxTimesBetweenAPIInvocations[i-1], actualDelta,
float64(tt.want.timeFudgeFactor),
"for API invocation %d", i,
)
}
})
}
}

View File

@@ -32,11 +32,24 @@ func TestPodShutdown_Disruptive(t *testing.T) {
env := testlib.IntegrationEnv(t, testlib.SkipPodRestartAssertions()).
WithKubeDistribution(testlib.KindDistro)
testShutdownAllPodsOfApp(t, env, env.ConciergeNamespace, env.ConciergeAppName, "-kube-cert-agent-")
testShutdownAllPodsOfApp(t, env, env.SupervisorNamespace, env.SupervisorAppName, "")
shutdownAllPodsOfApp(t, env, env.ConciergeNamespace, env.ConciergeAppName, true)
shutdownAllPodsOfApp(t, env, env.SupervisorNamespace, env.SupervisorAppName, false)
}
func testShutdownAllPodsOfApp(t *testing.T, env *testlib.TestEnv, namespace string, appName string, ignorePodsWithNameSubstring string) {
func shutdownAllPodsOfApp(
t *testing.T,
env *testlib.TestEnv,
namespace string,
appName string,
isConcierge bool,
) {
t.Helper()
ignorePodsWithNameSubstring := ""
if isConcierge {
ignorePodsWithNameSubstring = "-kube-cert-agent-"
}
// Precondition: the app should have some pods running initially.
initialPods := getRunningPodsByNamePrefix(t, namespace, appName+"-", ignorePodsWithNameSubstring)
require.Greater(t, len(initialPods), 0)
@@ -114,6 +127,12 @@ func testShutdownAllPodsOfApp(t *testing.T, env *testlib.TestEnv, namespace stri
"did not find expected message in pod log for pod %q", pl.pod.Name)
require.Containsf(t, pl.logsBuf.String(), `[graceful-termination] apiserver is exiting`,
"did not find expected message in pod log for pod %q", pl.pod.Name)
if isConcierge {
require.Containsf(t, pl.logsBuf.String(), `fetch-impersonation-proxy-tokens start hook's background goroutine has finished`,
"did not find expected message in pod log for pod %q", pl.pod.Name)
}
t.Logf("found expected graceful-termination messages in the logs of pod %q", pl.pod.Name)
}