Skip to content
51 changes: 51 additions & 0 deletions cmd/crossplane/common/kube/impersonation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
Copyright 2026 The Crossplane 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 kube contains shared helpers for crossplane CLI commands that talk
// to a Kubernetes cluster.
package kube

import "k8s.io/client-go/rest"

// ImpersonationFlags are the kubectl-compatible privilege-elevation flags
// (--as, --as-group, --as-uid). Embed it into a command's Kong flag struct with
// the `embed:""` tag, then call Apply on the command's *rest.Config before
// building its client.
type ImpersonationFlags struct {
As string `help:"Username to impersonate for the operation. User could be a regular user or a service account in a namespace." name:"as"`
AsGroup []string `help:"Group to impersonate for the operation, this flag can be repeated to specify multiple groups." name:"as-group" sep:"none"`
AsUID string `help:"UID to impersonate for the operation." name:"as-uid"`
}

// Apply sets impersonation on the given rest.Config. Unset fields and a nil cfg
// are no-ops, so it is always safe to call.
func (f ImpersonationFlags) Apply(cfg *rest.Config) {
if cfg == nil {
return
}

if f.As != "" {
cfg.Impersonate.UserName = f.As
}

if f.AsUID != "" {
cfg.Impersonate.UID = f.AsUID
}

if len(f.AsGroup) > 0 {
cfg.Impersonate.Groups = f.AsGroup
}
}
121 changes: 121 additions & 0 deletions cmd/crossplane/common/kube/impersonation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
Copyright 2026 The Crossplane 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 kube

import (
"testing"

"github.com/alecthomas/kong"
"github.com/google/go-cmp/cmp"
"k8s.io/client-go/rest"
)

func TestApply(t *testing.T) {
type args struct {
flags ImpersonationFlags
cfg *rest.Config
}
type want struct {
impersonate rest.ImpersonationConfig
}

cases := map[string]struct {
reason string
args args
want want
}{
"Empty": {
reason: "No flags set should leave Impersonate empty.",
args: args{flags: ImpersonationFlags{}, cfg: &rest.Config{}},
want: want{impersonate: rest.ImpersonationConfig{}},
},
"UserOnly": {
reason: "Only --as sets UserName.",
args: args{flags: ImpersonationFlags{As: "jane@example.com"}, cfg: &rest.Config{}},
want: want{impersonate: rest.ImpersonationConfig{UserName: "jane@example.com"}},
},
"GroupsOnly": {
reason: "Only --as-group sets Groups.",
args: args{flags: ImpersonationFlags{AsGroup: []string{"team-a", "team-b"}}, cfg: &rest.Config{}},
want: want{impersonate: rest.ImpersonationConfig{Groups: []string{"team-a", "team-b"}}},
},
"UIDOnly": {
reason: "Only --as-uid sets UID.",
args: args{flags: ImpersonationFlags{AsUID: "1000"}, cfg: &rest.Config{}},
want: want{impersonate: rest.ImpersonationConfig{UID: "1000"}},
},
"All": {
reason: "All flags set all fields.",
args: args{
flags: ImpersonationFlags{As: "system:serviceaccount:team-a:reader", AsGroup: []string{"team-a-admins"}, AsUID: "42"},
cfg: &rest.Config{},
},
want: want{impersonate: rest.ImpersonationConfig{UserName: "system:serviceaccount:team-a:reader", Groups: []string{"team-a-admins"}, UID: "42"}},
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
tc.args.flags.Apply(tc.args.cfg)
if diff := cmp.Diff(tc.want.impersonate, tc.args.cfg.Impersonate); diff != "" {
t.Errorf("%s\nApply(...): -want, +got:\n%s", tc.reason, diff)
}
})
}
}

func TestApplyNilConfig(_ *testing.T) {
// Must not panic on a nil config.
ImpersonationFlags{As: "jane"}.Apply(nil)
}

func TestImpersonationFlagsParse(t *testing.T) {
cases := map[string]struct {
reason string
args []string
want ImpersonationFlags
}{
"Repeatable": {
reason: "--as-group can be repeated to specify multiple groups.",
args: []string{"--as=jane", "--as-group=team-a", "--as-group=team-b", "--as-uid=42"},
want: ImpersonationFlags{As: "jane", AsGroup: []string{"team-a", "team-b"}, AsUID: "42"},
},
"NoCommaSplit": {
reason: "sep:none means a comma is part of the group name, matching kubectl.",
args: []string{"--as-group=team-a,team-b"},
want: ImpersonationFlags{AsGroup: []string{"team-a,team-b"}},
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
var cli struct {
Impersonation ImpersonationFlags `embed:""`
}
p, err := kong.New(&cli)
if err != nil {
t.Fatalf("%s\nkong.New(): unexpected error: %v", tc.reason, err)
}
if _, err := p.Parse(tc.args); err != nil {
t.Fatalf("%s\nParse(%v): unexpected error: %v", tc.reason, tc.args, err)
}
if diff := cmp.Diff(tc.want, cli.Impersonation); diff != "" {
t.Errorf("%s\nParse(%v): -want, +got:\n%s", tc.reason, tc.args, diff)
}
})
}
}
47 changes: 40 additions & 7 deletions cmd/crossplane/completion/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"k8s.io/client-go/tools/clientcmd"
controllerClient "sigs.k8s.io/controller-runtime/pkg/client"

"github.com/crossplane/cli/v2/cmd/crossplane/common/kube"
"github.com/crossplane/cli/v2/cmd/crossplane/internal"
)

Expand Down Expand Up @@ -48,7 +49,7 @@ func Predictors() map[string]complete.Predictor {
// last completed argument.
func kubernetesResourcePredictor() complete.PredictFunc {
return func(a complete.Args) []string {
_, kubeconfig, _, err := kubernetesClient(parseConfigOverride(a))
_, kubeconfig, _, err := kubernetesClient(parseConfigOverride(a), parseImpersonation(a))
if err != nil {
return nil
}
Expand Down Expand Up @@ -103,7 +104,7 @@ func kubernetesResourcePredictor() complete.PredictFunc {
// last completed argument.
func kubernetesResourceNamePredictor() complete.PredictFunc {
return func(a complete.Args) []string {
client, kubeconfig, clientconfig, err := kubernetesClient(parseConfigOverride(a))
client, kubeconfig, clientconfig, err := kubernetesClient(parseConfigOverride(a), parseImpersonation(a))
if err != nil {
return nil
}
Expand Down Expand Up @@ -189,7 +190,7 @@ func contextPredictor() complete.PredictFunc {
// last completed argument.
func namespacePredictor() complete.PredictFunc {
return func(a complete.Args) []string {
client, err := kubernetesClientset()
client, err := kubernetesClientset(parseImpersonation(a))
if err != nil {
return nil
}
Expand All @@ -211,8 +212,9 @@ func namespacePredictor() complete.PredictFunc {
}
}

// kubernetesClientset returns a Kubernetes clientset using the default kubeconfig.
func kubernetesClientset() (*kubernetes.Clientset, error) {
// kubernetesClientset returns a Kubernetes clientset using the default
// kubeconfig and the given impersonation flags.
func kubernetesClientset(imp kube.ImpersonationFlags) (*kubernetes.Clientset, error) {
clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
clientcmd.NewDefaultClientConfigLoadingRules(),
&clientcmd.ConfigOverrides{},
Expand All @@ -223,11 +225,14 @@ func kubernetesClientset() (*kubernetes.Clientset, error) {
return nil, err
}

imp.Apply(kubeConfig)

return kubernetes.NewForConfig(kubeConfig)
}

// kubernetesClient returns a Kubernetes client and a rest.Config using the provided config overrides.
func kubernetesClient(configOverrides *clientcmd.ConfigOverrides) (controllerClient.Client, *rest.Config, clientcmd.ClientConfig, error) {
// kubernetesClient returns a Kubernetes client and a rest.Config using the
// provided config overrides and impersonation flags.
func kubernetesClient(configOverrides *clientcmd.ConfigOverrides, imp kube.ImpersonationFlags) (controllerClient.Client, *rest.Config, clientcmd.ClientConfig, error) {
clientconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
clientcmd.NewDefaultClientConfigLoadingRules(),
configOverrides,
Expand All @@ -238,6 +243,8 @@ func kubernetesClient(configOverrides *clientcmd.ConfigOverrides) (controllerCli
return nil, nil, nil, err
}

imp.Apply(kubeconfig)

client, err := controllerClient.New(rest.CopyConfig(kubeconfig), controllerClient.Options{})
if err != nil {
return nil, nil, nil, err
Expand All @@ -262,6 +269,32 @@ func parseConfigOverride(a complete.Args) *clientcmd.ConfigOverrides {
}
}

// parseImpersonation parses the kubectl-compatible impersonation flags (--as,
// --as-group, --as-uid) from the completed command line arguments. Supports
// both "--flag value" and "--flag=value" forms; --as-group may be repeated.
func parseImpersonation(a complete.Args) kube.ImpersonationFlags {
var imp kube.ImpersonationFlags

for i, arg := range a.All {
switch {
case arg == "--as" && i+1 < len(a.All):
imp.As = a.All[i+1]
case strings.HasPrefix(arg, "--as="):
imp.As = strings.TrimPrefix(arg, "--as=")
case arg == "--as-uid" && i+1 < len(a.All):
imp.AsUID = a.All[i+1]
case strings.HasPrefix(arg, "--as-uid="):
imp.AsUID = strings.TrimPrefix(arg, "--as-uid=")
case arg == "--as-group" && i+1 < len(a.All):
imp.AsGroup = append(imp.AsGroup, a.All[i+1])
case strings.HasPrefix(arg, "--as-group="):
imp.AsGroup = append(imp.AsGroup, strings.TrimPrefix(arg, "--as-group="))
}
}

return imp
}

// parseNamespaceOverride parses the namespace override from the completed command line arguments.
func parseNamespaceOverride(a complete.Args) string {
namespace := ""
Expand Down
48 changes: 48 additions & 0 deletions cmd/crossplane/completion/completion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package completion

import (
"testing"

"github.com/google/go-cmp/cmp"
"github.com/posener/complete"

"github.com/crossplane/cli/v2/cmd/crossplane/common/kube"
)

func TestParseImpersonation(t *testing.T) {
cases := map[string]struct {
reason string
args []string
want kube.ImpersonationFlags
}{
"Equals": {
reason: "The --flag=value form is parsed.",
args: []string{"trace", "x", "--as=jane", "--as-uid=42", "--as-group=team-a"},
want: kube.ImpersonationFlags{As: "jane", AsUID: "42", AsGroup: []string{"team-a"}},
},
"Space": {
reason: "The --flag value form is parsed.",
args: []string{"--as", "jane", "--as-uid", "42", "--as-group", "team-a"},
want: kube.ImpersonationFlags{As: "jane", AsUID: "42", AsGroup: []string{"team-a"}},
},
"RepeatableGroups": {
reason: "--as-group can be repeated.",
args: []string{"--as-group=team-a", "--as-group", "team-b"},
want: kube.ImpersonationFlags{AsGroup: []string{"team-a", "team-b"}},
},
"None": {
reason: "No impersonation flags yields the zero value.",
args: []string{"trace", "x"},
want: kube.ImpersonationFlags{},
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := parseImpersonation(complete.Args{All: tc.args})
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("%s\nparseImpersonation(): -want, +got:\n%s", tc.reason, diff)
}
})
}
}
6 changes: 6 additions & 0 deletions cmd/crossplane/top/top.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import (
"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
"github.com/crossplane/crossplane-runtime/v2/pkg/logging"

"github.com/crossplane/cli/v2/cmd/crossplane/common/kube"

_ "embed"
)

Expand All @@ -58,6 +60,8 @@ const (
type Cmd struct {
Summary bool `help:"Adds summary header for all Crossplane pods." name:"summary" short:"s"`
Namespace string `default:"crossplane-system" help:"Show pods from a specific namespace, defaults to crossplane-system." name:"namespace" predictor:"namespace" short:"n"`

Impersonation kube.ImpersonationFlags `embed:""`
}

// Help returns help instructions for the top command.
Expand Down Expand Up @@ -103,6 +107,8 @@ func (c *Cmd) Run(k *kong.Context, logger logging.Logger) error {
return errors.Wrap(err, errKubeConfig)
}

c.Impersonation.Apply(config)

logger.Debug("Found kubeconfig")

// Create the clientset for Kubernetes
Expand Down
Loading