Skip to content

Commit

Permalink
OSD-22639: prompt, store and reuse elevate reason
Browse files Browse the repository at this point in the history
  • Loading branch information
Tof1973 committed May 3, 2024
1 parent 51236e1 commit 391bf80
Show file tree
Hide file tree
Showing 7 changed files with 247 additions and 27 deletions.
2 changes: 1 addition & 1 deletion cmd/ocm-backplane/config/troubleshoot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
13 changes: 9 additions & 4 deletions cmd/ocm-backplane/elevate/elevate.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ import (
)

var ElevateCmd = &cobra.Command{
Use: "elevate <REASON> <COMMAND>",
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 [<REASON> [<COMMAND>]]",
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 <reason> -- get po -A",
Args: cobra.MinimumNArgs(2),
RunE: runElevate,
SilenceUsage: true,
}
Expand Down
6 changes: 3 additions & 3 deletions pkg/cli/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,23 +83,23 @@ 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",
proxies: []string{"https://dummy.com", "https://dummy.proxy"},
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",
proxies: []string{"-", "gellso", "https://dummy.com"},
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 {
Expand Down
25 changes: 23 additions & 2 deletions pkg/elevate/elevate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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
}
Expand Down
114 changes: 114 additions & 0 deletions pkg/elevate/elevate_context.go
Original file line number Diff line number Diff line change
@@ -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
}
77 changes: 60 additions & 17 deletions pkg/elevate/elevate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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{},
Expand All @@ -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) {
Expand Down Expand Up @@ -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")
}
Expand All @@ -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")
}
})
}
Loading

0 comments on commit 391bf80

Please sign in to comment.