-
Notifications
You must be signed in to change notification settings - Fork 137
Harden windowless kubectl exec authentication #12950
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
Closed
brooke-hamilton
wants to merge
4
commits into
main
from
brooke-hamilton-harden-windowless-kubectl-execution-and
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
d66fc24
Harden windowless kubectl exec authentication
brooke-hamilton 91c2ddb
Clarify the PostgreSQL helper documentation link
brooke-hamilton c7414ba
Recognize pgbackup in the spelling dictionary
brooke-hamilton da8010a
Preserve kubectl context selection and preflight cancellation
brooke-hamilton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -961,6 +961,7 @@ periodSeconds | |
| persistentVolume | ||
| persistentVolumes | ||
| pfx | ||
| pgbackup | ||
| pkgs | ||
| plaidResource | ||
| plainHTTP | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| /* | ||
| Copyright 2026 The Radius Authors. | ||
|
|
||
| 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 | ||
|
|
||
| http://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 pgbackup | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "os/exec" | ||
|
|
||
| "github.com/radius-project/radius/pkg/process" | ||
| "k8s.io/client-go/tools/clientcmd" | ||
| "k8s.io/client-go/tools/clientcmd/api" | ||
| ) | ||
|
|
||
| func kubectlCommand(ctx context.Context, kubeContext, namespace string, args ...string) (*exec.Cmd, error) { | ||
| if process.IsWindowless() { | ||
| if err := ctx.Err(); err != nil { | ||
| return nil, err | ||
| } | ||
| err := validateExecAuth(kubeContext) | ||
| if ctxErr := ctx.Err(); ctxErr != nil { | ||
| return nil, ctxErr | ||
| } | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| } | ||
|
|
||
| args = append([]string{"--context", kubeContext, "-n", namespace}, args...) | ||
| return process.CommandContext(ctx, "kubectl", args...), nil | ||
| } | ||
|
|
||
| func validateExecAuth(kubeContext string) error { | ||
| // Use kubectl's file-list merging and API defaults, but never migrate or write | ||
| // kubeconfig files during this read-only check. | ||
| rules := clientcmd.NewDefaultClientConfigLoadingRules() | ||
| rules.MigrationRules = nil | ||
| config, err := rules.Load() | ||
| if err != nil { | ||
| return fmt.Errorf("failed to load kubectl configuration: %w", err) | ||
| } | ||
|
|
||
| overrides := &clientcmd.ConfigOverrides{ | ||
| CurrentContext: kubeContext, | ||
| ClusterDefaults: clientcmd.ClusterDefaults, | ||
| } | ||
| // MergedRawConfig validates only the selected configuration, without creating | ||
| // a transport, authenticating, or executing a credential plugin. | ||
| selected, err := clientcmd.NewNonInteractiveClientConfig(*config, kubeContext, overrides, rules).MergedRawConfig() | ||
| if err != nil { | ||
| return fmt.Errorf("invalid kubectl configuration: %w", err) | ||
| } | ||
|
|
||
| selectedContext := selected.Contexts[selected.CurrentContext] | ||
| auth := selected.AuthInfos[selectedContext.AuthInfo] | ||
| if auth.Exec != nil && auth.Exec.InteractiveMode == api.AlwaysExecInteractiveMode { | ||
| return fmt.Errorf("kubectl context %q requires interactive exec authentication (interactiveMode: Always), but Radius is running without a Windows console; configure non-interactive Kubernetes credentials or run Radius from an attached console", selected.CurrentContext) | ||
| } | ||
| return nil | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| /* | ||
| Copyright 2026 The Radius Authors. | ||
|
|
||
| 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 | ||
|
|
||
| http://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 pgbackup | ||
|
|
||
| import ( | ||
| "context" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "strings" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| "k8s.io/client-go/tools/clientcmd" | ||
| "k8s.io/client-go/tools/clientcmd/api" | ||
| ) | ||
|
|
||
| func TestValidateExecAuth(t *testing.T) { | ||
| installKubectlHelpers(t) | ||
| for _, tt := range []struct { | ||
| name string | ||
| mode api.ExecInteractiveMode | ||
| version string | ||
| wantErr string | ||
| }{ | ||
| {name: "never", mode: api.NeverExecInteractiveMode, version: "v1"}, | ||
| {name: "if available", mode: api.IfAvailableExecInteractiveMode, version: "v1"}, | ||
| {name: "always", mode: api.AlwaysExecInteractiveMode, version: "v1", wantErr: "interactiveMode: Always"}, | ||
| {name: "v1 requires mode", version: "v1", wantErr: "interactiveMode must be specified"}, | ||
| {name: "v1beta1 defaults mode", version: "v1beta1"}, | ||
| {name: "invalid mode", mode: "sometimes", version: "v1beta1", wantErr: "invalid interactiveMode"}, | ||
| } { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| path := writeKubeconfig(t, execAuthConfig(tt.mode, "client.authentication.k8s.io/"+tt.version)) | ||
| t.Setenv("KUBECONFIG", path) | ||
| err := validateExecAuth(testContext) | ||
| if tt.wantErr != "" { | ||
| require.ErrorContains(t, err, tt.wantErr) | ||
| } else { | ||
| require.NoError(t, err) | ||
| } | ||
| require.NoFileExists(t, os.Getenv(helperPluginEnv)) | ||
| }) | ||
| } | ||
|
|
||
| for _, tt := range []struct { | ||
| name string | ||
| modify func(*api.Config) | ||
| context string | ||
| wantErr string | ||
| }{ | ||
| {name: "no exec auth", modify: func(c *api.Config) { c.AuthInfos["selected-user"].Exec = nil }}, | ||
| {name: "unused invalid exec", modify: func(c *api.Config) { c.AuthInfos["unused-user"].Exec.InteractiveMode = "invalid" }}, | ||
| {name: "explicit context overrides current", context: testContext, modify: func(c *api.Config) { c.CurrentContext = "unused" }}, | ||
| {name: "explicit context ignores missing current", context: testContext, modify: func(c *api.Config) { c.CurrentContext = "missing" }}, | ||
| {name: "explicit context without exec ignores missing current", context: testContext, modify: func(c *api.Config) { | ||
| c.CurrentContext = "missing" | ||
| c.AuthInfos["selected-user"].Exec = nil | ||
| }}, | ||
| {name: "current context is used", modify: func(c *api.Config) { c.CurrentContext = "unused" }, wantErr: "interactiveMode: Always"}, | ||
| {name: "missing context", context: "missing", wantErr: "context"}, | ||
| {name: "missing current context", modify: func(c *api.Config) { c.CurrentContext = "missing" }, wantErr: "context"}, | ||
| {name: "missing command", modify: func(c *api.Config) { c.AuthInfos["selected-user"].Exec.Command = "" }, wantErr: "command must be specified"}, | ||
| {name: "missing API version", modify: func(c *api.Config) { c.AuthInfos["selected-user"].Exec.APIVersion = "" }, wantErr: "apiVersion must be specified"}, | ||
| {name: "conflicting auth", modify: func(c *api.Config) { | ||
| c.AuthInfos["selected-user"].AuthProvider = &api.AuthProviderConfig{Name: "unused"} | ||
| }, wantErr: "authProvider cannot be provided"}, | ||
| {name: "relative missing CA", modify: func(c *api.Config) { c.Clusters["cluster"].CertificateAuthority = "missing-ca" }, wantErr: "unable to read certificate-authority"}, | ||
| } { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| config := execAuthConfig(api.NeverExecInteractiveMode, "client.authentication.k8s.io/v1") | ||
| if tt.modify != nil { | ||
| tt.modify(&config) | ||
| } | ||
| t.Setenv("KUBECONFIG", writeKubeconfig(t, config)) | ||
| err := validateExecAuth(tt.context) | ||
| if tt.wantErr != "" { | ||
| require.ErrorContains(t, err, tt.wantErr) | ||
| } else { | ||
| require.NoError(t, err) | ||
| } | ||
| require.NoFileExists(t, os.Getenv(helperPluginEnv)) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func mergedKubeconfig(t *testing.T, mode api.ExecInteractiveMode) string { | ||
| t.Helper() | ||
| first := *api.NewConfig() | ||
| first.CurrentContext = testContext | ||
| first.Contexts[testContext] = &api.Context{Cluster: "cluster", AuthInfo: "selected-user"} | ||
| first.AuthInfos["selected-user"] = &api.AuthInfo{Exec: &api.ExecConfig{ | ||
| Command: "credential-plugin", APIVersion: "client.authentication.k8s.io/v1", InteractiveMode: mode, | ||
| }} | ||
| second := execAuthConfig(api.AlwaysExecInteractiveMode, "client.authentication.k8s.io/v1") | ||
| second.CurrentContext = "unused" | ||
| second.Contexts[testContext] = &api.Context{Cluster: "cluster", AuthInfo: "unused-user"} | ||
| firstPath := writeKubeconfig(t, first) | ||
| return strings.Join([]string{firstPath, "", filepath.Join(t.TempDir(), "missing"), writeKubeconfig(t, second), firstPath}, string(os.PathListSeparator)) | ||
| } | ||
|
|
||
| func TestValidateExecAuth_Merging(t *testing.T) { | ||
| installKubectlHelpers(t) | ||
| for _, mode := range []api.ExecInteractiveMode{api.NeverExecInteractiveMode, api.AlwaysExecInteractiveMode} { | ||
| t.Run(string(mode), func(t *testing.T) { | ||
| t.Setenv("KUBECONFIG", mergedKubeconfig(t, mode)) | ||
| for _, kubeContext := range []string{"", testContext} { | ||
| err := validateExecAuth(kubeContext) | ||
| if mode == api.AlwaysExecInteractiveMode { | ||
| require.ErrorContains(t, err, "interactiveMode: Always") | ||
| } else { | ||
| require.NoError(t, err, "first user and context win; cluster comes from the second file") | ||
| } | ||
| } | ||
| require.NoFileExists(t, os.Getenv(helperPluginEnv)) | ||
| }) | ||
| } | ||
| t.Run("bad second file is not ignored", func(t *testing.T) { | ||
| good := writeKubeconfig(t, execAuthConfig(api.NeverExecInteractiveMode, "client.authentication.k8s.io/v1")) | ||
| bad := filepath.Join(t.TempDir(), "bad") | ||
| require.NoError(t, os.WriteFile(bad, []byte("not: [valid yaml"), 0o600)) | ||
| t.Setenv("KUBECONFIG", good+string(os.PathListSeparator)+bad) | ||
| require.ErrorContains(t, validateExecAuth(testContext), "failed to load kubectl configuration") | ||
| }) | ||
| } | ||
|
|
||
| func TestValidateExecAuth_DefaultHome(t *testing.T) { | ||
| const helperEnv = "RADIUS_PGBACKUP_TEST_HOME" | ||
| if os.Getenv(helperEnv) != "" { | ||
| require.Equal(t, filepath.Join(os.Getenv(helperEnv), ".kube", "config"), clientcmd.RecommendedHomeFile) | ||
| require.ErrorContains(t, validateExecAuth(""), "interactiveMode: Always") | ||
| return | ||
| } | ||
| home := t.TempDir() | ||
| config := execAuthConfig(api.AlwaysExecInteractiveMode, "client.authentication.k8s.io/v1") | ||
| require.NoError(t, clientcmd.WriteToFile(config, filepath.Join(home, ".kube", "config"))) | ||
| ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) | ||
| defer cancel() | ||
| cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestValidateExecAuth_DefaultHome$") | ||
| cmd.Env = append(os.Environ(), helperEnv+"="+home, "HOME="+home, "USERPROFILE="+home, "KUBECONFIG=") | ||
| out, err := cmd.CombinedOutput() | ||
| require.NoError(t, err, string(out)) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| //go:build !windows | ||
|
|
||
| /* | ||
| Copyright 2026 The Radius Authors. | ||
|
|
||
| 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 | ||
|
|
||
| http://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 pgbackup | ||
|
|
||
| import "testing" | ||
|
|
||
| func TestKubectl(t *testing.T) { | ||
| testKubectl(t) | ||
| } | ||
|
|
||
| func helperConsoleWindow() bool { | ||
| return false | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.