Skip to content

Commit 4a9dcce

Browse files
joelanfordclaude
andcommitted
Switch from token-based auth to ServiceAccount impersonation
This commit replaces token-based authentication with ServiceAccount impersonation for better security and simpler token management. Changes: Authentication layer: - Added ServiceAccountImpersonationConfig() function that returns an ImpersonationConfig for a ServiceAccount user - Updated ServiceAccountRestConfigMapper() to use impersonation via NewImpersonatingRoundTripper instead of TokenInjectingRoundTripper - Removed TokenGetter parameter from ServiceAccountRestConfigMapper - Deleted tokengetter.go, tokengetter_test.go, and tripper.go as they are no longer needed RBAC: - Changed from serviceaccounts/token create to serviceaccounts impersonate permission Controller: - Removed ServiceAccountNotFoundError handling since impersonation doesn't require the ServiceAccount to exist beforehand - Removed authentication package import from controller Main setup: - Removed TokenGetter initialization - Updated userInfoMapper to use ServiceAccountImpersonationConfig Tests: - Updated tests to verify impersonation headers instead of token injection - Added tests for ServiceAccountImpersonationConfig - Updated controller tests that referenced TokenGetter Benefits of impersonation over tokens: - No need to manage token lifecycle or expiration - No need to check if ServiceAccount exists before use - Simpler code with fewer moving parts - More secure as no tokens are created or cached - More secure as it is no longer required to create highly privileged ServiceAccounts that could be used by workloads in the install namespace. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 913c258 commit 4a9dcce

File tree

17 files changed

+90
-370
lines changed

17 files changed

+90
-370
lines changed

cmd/operator-controller/main.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -646,8 +646,7 @@ func setupHelm(
646646
if err != nil {
647647
return fmt.Errorf("unable to create core client: %w", err)
648648
}
649-
tokenGetter := authentication.NewTokenGetter(coreClient, authentication.WithExpirationDuration(1*time.Hour))
650-
clientRestConfigMapper := action.ServiceAccountRestConfigMapper(tokenGetter)
649+
clientRestConfigMapper := action.ServiceAccountRestConfigMapper()
651650
if features.OperatorControllerFeatureGate.Enabled(features.SyntheticPermissions) {
652651
clientRestConfigMapper = action.SyntheticUserRestConfigMapper(clientRestConfigMapper)
653652
}
@@ -681,7 +680,8 @@ func setupHelm(
681680
} else if ext.Spec.ServiceAccount.Name == "" || ext.Spec.Namespace == "" {
682681
return nil, errors.New("service account name and namespace must be specified")
683682
}
684-
return &user.DefaultInfo{Name: fmt.Sprintf("system:serviceaccount:%s:%s", ext.Spec.Namespace, ext.Spec.ServiceAccount.Name)}, nil
683+
saConfig := authentication.ServiceAccountImpersonationConfig(*ext)
684+
return &user.DefaultInfo{Name: saConfig.UserName, Groups: saConfig.Groups}, nil
685685
})
686686
}
687687

config/samples/olm_v1_clusterextension.yaml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ kind: Namespace
44
metadata:
55
name: argocd
66
---
7-
apiVersion: v1
8-
kind: ServiceAccount
9-
metadata:
10-
name: argocd-installer
11-
namespace: argocd
7+
# NOTE: The ServiceAccount resource is intentionally NOT created here.
8+
# OLM v1 uses Kubernetes impersonation and does not require the ServiceAccount to exist.
9+
# For security reasons, you should NOT create a ServiceAccount resource for the installer,
10+
# as it would be highly privileged and could be mounted by other pods in the namespace.
11+
# Instead, only the RBAC resources (ClusterRole, ClusterRoleBinding, Role, RoleBinding)
12+
# are created, which reference the ServiceAccount name "argocd-installer".
13+
# OLM will impersonate that ServiceAccount and be subject to these RBAC permissions.
1214
---
1315
apiVersion: rbac.authorization.k8s.io/v1
1416
kind: ClusterRoleBinding

docs/howto/derive-service-account.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -300,12 +300,20 @@ Therefore, the following permissions must be given to the installer service acco
300300
Once the installer service account required cluster-scoped and namespace-scoped permissions have been collected:
301301

302302
1. Create the installation namespace
303-
2. Create the installer `ServiceAccount`
304-
3. Create the installer `ClusterRole`
305-
4. Create the `ClusterRoleBinding` between the installer service account and its cluster role
306-
5. Create the installer `Role`
307-
6. Create the `RoleBinding` between the installer service account and its role
308-
7. Create the `ClusterExtension`
303+
2. Create the installer `ClusterRole`
304+
3. Create the `ClusterRoleBinding` between the installer service account and its cluster role
305+
4. Create the installer `Role`
306+
5. Create the `RoleBinding` between the installer service account and its role
307+
6. Create the `ClusterExtension`
308+
309+
!!! important "Do Not Create the ServiceAccount Resource"
310+
OLM v1 uses Kubernetes impersonation and does not require the ServiceAccount to exist as an actual resource.
311+
For security reasons, you should **NOT** create a ServiceAccount resource for the installer,
312+
as it would be highly privileged and could be mounted by other pods in the namespace.
313+
314+
Instead, only create the RBAC resources (ClusterRole, ClusterRoleBinding, Role, RoleBinding)
315+
that reference the ServiceAccount name. OLM will impersonate that ServiceAccount and be subject
316+
to these RBAC permissions without the ServiceAccount resource actually existing in the cluster.
309317

310318
A manifest with the full set of resources can be found [here](https://github.com/operator-framework/operator-controller/blob/main/config/samples/olm_v1_clusterextension.yaml).
311319

docs/tutorials/upgrade-extension.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,6 @@ saving it to a local file and then running `kubectl apply -f FILENAME`:
3333
metadata:
3434
name: argocd
3535
---
36-
apiVersion: v1
37-
kind: ServiceAccount
38-
metadata:
39-
name: argocd-installer
40-
namespace: argocd
41-
---
4236
apiVersion: rbac.authorization.k8s.io/v1
4337
kind: ClusterRoleBinding
4438
metadata:
@@ -376,4 +370,4 @@ kubectl get clusterextension argocd -o jsonpath-as-json="{.status.install}"
376370
After your upgrade, the contents of the `kubectl.kubernetes.io/last-applied-configuration` annotation field will
377371
differ depending on your method of upgrade. If you apply a new ClusterExtension manifest as in the first method shown,
378372
the last applied configuration will show the new version since we replaced the existing manifest. If you use the patch
379-
method or `kubectl edit clusterextension`, then the last applied configuration will show the old version.
373+
method or `kubectl edit clusterextension`, then the last applied configuration will show the old version.

helm/olmv1/templates/rbac/clusterrole-operator-controller-manager-role.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ rules:
1212
- apiGroups:
1313
- ""
1414
resources:
15-
- serviceaccounts/token
15+
- serviceaccounts
1616
verbs:
17-
- create
17+
- impersonate
1818
{{- if (has "SyntheticPermissions" .Values.options.operatorController.features.enabled) }}
1919
- apiGroups:
2020
- ""

internal/operator-controller/action/restconfig.go

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"fmt"
66
"net/http"
77

8-
"k8s.io/apimachinery/pkg/types"
98
"k8s.io/client-go/rest"
109
"k8s.io/client-go/transport"
1110
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -34,25 +33,19 @@ func SyntheticUserRestConfigMapper(defaultAuthMapper func(ctx context.Context, o
3433
}
3534

3635
// ServiceAccountRestConfigMapper returns an AuthConfigMapper scoped to the service account defined in o, which is expected to
37-
// be a ClusterExtension
38-
func ServiceAccountRestConfigMapper(tokenGetter *authentication.TokenGetter) func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) {
36+
// be a ClusterExtension. It uses impersonation, which allows authentication as a ServiceAccount without requiring the
37+
// ServiceAccount to actually exist or having to manage tokens.
38+
func ServiceAccountRestConfigMapper() func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) {
3939
return func(ctx context.Context, o client.Object, c *rest.Config) (*rest.Config, error) {
4040
cExt, err := validate(o, c)
4141
if err != nil {
4242
return nil, err
4343
}
44-
saConfig := rest.AnonymousClientConfig(c)
45-
saConfig.Wrap(func(rt http.RoundTripper) http.RoundTripper {
46-
return &authentication.TokenInjectingRoundTripper{
47-
Tripper: rt,
48-
TokenGetter: tokenGetter,
49-
Key: types.NamespacedName{
50-
Name: cExt.Spec.ServiceAccount.Name,
51-
Namespace: cExt.Spec.Namespace,
52-
},
53-
}
44+
cc := rest.CopyConfig(c)
45+
cc.Wrap(func(rt http.RoundTripper) http.RoundTripper {
46+
return transport.NewImpersonatingRoundTripper(authentication.ServiceAccountImpersonationConfig(*cExt), rt)
5447
})
55-
return saConfig, nil
48+
return cc, nil
5649
}
5750
}
5851

internal/operator-controller/action/restconfig_test.go

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,11 @@ import (
99
"github.com/stretchr/testify/require"
1010
corev1 "k8s.io/api/core/v1"
1111
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12-
"k8s.io/apimachinery/pkg/types"
1312
"k8s.io/client-go/rest"
1413
"sigs.k8s.io/controller-runtime/pkg/client"
1514

1615
ocv1 "github.com/operator-framework/operator-controller/api/v1"
1716
"github.com/operator-framework/operator-controller/internal/operator-controller/action"
18-
"github.com/operator-framework/operator-controller/internal/operator-controller/authentication"
1917
)
2018

2119
func Test_ServiceAccountRestConfigMapper(t *testing.T) {
@@ -55,21 +53,21 @@ func Test_ServiceAccountRestConfigMapper(t *testing.T) {
5553
},
5654
} {
5755
t.Run(tc.description, func(t *testing.T) {
58-
tokenGetter := &authentication.TokenGetter{}
59-
saMapper := action.ServiceAccountRestConfigMapper(tokenGetter)
56+
saMapper := action.ServiceAccountRestConfigMapper()
6057
actualCfg, err := saMapper(context.Background(), tc.obj, tc.cfg)
6158
if tc.expectedError != nil {
6259
require.Nil(t, actualCfg)
6360
require.EqualError(t, err, tc.expectedError.Error())
6461
} else {
6562
require.NoError(t, err)
66-
transport, err := rest.TransportFor(actualCfg)
67-
require.NoError(t, err)
68-
require.NotNil(t, transport)
69-
tokenInjectionRoundTripper, ok := transport.(*authentication.TokenInjectingRoundTripper)
70-
require.True(t, ok)
71-
require.Equal(t, tokenGetter, tokenInjectionRoundTripper.TokenGetter)
72-
require.Equal(t, types.NamespacedName{Name: "my-service-account", Namespace: "my-namespace"}, tokenInjectionRoundTripper.Key)
63+
require.NotNil(t, actualCfg)
64+
65+
// test that the impersonation headers are appropriately injected into the request
66+
// nolint:bodyclose
67+
_, _ = actualCfg.WrapTransport(fakeRoundTripper(func(req *http.Request) (*http.Response, error) {
68+
require.Equal(t, "system:serviceaccount:my-namespace:my-service-account", req.Header.Get("Impersonate-User"))
69+
return &http.Response{}, nil
70+
})).RoundTrip(&http.Request{})
7371
}
7472
})
7573
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package authentication
2+
3+
import (
4+
"fmt"
5+
6+
"k8s.io/client-go/transport"
7+
8+
ocv1 "github.com/operator-framework/operator-controller/api/v1"
9+
)
10+
11+
// ServiceAccountImpersonationConfig returns an ImpersonationConfig for impersonating a ServiceAccount.
12+
// This allows authentication as a ServiceAccount without requiring an actual token.
13+
func ServiceAccountImpersonationConfig(ext ocv1.ClusterExtension) transport.ImpersonationConfig {
14+
return transport.ImpersonationConfig{
15+
UserName: fmt.Sprintf("system:serviceaccount:%s:%s", ext.Spec.Namespace, ext.Spec.ServiceAccount.Name),
16+
}
17+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package authentication_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
9+
ocv1 "github.com/operator-framework/operator-controller/api/v1"
10+
"github.com/operator-framework/operator-controller/internal/operator-controller/authentication"
11+
)
12+
13+
func TestServiceAccountImpersonationConfig(t *testing.T) {
14+
config := authentication.ServiceAccountImpersonationConfig(ocv1.ClusterExtension{
15+
ObjectMeta: metav1.ObjectMeta{
16+
Name: "my-ext",
17+
},
18+
Spec: ocv1.ClusterExtensionSpec{Namespace: "my-namespace", ServiceAccount: ocv1.ServiceAccountReference{Name: "my-service-account"}},
19+
})
20+
require.Equal(t, "system:serviceaccount:my-namespace:my-service-account", config.UserName)
21+
require.Nil(t, config.Groups)
22+
require.Empty(t, config.UID)
23+
require.Empty(t, config.Extra)
24+
}

internal/operator-controller/authentication/tokengetter.go

Lines changed: 0 additions & 128 deletions
This file was deleted.

0 commit comments

Comments
 (0)