Skip to content

Commit c6c34c8

Browse files
committed
authentication: add custom SA lookup with ttl cache for non-local clusters
Signed-off-by: Dr. Stefan Schimanski <[email protected]>
1 parent 6663f2e commit c6c34c8

File tree

7 files changed

+192
-9
lines changed

7 files changed

+192
-9
lines changed

cmd/kcp/kcp.go

+13-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"os"
2222
"strings"
2323

24+
"github.com/kcp-dev/client-go/kubernetes"
2425
"github.com/spf13/cobra"
2526

2627
"k8s.io/apimachinery/pkg/util/errors"
@@ -40,6 +41,7 @@ import (
4041
"github.com/kcp-dev/kcp/pkg/embeddedetcd"
4142
kcpfeatures "github.com/kcp-dev/kcp/pkg/features"
4243
"github.com/kcp-dev/kcp/pkg/server"
44+
kcpinformers "github.com/kcp-dev/kcp/sdk/client/informers/externalversions"
4345
"github.com/kcp-dev/kcp/sdk/cmd/help"
4446
)
4547

@@ -85,7 +87,13 @@ func main() {
8587
}
8688
}
8789

88-
kcpOptions := options.NewOptions(rootDir)
90+
// these are late initialized on option->config. Hence, we pass the pointers here.
91+
var (
92+
delayedKcpInformers kcpinformers.SharedInformerFactory
93+
delayedClusterKubeClient kubernetes.ClusterInterface
94+
)
95+
96+
kcpOptions := options.NewOptions(rootDir, &delayedClusterKubeClient, &delayedKcpInformers)
8997
kcpOptions.Server.GenericControlPlane.Logs.Verbosity = logsapiv1.VerbosityLevel(2)
9098
kcpOptions.Server.Extra.AdditionalMappingsFile = additionalMappingsFile
9199

@@ -132,6 +140,10 @@ func main() {
132140
return err
133141
}
134142

143+
// set the delayed client and informers, used in the service account lookup
144+
delayedKcpInformers = serverConfig.KcpSharedInformerFactory
145+
delayedClusterKubeClient = serverConfig.KubeClusterClient
146+
135147
completedConfig, err := serverConfig.Complete()
136148
if err != nil {
137149
return err

cmd/kcp/options/options.go

+5-2
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ package options
1919
import (
2020
"io"
2121

22+
"github.com/kcp-dev/client-go/kubernetes"
23+
2224
cliflag "k8s.io/component-base/cli/flag"
2325

2426
serveroptions "github.com/kcp-dev/kcp/pkg/server/options"
27+
kcpinformers "github.com/kcp-dev/kcp/sdk/client/informers/externalversions"
2528
)
2629

2730
type Options struct {
@@ -34,11 +37,11 @@ type Options struct {
3437

3538
type ExtraOptions struct{}
3639

37-
func NewOptions(rootDir string) *Options {
40+
func NewOptions(rootDir string, delayedClusterKubeClient *kubernetes.ClusterInterface, delayedKcpInformers *kcpinformers.SharedInformerFactory) *Options {
3841
opts := &Options{
3942
Output: nil,
4043

41-
Server: *serveroptions.NewOptions(rootDir),
44+
Server: *serveroptions.NewOptions(rootDir, delayedClusterKubeClient, delayedKcpInformers),
4245
Generic: *NewGeneric(rootDir),
4346
Extra: ExtraOptions{},
4447
}

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/go-logr/logr v1.4.2
1313
github.com/google/go-cmp v0.6.0
1414
github.com/google/uuid v1.6.0
15+
github.com/jellydator/ttlcache/v3 v3.3.0
1516
github.com/kcp-dev/apimachinery/v2 v2.0.1-0.20240817110845-a9eb9752bfeb
1617
github.com/kcp-dev/client-go v0.0.0-20240912145314-f5949d81732a
1718
github.com/kcp-dev/kcp/sdk v0.0.0-00010101000000-000000000000

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
138138
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
139139
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
140140
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
141+
github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
142+
github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw=
141143
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
142144
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
143145
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=

pkg/server/options/options.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"strings"
2424
"time"
2525

26+
"github.com/kcp-dev/client-go/kubernetes"
2627
"github.com/spf13/pflag"
2728

2829
"k8s.io/apimachinery/pkg/util/sets"
@@ -34,6 +35,7 @@ import (
3435
etcdoptions "github.com/kcp-dev/kcp/pkg/embeddedetcd/options"
3536
kcpfeatures "github.com/kcp-dev/kcp/pkg/features"
3637
"github.com/kcp-dev/kcp/pkg/server/options/batteries"
38+
kcpinformers "github.com/kcp-dev/kcp/sdk/client/informers/externalversions"
3739
)
3840

3941
type Options struct {
@@ -91,7 +93,7 @@ type CompletedOptions struct {
9193
}
9294

9395
// NewOptions creates a new Options with default parameters.
94-
func NewOptions(rootDir string) *Options {
96+
func NewOptions(rootDir string, delayedKubeClusterClient *kubernetes.ClusterInterface, delayedKcpInformers *kcpinformers.SharedInformerFactory) *Options {
9597
o := &Options{
9698
GenericControlPlane: *controlplaneapiserver.NewOptions(),
9799
EmbeddedEtcd: *etcdoptions.NewOptions(rootDir),
@@ -119,6 +121,7 @@ func NewOptions(rootDir string) *Options {
119121
// override all the stuff
120122
o.GenericControlPlane.SecureServing.ServerCert.CertDirectory = rootDir
121123
o.GenericControlPlane.Authentication.ServiceAccounts.Issuers = []string{"https://kcp.default.svc"}
124+
o.GenericControlPlane.Authentication.ServiceAccounts.OptionalTokenGetter = newServiceAccountTokenCache(delayedKubeClusterClient, delayedKcpInformers)
122125
o.GenericControlPlane.Etcd.StorageConfig.Transport.ServerList = []string{"embedded"}
123126
o.GenericControlPlane.Authorization = nil // we have our own
124127

pkg/server/options/serviceaccounts.go

+165
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/*
2+
Copyright 2025 The KCP Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package options
18+
19+
import (
20+
"context"
21+
"time"
22+
23+
"github.com/jellydator/ttlcache/v3"
24+
kcpkubernetesinformers "github.com/kcp-dev/client-go/informers"
25+
"github.com/kcp-dev/client-go/kubernetes"
26+
"github.com/kcp-dev/logicalcluster/v3"
27+
28+
corev1 "k8s.io/api/core/v1"
29+
kerrors "k8s.io/apimachinery/pkg/api/errors"
30+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
31+
"k8s.io/apimachinery/pkg/runtime/schema"
32+
"k8s.io/apimachinery/pkg/types"
33+
clientset "k8s.io/client-go/kubernetes"
34+
kubecorev1lister "k8s.io/client-go/listers/core/v1"
35+
"k8s.io/kubernetes/pkg/serviceaccount"
36+
37+
corev1alpha1 "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1"
38+
kcpinformers "github.com/kcp-dev/kcp/sdk/client/informers/externalversions"
39+
corev1alpha1listers "github.com/kcp-dev/kcp/sdk/client/listers/core/v1alpha1"
40+
)
41+
42+
const (
43+
// SuccessCacheTTL is the TTL to cache a successful lookup for remote clusters.
44+
SuccessCacheTTL = 1 * time.Minute
45+
// FailureCacheTTL is the TTL to cache a failed lookup for remote clusters.
46+
FailureCacheTTL = 10 * time.Second
47+
)
48+
49+
type cacheKey struct {
50+
clusterName logicalcluster.Name
51+
types.NamespacedName
52+
}
53+
54+
// newServiceAccountTokenCache creates a new service account token cache backed
55+
// by ttl caches for all remote clusters, and local informers logic for local
56+
// clusters.
57+
func newServiceAccountTokenCache(delayedKubeClusterClient *kubernetes.ClusterInterface, delayedKcpInformers *kcpinformers.SharedInformerFactory) func(kubeInformers kcpkubernetesinformers.SharedInformerFactory) serviceaccount.ServiceAccountTokenClusterGetter {
58+
return func(kubeInformers kcpkubernetesinformers.SharedInformerFactory) serviceaccount.ServiceAccountTokenClusterGetter {
59+
return &serviceAccountTokenCache{
60+
delayedKubeClusterClient: delayedKubeClusterClient,
61+
62+
delayedKcpInformers: delayedKcpInformers,
63+
kubeInformers: kubeInformers,
64+
65+
serviceAccountCache: ttlcache.New[cacheKey, *corev1.ServiceAccount](),
66+
secretCache: ttlcache.New[cacheKey, *corev1.Secret](),
67+
}
68+
}
69+
}
70+
71+
type serviceAccountTokenCache struct {
72+
delayedKubeClusterClient *kubernetes.ClusterInterface
73+
74+
delayedKcpInformers *kcpinformers.SharedInformerFactory
75+
kubeInformers kcpkubernetesinformers.SharedInformerFactory
76+
77+
serviceAccountCache *ttlcache.Cache[cacheKey, *corev1.ServiceAccount]
78+
secretCache *ttlcache.Cache[cacheKey, *corev1.Secret]
79+
}
80+
81+
func (c *serviceAccountTokenCache) Cluster(clusterName logicalcluster.Name) serviceaccount.ServiceAccountTokenGetter {
82+
return &serviceAccountTokenGetter{
83+
kubeClient: (*c.delayedKubeClusterClient).Cluster(clusterName.Path()),
84+
85+
logicalClusters: (*c.delayedKcpInformers).Core().V1alpha1().LogicalClusters().Cluster(clusterName).Lister(),
86+
serviceAccounts: c.kubeInformers.Core().V1().ServiceAccounts().Cluster(clusterName).Lister(),
87+
secrets: c.kubeInformers.Core().V1().Secrets().Cluster(clusterName).Lister(),
88+
89+
serviceAccountCache: c.serviceAccountCache,
90+
secretCache: c.secretCache,
91+
92+
clusterName: clusterName,
93+
}
94+
}
95+
96+
type serviceAccountTokenGetter struct {
97+
kubeClient clientset.Interface
98+
99+
logicalClusters corev1alpha1listers.LogicalClusterLister
100+
serviceAccounts kubecorev1lister.ServiceAccountLister
101+
secrets kubecorev1lister.SecretLister
102+
103+
serviceAccountCache *ttlcache.Cache[cacheKey, *corev1.ServiceAccount]
104+
secretCache *ttlcache.Cache[cacheKey, *corev1.Secret]
105+
106+
clusterName logicalcluster.Name
107+
}
108+
109+
func (g *serviceAccountTokenGetter) GetServiceAccount(namespace, name string) (*corev1.ServiceAccount, error) {
110+
// local cluster?
111+
if _, err := g.logicalClusters.Get(corev1alpha1.LogicalClusterName); err != nil {
112+
return g.serviceAccounts.ServiceAccounts(namespace).Get(name)
113+
}
114+
115+
// cached?
116+
if sa := g.serviceAccountCache.Get(cacheKey{g.clusterName, types.NamespacedName{Namespace: namespace, Name: name}}); sa != nil && sa.Value() != nil {
117+
return sa.Value(), nil
118+
}
119+
120+
// fetch with external client
121+
// TODO(sttts): here it's little racy, as we might fetch the service account multiple times.
122+
sa, err := g.kubeClient.CoreV1().ServiceAccounts(namespace).Get(context.Background(), name, metav1.GetOptions{})
123+
if err != nil && !kerrors.IsNotFound(err) {
124+
return nil, err
125+
} else if kerrors.IsNotFound(err) {
126+
ttl := ttlcache.WithTTL[cacheKey, *corev1.ServiceAccount](FailureCacheTTL)
127+
g.serviceAccountCache.GetOrSet(cacheKey{g.clusterName, types.NamespacedName{Namespace: namespace, Name: name}}, nil, ttl)
128+
}
129+
130+
g.serviceAccountCache.Set(cacheKey{g.clusterName, types.NamespacedName{Namespace: namespace, Name: name}}, sa, SuccessCacheTTL)
131+
return sa, nil
132+
}
133+
134+
func (g *serviceAccountTokenGetter) GetPod(_, name string) (*corev1.Pod, error) {
135+
return nil, kerrors.NewNotFound(schema.GroupResource{Group: "", Resource: "pods"}, name)
136+
}
137+
138+
func (g *serviceAccountTokenGetter) GetSecret(namespace, name string) (*corev1.Secret, error) {
139+
// local cluster?
140+
if _, err := g.logicalClusters.Get(corev1alpha1.LogicalClusterName); err != nil {
141+
return g.secrets.Secrets(namespace).Get(name)
142+
}
143+
144+
// cached?
145+
if secret := g.secretCache.Get(cacheKey{g.clusterName, types.NamespacedName{Namespace: namespace, Name: name}}); secret != nil && secret.Value() != nil {
146+
return secret.Value(), nil
147+
}
148+
149+
// fetch with external client
150+
// TODO(sttts): here it's little racy, as we might fetch the secret multiple times.
151+
secret, err := g.kubeClient.CoreV1().Secrets(namespace).Get(context.Background(), name, metav1.GetOptions{})
152+
if err != nil && !kerrors.IsNotFound(err) {
153+
return nil, err
154+
} else if kerrors.IsNotFound(err) {
155+
ttl := ttlcache.WithTTL[cacheKey, *corev1.Secret](FailureCacheTTL)
156+
g.secretCache.GetOrSet(cacheKey{g.clusterName, types.NamespacedName{Namespace: namespace, Name: name}}, nil, ttl)
157+
}
158+
159+
g.secretCache.Set(cacheKey{g.clusterName, types.NamespacedName{Namespace: namespace, Name: name}}, secret, SuccessCacheTTL)
160+
return secret, nil
161+
}
162+
163+
func (g *serviceAccountTokenGetter) GetNode(name string) (*corev1.Node, error) {
164+
return nil, kerrors.NewNotFound(schema.GroupResource{Group: "", Resource: "nodes"}, name)
165+
}

test/e2e/authorizer/serviceaccounts_test.go

+2-5
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ func TestServiceAccounts(t *testing.T) {
183183
})
184184

185185
t.Run("Access another workspace in the same org", func(t *testing.T) {
186-
t.Log("Create workspace with the same name ")
186+
t.Log("Create workspace with the same name")
187187
otherPath, _ := framework.NewWorkspaceFixture(t, server, orgPath)
188188
_, err := kubeClusterClient.Cluster(otherPath).CoreV1().Namespaces().Create(ctx, &corev1.Namespace{
189189
ObjectMeta: metav1.ObjectMeta{
@@ -236,10 +236,7 @@ func TestServiceAccounts(t *testing.T) {
236236
t.Log("Accessing other workspace with the (there foreign) service account should eventually work because it is authenticated")
237237
framework.Eventually(t, func() (bool, string) {
238238
_, err := saKubeClusterClient.Cluster(otherPath).CoreV1().ConfigMaps(namespace.Name).List(ctx, metav1.ListOptions{})
239-
if err != nil {
240-
return false, err.Error()
241-
}
242-
return true, ""
239+
return err == nil, fmt.Sprintf("err = %v", err)
243240
}, wait.ForeverTestTimeout, time.Millisecond*100)
244241

245242
t.Log("Taking away the authenticated access to the other workspace, restricting to only service accounts")

0 commit comments

Comments
 (0)