Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OSD-22639: prompt, store and reuse elevate reason #406

Merged
merged 3 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ To setup the PS1(prompt) for bash/zsh, please follow [these instructions](https:
| `ocm backplane console [flags]` | Launch the OpenShift console of the current logged in cluster |
| `ocm backplane cloud console` | Launch the current logged in cluster's cloud provider console |
| `ocm backplane cloud credentials [flags]` | Retrieve a set of temporary cloud credentials for the cluster's cloud provider |
| `ocm backplane elevate <reason> -- <command>` | Elevate privileges to backplane-cluster-admin and add a reason to the api request |
| `ocm backplane elevate <reason> -- <command>` | Elevate privileges to backplane-cluster-admin and add a reason to the api request, this reason will be stored for 20min for future usage |
| `ocm backplane monitoring <prometheus/alertmanager/thanos/grafana> [flags]` | Launch the specified monitoring UI (Deprecated following v4.11 for cluster monitoring stack) |
| `ocm backplane script describe <script> [flags]` | Describe the given backplane script |
| `ocm backplane script list [flags]` | List available backplane scripts |
Expand Down Expand Up @@ -268,6 +268,64 @@ Folowing command delete the session
```
ocm backplane session --delete <session-name>
```
## Backplane elevate
If you need to run some oc command(s) with elevation using backplane-cluster-admin user, you can use the elevate command for this.

Backplane elevate takes as first positional argument the reason for this elevation. If the first argument is an empty string, then it will be considered as an empty reason, but you cannot just skip the reason argument if you provide also other positional argument(s).
If you want to not provide an empty string as reason, you can use the -n/--no-reason option and oc command will start at first positional argument.

The elevate command requires a none empty reason for the elevation. When a reason is provided it will be used for future usage, in order you do not have to provide a reason for each elevation commands. The reasons are stored in the kubeconfig context, so it is valid only for the cluster for which it has been provided. When a reason is created/used, the last used reason timestamp is updated in the context, and the reason will be kept for 20min after its last usage, in order to avoid bad usage.
Tof1973 marked this conversation as resolved.
Show resolved Hide resolved

When you use the elevate command with an empty reason, it will look if a non expired reason is stored in the current context for this server, and if there is one it will use it. If there is no reason stored in current context, then if the stdin and stderr are not redirected to pipe or file, a prompt will be done to ask for the reason.

### Run an elevate command with reason
```
$ ocm-backplane evate 'OHSS-xxxxxx' -- get secret xxx
Tof1973 marked this conversation as resolved.
Show resolved Hide resolved
```
The provided reason will be used for elevation, but also stored for future elevation on this cluster.
If a reason was already stored in the current_context, then this provided reason will be added to it.

### Run an elevate command with empty reason
If you run the elevate command with an empty reason for the first time (or after the expiration), then you will be prompt for the reason if possible
```
$ ocm-backplane elevate '' -- get secret xxx
Please enter a reason for elevation, it will be stored in current context for 20 minutes : <here you can enter your reason>
```
or
```
$ ocm-backplane elevate -n -- get secret xxx
Please enter a reason for elevation, it will be stored in current context for 20 minutes : <here you can enter your reason>
```
If then you rerun an elevate command, for the same cluster, before the expiration delay, no prompt will be done and previous reason will be used for elevation.

### Run elevate without command
You can initialize the reson context for a cluster without running a command, then the reason will be used for future commands
```
$ ocm-backplane elevate 'OHSS-xxxxxx'
```
or you can not provide the reason and will be prompt for it if needed
```
$ ocm-backplane elevate
Please enter a reason for elevation, it will be stored in current context for 20 minutes : <here you can enter your reason>
```

### Run elevate without (stored) reason and without valid prompt

If a prompt is required but that stdin and/or stderr are redirected to file or output, then an error will be generated.
```
$ cat patch.json | ocm-backplane elevate -n -- patch -f -
ERRO[0000] please enter a reason for elevation
$ ocm-backplane elevate -n -- get secret xxx 2> error.txt
ERRO[0000] please enter a reason for elevation
```
In order to avoid those errors, you can either run the the elevate without command before or provide a none empty reason.

No issue if only stdout is redirected.
```
$ ocm-backplane elevate -n -- get secret xxx | grep xxx
Please enter a reason for elevation, it will be stored in current context for 20 minutes : <here you can enter your reason>
```

## Promotion/Release cycle of backplane CLI
Backplane CLI has a default release cycle of every 2 weeks

Expand Down
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
27 changes: 23 additions & 4 deletions cmd/ocm-backplane/elevate/elevate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,35 @@ import (
"github.com/spf13/cobra"
)

var noReason bool
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,
}

func init() {
ElevateCmd.Flags().BoolVarP(
&noReason,
"no-reason",
"n",
false,
"Do not take reason as first argument, and prompt for it if needed and possible.",
)
}

func runElevate(cmd *cobra.Command, argv []string) error {
if noReason {
argv = append([]string{""}, argv...)
}
return elevate.RunElevate(argv)
}
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
Tof1973 marked this conversation as resolved.
Show resolved Hide resolved
)

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
}
Loading