From 391bf809c60ebf926d62a76bd21eec555979a64a Mon Sep 17 00:00:00 2001 From: Tof1973 Date: Mon, 29 Apr 2024 13:39:28 +0200 Subject: [PATCH] OSD-22639: prompt, store and reuse elevate reason --- cmd/ocm-backplane/config/troubleshoot_test.go | 2 +- cmd/ocm-backplane/elevate/elevate.go | 13 +- pkg/cli/config/config_test.go | 6 +- pkg/elevate/elevate.go | 25 +++- pkg/elevate/elevate_context.go | 114 ++++++++++++++++++ pkg/elevate/elevate_test.go | 77 +++++++++--- pkg/utils/util.go | 37 ++++++ 7 files changed, 247 insertions(+), 27 deletions(-) create mode 100644 pkg/elevate/elevate_context.go diff --git a/cmd/ocm-backplane/config/troubleshoot_test.go b/cmd/ocm-backplane/config/troubleshoot_test.go index 899d4914..37b6ea89 100644 --- a/cmd/ocm-backplane/config/troubleshoot_test.go +++ b/cmd/ocm-backplane/config/troubleshoot_test.go @@ -100,7 +100,7 @@ var _ = Describe("troubleshoot command", func() { getBackplaneConfiguration = func() (bpConfig config.BackplaneConfiguration, err error) { result := "http://example:8888" bpConfig.ProxyURL = &result - return bpConfig,nil + return bpConfig, nil } err := o.checkBPCli() Expect(err).To(BeNil()) diff --git a/cmd/ocm-backplane/elevate/elevate.go b/cmd/ocm-backplane/elevate/elevate.go index 3d794e3c..af98f6fd 100644 --- a/cmd/ocm-backplane/elevate/elevate.go +++ b/cmd/ocm-backplane/elevate/elevate.go @@ -6,11 +6,16 @@ import ( ) var ElevateCmd = &cobra.Command{ - Use: "elevate ", - Short: "Give a justification for elevating privileges to backplane-cluster-admin and attach it to your user object", - Long: `Elevate to backplane-cluster-admin, and give a reason to do so. This will then be forwarded to your audit collection backend of your choice as the 'Impersonate-User-Extra' HTTP header, which can then be used for tracking, compliance, and security reasons. The command creates a temporary kubeconfig and clusterrole for your user, to allow you to add the extra header to your Kube API request.`, + Use: "elevate [ []]", + Short: "Give a justification for elevating privileges to backplane-cluster-admin and attach it to your user object", + Long: `Elevate to backplane-cluster-admin, and give a reason to do so. +This will then be forwarded to your audit collection backend of your choice as the 'Impersonate-User-Extra' HTTP header, which can then be used for tracking, compliance, and security reasons. +The command creates a temporary kubeconfig and clusterrole for your user, to allow you to add the extra header to your Kube API request. +The provided reason will be store for 20 minutes in order to be used by future elevate commands if the next provided reason is empty. +If the provided reason is empty and no elevation with reason has been done in the last 20 min, and if also the stdin and stderr are not redirection, +then a prompt will be done to enter a none empty reason that will be also stored for future elevation. +If no COMMAND (and eventualy also REASON) is/are provided then the command will just be used to initialize elevate context for future elevate command.`, Example: "ocm backplane elevate -- get po -A", - Args: cobra.MinimumNArgs(2), RunE: runElevate, SilenceUsage: true, } diff --git a/pkg/cli/config/config_test.go b/pkg/cli/config/config_test.go index 02c7ad70..0906fcc7 100644 --- a/pkg/cli/config/config_test.go +++ b/pkg/cli/config/config_test.go @@ -83,7 +83,7 @@ func TestBackplaneConfiguration_getFirstWorkingProxyURL(t *testing.T) { clientDoFunc: func(client *http.Client, req *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusOK}, nil }, - want: "https://dummy.com", + want: "https://dummy.com", }, { name: "multiple-valid-proxies", @@ -91,7 +91,7 @@ func TestBackplaneConfiguration_getFirstWorkingProxyURL(t *testing.T) { clientDoFunc: func(client *http.Client, req *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusOK}, nil }, - want: "https://dummy.com", + want: "https://dummy.com", }, { name: "multiple-mixed-proxies", @@ -99,7 +99,7 @@ func TestBackplaneConfiguration_getFirstWorkingProxyURL(t *testing.T) { clientDoFunc: func(client *http.Client, req *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusOK}, nil }, - want: "https://dummy.com", + want: "https://dummy.com", }, } for _, tt := range tests { diff --git a/pkg/elevate/elevate.go b/pkg/elevate/elevate.go index 4740b123..5a56b2c6 100644 --- a/pkg/elevate/elevate.go +++ b/pkg/elevate/elevate.go @@ -20,6 +20,10 @@ var ( ) func AddElevationReasonToRawKubeconfig(config api.Config, elevationReason string) error { + return AddElevationReasonsToRawKubeconfig(config, []string{elevationReason}) +} + +func AddElevationReasonsToRawKubeconfig(config api.Config, elevationReasons []string) error { logger.Debugln("Adding reason for backplane-cluster-admin elevation") if config.Contexts[config.CurrentContext] == nil { return errors.New("no current kubeconfig context") @@ -35,7 +39,7 @@ func AddElevationReasonToRawKubeconfig(config api.Config, elevationReason string config.AuthInfos[currentCtxUsername].ImpersonateUserExtra = make(map[string][]string) } - config.AuthInfos[currentCtxUsername].ImpersonateUserExtra["reason"] = []string{elevationReason} + config.AuthInfos[currentCtxUsername].ImpersonateUserExtra["reason"] = elevationReasons config.AuthInfos[currentCtxUsername].Impersonate = "backplane-cluster-admin" return nil @@ -48,8 +52,25 @@ func RunElevate(argv []string) error { return err } + logger.Debug("Compute and store reason from/to kubeconfig ElevateContext") + var elevateReason string + if len(argv) == 0 { + elevateReason = "" + } else { + elevateReason = argv[0] + } + elevationReasons, err := ComputeElevateContextAndStoreToKubeConfigFileAndGetReasons(config, elevateReason) + if err != nil { + return err + } + + // If no command are provided, then we just initiate elevate context + if len(argv) < 2 { + return nil + } + logger.Debug("Adding impersonation RBAC allow permissions to kubeconfig") - err = AddElevationReasonToRawKubeconfig(config, argv[0]) + err = AddElevationReasonsToRawKubeconfig(config, elevationReasons) if err != nil { return err } diff --git a/pkg/elevate/elevate_context.go b/pkg/elevate/elevate_context.go new file mode 100644 index 00000000..035515d1 --- /dev/null +++ b/pkg/elevate/elevate_context.go @@ -0,0 +1,114 @@ +package elevate + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/openshift/backplane-cli/pkg/utils" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" +) + +const ( + elevateExtensionName = "ElevateContext" + elevateExtensionRetentionMinutes = 20 +) + +var ( + ModifyConfig = clientcmd.ModifyConfig + AskQuestionFromPrompt = utils.AskQuestionFromPrompt +) + +type ElevateContext struct { + Reasons []string `json:"reasons"` + LastUsed time.Time `json:"lastUsed"` +} + +/////////////////////////////////////////////////////////////////////// +// runtime.Object interface func implementation for ElevateContext type + +// DeepCopyObject creates a deep copy of the ElevateContext. +func (r *ElevateContext) DeepCopyObject() runtime.Object { + return &ElevateContext{ + Reasons: append([]string(nil), r.Reasons...), + LastUsed: r.LastUsed, + } +} + +// GetObjectKind returns the schema.GroupVersionKind of the object. +func (r *ElevateContext) GetObjectKind() schema.ObjectKind { + // return schema.EmptyObjectKind + return &runtime.TypeMeta{ + Kind: "ElevateContext", + APIVersion: "v1", + } +} + +/////////////////////////////////////////////////////////////////////// + +// in some cases (mainly when config is created from json) the "ElevateContext Extension" is created as runtime.Unknow object +// instead of the desired ElevateContext, so we need to Unmarshal the raw definition in that case +func GetElevateContextReasons(config api.Config) []string { + if currentContext := config.Contexts[config.CurrentContext]; currentContext != nil { + var elevateContext *ElevateContext + var ok bool + if object := currentContext.Extensions[elevateExtensionName]; object != nil { + //Let's first try to cast the extension object in ElevateContext + if elevateContext, ok = object.(*ElevateContext); !ok { + // and if it does not work, let's try cast the extension object in Unknown + if unknownObject, ok := object.(*runtime.Unknown); ok { + // and unmarshal the unknown raw JSON string into the ElevateContext + _ = json.Unmarshal([]byte(unknownObject.Raw), &elevateContext) + } + } + // We should keep the stored ElevateContext only if it is still valid + if elevateContext != nil && time.Since(elevateContext.LastUsed) <= elevateExtensionRetentionMinutes*time.Minute { + return elevateContext.Reasons + } + } + } + return []string{} +} + +func ComputeElevateContextAndStoreToKubeConfigFileAndGetReasons(config api.Config, elevationReason string) ([]string, error) { + currentCtx := config.Contexts[config.CurrentContext] + if currentCtx == nil { + return nil, errors.New("no current kubeconfig context") + } + + // let's first retrieve previous elevateContext if any, and add any provided reason. + elevationReasons := utils.AppendUniqNoneEmptyString( + GetElevateContextReasons(config), + elevationReason, + ) + // if we still do not have reason, then let's try to have the reason from prompt + if len(elevationReasons) == 0 { + elevationReasons = utils.AppendUniqNoneEmptyString( + elevationReasons, + AskQuestionFromPrompt(fmt.Sprintf("Please enter a reason for elevation, it will be stored in current context for %d minutes : ", elevateExtensionRetentionMinutes)), + ) + } + // and raise an error if not possible + if len(elevationReasons) == 0 { + return nil, errors.New("please enter a reason for elevation") + } + + // Store the ElevateContext in config current context Extensions map + if currentCtx.Extensions == nil { + currentCtx.Extensions = map[string]runtime.Object{} + } + currentCtx.Extensions[elevateExtensionName] = &ElevateContext{ + Reasons: elevationReasons, + LastUsed: time.Now(), + } + + // Save the config to default path. + configAccess := clientcmd.NewDefaultPathOptions() + err := ModifyConfig(configAccess, config, true) + + return elevationReasons, err +} diff --git a/pkg/elevate/elevate_test.go b/pkg/elevate/elevate_test.go index 9da82b39..59df8e33 100644 --- a/pkg/elevate/elevate_test.go +++ b/pkg/elevate/elevate_test.go @@ -6,7 +6,10 @@ import ( "os" "os/exec" "testing" + "time" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" ) @@ -26,7 +29,7 @@ func fakeExecCommandSuccess(command string, args ...string) *exec.Cmd { return cmd } -var fakeAPIConfig = api.Config { +var fakeAPIConfig = api.Config{ Kind: "Config", APIVersion: "v1", Preferences: api.Preferences{}, @@ -51,7 +54,20 @@ var fakeAPIConfig = api.Config { } func fakeReadKubeConfigRaw() (api.Config, error) { - return fakeAPIConfig, nil + return *fakeAPIConfig.DeepCopy(), nil +} + +func fakeReadKubeConfigRawWithReasons(lastUsedMinutes time.Duration) func() (api.Config, error) { + return func() (api.Config, error) { + config := *fakeAPIConfig.DeepCopy() + config.Contexts[config.CurrentContext].Extensions = map[string]runtime.Object{ + elevateExtensionName: &ElevateContext{ + Reasons: []string{"dymmy reason"}, + LastUsed: time.Now().Add(-lastUsedMinutes * time.Minute), + }, + } + return config, nil + } } func TestHelperProcessError(t *testing.T) { @@ -88,15 +104,20 @@ func TestAddElevationReasonToRawKubeconfig(t *testing.T) { t.Run("it succeeds if the auth info exists for the current context", func(t *testing.T) { if err := AddElevationReasonToRawKubeconfig(fakeAPIConfig, "Production cluster"); err != nil { - t.Errorf("Expected no errors, got %v", err) + t.Error("Expected no errors, got", err) } }) } func TestRunElevate(t *testing.T) { + // We do ot want to realy override any config files or remove them + ModifyConfig = func(configAccess clientcmd.ConfigAccess, newConfig api.Config, relativizePaths bool) error { + return nil + } + OsRemove = func(name string) error { return nil } + t.Run("It returns an error if we cannot load the kubeconfig", func(t *testing.T) { ExecCmd = exec.Command - OsRemove = os.Remove ReadKubeConfigRaw = func() (api.Config, error) { return *api.NewConfig(), errors.New("cannot load kfg") } @@ -107,49 +128,71 @@ func TestRunElevate(t *testing.T) { t.Run("It returns an error if kubeconfig has no current context", func(t *testing.T) { ExecCmd = exec.Command - OsRemove = os.Remove ReadKubeConfigRaw = func() (api.Config, error) { return *api.NewConfig(), nil } - if err := RunElevate([]string{"oc", "get pods"}); err == nil { + if err := RunElevate([]string{"reason", "get", "pods"}); err == nil { t.Error("Expected error, got nil") } }) t.Run("It returns an error if the exec command has errors", func(t *testing.T) { ExecCmd = fakeExecCommandError - OsRemove = os.Remove ReadKubeConfigRaw = fakeReadKubeConfigRaw - if err := RunElevate([]string{"oc", "get pods"}); err == nil { + if err := RunElevate([]string{"reason", "get", "pods"}); err == nil { t.Error("Expected error, got nil") } }) t.Run("It suceeds if the command succeeds, we can clean up the tmp kubeconfig and KUBECONFIG is still set to previous definbed value", func(t *testing.T) { ExecCmd = fakeExecCommandSuccess - OsRemove = func(name string) error { return nil } ReadKubeConfigRaw = fakeReadKubeConfigRaw mockKubeconfig := "/tmp/dummy_kubeconfig" os.Setenv("KUBECONFIG", mockKubeconfig) - if err := RunElevate([]string{"oc", "get pods"}); err != nil { - t.Errorf("Expected no errors, got %v", err) + if err := RunElevate([]string{"reason", "get", "pods"}); err != nil { + t.Error("Expected no errors, got", err) } - if kubeconfigPath, kubeconfigDefined := os.LookupEnv("KUBECONFIG"); ! kubeconfigDefined || kubeconfigPath != mockKubeconfig { - t.Errorf("Expected KUBECONFIG to be definied to previous value, got %v", kubeconfigPath) + if kubeconfigPath, kubeconfigDefined := os.LookupEnv("KUBECONFIG"); !kubeconfigDefined || kubeconfigPath != mockKubeconfig { + t.Error("Expected KUBECONFIG to be definied to previous value, got", kubeconfigPath) } }) t.Run("It suceeds if the command succeeds, we can clean up the tmp kubeconfig and KUBECONFIG is still not set", func(t *testing.T) { ExecCmd = fakeExecCommandSuccess - OsRemove = func(name string) error { return nil } ReadKubeConfigRaw = fakeReadKubeConfigRaw os.Unsetenv("KUBECONFIG") - if err := RunElevate([]string{"oc", "get pods"}); err != nil { - t.Errorf("Expected no errors, got %v", err) + if err := RunElevate([]string{"reason", "get", "pods"}); err != nil { + t.Error("Expected no errors, got", err) } if kubeconfigPath, kubeconfigDefined := os.LookupEnv("KUBECONFIG"); kubeconfigDefined { - t.Errorf("Expected KUBECONFIG to not be definied as previously, got %v", kubeconfigPath) + t.Error("Expected KUBECONFIG to not be definied as previously, got", kubeconfigPath) } }) + t.Run("It returns an error if reason is empty and no ElevateContext", func(t *testing.T) { + ExecCmd = fakeExecCommandSuccess + AskQuestionFromPrompt = func(name string) string { return "" } + ReadKubeConfigRaw = fakeReadKubeConfigRaw + if err := RunElevate([]string{"", "get", "pods"}); err == nil { + t.Error("Expected error, got nil") + } + }) + + t.Run("It suceeds if reason is empty and ElevateContext present with Reasons still valid", func(t *testing.T) { + ExecCmd = fakeExecCommandSuccess + AskQuestionFromPrompt = func(name string) string { return "" } + ReadKubeConfigRaw = fakeReadKubeConfigRawWithReasons(elevateExtensionRetentionMinutes - 1) + if err := RunElevate([]string{"", "get", "pods"}); err != nil { + t.Error("Expected nil, got", err) + } + }) + + t.Run("It returns an error if reason is empty and ElevateContext present with Reasons to old", func(t *testing.T) { + ExecCmd = fakeExecCommandSuccess + AskQuestionFromPrompt = func(name string) string { return "" } + ReadKubeConfigRaw = fakeReadKubeConfigRawWithReasons(elevateExtensionRetentionMinutes + 1) + if err := RunElevate([]string{"", "get", "pods"}); err == nil { + t.Error("Expected err, got nil") + } + }) } diff --git a/pkg/utils/util.go b/pkg/utils/util.go index 727c6474..dad6af49 100644 --- a/pkg/utils/util.go +++ b/pkg/utils/util.go @@ -1,6 +1,7 @@ package utils import ( + "bufio" "encoding/json" "fmt" "io" @@ -250,3 +251,39 @@ func CheckBackplaneVersion(cmd *cobra.Command) { logger.WithField("Current version", info.Version).WithField("Latest version", latestVersion).Warn("Your Backplane CLI is not up to date. Please run the command 'ocm backplane upgrade' to upgrade to the latest version") } + +// CheckValidPrompt checks that the stdin and stderr are valid for prompt +// and are not provided by a pipe or file +func CheckValidPrompt() bool { + stdin, _ := os.Stdin.Stat() + stdout, _ := os.Stderr.Stat() + return (stdin.Mode()&os.ModeCharDevice) != 0 && (stdout.Mode()&os.ModeCharDevice) != 0 +} + +// AskQuestionFromPrompt will first check if stdIn/Err are valid for promting, if not the it will just return empty string +// otherwise if will display the question to stderr and read answer as returned string +func AskQuestionFromPrompt(question string) string { + if CheckValidPrompt() { + // Create a new scanner to read from stdin + scanner := bufio.NewScanner(os.Stdin) + os.Stderr.WriteString(question) + // Read the entire line (until the user presses Enter) + if scanner.Scan() { + return scanner.Text() + } + } + return "" +} + +// AppendUniqNoneEmptyString will append a string to a slice if that string is not empty and is not already part of the slice +func AppendUniqNoneEmptyString(slice []string, element string) []string { + if element == "" { + return slice + } + for _, existing := range slice { + if existing == element { + return slice // Element already exists, no need to add + } + } + return append(slice, element) // Append the element +}