From b7dd46c4e61f74d266d14e4c020fcba4bd3ce1c0 Mon Sep 17 00:00:00 2001
From: Guhan Eswaran <ge746u@att.com>
Date: Wed, 30 Sep 2020 13:26:05 +0000
Subject: [PATCH] Support rotation of svc account tokens

This patchset introduces airshipctl command
- airshipctl cluster rotate-sa-token which basically rotates SA tokens

Previous work: https://review.opendev.org/#/c/749470/

Change-Id: Ibe932fa8d2831979e5b2ac2781f746e8ec2fdc3c
---
 cmd/cluster/cluster.go                        |   2 +
 cmd/cluster/resetsatoken/resetsatoken.go      |  76 ++++++++
 cmd/cluster/resetsatoken/resetsatoken_test.go |  36 ++++
 .../reset-with-help.golden                    |  24 +++
 .../cluster-cmd-with-help.golden              |   9 +-
 docs/source/cli/airshipctl_cluster.md         |   1 +
 .../cli/airshipctl_cluster_rotate-sa-token.md |  49 ++++++
 pkg/cluster/resetsatoken/command.go           |  60 +++++++
 pkg/cluster/resetsatoken/command_test.go      |  85 +++++++++
 pkg/cluster/resetsatoken/errors.go            |  46 +++++
 pkg/cluster/resetsatoken/resetsatoken.go      | 139 +++++++++++++++
 pkg/cluster/resetsatoken/resetsatoken_test.go | 166 ++++++++++++++++++
 .../resetsatoken/testdata/airshipconfig.yaml  |  23 +++
 .../resetsatoken/testdata/kubeconfig.yaml     |  19 ++
 pkg/cluster/resetsatoken/testdata/pod.yaml    |  25 +++
 pkg/cluster/resetsatoken/testdata/secret.yaml |   6 +
 16 files changed, 762 insertions(+), 4 deletions(-)
 create mode 100644 cmd/cluster/resetsatoken/resetsatoken.go
 create mode 100644 cmd/cluster/resetsatoken/resetsatoken_test.go
 create mode 100644 cmd/cluster/resetsatoken/testdata/TestResetTokenGoldenOutput/reset-with-help.golden
 create mode 100644 docs/source/cli/airshipctl_cluster_rotate-sa-token.md
 create mode 100644 pkg/cluster/resetsatoken/command.go
 create mode 100644 pkg/cluster/resetsatoken/command_test.go
 create mode 100644 pkg/cluster/resetsatoken/errors.go
 create mode 100644 pkg/cluster/resetsatoken/resetsatoken.go
 create mode 100644 pkg/cluster/resetsatoken/resetsatoken_test.go
 create mode 100644 pkg/cluster/resetsatoken/testdata/airshipconfig.yaml
 create mode 100644 pkg/cluster/resetsatoken/testdata/kubeconfig.yaml
 create mode 100644 pkg/cluster/resetsatoken/testdata/pod.yaml
 create mode 100644 pkg/cluster/resetsatoken/testdata/secret.yaml

diff --git a/cmd/cluster/cluster.go b/cmd/cluster/cluster.go
index d615359f5..22e1be2bd 100644
--- a/cmd/cluster/cluster.go
+++ b/cmd/cluster/cluster.go
@@ -17,6 +17,7 @@ package cluster
 import (
 	"github.com/spf13/cobra"
 
+	"opendev.org/airship/airshipctl/cmd/cluster/resetsatoken"
 	"opendev.org/airship/airshipctl/pkg/config"
 )
 
@@ -39,6 +40,7 @@ func NewClusterCommand(cfgFactory config.Factory) *cobra.Command {
 	clusterRootCmd.AddCommand(NewInitCommand(cfgFactory))
 	clusterRootCmd.AddCommand(NewMoveCommand(cfgFactory))
 	clusterRootCmd.AddCommand(NewStatusCommand(cfgFactory))
+	clusterRootCmd.AddCommand(resetsatoken.NewResetCommand(cfgFactory))
 
 	return clusterRootCmd
 }
diff --git a/cmd/cluster/resetsatoken/resetsatoken.go b/cmd/cluster/resetsatoken/resetsatoken.go
new file mode 100644
index 000000000..7d9045915
--- /dev/null
+++ b/cmd/cluster/resetsatoken/resetsatoken.go
@@ -0,0 +1,76 @@
+/*
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package resetsatoken
+
+import (
+	"github.com/spf13/cobra"
+
+	"opendev.org/airship/airshipctl/pkg/cluster/resetsatoken"
+	"opendev.org/airship/airshipctl/pkg/config"
+	"opendev.org/airship/airshipctl/pkg/log"
+)
+
+const (
+	resetLong = `
+Use to reset/rotate the Service Account(SA) tokens and additionally restart the
+corresponding pods to get the latest token data reflected in the pod spec
+
+Secret-namespace is a mandatory field and secret-name is optional. If secret-
+name is not given, all the SA tokens in that particular namespace is considered,
+else only that particular input secret-name`
+
+	resetExample = `
+# To rotate a particular SA token
+airshipctl cluster rotate-sa-token -n cert-manager -s cert-manager-token-vvn9p
+
+# To rotate all the SA tokens in cert-manager namespace
+airshipctl cluster rotate-sa-token -n cert-manager
+`
+)
+
+// NewResetCommand creates a new command for generating secret information
+func NewResetCommand(cfgFactory config.Factory) *cobra.Command {
+	r := &resetsatoken.ResetCommand{
+		Options:    resetsatoken.ResetFlags{},
+		CfgFactory: cfgFactory,
+	}
+
+	resetCmd := &cobra.Command{
+		Use:     "rotate-sa-token",
+		Short:   "Rotate tokens of Service Accounts",
+		Long:    resetLong[1:],
+		Example: resetExample,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return r.RunE()
+		},
+	}
+
+	resetCmd.Flags().StringVarP(&r.Options.Namespace, "secret-namespace", "n", "",
+		"namespace of the Service Account Token")
+	resetCmd.Flags().StringVarP(&r.Options.SecretName, "secret-name", "s", "",
+		"name of the secret containing Service Account Token")
+	resetCmd.Flags().StringVar(&r.Options.Kubeconfig, "kubeconfig", "",
+		"Path to kubeconfig associated with cluster being managed")
+
+	err := resetCmd.MarkFlagRequired("secret-namespace")
+	if err != nil {
+		log.Fatal(err)
+	}
+	err = resetCmd.MarkFlagRequired("kubeconfig")
+	if err != nil {
+		log.Fatalf("marking kubeconfig flag required failed: %v", err)
+	}
+	return resetCmd
+}
diff --git a/cmd/cluster/resetsatoken/resetsatoken_test.go b/cmd/cluster/resetsatoken/resetsatoken_test.go
new file mode 100644
index 000000000..994552a58
--- /dev/null
+++ b/cmd/cluster/resetsatoken/resetsatoken_test.go
@@ -0,0 +1,36 @@
+/*
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package resetsatoken_test
+
+import (
+	"testing"
+
+	"opendev.org/airship/airshipctl/cmd/cluster/resetsatoken"
+	"opendev.org/airship/airshipctl/testutil"
+)
+
+func TestResetToken(t *testing.T) {
+	cmdTests := []*testutil.CmdTest{
+		{
+			Name:    "reset-with-help",
+			CmdLine: "--help",
+			Cmd:     resetsatoken.NewResetCommand(nil),
+		},
+	}
+
+	for _, tt := range cmdTests {
+		testutil.RunTest(t, tt)
+	}
+}
diff --git a/cmd/cluster/resetsatoken/testdata/TestResetTokenGoldenOutput/reset-with-help.golden b/cmd/cluster/resetsatoken/testdata/TestResetTokenGoldenOutput/reset-with-help.golden
new file mode 100644
index 000000000..7a1f49531
--- /dev/null
+++ b/cmd/cluster/resetsatoken/testdata/TestResetTokenGoldenOutput/reset-with-help.golden
@@ -0,0 +1,24 @@
+Use to reset/rotate the Service Account(SA) tokens and additionally restart the
+corresponding pods to get the latest token data reflected in the pod spec
+
+Secret-namespace is a mandatory field and secret-name is optional. If secret-
+name is not given, all the SA tokens in that particular namespace is considered,
+else only that particular input secret-name
+
+Usage:
+  rotate-sa-token [flags]
+
+Examples:
+
+# To rotate a particular SA token
+airshipctl cluster rotate-sa-token -n cert-manager -s cert-manager-token-vvn9p
+
+# To rotate all the SA tokens in cert-manager namespace
+airshipctl cluster rotate-sa-token -n cert-manager
+
+
+Flags:
+  -h, --help                      help for rotate-sa-token
+      --kubeconfig string         Path to kubeconfig associated with cluster being managed
+  -s, --secret-name string        name of the secret containing Service Account Token
+  -n, --secret-namespace string   namespace of the Service Account Token
diff --git a/cmd/cluster/testdata/TestNewClusterCommandGoldenOutput/cluster-cmd-with-help.golden b/cmd/cluster/testdata/TestNewClusterCommandGoldenOutput/cluster-cmd-with-help.golden
index 416509fc3..f35d3d944 100644
--- a/cmd/cluster/testdata/TestNewClusterCommandGoldenOutput/cluster-cmd-with-help.golden
+++ b/cmd/cluster/testdata/TestNewClusterCommandGoldenOutput/cluster-cmd-with-help.golden
@@ -5,10 +5,11 @@ Usage:
   cluster [command]
 
 Available Commands:
-  help        Help about any command
-  init        Deploy cluster-api provider components
-  move        Move Cluster API objects, provider specific objects and all dependencies to the target cluster
-  status      Retrieve statuses of deployed cluster components
+  help            Help about any command
+  init            Deploy cluster-api provider components
+  move            Move Cluster API objects, provider specific objects and all dependencies to the target cluster
+  rotate-sa-token Rotate tokens of Service Accounts
+  status          Retrieve statuses of deployed cluster components
 
 Flags:
   -h, --help   help for cluster
diff --git a/docs/source/cli/airshipctl_cluster.md b/docs/source/cli/airshipctl_cluster.md
index 4a71842ac..c88e70743 100644
--- a/docs/source/cli/airshipctl_cluster.md
+++ b/docs/source/cli/airshipctl_cluster.md
@@ -26,5 +26,6 @@ such as getting status and deploying initial infrastructure.
 * [airshipctl](airshipctl.md)	 - A unified entrypoint to various airship components
 * [airshipctl cluster init](airshipctl_cluster_init.md)	 - Deploy cluster-api provider components
 * [airshipctl cluster move](airshipctl_cluster_move.md)	 - Move Cluster API objects, provider specific objects and all dependencies to the target cluster
+* [airshipctl cluster rotate-sa-token](airshipctl_cluster_rotate-sa-token.md)	 - Rotate tokens of Service Accounts
 * [airshipctl cluster status](airshipctl_cluster_status.md)	 - Retrieve statuses of deployed cluster components
 
diff --git a/docs/source/cli/airshipctl_cluster_rotate-sa-token.md b/docs/source/cli/airshipctl_cluster_rotate-sa-token.md
new file mode 100644
index 000000000..f5a0aa307
--- /dev/null
+++ b/docs/source/cli/airshipctl_cluster_rotate-sa-token.md
@@ -0,0 +1,49 @@
+## airshipctl cluster rotate-sa-token
+
+Rotate tokens of Service Accounts
+
+### Synopsis
+
+Use to reset/rotate the Service Account(SA) tokens and additionally restart the
+corresponding pods to get the latest token data reflected in the pod spec
+
+Secret-namespace is a mandatory field and secret-name is optional. If secret-
+name is not given, all the SA tokens in that particular namespace is considered,
+else only that particular input secret-name
+
+```
+airshipctl cluster rotate-sa-token [flags]
+```
+
+### Examples
+
+```
+
+# To rotate a particular SA token
+airshipctl cluster rotate-sa-token -n cert-manager -s cert-manager-token-vvn9p
+
+# To rotate all the SA tokens in cert-manager namespace
+airshipctl cluster rotate-sa-token -n cert-manager
+
+```
+
+### Options
+
+```
+  -h, --help                      help for rotate-sa-token
+      --kubeconfig string         Path to kubeconfig associated with cluster being managed
+  -s, --secret-name string        name of the secret containing Service Account Token
+  -n, --secret-namespace string   namespace of the Service Account Token
+```
+
+### Options inherited from parent commands
+
+```
+      --airshipconf string   Path to file for airshipctl configuration. (default "$HOME/.airship/config")
+      --debug                enable verbose output
+```
+
+### SEE ALSO
+
+* [airshipctl cluster](airshipctl_cluster.md)	 - Manage Kubernetes clusters
+
diff --git a/pkg/cluster/resetsatoken/command.go b/pkg/cluster/resetsatoken/command.go
new file mode 100644
index 000000000..c75118555
--- /dev/null
+++ b/pkg/cluster/resetsatoken/command.go
@@ -0,0 +1,60 @@
+/*
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+     https://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package resetsatoken
+
+import (
+	"opendev.org/airship/airshipctl/pkg/config"
+	"opendev.org/airship/airshipctl/pkg/k8s/client"
+	"opendev.org/airship/airshipctl/pkg/log"
+)
+
+// ResetFlags flags for reset command
+type ResetFlags struct {
+	Namespace  string
+	SecretName string
+	Kubeconfig string
+}
+
+// ResetCommand for reset command
+type ResetCommand struct {
+	Options    ResetFlags
+	CfgFactory config.Factory
+}
+
+// RunE implements the functionality for resetsatoken
+func (c *ResetCommand) RunE() error {
+	airshipconfig, err := c.CfgFactory()
+	if err != nil {
+		return err
+	}
+
+	factory := client.DefaultClient
+
+	kclient, err := factory(airshipconfig.LoadedConfigPath(), c.Options.Kubeconfig)
+	if err != nil {
+		return err
+	}
+
+	manager, err := NewTokenManager(kclient.ClientSet())
+	if err != nil {
+		return err
+	}
+
+	log.Printf("Starting Token Rotation")
+
+	err = manager.RotateToken(c.Options.Namespace, c.Options.SecretName)
+	if err != nil {
+		return ErrRotateTokenFail{Err: err.Error()}
+	}
+	return nil
+}
diff --git a/pkg/cluster/resetsatoken/command_test.go b/pkg/cluster/resetsatoken/command_test.go
new file mode 100644
index 000000000..31fc3ba1f
--- /dev/null
+++ b/pkg/cluster/resetsatoken/command_test.go
@@ -0,0 +1,85 @@
+/*
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+     https://www.apache.org/licenses/LICENSE-2.0
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package resetsatoken_test
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"opendev.org/airship/airshipctl/pkg/cluster/resetsatoken"
+	"opendev.org/airship/airshipctl/pkg/config"
+	"opendev.org/airship/airshipctl/pkg/k8s/client"
+	"opendev.org/airship/airshipctl/pkg/k8s/client/fake"
+)
+
+func TestRunE(t *testing.T) {
+	airshipConfigPath := "testdata/airshipconfig.yaml"
+	kubeConfigPath := "testdata/kubeconfig.yaml"
+
+	tests := []struct {
+		testCaseName string
+		testErr      string
+		resetFlags   resetsatoken.ResetFlags
+		cfgFactory   config.Factory
+	}{
+		{
+			testCaseName: "invalid config factory",
+			cfgFactory: func() (*config.Config, error) {
+				return nil, fmt.Errorf("test config error")
+			},
+			resetFlags: resetsatoken.ResetFlags{},
+			testErr:    "test config error",
+		},
+		{
+			testCaseName: "valid config factory",
+			cfgFactory:   config.CreateFactory(&airshipConfigPath),
+			resetFlags: resetsatoken.ResetFlags{
+				SecretName: "test-secret",
+				Namespace:  "test-namespace",
+			},
+			testErr: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.testCaseName, func(t *testing.T) {
+			command := resetsatoken.ResetCommand{
+				Options:    tt.resetFlags,
+				CfgFactory: tt.cfgFactory,
+			}
+			err := command.RunE()
+			if tt.testErr != "" {
+				assert.Contains(t, err.Error(), tt.testErr)
+			} else {
+				fakeConfig, err := command.CfgFactory()
+				assert.NoError(t, err)
+
+				factory := client.DefaultClient
+				_, err = factory(fakeConfig.LoadedConfigPath(), kubeConfigPath)
+				assert.NoError(t, err)
+
+				fakeClient := fake.NewClient()
+				assert.NotEmpty(t, fakeClient)
+
+				clientset := fakeClient.ClientSet()
+				fakeManager, err := resetsatoken.NewTokenManager(clientset)
+				assert.NoError(t, err)
+
+				err = fakeManager.RotateToken(command.Options.Namespace, command.Options.SecretName)
+				assert.Error(t, err)
+			}
+		})
+	}
+}
diff --git a/pkg/cluster/resetsatoken/errors.go b/pkg/cluster/resetsatoken/errors.go
new file mode 100644
index 000000000..e0e706853
--- /dev/null
+++ b/pkg/cluster/resetsatoken/errors.go
@@ -0,0 +1,46 @@
+/*
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package resetsatoken
+
+import (
+	"fmt"
+)
+
+// ErrNoSATokenFound is returned if there are no SA tokens found in the provided namespace
+type ErrNoSATokenFound struct {
+	namespace string
+}
+
+// ErrNotSAToken is returned if the user input is not an SA token
+type ErrNotSAToken struct {
+	secretName string
+}
+
+// ErrRotateTokenFail is called when there is a failure in rotating the SA token
+type ErrRotateTokenFail struct {
+	Err string
+}
+
+func (e ErrNoSATokenFound) Error() string {
+	return fmt.Sprintf("no service account tokens found in namespace %s", e.namespace)
+}
+
+func (e ErrNotSAToken) Error() string {
+	return fmt.Sprintf("%s is not a Service Account Token", e.secretName)
+}
+
+func (e ErrRotateTokenFail) Error() string {
+	return fmt.Sprintf("failed to rotate token: %s", e.Err)
+}
diff --git a/pkg/cluster/resetsatoken/resetsatoken.go b/pkg/cluster/resetsatoken/resetsatoken.go
new file mode 100644
index 000000000..4b48c557d
--- /dev/null
+++ b/pkg/cluster/resetsatoken/resetsatoken.go
@@ -0,0 +1,139 @@
+/*
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package resetsatoken
+
+import (
+	"fmt"
+
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+
+	"opendev.org/airship/airshipctl/pkg/log"
+)
+
+const (
+	replicaSetKind = "ReplicaSet"
+)
+
+// TokenManager manages service account rotation
+type TokenManager struct {
+	kclient kubernetes.Interface
+}
+
+// NewTokenManager returns an instance of a TokenManager
+func NewTokenManager(kclient kubernetes.Interface) (*TokenManager, error) {
+	return &TokenManager{
+		kclient: kclient,
+	}, nil
+}
+
+// RotateToken - rotates token > 1. Deletes the secret and 2. Deletes its pod
+// Deleting the SA Secret recreates a new secret with a new token information
+// However, the pods referencing to the old secret needs to be refreshed
+// manually and hence deleting the pod to allow it to get recreated with new
+// secret reference
+func (manager TokenManager) RotateToken(ns string, secretName string) error {
+	if secretName == "" {
+		return manager.rotateAllTokens(ns)
+	}
+	return manager.rotateSingleToken(ns, secretName)
+}
+
+// deleteSecret- deletes the secret
+func (manager TokenManager) deleteSecret(secretName string, ns string) error {
+	return manager.kclient.CoreV1().Secrets(ns).Delete(secretName, &metav1.DeleteOptions{})
+}
+
+// deletePod - identifies the secret relationship with pods and deletes corresponding pods
+// if its part of replicaset
+func (manager TokenManager) deletePod(secretName string, ns string) error {
+	pods, err := manager.kclient.CoreV1().Pods(ns).List(metav1.ListOptions{})
+	if err != nil {
+		return err
+	}
+
+	for _, pod := range pods.Items {
+		for _, volume := range pod.Spec.Volumes {
+			if volume.Name == secretName {
+				if manager.isReplicaSet(pod.OwnerReferences) {
+					log.Printf("Deleting pod - %s in %s", pod.Name, ns)
+					if deleteErr := manager.kclient.CoreV1().Pods(ns).Delete(pod.Name,
+						&metav1.DeleteOptions{}); deleteErr != nil {
+						log.Printf("Failed to delete pod: %v", err.Error())
+					}
+				}
+			}
+		}
+	}
+	return nil
+}
+
+// rotateAllTokens rotates all the tokens in the given namespace
+func (manager TokenManager) rotateAllTokens(ns string) error {
+	tokenTypeFieldSelector := fmt.Sprintf("type=%s", corev1.SecretTypeServiceAccountToken)
+	listOptions := metav1.ListOptions{FieldSelector: tokenTypeFieldSelector}
+
+	secrets, err := manager.kclient.CoreV1().Secrets(ns).List(listOptions)
+	if err != nil {
+		return err
+	}
+
+	if len(secrets.Items) == 0 {
+		return ErrNoSATokenFound{namespace: ns}
+	}
+
+	for _, secret := range secrets.Items {
+		err := manager.rotate(secret.Name, secret.Namespace)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// rotateSingleToken rotates a given token in the given ns
+func (manager TokenManager) rotateSingleToken(ns string, secretName string) error {
+	secret, err := manager.kclient.CoreV1().Secrets(ns).Get(secretName, metav1.GetOptions{})
+	if err != nil {
+		return err
+	}
+
+	if secret.Type != corev1.SecretTypeServiceAccountToken {
+		return ErrNotSAToken{secretName: secretName}
+	}
+	return manager.rotate(secretName, ns)
+}
+
+// rotate performs delete action for secrets and its pods
+func (manager TokenManager) rotate(secretName string, secretNamespace string) error {
+	log.Printf("Rotating token - %s in %s", secretName, secretNamespace)
+	err := manager.deleteSecret(secretName, secretNamespace)
+	if err != nil {
+		return err
+	}
+
+	return manager.deletePod(secretName, secretNamespace)
+}
+
+// isReplicaSet checks if the pod is controlled by a ReplicaSet making it safe to delete
+func (manager TokenManager) isReplicaSet(ownerReferences []metav1.OwnerReference) bool {
+	for _, ownerRef := range ownerReferences {
+		if ownerRef.Kind == replicaSetKind {
+			return true
+		}
+	}
+	return false
+}
diff --git a/pkg/cluster/resetsatoken/resetsatoken_test.go b/pkg/cluster/resetsatoken/resetsatoken_test.go
new file mode 100644
index 000000000..8f35e1f51
--- /dev/null
+++ b/pkg/cluster/resetsatoken/resetsatoken_test.go
@@ -0,0 +1,166 @@
+/*
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     https://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+package resetsatoken_test
+
+import (
+	"io/ioutil"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/yaml"
+	kfake "k8s.io/client-go/kubernetes/fake"
+	"k8s.io/client-go/kubernetes/scheme"
+	ktesting "k8s.io/client-go/testing"
+
+	"opendev.org/airship/airshipctl/pkg/cluster/resetsatoken"
+	"opendev.org/airship/airshipctl/pkg/k8s/client/fake"
+	"opendev.org/airship/airshipctl/testutil"
+)
+
+type testCase struct {
+	name             string
+	existingSecrets  []*v1.Secret
+	existingPods     []*v1.Pod
+	secretName       string
+	secretNamespace  string
+	numPodDeletes    int
+	numSecretDeletes int
+	expectErr        bool
+}
+
+var testCases = []testCase{
+	{
+		name:      "no-pods-secrets",
+		expectErr: true,
+	},
+	{
+		name:             "valid-secret-no-pod",
+		secretName:       "valid-secret",
+		secretNamespace:  "valid-namespace",
+		existingSecrets:  []*v1.Secret{getSecret()},
+		numSecretDeletes: 1,
+	},
+	{
+		name:             "valid-secret-no-pod-empty-filter",
+		secretNamespace:  "valid-namespace",
+		existingSecrets:  []*v1.Secret{getSecret()},
+		numSecretDeletes: 1,
+	},
+	{
+		name:            "invalid-secret-no-pod",
+		secretName:      "invalid-secret",
+		existingSecrets: []*v1.Secret{getSecret()},
+		secretNamespace: "valid-namespace",
+	},
+	{
+		name:            "unmatched-secret-pod",
+		secretName:      "invalid-secret",
+		secretNamespace: "valid-namespace",
+		existingPods:    []*v1.Pod{getPod()},
+		existingSecrets: []*v1.Secret{getSecret()},
+	},
+	{
+		name:             "matched-secret-pod",
+		secretName:       "valid-secret",
+		secretNamespace:  "valid-namespace",
+		existingPods:     []*v1.Pod{getPod()},
+		existingSecrets:  []*v1.Secret{getSecret()},
+		numPodDeletes:    1,
+		numSecretDeletes: 1,
+	},
+}
+
+func TestResetSaToken(t *testing.T) {
+	for _, testCase := range testCases {
+		cfg, _ := testutil.InitConfig(t)
+
+		var objects []runtime.Object
+		for _, pod := range testCase.existingPods {
+			objects = append(objects, pod)
+		}
+		for _, secret := range testCase.existingSecrets {
+			objects = append(objects, secret)
+		}
+		ra := fake.WithTypedObjects(objects...)
+		kclient := fake.NewClient(ra)
+
+		assert.NotEmpty(t, kclient)
+		assert.NotEmpty(t, cfg)
+
+		clientset := kclient.ClientSet()
+		manager, err := resetsatoken.NewTokenManager(clientset)
+		assert.NoError(t, err)
+
+		err = manager.RotateToken(testCase.secretNamespace, testCase.secretName)
+		if testCase.expectErr {
+			assert.Error(t, err)
+			continue
+		}
+
+		actions := clientset.(*kfake.Clientset).Actions()
+
+		podDeleteActions := filterActions(actions, "pods", "delete")
+		assert.Len(t, podDeleteActions, testCase.numPodDeletes)
+
+		secretDeleteActions := filterActions(actions, "secrets", "delete")
+		assert.Len(t, secretDeleteActions, testCase.numSecretDeletes)
+	}
+}
+
+func getSecret() *v1.Secret {
+	object := readObjectFromFile("testdata/secret.yaml")
+	if secret, ok := object.(*v1.Secret); ok {
+		return secret
+	}
+	return nil
+}
+
+func getPod() *v1.Pod {
+	object := readObjectFromFile("testdata/pod.yaml")
+	if pod, ok := object.(*v1.Pod); ok {
+		return pod
+	}
+	return nil
+}
+
+func readObjectFromFile(fileName string) runtime.Object {
+	contents, err := ioutil.ReadFile(fileName)
+	if err != nil {
+		return nil
+	}
+	jsonContents, err := yaml.ToJSON(contents)
+	if err != nil {
+		return nil
+	}
+
+	object, err := runtime.Decode(scheme.Codecs.UniversalDeserializer(), jsonContents)
+	if err != nil {
+		return nil
+	}
+	return object
+}
+
+func filterActions(actions []ktesting.Action, resource string, verb string) []ktesting.Action {
+	var result []ktesting.Action
+	for _, action := range actions {
+		if action.GetVerb() == verb && action.GetResource().Resource == resource {
+			result = append(result, action)
+		}
+	}
+	return result
+}
diff --git a/pkg/cluster/resetsatoken/testdata/airshipconfig.yaml b/pkg/cluster/resetsatoken/testdata/airshipconfig.yaml
new file mode 100644
index 000000000..fe1b4ecdf
--- /dev/null
+++ b/pkg/cluster/resetsatoken/testdata/airshipconfig.yaml
@@ -0,0 +1,23 @@
+apiVersion: airshipit.org/v1alpha1
+contexts:
+  dummy_cluster:
+    contextKubeconf: dummycluster_ephemeral
+    manifest: dummy_manifest
+currentContext: dummy_cluster
+kind: Config
+manifests:
+  dummy_manifest:
+    primaryRepositoryName: primary
+    repositories:
+      primary:
+        auth:
+          sshKey: testdata/test-key.pem
+          type: ssh-key
+        checkout:
+          branch: ""
+          force: false
+          remoteRef: ""
+          tag: v1.0.1
+        url: http://dummy.url.com/primary.git
+    subPath: site
+    targetPath: testdata
diff --git a/pkg/cluster/resetsatoken/testdata/kubeconfig.yaml b/pkg/cluster/resetsatoken/testdata/kubeconfig.yaml
new file mode 100644
index 000000000..b0d205918
--- /dev/null
+++ b/pkg/cluster/resetsatoken/testdata/kubeconfig.yaml
@@ -0,0 +1,19 @@
+apiVersion: v1
+clusters:
+- cluster:
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUN5RENDQWJDZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRFNU1Ea3lPVEUzTURNd09Wb1hEVEk1TURreU5qRTNNRE13T1Zvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTUZyCkdxM0kyb2dZci81Y01Udy9Na1pORTNWQURzdEdyU240WjU2TDhPUGhMcUhDN2t1dno2dVpES3dCSGtGeTBNK2MKRXIzd2piUGE1aTV5NmkyMGtxSHBVMjdPZTA0dzBXV2s4N0RSZVlWaGNoZVJHRXoraWt3SndIcGRmMjJVemZNKwpkSDBzaUhuMVd6UnovYk4za3hMUzJlMnZ2U1Y3bmNubk1YRUd4OXV0MUY0NThHeWxxdmxXTUlWMzg5Q2didXFDCkcwcFdiMTBLM0RVZWdiT25Xa1FmSm5sTWRRVVZDUVdZZEZaaklrcWtkWi9hVTRobkNEV01oZXNWRnFNaDN3VVAKczhQay9BNWh1ZFFPbnFRNDVIWXZLdjZ5RjJWcDUyWExBRUx3NDJ4aVRKZlh0V1h4eHR6cU4wY1lyL2VxeS9XMQp1YVVGSW5xQjFVM0JFL1oxbmFrQ0F3RUFBYU1qTUNFd0RnWURWUjBQQVFIL0JBUURBZ0trTUE4R0ExVWRFd0VCCi93UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFKUUVKQVBLSkFjVDVuK3dsWGJsdU9mS0J3c2gKZTI4R1c5R2QwM0N0NGF3RzhzMXE1ZHNua2tpZmVTUENHVFZ1SXF6UTZDNmJaSk9SMDMvVEl5ejh6NDJnaitDVApjWUZXZkltM2RKTnpRL08xWkdySXZZNWdtcWJtWDlpV0JaU24rRytEOGxubzd2aGMvY0tBRFR5OTMvVU92MThuCkdhMnIrRGJJcHcyTWVBVEl2elpxRS9RWlVSQ25DMmdjUFhTVzFqN2h4R3o1a3ZNcGVDZTdQYVUvdVFvblVHSWsKZ2t6ZzI4NHQvREhUUzc4N1V1SUg5cXBaV09yTFNMOGFBeUxQUHhWSXBteGZmbWRETE9TS2VUemRlTmxoSitUMwowQlBVaHBQTlJBNTNJN0hRQjhVUDR2elNONTkzZ1VFbVlFQ2Jic2RYSzB6ZVR6SDdWWHR2Zmd5WTVWWT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
+    server: https://127.0.0.1:6443
+  name: dummycluster_ephemeral
+contexts:
+- context:
+    cluster: dummycluster_ephemeral
+    user: kubernetes-admin
+  name: dummy_cluster
+current-context: dummy_cluster
+kind: Config
+preferences: {}
+users:
+- name: kubernetes-admin
+  user:
+    client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUM4akNDQWRxZ0F3SUJBZ0lJQXhEdzk2RUY4SXN3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB4T1RBNU1qa3hOekF6TURsYUZ3MHlNREE1TWpneE56QXpNVEphTURReApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1Sa3dGd1lEVlFRREV4QnJkV0psY201bGRHVnpMV0ZrCmJXbHVNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXV6R0pZdlBaNkRvaTQyMUQKSzhXSmFaQ25OQWQycXo1cC8wNDJvRnpRUGJyQWd6RTJxWVZrek9MOHhBVmVSN1NONXdXb1RXRXlGOEVWN3JyLwo0K0hoSEdpcTVQbXF1SUZ5enpuNi9JWmM4alU5eEVmenZpa2NpckxmVTR2UlhKUXdWd2dBU05sMkFXQUloMmRECmRUcmpCQ2ZpS1dNSHlqMFJiSGFsc0J6T3BnVC9IVHYzR1F6blVRekZLdjJkajVWMU5rUy9ESGp5UlJKK0VMNlEKQlltR3NlZzVQNE5iQzllYnVpcG1NVEFxL0p1bU9vb2QrRmpMMm5acUw2Zkk2ZkJ0RjVPR2xwQ0IxWUo4ZnpDdApHUVFaN0hUSWJkYjJ0cDQzRlZPaHlRYlZjSHFUQTA0UEoxNSswV0F5bVVKVXo4WEE1NDRyL2J2NzRKY0pVUkZoCmFyWmlRd0lEQVFBQm95Y3dKVEFPQmdOVkhROEJBZjhFQkFNQ0JhQXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUgKQXdJd0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFMMmhIUmVibEl2VHJTMFNmUVg1RG9ueVVhNy84aTg1endVWApSd3dqdzFuS0U0NDJKbWZWRGZ5b0hRYUM4Ti9MQkxyUXM0U0lqU1JYdmFHU1dSQnRnT1RRV21Db1laMXdSbjdwCndDTXZQTERJdHNWWm90SEZpUFl2b1lHWFFUSXA3YlROMmg1OEJaaEZ3d25nWUovT04zeG1rd29IN1IxYmVxWEYKWHF1TTluekhESk41VlZub1lQR09yRHMwWlg1RnNxNGtWVU0wVExNQm9qN1ZIRDhmU0E5RjRYNU4yMldsZnNPMAo4aksrRFJDWTAyaHBrYTZQQ0pQS0lNOEJaMUFSMG9ZakZxT0plcXpPTjBqcnpYWHh4S2pHVFVUb1BldVA5dCtCCjJOMVA1TnI4a2oxM0lrend5Q1NZclFVN09ZM3ltZmJobHkrcXZxaFVFa014MlQ1SkpmQT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
+    client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkFBS0NBUUVBdXpHSll2UFo2RG9pNDIxREs4V0phWkNuTkFkMnF6NXAvMDQyb0Z6UVBickFnekUyCnFZVmt6T0w4eEFWZVI3U041d1dvVFdFeUY4RVY3cnIvNCtIaEhHaXE1UG1xdUlGeXp6bjYvSVpjOGpVOXhFZnoKdmlrY2lyTGZVNHZSWEpRd1Z3Z0FTTmwyQVdBSWgyZERkVHJqQkNmaUtXTUh5ajBSYkhhbHNCek9wZ1QvSFR2MwpHUXpuVVF6Rkt2MmRqNVYxTmtTL0RIanlSUkorRUw2UUJZbUdzZWc1UDROYkM5ZWJ1aXBtTVRBcS9KdW1Pb29kCitGakwyblpxTDZmSTZmQnRGNU9HbHBDQjFZSjhmekN0R1FRWjdIVEliZGIydHA0M0ZWT2h5UWJWY0hxVEEwNFAKSjE1KzBXQXltVUpVejhYQTU0NHIvYnY3NEpjSlVSRmhhclppUXdJREFRQUJBb0lCQVFDU0pycjlaeVpiQ2dqegpSL3VKMFZEWCt2aVF4c01BTUZyUjJsOE1GV3NBeHk1SFA4Vk4xYmc5djN0YUVGYnI1U3hsa3lVMFJRNjNQU25DCm1uM3ZqZ3dVQWlScllnTEl5MGk0UXF5VFBOU1V4cnpTNHRxTFBjM3EvSDBnM2FrNGZ2cSsrS0JBUUlqQnloamUKbnVFc1JpMjRzT3NESlM2UDE5NGlzUC9yNEpIM1M5bFZGbkVuOGxUR2c0M1kvMFZoMXl0cnkvdDljWjR5ZUNpNwpjMHFEaTZZcXJZaFZhSW9RRW1VQjdsbHRFZkZzb3l4VDR6RTE5U3pVbkRoMmxjYTF1TzhqcmI4d2xHTzBoQ2JyClB1R1l2WFFQa3Q0VlNmalhvdGJ3d2lBNFRCVERCRzU1bHp6MmNKeS9zSS8zSHlYbEMxcTdXUmRuQVhhZ1F0VzkKOE9DZGRkb0JBb0dCQU5NcUNtSW94REtyckhZZFRxT1M1ZFN4cVMxL0NUN3ZYZ0pScXBqd2Y4WHA2WHo0KzIvTAozVXFaVDBEL3dGTkZkc1Z4eFYxMnNYMUdwMHFWZVlKRld5OVlCaHVSWGpTZ0ZEWldSY1Z1Y01sNVpPTmJsbmZGCjVKQ0xnNXFMZ1g5VTNSRnJrR3A0R241UDQxamg4TnhKVlhzZG5xWE9xNTFUK1RRT1UzdkpGQjc1QW9HQkFPTHcKalp1cnZtVkZyTHdaVGgvRDNpWll5SVV0ZUljZ2NKLzlzbTh6L0pPRmRIbFd4dGRHUFVzYVd1MnBTNEhvckFtbgpqTm4vSTluUXd3enZ3MWUzVVFPbUhMRjVBczk4VU5hbk5TQ0xNMW1yaXZHRXJ1VHFnTDM1bU41eFZPdTUxQU5JCm4yNkFtODBJT2JDeEtLa0R0ZXJSaFhHd3g5c1pONVJCbG9VRThZNGJBb0dBQ3ZsdVhMZWRxcng5VkE0bDNoNXUKVDJXRVUxYjgxZ1orcmtRc1I1S0lNWEw4cllBTElUNUpHKzFuendyN3BkaEFXZmFWdVV2SDRhamdYT0h6MUs5aQpFODNSVTNGMG9ldUg0V01PY1RwU0prWm0xZUlXcWRiaEVCb1FGdUlWTXRib1BsV0d4ZUhFRHJoOEtreGp4aThSCmdEcUQyajRwY1IzQ0g5QjJ5a0lqQjVFQ2dZRUExc0xXLys2enE1c1lNSm14K1JXZThhTXJmL3pjQnVTSU1LQWgKY0dNK0wwMG9RSHdDaUU4TVNqcVN1ajV3R214YUFuanhMb3ZwSFlRV1VmUEVaUW95UE1YQ2VhRVBLOU4xbk8xMwp0V2lHRytIZkIxaU5PazFCc0lhNFNDbndOM1FRVTFzeXBaeEgxT3hueS9LYmkvYmEvWEZ5VzNqMGFUK2YvVWxrCmJGV1ZVdWtDZ1lFQTBaMmRTTFlmTjV5eFNtYk5xMWVqZXdWd1BjRzQxR2hQclNUZEJxdHFac1doWGE3aDdLTWEKeHdvamh5SXpnTXNyK2tXODdlajhDQ2h0d21sQ1p5QU92QmdOZytncnJ1cEZLM3FOSkpKeU9YREdHckdpbzZmTQp5aXB3Q2tZVGVxRThpZ1J6UkI5QkdFUGY4eVpjMUtwdmZhUDVhM0lRZmxiV0czbGpUemNNZVZjPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
diff --git a/pkg/cluster/resetsatoken/testdata/pod.yaml b/pkg/cluster/resetsatoken/testdata/pod.yaml
new file mode 100644
index 000000000..8e6b6f1b8
--- /dev/null
+++ b/pkg/cluster/resetsatoken/testdata/pod.yaml
@@ -0,0 +1,25 @@
+apiVersion: v1
+kind: Pod
+metadata:
+  name: valid-pod
+  namespace: valid-namespace
+  ownerReferences:
+  - apiVersion: apps/v1
+    blockOwnerDeletion: true
+    controller: true
+    kind: ReplicaSet
+    name: valid-pod-rs
+spec:
+  containers:
+    - image: pod-image
+      volumeMounts:
+        - mountPath: /var/run/secrets/kubernetes.io/serviceaccount
+          name: valid-secret
+          readOnly: true
+  serviceAccount: valid-serviceaccount
+  serviceAccountName: valid-serviceaccount
+  volumes:
+    - name: valid-secret
+      secret:
+        defaultMode: 420
+        secretName: valid-secret
diff --git a/pkg/cluster/resetsatoken/testdata/secret.yaml b/pkg/cluster/resetsatoken/testdata/secret.yaml
new file mode 100644
index 000000000..17ce464d4
--- /dev/null
+++ b/pkg/cluster/resetsatoken/testdata/secret.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: Secret
+metadata:
+  name: valid-secret
+  namespace: valid-namespace
+type: kubernetes.io/service-account-token