diff --git a/cmd/d8/root.go b/cmd/d8/root.go index 3581426e..3de206cd 100644 --- a/cmd/d8/root.go +++ b/cmd/d8/root.go @@ -41,12 +41,12 @@ import ( "github.com/deckhouse/deckhouse-cli/cmd/plugins/flags" backup "github.com/deckhouse/deckhouse-cli/internal/backup/cmd" data "github.com/deckhouse/deckhouse-cli/internal/data/cmd" + iam "github.com/deckhouse/deckhouse-cli/internal/iam/cmd" mirror "github.com/deckhouse/deckhouse-cli/internal/mirror/cmd" "github.com/deckhouse/deckhouse-cli/internal/network" status "github.com/deckhouse/deckhouse-cli/internal/status/cmd" system "github.com/deckhouse/deckhouse-cli/internal/system/cmd" "github.com/deckhouse/deckhouse-cli/internal/tools" - useroperation "github.com/deckhouse/deckhouse-cli/internal/useroperation/cmd" "github.com/deckhouse/deckhouse-cli/internal/version" "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" ) @@ -107,7 +107,7 @@ func (r *RootCommand) registerCommands() { r.cmd.AddCommand(data.NewCommand()) r.cmd.AddCommand(mirror.NewCommand()) r.cmd.AddCommand(status.NewCommand()) - r.cmd.AddCommand(useroperation.NewCommand()) + r.cmd.AddCommand(iam.NewCommand()) r.cmd.AddCommand(network.NewCommand()) r.cmd.AddCommand(tools.NewCommand()) r.cmd.AddCommand(commands.NewVirtualizationCommand()) diff --git a/go.mod b/go.mod index 7d907b97..8dd57cd7 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/werf/werf/v2 v2.63.1 gitlab.com/greyxor/slogor v1.2.11 go.cypherpunks.ru/gogost/v5 v5.13.0 + golang.org/x/crypto v0.46.0 golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 golang.org/x/term v0.38.0 gopkg.in/yaml.v3 v3.0.1 @@ -604,7 +605,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.29.0 // indirect diff --git a/go.sum b/go.sum index a388bb87..0aeba928 100644 --- a/go.sum +++ b/go.sum @@ -420,8 +420,6 @@ github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80N github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/deckhouse/deckhouse/pkg/log v0.2.0 h1:6tmZQLwNb1o/hP1gzJQBjcwfA/bubbgObovXzxq+Exo= github.com/deckhouse/deckhouse/pkg/log v0.2.0/go.mod h1:pbAxTSDcPmwyl3wwKDcEB3qdxHnRxqTV+J0K+sha8bw= -github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260414071219-2e3c2ca0fbaf h1:TqeCxB/ys6yx+pPB94rBJf+DbvbuamQxUFcu5dNWiRc= -github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260414071219-2e3c2ca0fbaf/go.mod h1:KDf44MqEif8jAKCehKJqOg0k4sJcnetKJKDGd0IFQjI= github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260414112803-53a5662881d9 h1:Il2d6wB6SdgjmD5ojC48qT9eQyITuANzIFjSd0DCdUI= github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260414112803-53a5662881d9/go.mod h1:KDf44MqEif8jAKCehKJqOg0k4sJcnetKJKDGd0IFQjI= github.com/deckhouse/delivery-kit-sdk v1.0.1-0.20251022121655-0cbac8223333 h1:9YuKuSrRfTSdAaLIUwi0t4bkedoNgj3IMFXPcP/rkSo= diff --git a/internal/iam/access/cmd/access.go b/internal/iam/access/cmd/access.go new file mode 100644 index 00000000..c5f7cea9 --- /dev/null +++ b/internal/iam/access/cmd/access.go @@ -0,0 +1,51 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/system/flags" +) + +var accessLong = templates.LongDesc(` +Grant and revoke access in Deckhouse (current authz model). + +For inspecting existing rules and effective access (warnings, group +cycles, manual rules), use the top-level "d8 iam get" / "d8 iam list". + +© Flant JSC 2026`) + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "access", + Short: "Grant or revoke access (current authz model)", + Long: accessLong, + SilenceErrors: true, + SilenceUsage: true, + } + + flags.AddPersistentFlags(cmd) + + cmd.AddCommand( + newGrantCommand(), + newRevokeCommand(), + ) + + return cmd +} diff --git a/internal/iam/access/cmd/access_test.go b/internal/iam/access/cmd/access_test.go new file mode 100644 index 00000000..3c41221e --- /dev/null +++ b/internal/iam/access/cmd/access_test.go @@ -0,0 +1,242 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" +) + +func TestParseSubjectKind(t *testing.T) { + tests := []struct { + input string + want iamtypes.SubjectKind + wantErr string + }{ + {input: "user", want: iamtypes.KindUser}, + {input: "User", want: iamtypes.KindUser}, + {input: "group", want: iamtypes.KindGroup}, + {input: "Group", want: iamtypes.KindGroup}, + {input: "serviceaccount", wantErr: "invalid subject kind"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := parseSubjectKind(tt.input) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestParseScope(t *testing.T) { + tests := []struct { + name string + scope string + namespaces []string + want iamtypes.Scope + wantNS []string + wantLabels map[string]string + wantErr string + }{ + {name: "namespace", namespaces: []string{"dev"}, want: iamtypes.ScopeNamespace, wantNS: []string{"dev"}}, + {name: "namespace multi", namespaces: []string{"dev", "stage"}, want: iamtypes.ScopeNamespace, wantNS: []string{"dev", "stage"}}, + {name: "scope cluster", scope: "cluster", want: iamtypes.ScopeCluster}, + {name: "scope all-namespaces", scope: "all-namespaces", want: iamtypes.ScopeAllNamespaces}, + {name: "scope all alias", scope: "all", want: iamtypes.ScopeAllNamespaces}, + {name: "scope labels single", scope: "labels=team=platform", want: iamtypes.ScopeLabels, wantLabels: map[string]string{"team": "platform"}}, + {name: "scope labels multi", scope: "labels=team=platform,tier=prod", want: iamtypes.ScopeLabels, wantLabels: map[string]string{"team": "platform", "tier": "prod"}}, + {name: "none", wantErr: "one of"}, + {name: "namespace + scope mutually exclusive", scope: "cluster", namespaces: []string{"dev"}, wantErr: "mutually exclusive"}, + {name: "invalid scope", scope: "global", wantErr: "invalid --scope"}, + {name: "labels empty", scope: "labels=", wantErr: "labels=... must contain"}, + {name: "labels malformed pair", scope: "labels=team", wantErr: "expected key=value"}, + {name: "labels empty key", scope: "labels==prod", wantErr: "key and value must be non-empty"}, + {name: "labels duplicate key", scope: "labels=team=a,team=b", wantErr: "duplicate label key"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ns, labels, err := parseScope(tt.scope, tt.namespaces) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantNS, ns) + assert.Equal(t, tt.wantLabels, labels) + }) + } +} + +func TestParseLabelMatch(t *testing.T) { + got, err := parseLabelMatch("team=platform,tier=prod") + require.NoError(t, err) + assert.Equal(t, map[string]string{"team": "platform", "tier": "prod"}, got) + + got, err = parseLabelMatch("k=v") + require.NoError(t, err) + assert.Equal(t, map[string]string{"k": "v"}, got) +} + +func TestValidateAccessLevel(t *testing.T) { + tests := []struct { + name string + level string + namespaced bool + wantErr string + }{ + {name: "User namespaced", level: "User", namespaced: true}, + {name: "Admin namespaced", level: "Admin", namespaced: true}, + {name: "ClusterAdmin namespaced", level: "ClusterAdmin", namespaced: true, wantErr: "not valid for namespaced scope"}, + {name: "SuperAdmin namespaced", level: "SuperAdmin", namespaced: true, wantErr: "not valid for namespaced scope"}, + {name: "User cluster", level: "User", namespaced: false}, + {name: "ClusterAdmin cluster", level: "ClusterAdmin", namespaced: false}, + {name: "SuperAdmin cluster", level: "SuperAdmin", namespaced: false}, + {name: "Invalid", level: "NotALevel", namespaced: false, wantErr: "invalid access level"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAccessLevel(tt.level, tt.namespaced) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestMaxAccessLevel(t *testing.T) { + tests := []struct { + name string + levels []string + want string + }{ + {name: "single", levels: []string{"Admin"}, want: "Admin"}, + {name: "multiple ascending", levels: []string{"User", "Admin"}, want: "Admin"}, + {name: "with cluster levels", levels: []string{"Admin", "ClusterAdmin", "Editor"}, want: "ClusterAdmin"}, + {name: "super admin", levels: []string{"SuperAdmin", "User"}, want: "SuperAdmin"}, + {name: "empty", levels: nil, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, maxAccessLevel(tt.levels)) + }) + } +} + +// TestCanonicalGrantSpec_OldScopesJSONStable freezes the on-disk shape of +// the canonical-spec annotation for the three pre-labels scopes. The field +// is what `createOrUpdateGrant` uses to detect "same spec → no-op", and a +// silent shape change (e.g. losing omitempty on LabelMatch, or changing the +// field order Go's encoding/json emits for these exact fields) would treat +// every previously-applied grant as needing an Update. That would break +// idempotency on upgrade. +func TestCanonicalGrantSpec_OldScopesJSONStable(t *testing.T) { + tests := []struct { + name string + spec *canonicalGrantSpec + want string + }{ + { + name: "namespaced", + spec: &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "Admin", + ScopeType: "namespace", + Namespaces: []string{"dev"}, + }, + want: `{"model":"current","subjectKind":"User","subjectRef":"anton","subjectPrincipal":"anton@abc.com","accessLevel":"Admin","scopeType":"namespace","namespaces":["dev"],"allowScale":false,"portForwarding":false}`, + }, + { + name: "cluster", + spec: &canonicalGrantSpec{ + Model: "current", + SubjectKind: "Group", + SubjectRef: "admins", + SubjectPrincipal: "admins", + AccessLevel: "ClusterAdmin", + ScopeType: "cluster", + }, + want: `{"model":"current","subjectKind":"Group","subjectRef":"admins","subjectPrincipal":"admins","accessLevel":"ClusterAdmin","scopeType":"cluster","allowScale":false,"portForwarding":false}`, + }, + { + name: "all-namespaces", + spec: &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "SuperAdmin", + ScopeType: "all-namespaces", + }, + want: `{"model":"current","subjectKind":"User","subjectRef":"anton","subjectPrincipal":"anton@abc.com","accessLevel":"SuperAdmin","scopeType":"all-namespaces","allowScale":false,"portForwarding":false}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.spec.JSON() + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCanonicalGrantSpec_JSON_Deterministic(t *testing.T) { + spec := &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "Admin", + ScopeType: "namespace", + Namespaces: []string{"stage", "dev"}, + } + + j1, err := spec.JSON() + require.NoError(t, err) + + // Namespaces in different order should produce same JSON (sorted) + spec2 := &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "Admin", + ScopeType: "namespace", + Namespaces: []string{"dev", "stage"}, + } + j2, err := spec2.JSON() + require.NoError(t, err) + + assert.Equal(t, j1, j2, "namespace order should not affect canonical JSON") +} + diff --git a/internal/iam/access/cmd/completion.go b/internal/iam/access/cmd/completion.go new file mode 100644 index 00000000..7cf319e6 --- /dev/null +++ b/internal/iam/access/cmd/completion.go @@ -0,0 +1,111 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +// subjectKinds is the first positional arg of grant/revoke. +var subjectKinds = []string{"user", "group"} + +// allAccessLevelsList aliases the ordered slice in types.go so completion +// and validation share a single source of truth. +var allAccessLevelsList = allAccessLevelsOrdered + +// completeSubjectAndName completes "user|group NAME" positional pairs. +func completeSubjectAndName(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + switch len(args) { + case 0: + return utilk8s.FilterByPrefix(subjectKinds, toComplete), cobra.ShellCompDirectiveNoFileComp + case 1: + switch args[0] { + case "user": + return utilk8s.CompleteResourceNames(cmd, iamtypes.UserGVR, "", toComplete) + case "group": + return utilk8s.CompleteResourceNames(cmd, iamtypes.GroupGVR, "", toComplete) + } + } + return nil, cobra.ShellCompDirectiveNoFileComp +} + +func completeAccessLevels(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return utilk8s.FilterByPrefix(allAccessLevelsList, toComplete), cobra.ShellCompDirectiveNoFileComp +} + +func completeNamespacesFlag(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return utilk8s.CompleteNamespaces(cmd, toComplete) +} + +// completeScopeFlag offers static suggestions for --scope. The labels= form +// is template-only because we cannot reasonably enumerate every possible +// K=V pair the user might want. +func completeScopeFlag(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + candidates := []string{"cluster", "all-namespaces", "labels="} + return utilk8s.FilterByPrefix(candidates, toComplete), cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace +} + +// completeRuleRef completes "CAR/" and "AR//" for +// `d8 iam get rule REF`. Short prefixes (CAR, AR) are preferred in output; +// long prefixes (ClusterAuthorizationRule, AuthorizationRule) are also +// accepted as valid input. +func completeRuleRef(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Pick which branch to fetch by the committed prefix. Short prefixes + // like "C"/"CA" still query both — cobra filters the result. + wantCAR, wantAR := true, true + switch { + case strings.HasPrefix(toComplete, "CAR/"), + strings.HasPrefix(toComplete, "ClusterAuthorizationRule/"): + wantAR = false + case strings.HasPrefix(toComplete, "AR/"), + strings.HasPrefix(toComplete, "AuthorizationRule/"): + wantCAR = false + } + + var suggestions []string + + if wantCAR { + list, err := dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR).List(cmd.Context(), metav1.ListOptions{}) + if err == nil { + for i := range list.Items { + suggestions = append(suggestions, "CAR/"+list.Items[i].GetName()) + } + } + } + if wantAR { + list, err := dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace("").List(cmd.Context(), metav1.ListOptions{}) + if err == nil { + for i := range list.Items { + obj := &list.Items[i] + suggestions = append(suggestions, "AR/"+obj.GetNamespace()+"/"+obj.GetName()) + } + } + } + + return utilk8s.FilterByPrefix(suggestions, toComplete), cobra.ShellCompDirectiveNoFileComp +} diff --git a/internal/iam/access/cmd/grant.go b/internal/iam/access/cmd/grant.go new file mode 100644 index 00000000..97a6f05e --- /dev/null +++ b/internal/iam/access/cmd/grant.go @@ -0,0 +1,504 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "errors" + "fmt" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/spf13/cobra" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/dynamic" + "k8s.io/kubectl/pkg/util/templates" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var grantLong = templates.LongDesc(` +Grant access to a user or group using the current authorization model. + +For users, the subject is resolved by reading User.spec.email from the +cluster, because the current authz model requires email as the subject +name when user-authn is used. + +Specify the scope via -n/--namespace OR --scope (mutually exclusive): + + -n/--namespace NS AuthorizationRule per namespace. Repeat to target + several namespaces. User, PrivilegedUser, Editor, + Admin levels only. + --scope cluster ClusterAuthorizationRule, every namespace EXCEPT + system ones (d8-*, kube-system). + --scope all-namespaces ClusterAuthorizationRule with matchAny: true. + Covers ALL namespaces, including system ones. + --scope labels=K=V[,...] ClusterAuthorizationRule with + namespaceSelector.labelSelector.matchLabels. + +--allow-scale and --port-forwarding add capabilities to any scope. + +© Flant JSC 2026`) + +var grantExample = templates.Examples(` + # Namespaced grants (one AR per --namespace) + d8 iam access grant user anton --access-level Admin -n dev -n stage + + # Cluster-wide (no system namespaces) + d8 iam access grant user anton --access-level ClusterAdmin --scope cluster + + # Cluster-wide including system namespaces + d8 iam access grant user anton --access-level ClusterAdmin --scope all-namespaces + + # Match by namespace labels + d8 iam access grant group admins --access-level Editor --scope labels=team=platform,tier=prod + + # Add port-forwarding capability + d8 iam access grant group admins --access-level Editor -n dev --port-forwarding + + # Preview the manifest without applying + d8 iam access grant user anton --access-level Admin -n dev --dry-run -o yaml`) + +func newGrantCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "grant (user|group) NAME --access-level LEVEL [scope]", + Short: "Grant access to a user or group (current authz model)", + Long: grantLong, + Example: grantExample, + Args: cobra.ExactArgs(2), + ValidArgsFunction: completeSubjectAndName, + SilenceErrors: true, + SilenceUsage: true, + RunE: runGrant, + } + + cmd.Flags().String("access-level", "", "Access level: User, PrivilegedUser, Editor, Admin, ClusterEditor, ClusterAdmin, SuperAdmin") + cmd.Flags().StringSliceP("namespace", "n", nil, "Target namespace(s) for namespaced grants (repeatable). Mutually exclusive with --scope") + cmd.Flags().String("scope", "", "Cluster-wide scope: cluster | all-namespaces | labels=K=V[,K2=V2,...]. Mutually exclusive with -n/--namespace") + cmd.Flags().Bool("port-forwarding", false, "Allow port-forwarding") + cmd.Flags().Bool("allow-scale", false, "Allow scaling workloads") + cmd.Flags().Bool("dry-run", false, "Print the resource(s) that would be created without applying") + utilk8s.AddOutputFlag(cmd, "name", "name", "yaml", "json") + + _ = cmd.RegisterFlagCompletionFunc("access-level", completeAccessLevels) + _ = cmd.RegisterFlagCompletionFunc("namespace", completeNamespacesFlag) + _ = cmd.RegisterFlagCompletionFunc("scope", completeScopeFlag) + + return cmd +} + +func runGrant(cmd *cobra.Command, args []string) error { + subjectKindStr := args[0] + subjectName := args[1] + + accessLevel, _ := cmd.Flags().GetString("access-level") + namespaces, _ := cmd.Flags().GetStringSlice("namespace") + scopeFlag, _ := cmd.Flags().GetString("scope") + portForwarding, _ := cmd.Flags().GetBool("port-forwarding") + allowScale, _ := cmd.Flags().GetBool("allow-scale") + dryRun, _ := cmd.Flags().GetBool("dry-run") + outputFmt, _ := cmd.Flags().GetString("output") + + if accessLevel == "" { + return errors.New("--access-level is required") + } + + subjectKind, err := parseSubjectKind(subjectKindStr) + if err != nil { + return err + } + + scopeType, scopeNS, labelMatch, err := parseScope(scopeFlag, namespaces) + if err != nil { + return err + } + + isNamespaced := scopeType == iamtypes.ScopeNamespace + if err := validateAccessLevel(accessLevel, isNamespaced); err != nil { + return err + } + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + var subjectPrincipal string + switch subjectKind { + case iamtypes.KindUser: + subjectPrincipal, err = resolveUserEmail(cmd.Context(), dyn, subjectName) + if err != nil { + return err + } + case iamtypes.KindGroup: + subjectPrincipal = subjectName + if _, err := dyn.Resource(iamtypes.GroupGVR).Get(cmd.Context(), subjectName, metav1.GetOptions{}); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: local Group CR %q not found. Grant may target an external provider group.\n", subjectName) + } + } + + opts := grantOpts{ + subjectKind: subjectKind, + subjectRef: subjectName, + subjectPrincipal: subjectPrincipal, + accessLevel: accessLevel, + scopeType: scopeType, + namespaces: scopeNS, + labelMatch: labelMatch, + allowScale: allowScale, + portForwarding: portForwarding, + dryRun: dryRun, + outputFmt: outputFmt, + } + return applyGrants(cmd, dyn, opts) +} + +// grantOpts captures everything needed to materialize one or more grants. It +// exists so applyGrants doesn't take 10+ parameters and so the caller can be +// read top-to-bottom without arg-counting. +type grantOpts struct { + subjectKind iamtypes.SubjectKind + subjectRef string // local CR name (User.metadata.name or Group.metadata.name) + subjectPrincipal string // value that ends up in spec.subjects[].name + accessLevel string + scopeType iamtypes.Scope + namespaces []string + labelMatch map[string]string + allowScale bool + portForwarding bool + dryRun bool + outputFmt string +} + +// applyGrants iterates every namespace for namespace-scoped grants and falls +// back to a single iteration for cluster-scoped ones. The resource client is +// chosen from the scope, and from then on the cluster vs namespaced paths are +// identical. +func applyGrants(cmd *cobra.Command, dyn dynamic.Interface, opts grantOpts) error { + specs, err := canonicalGrantSpecs(canonicalGrantInput{ + SubjectKind: opts.subjectKind, + SubjectRef: opts.subjectRef, + SubjectPrincipal: opts.subjectPrincipal, + AccessLevel: opts.accessLevel, + ScopeType: opts.scopeType, + Namespaces: opts.namespaces, + LabelMatch: opts.labelMatch, + AllowScale: opts.allowScale, + PortForwarding: opts.portForwarding, + }) + if err != nil { + return err + } + + var errs *multierror.Error + for _, spec := range specs { + client, err := grantClient(dyn, spec) + if err != nil { + return err + } + obj, err := buildGrantObject(spec) + if err != nil { + return err + } + + if opts.dryRun { + if err := utilk8s.PrintObject(cmd.OutOrStdout(), obj, opts.outputFmt); err != nil { + return err + } + continue + } + + result, err := createOrUpdateGrant(cmd, client, obj, spec) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to create grant for %s: %v\n", grantHumanScope(spec), err) + errs = multierror.Append(errs, fmt.Errorf("%s: %w", grantHumanScope(spec), err)) + continue + } + if err := utilk8s.PrintObject(cmd.OutOrStdout(), result, opts.outputFmt); err != nil { + return err + } + } + return errs.ErrorOrNil() +} + +// canonicalGrantSpecs expands a canonicalGrantInput into one canonicalGrantSpec +// per concrete object that will be created. Namespaced scope produces N specs +// (one per namespace), cluster/all-namespaces/labels produce a single spec. +// Used by both applyGrants and revokeManagedGrants so the two flows agree on +// object identity (since the d8-managed name is derived from the canonical spec). +func canonicalGrantSpecs(in canonicalGrantInput) ([]*canonicalGrantSpec, error) { + base := &canonicalGrantSpec{ + Model: iamtypes.ModelCurrent, + SubjectKind: in.SubjectKind, + SubjectRef: in.SubjectRef, + SubjectPrincipal: in.SubjectPrincipal, + AccessLevel: in.AccessLevel, + ScopeType: in.ScopeType, + AllowScale: in.AllowScale, + PortForwarding: in.PortForwarding, + } + + switch in.ScopeType { + case iamtypes.ScopeNamespace: + if len(in.Namespaces) == 0 { + return nil, errors.New("namespaced grant requires at least one namespace") + } + specs := make([]*canonicalGrantSpec, 0, len(in.Namespaces)) + for _, ns := range in.Namespaces { + if ns == "" { + return nil, errors.New("namespace name must not be empty") + } + s := *base + s.Namespaces = []string{ns} + specs = append(specs, &s) + } + return specs, nil + case iamtypes.ScopeCluster, iamtypes.ScopeAllNamespaces: + return []*canonicalGrantSpec{base}, nil + case iamtypes.ScopeLabels: + if len(in.LabelMatch) == 0 { + return nil, errors.New("labels scope requires at least one K=V pair") + } + s := *base + // copy to avoid sharing the caller's map + s.LabelMatch = make(map[string]string, len(in.LabelMatch)) + for k, v := range in.LabelMatch { + s.LabelMatch[k] = v + } + return []*canonicalGrantSpec{&s}, nil + default: + return nil, fmt.Errorf("unsupported scope %q", in.ScopeType) + } +} + +// grantClient picks the right dynamic.ResourceInterface for a spec. A +// namespaced spec has exactly one namespace at this point (canonicalSpecs +// guarantees that). +func grantClient(dyn dynamic.Interface, spec *canonicalGrantSpec) (dynamic.ResourceInterface, error) { + switch spec.ScopeType { + case iamtypes.ScopeNamespace: + if len(spec.Namespaces) != 1 { + return nil, fmt.Errorf("namespaced grant must target exactly one namespace, got %d", len(spec.Namespaces)) + } + return dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace(spec.Namespaces[0]), nil + case iamtypes.ScopeCluster, iamtypes.ScopeAllNamespaces, iamtypes.ScopeLabels: + return dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR), nil + default: + return nil, fmt.Errorf("unsupported scope %q", spec.ScopeType) + } +} + +// buildGrantObject produces the Unstructured for a grant — AR for namespaced, +// CAR for cluster/all-namespaces/labels. The object kind/apiVersion/namespace +// branch off ScopeType but the rest is shared. +func buildGrantObject(spec *canonicalGrantSpec) (*unstructured.Unstructured, error) { + name, err := generateGrantName(spec) + if err != nil { + return nil, err + } + labels := grantLabels(spec) + annotations, err := grantAnnotations(spec) + if err != nil { + return nil, err + } + + ruleSpec := map[string]any{ + "accessLevel": spec.AccessLevel, + "allowScale": spec.AllowScale, + "portForwarding": spec.PortForwarding, + "subjects": []any{ + map[string]any{ + "kind": string(spec.SubjectKind), + "name": spec.SubjectPrincipal, + }, + }, + } + + metadata := map[string]any{ + "name": name, + "labels": toAnyMap(labels), + "annotations": toAnyMap(annotations), + } + + var apiVersion, kind string + switch spec.ScopeType { + case iamtypes.ScopeNamespace: + // canonicalGrantSpecs guarantees exactly one namespace per spec for + // namespaced scope; recheck it here so this function stays correct + // even if a future caller bypasses the expansion helper. + if len(spec.Namespaces) != 1 { + return nil, fmt.Errorf("namespaced grant must target exactly one namespace, got %d", len(spec.Namespaces)) + } + if spec.Namespaces[0] == "" { + return nil, errors.New("namespaced grant has empty namespace") + } + apiVersion = iamtypes.APIVersionDeckhouseV1Alpha1 + kind = iamtypes.KindAuthorizationRule + metadata["namespace"] = spec.Namespaces[0] + case iamtypes.ScopeCluster: + apiVersion = iamtypes.APIVersionDeckhouseV1 + kind = iamtypes.KindClusterAuthorizationRule + case iamtypes.ScopeAllNamespaces: + apiVersion = iamtypes.APIVersionDeckhouseV1 + kind = iamtypes.KindClusterAuthorizationRule + ruleSpec["namespaceSelector"] = map[string]any{"matchAny": true} + case iamtypes.ScopeLabels: + if len(spec.LabelMatch) == 0 { + return nil, errors.New("labels scope requires at least one label pair") + } + apiVersion = iamtypes.APIVersionDeckhouseV1 + kind = iamtypes.KindClusterAuthorizationRule + matchLabels := make(map[string]any, len(spec.LabelMatch)) + for k, v := range spec.LabelMatch { + matchLabels[k] = v + } + ruleSpec["namespaceSelector"] = map[string]any{ + "labelSelector": map[string]any{ + "matchLabels": matchLabels, + }, + } + default: + return nil, fmt.Errorf("unsupported scope %q", spec.ScopeType) + } + + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": metadata, + "spec": ruleSpec, + }, + }, nil +} + +// createOrUpdateGrant creates the object, or updates it if a same-named +// d8-managed object exists with a different canonical spec. Same canonical +// spec is treated as a no-op. +// +// A non-NotFound Get error is propagated rather than swallowed; otherwise a +// transient API failure (timeout, RBAC issue, conflict) would silently fall +// through to Create and produce a misleading "AlreadyExists" or — worse — a +// blind overwrite if the cached object differs from server state. +func createOrUpdateGrant(cmd *cobra.Command, client dynamic.ResourceInterface, + obj *unstructured.Unstructured, spec *canonicalGrantSpec) (*unstructured.Unstructured, error) { + name := obj.GetName() + scope := grantHumanScope(spec) + + existing, err := client.Get(cmd.Context(), name, metav1.GetOptions{}) + switch { + case err == nil: + existingAnnot := existing.GetAnnotations() + newAnnot := obj.GetAnnotations() + if existingAnnot[iamtypes.AnnotationAccessCanonicalSpec] == newAnnot[iamtypes.AnnotationAccessCanonicalSpec] { + cmd.PrintErrf("%s/%s unchanged\n", scope, name) + return existing, nil + } + obj.SetResourceVersion(existing.GetResourceVersion()) + return client.Update(cmd.Context(), obj, metav1.UpdateOptions{}) + case apierrors.IsNotFound(err): + return client.Create(cmd.Context(), obj, metav1.CreateOptions{}) + default: + return nil, fmt.Errorf("getting %s/%s: %w", scope, name, err) + } +} + +// grantHumanScope renders the source kind (with optional namespace) used in +// progress and warning messages so output stays consistent with revoke. +func grantHumanScope(spec *canonicalGrantSpec) string { + if spec.ScopeType == iamtypes.ScopeNamespace && len(spec.Namespaces) == 1 { + return iamtypes.KindAuthorizationRule + "/" + spec.Namespaces[0] + } + return iamtypes.KindClusterAuthorizationRule +} + +func parseSubjectKind(s string) (iamtypes.SubjectKind, error) { + switch strings.ToLower(s) { + case "user": + return iamtypes.KindUser, nil + case "group": + return iamtypes.KindGroup, nil + default: + return "", fmt.Errorf("invalid subject kind %q: must be user or group", s) + } +} + +// parseScope translates the user-facing -n/--namespace and --scope flags into +// a canonical (Scope, namespaces, labelMatch) triple. Exactly one of the two +// flag sets must be set. +func parseScope(scope string, namespaces []string) (iamtypes.Scope, []string, map[string]string, error) { + hasNS := len(namespaces) > 0 + hasScope := scope != "" + + switch { + case hasNS && hasScope: + return "", nil, nil, errors.New("-n/--namespace and --scope are mutually exclusive") + case !hasNS && !hasScope: + return "", nil, nil, errors.New("one of -n/--namespace or --scope must be specified") + case hasNS: + return iamtypes.ScopeNamespace, namespaces, nil, nil + } + + switch { + case scope == "cluster": + return iamtypes.ScopeCluster, nil, nil, nil + case scope == "all-namespaces", scope == "all": + return iamtypes.ScopeAllNamespaces, nil, nil, nil + case strings.HasPrefix(scope, "labels="): + labels, err := parseLabelMatch(strings.TrimPrefix(scope, "labels=")) + if err != nil { + return "", nil, nil, fmt.Errorf("--scope: %w", err) + } + return iamtypes.ScopeLabels, nil, labels, nil + default: + return "", nil, nil, fmt.Errorf("invalid --scope %q: expected one of cluster, all-namespaces, labels=K=V[,K2=V2,...]", scope) + } +} + +// parseLabelMatch parses "K=V[,K2=V2,...]" into a deterministic map. Empty +// keys, empty values and duplicate keys are rejected so the resulting +// labelSelector cannot be ambiguous. +func parseLabelMatch(s string) (map[string]string, error) { + if s == "" { + return nil, errors.New("labels=... must contain at least one K=V pair") + } + out := make(map[string]string) + for _, pair := range strings.Split(s, ",") { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + return nil, fmt.Errorf("invalid label pair %q: expected key=value", pair) + } + k, v := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) + if k == "" || v == "" { + return nil, fmt.Errorf("invalid label pair %q: key and value must be non-empty", pair) + } + if _, dup := out[k]; dup { + return nil, fmt.Errorf("duplicate label key %q", k) + } + out[k] = v + } + return out, nil +} + +func toAnyMap(m map[string]string) map[string]any { + result := make(map[string]any, len(m)) + for k, v := range m { + result[k] = v + } + return result +} diff --git a/internal/iam/access/cmd/grant_errors_test.go b/internal/iam/access/cmd/grant_errors_test.go new file mode 100644 index 00000000..7b1a3201 --- /dev/null +++ b/internal/iam/access/cmd/grant_errors_test.go @@ -0,0 +1,206 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + clienttesting "k8s.io/client-go/testing" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" +) + +// fakeListKinds tells the dynamic fake client about list kinds for the GVRs +// used by the access subcommand. Without this the fake client panics on the +// first List() call. Kept local to this test file because production code +// never builds a dynamic client this way. +func fakeListKinds() map[schema.GroupVersionResource]string { + return map[schema.GroupVersionResource]string{ + iamtypes.AuthorizationRuleGVR: "AuthorizationRuleList", + iamtypes.ClusterAuthorizationRuleGVR: "ClusterAuthorizationRuleList", + iamtypes.GroupGVR: "GroupList", + iamtypes.UserGVR: "UserList", + } +} + +// TestBuildGrantObject_RejectsInvalidNamespaceSpec exercises the defensive +// validation in buildGrantObject that rejects namespaced specs without +// exactly one non-empty namespace. We deliberately bypass canonicalGrantSpecs +// to simulate a future caller that constructs a spec by hand. +func TestBuildGrantObject_RejectsInvalidNamespaceSpec(t *testing.T) { + base := &canonicalGrantSpec{ + Model: iamtypes.ModelCurrent, + SubjectKind: iamtypes.KindUser, + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "Admin", + ScopeType: iamtypes.ScopeNamespace, + } + + t.Run("zero namespaces", func(t *testing.T) { + spec := *base + spec.Namespaces = nil + _, err := buildGrantObject(&spec) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one namespace") + }) + + t.Run("two namespaces", func(t *testing.T) { + spec := *base + spec.Namespaces = []string{"dev", "stage"} + _, err := buildGrantObject(&spec) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one namespace") + }) + + t.Run("empty namespace string", func(t *testing.T) { + spec := *base + spec.Namespaces = []string{""} + _, err := buildGrantObject(&spec) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty namespace") + }) + + t.Run("happy path: one non-empty namespace", func(t *testing.T) { + spec := *base + spec.Namespaces = []string{"dev"} + obj, err := buildGrantObject(&spec) + require.NoError(t, err) + assert.Equal(t, iamtypes.KindAuthorizationRule, obj.GetKind()) + assert.Equal(t, "dev", obj.GetNamespace()) + }) +} + +// TestCreateOrUpdateGrant_NotFoundCreates verifies the happy "first create" +// path: when the server returns NotFound on Get, we fall through to Create +// and return the created object. +func TestCreateOrUpdateGrant_NotFoundCreates(t *testing.T) { + scheme := runtime.NewScheme() + dyn := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, fakeListKinds()) + client := dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR) + + spec := &canonicalGrantSpec{ + Model: iamtypes.ModelCurrent, + SubjectKind: iamtypes.KindUser, + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "ClusterAdmin", + ScopeType: iamtypes.ScopeCluster, + } + obj, err := buildGrantObject(spec) + require.NoError(t, err) + + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + + got, err := createOrUpdateGrant(cmd, client, obj, spec) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, obj.GetName(), got.GetName()) + + stored, err := client.Get(t.Context(), obj.GetName(), metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, obj.GetName(), stored.GetName()) +} + +// TestCreateOrUpdateGrant_OtherGetErrorPropagates verifies that a non-NotFound +// Get error is surfaced rather than silently routed to Create. This is the +// exact regression the second refactor pass was supposed to prevent: a +// transient API failure used to fall through to Create() and produce a +// misleading "AlreadyExists" or a blind overwrite. +func TestCreateOrUpdateGrant_OtherGetErrorPropagates(t *testing.T) { + scheme := runtime.NewScheme() + dyn := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, fakeListKinds()) + + sentinel := errors.New("simulated etcd timeout") + dyn.PrependReactor("get", "clusterauthorizationrules", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, nil, sentinel + }) + + client := dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR) + + spec := &canonicalGrantSpec{ + Model: iamtypes.ModelCurrent, + SubjectKind: iamtypes.KindUser, + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "ClusterAdmin", + ScopeType: iamtypes.ScopeCluster, + } + obj, err := buildGrantObject(spec) + require.NoError(t, err) + + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + + _, err = createOrUpdateGrant(cmd, client, obj, spec) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel, + "non-NotFound Get error must propagate to the caller") + + // And critically: nothing was created — Create was never called because + // we bailed out on the Get error. + _, getErr := client.Get(t.Context(), obj.GetName(), metav1.GetOptions{}) + require.Error(t, getErr) + assert.True(t, apierrors.IsNotFound(getErr) || errors.Is(getErr, sentinel), + "object must not exist after a Get error short-circuited Create; got %v", getErr) +} + +// TestCreateOrUpdateGrant_SameSpecIsNoop verifies that re-running grant with +// the same canonical spec is treated as a no-op (no Update call and the +// existing object is returned). This is the contract createOrUpdateGrant +// uses to make `d8 iam access grant` idempotent. +func TestCreateOrUpdateGrant_SameSpecIsNoop(t *testing.T) { + scheme := runtime.NewScheme() + dyn := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, fakeListKinds()) + client := dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR) + + spec := &canonicalGrantSpec{ + Model: iamtypes.ModelCurrent, + SubjectKind: iamtypes.KindUser, + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "ClusterAdmin", + ScopeType: iamtypes.ScopeCluster, + } + obj, err := buildGrantObject(spec) + require.NoError(t, err) + + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + + // First call: created. + _, err = createOrUpdateGrant(cmd, client, obj, spec) + require.NoError(t, err) + + // Second call with a freshly-built object that has the same canonical + // spec annotation: must be a no-op (same canonical-spec annotation). + obj2, err := buildGrantObject(spec) + require.NoError(t, err) + got, err := createOrUpdateGrant(cmd, client, obj2, spec) + require.NoError(t, err) + assert.Equal(t, obj.GetName(), got.GetName()) +} diff --git a/internal/iam/access/cmd/list.go b/internal/iam/access/cmd/list.go new file mode 100644 index 00000000..0afb54d2 --- /dev/null +++ b/internal/iam/access/cmd/list.go @@ -0,0 +1,687 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/printers" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +// runInventory builds the access inventory once per command invocation and +// hands it (plus the resolved -o value) to fn. Every list/get command in +// this package goes through this helper so each only declares the actual +// rendering, not the open-client / open-inventory boilerplate. +func runInventory(cmd *cobra.Command, fn func(inv *accessInventory, outputFmt string) error) error { + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + inv, err := buildInventory(cmd.Context(), dyn) + if err != nil { + return err + } + outputFmt, _ := cmd.Flags().GetString("output") + return fn(inv, outputFmt) +} + +// NewListUsersCommand returns the cobra command behind "d8 iam list users". +// Exported so package listget can register it under "d8 iam list". +func NewListUsersCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "users", + Aliases: []string{"user"}, + Short: "List all users with their effective access", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return runInventory(cmd, func(inv *accessInventory, outputFmt string) error { + if outputFmt == "json" { + return printStructured(cmd.OutOrStdout(), buildUsersJSON(inv), outputFmt) + } + return printUsersTable(cmd, inv) + }) + }, + } + utilk8s.AddOutputFlag(cmd, "table", "table", "json") + return cmd +} + +// NewListGroupsCommand returns the cobra command behind "d8 iam list groups". +func NewListGroupsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "groups", + Aliases: []string{"group"}, + Short: "List all groups with their effective access", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return runInventory(cmd, func(inv *accessInventory, outputFmt string) error { + if outputFmt == "json" { + return printStructured(cmd.OutOrStdout(), buildGroupsJSON(inv), outputFmt) + } + return printGroupsTable(cmd, inv) + }) + }, + } + utilk8s.AddOutputFlag(cmd, "table", "table", "json") + return cmd +} + +// NewGetUserCommand returns the cobra command behind "d8 iam get user ". +// Output combines the user's identity, group memberships (direct + +// transitive), direct/inherited grants, the effective summary, and any +// warnings (group cycles, manual rules, orphaned subjects). +func NewGetUserCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "user ", + Aliases: []string{"users"}, + Short: "Show detailed access for a specific user", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return utilk8s.CompleteResourceNames(cmd, iamtypes.UserGVR, "", toComplete) + }, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + userName := args[0] + return runInventory(cmd, func(inv *accessInventory, outputFmt string) error { + if _, ok := inv.Users[userName]; !ok { + return fmt.Errorf("user %q not found", userName) + } + warnings := userWarnings(inv, userName) + switch outputFmt { + case "json", "yaml": + payload := buildUserAccessJSON(inv, userName) + payload.Warnings = denil(warnings) + return printStructured(cmd.OutOrStdout(), payload, outputFmt) + case "table", "": + return printUserDetail(cmd, inv, userName, warnings) + default: + return fmt.Errorf("%w %q; use table|json|yaml", errUnsupportedFormat, outputFmt) + } + }) + }, + } + utilk8s.AddOutputFlag(cmd, "table", "table", "json", "yaml") + return cmd +} + +// NewGetGroupCommand returns the cobra command behind "d8 iam get group ". +func NewGetGroupCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "group ", + Aliases: []string{"groups"}, + Short: "Show detailed access for a specific group", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return utilk8s.CompleteResourceNames(cmd, iamtypes.GroupGVR, "", toComplete) + }, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + groupName := args[0] + return runInventory(cmd, func(inv *accessInventory, outputFmt string) error { + if _, ok := inv.GroupMembers[groupName]; !ok { + return fmt.Errorf("group %q not found", groupName) + } + warnings := groupWarnings(inv, groupName) + switch outputFmt { + case "json", "yaml": + payload := buildGroupDetailJSON(inv, groupName) + payload.Warnings = denil(warnings) + return printStructured(cmd.OutOrStdout(), payload, outputFmt) + case "table", "": + return printGroupDetail(cmd, inv, groupName, warnings) + default: + return fmt.Errorf("%w %q; use table|json|yaml", errUnsupportedFormat, outputFmt) + } + }) + }, + } + utilk8s.AddOutputFlag(cmd, "table", "table", "json", "yaml") + return cmd +} + +// --- Table output --- + +func printUsersTable(cmd *cobra.Command, inv *accessInventory) error { + tw := printers.GetNewTabWriter(cmd.OutOrStdout()) + fmt.Fprintln(tw, "USER\tEMAIL\tGROUPS\tDIRECT\tINHERIT\tEFFECTIVE") + + users := make([]string, 0, len(inv.Users)) + for u := range inv.Users { + users = append(users, u) + } + sort.Strings(users) + + for _, userName := range users { + email := inv.Users[userName] + directGroups, _ := inv.ResolveUserGroups(userName) + directGrants, inheritedGrants := inv.UserGrants(userName) + allGrants := make([]normalizedGrant, 0, len(directGrants)+len(inheritedGrants)) + allGrants = append(allGrants, directGrants...) + allGrants = append(allGrants, inheritedGrants...) + summary := computeEffectiveSummary(allGrants) + + groupStr := "" + if len(directGroups) > 0 { + groupStr = strings.Join(directGroups, ",") + } + + fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%s\n", + userName, email, groupStr, + len(directGrants), len(inheritedGrants), + summary.String()) + } + return tw.Flush() +} + +func printGroupsTable(cmd *cobra.Command, inv *accessInventory) error { + tw := printers.GetNewTabWriter(cmd.OutOrStdout()) + fmt.Fprintln(tw, "GROUP\tMEMBERS\tNESTED\tGRANTS\tEFFECTIVE") + + groups := make([]string, 0, len(inv.GroupMembers)) + for g := range inv.GroupMembers { + groups = append(groups, g) + } + sort.Strings(groups) + + for _, groupName := range groups { + members := inv.GroupMembers[groupName] + userCount, nestedCount := 0, 0 + for _, m := range members { + if m.Kind == iamtypes.KindGroup { + nestedCount++ + } else { + userCount++ + } + } + + grants := inv.GroupGrants(groupName) + summary := computeEffectiveSummary(grants) + + fmt.Fprintf(tw, "%s\t%d\t%d\t%d\t%s\n", + groupName, userCount, nestedCount, len(grants), summary.String()) + } + return tw.Flush() +} + +func printUserDetail(cmd *cobra.Command, inv *accessInventory, userName string, warnings []string) error { + w := cmd.OutOrStdout() + email := inv.Users[userName] + directGroups, transitiveGroups := inv.ResolveUserGroups(userName) + directGrants, inheritedGrants := inv.UserGrants(userName) + allGrants := make([]normalizedGrant, 0, len(directGrants)+len(inheritedGrants)) + allGrants = append(allGrants, directGrants...) + allGrants = append(allGrants, inheritedGrants...) + summary := computeEffectiveSummary(allGrants) + + fmt.Fprintf(w, "User: %s\n", userName) + fmt.Fprintf(w, "Email: %s\n", email) + + fmt.Fprintf(w, "\nGroups (direct):\n") + printBulletList(w, directGroups) + + if len(transitiveGroups) > len(directGroups) { + fmt.Fprintf(w, "\nGroups (transitive):\n") + printBulletList(w, transitiveGroups) + } + + fmt.Fprintf(w, "\nDirect access:\n") + if len(directGrants) == 0 { + fmt.Fprintln(w, " ") + } + for _, g := range directGrants { + printGrantToWriter(w, &g, "") + } + + fmt.Fprintf(w, "\nInherited access:\n") + if len(inheritedGrants) == 0 { + fmt.Fprintln(w, " ") + } + for _, g := range inheritedGrants { + via := findViaGroup(inv, userName, g.SubjectPrincipal) + printGrantToWriter(w, &g, via) + } + + fmt.Fprintf(w, "\nEffective access summary:\n") + printEffectiveSummary(w, summary) + printWarnings(w, warnings) + + return nil +} + +func printGroupDetail(cmd *cobra.Command, inv *accessInventory, groupName string, warnings []string) error { + w := cmd.OutOrStdout() + members := inv.GroupMembers[groupName] + grants := inv.GroupGrants(groupName) + summary := computeEffectiveSummary(grants) + + fmt.Fprintf(w, "Group: %s\n", groupName) + + userMembers, groupMembers := partitionMembersByKind(members) + + fmt.Fprintf(w, "\nUser members (%d):\n", len(userMembers)) + printBulletList(w, userMembers) + + fmt.Fprintf(w, "\nNested groups (%d):\n", len(groupMembers)) + printBulletList(w, groupMembers) + + fmt.Fprintf(w, "\nGrants (%d):\n", len(grants)) + if len(grants) == 0 { + fmt.Fprintln(w, " ") + } + for _, g := range grants { + printGrantToWriter(w, &g, "") + } + + fmt.Fprintf(w, "\nEffective access summary:\n") + printEffectiveSummary(w, summary) + printWarnings(w, warnings) + + return nil +} + +func printGrantToWriter(w io.Writer, g *normalizedGrant, via string) { + scope := string(g.ScopeType) + if g.ScopeType == iamtypes.ScopeNamespace && len(g.ScopeNamespaces) > 0 { + scope = fmt.Sprintf("namespaces %s", strings.Join(g.ScopeNamespaces, ", ")) + } + managed := "" + if g.ManagedByD8 { + managed = " [d8-managed]" + } + + fmt.Fprintf(w, " - %s\n", g.AccessLevel) + fmt.Fprintf(w, " Scope: %s\n", scope) + fmt.Fprintf(w, " Source: %s%s\n", formatGrantSource(g), managed) + if via != "" { + fmt.Fprintf(w, " Via: group %s\n", via) + } + if g.AllowScale { + fmt.Fprintln(w, " allow-scale: true") + } + if g.PortForwarding { + fmt.Fprintln(w, " port-forwarding: true") + } +} + +func formatGrantSource(g *normalizedGrant) string { + return formatRuleRef(g.SourceKind, g.SourceNamespace, g.SourceName) +} + +// printEffectiveSummary renders the cluster/namespaced/port-forwarding/ +// allow-scale block. Capability lines append capabilityNote() when the +// capability is implicit (SuperAdmin wildcard) rather than an explicit flag. +func printEffectiveSummary(w io.Writer, summary *effectiveSummary) { + if summary.ClusterLevel != "" { + fmt.Fprintf(w, " cluster scope: %s\n", summary.ClusterLevel) + } + for ns, level := range summary.Namespaced { + fmt.Fprintf(w, " namespaced scope: %s(%s)\n", level, ns) + } + fmt.Fprintf(w, " port-forwarding: %v%s\n", summary.PortForwarding, capabilityNote(summary.PortForwardingImplicit)) + fmt.Fprintf(w, " allow-scale: %v%s\n", summary.AllowScale, capabilityNote(summary.AllowScaleImplicit)) +} + +func printWarnings(w io.Writer, warnings []string) { + if len(warnings) == 0 { + return + } + fmt.Fprintln(w, "\nWarnings:") + for _, warn := range warnings { + fmt.Fprintf(w, " ! %s\n", warn) + } +} + +func partitionMembersByKind(members []memberRef) ([]string, []string) { + var users, groups []string + for _, m := range members { + if m.Kind == iamtypes.KindGroup { + groups = append(groups, m.Name) + } else { + users = append(users, m.Name) + } + } + return users, groups +} + +func printBulletList(w io.Writer, items []string) { + if len(items) == 0 { + fmt.Fprintln(w, " ") + return + } + for _, item := range items { + fmt.Fprintf(w, " - %s\n", item) + } +} + +func findViaGroup(inv *accessInventory, userName, grantGroupName string) string { + directGroups, transitiveGroups := inv.ResolveUserGroups(userName) + for _, g := range directGroups { + if g == grantGroupName { + return g + } + } + for _, g := range transitiveGroups { + if g == grantGroupName { + return g + " (transitive)" + } + } + return grantGroupName +} + +// --- Warnings (folded in from the former "iam access explain") --- + +// userWarnings returns the same warning set that "iam access explain user" +// used to surface: cycles in any group the user transits, and grants from +// manual (non-d8-managed) rules so the operator knows that revoke cannot +// touch them. +func userWarnings(inv *accessInventory, userName string) []string { + if _, ok := inv.Users[userName]; !ok { + return nil + } + cycles := inv.DetectGroupCycles() + _, transitiveGroups := inv.ResolveUserGroups(userName) + directGrants, inheritedGrants := inv.UserGrants(userName) + + var out []string + for _, g := range transitiveGroups { + if cycle, ok := cycles[g]; ok { + out = append(out, fmt.Sprintf("group cycle detected: %s", strings.Join(cycle, " -> "))) + } + } + for _, g := range directGrants { + if !g.ManagedByD8 { + out = append(out, fmt.Sprintf("direct grant from manual object: %s", formatGrantSource(&g))) + } + } + for _, g := range inheritedGrants { + if !g.ManagedByD8 { + out = append(out, fmt.Sprintf("inherited grant from manual object: %s (via group %s)", + formatGrantSource(&g), g.SubjectPrincipal)) + } + } + return out +} + +// groupWarnings reports cycles touching the group and members that point at +// User/Group CRs that do not exist locally (orphaned references). +func groupWarnings(inv *accessInventory, groupName string) []string { + if _, ok := inv.GroupMembers[groupName]; !ok { + return nil + } + cycles := inv.DetectGroupCycles() + + var out []string + if cycle, ok := cycles[groupName]; ok { + out = append(out, fmt.Sprintf("group cycle detected: %s", strings.Join(cycle, " -> "))) + } + for _, m := range inv.GroupMembers[groupName] { + switch m.Kind { + case iamtypes.KindUser: + if _, ok := inv.Users[m.Name]; !ok { + out = append(out, fmt.Sprintf("user member %q not found as a local User CR (may be orphaned)", m.Name)) + } + case iamtypes.KindGroup: + if _, ok := inv.GroupMembers[m.Name]; !ok { + out = append(out, fmt.Sprintf("nested group %q not found as a local Group CR", m.Name)) + } + } + } + return out +} + +// --- JSON output: shared types --- +// +// Defining these once at package level (instead of inline in each +// printXxxJSON) keeps the wire format documented in one place and lets +// build-helpers share concrete types like memberJSON / grantJSON. +// Slice fields go through denil() so JSON emits "[]" instead of "null" +// — that contract is part of our CLI surface, locked down by tests. + +type userAccessJSON struct { + Kind string `json:"kind"` + Subject subjectJSONRef `json:"subject"` + Groups groupsJSONRef `json:"groups"` + + DirectGrants []grantJSON `json:"directGrants"` + InheritedGrants []grantJSON `json:"inheritedGrants"` + Effective effectiveJSON `json:"effectiveSummary"` + Warnings []string `json:"warnings"` +} + +type subjectJSONRef struct { + Kind string `json:"kind"` + RefName string `json:"refName"` + Principal string `json:"principal"` +} + +type groupsJSONRef struct { + Direct []string `json:"direct"` + Transitive []string `json:"transitive"` +} + +type grantJSON struct { + ViaGroup string `json:"viaGroup,omitempty"` + Source sourceJSON `json:"source"` + AccessLevel string `json:"accessLevel"` + Scope scopeJSON `json:"scope"` + AllowScale bool `json:"allowScale"` + PortForwarding bool `json:"portForwarding"` + ManagedByD8 bool `json:"managedByD8"` +} + +type sourceJSON struct { + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` +} + +type scopeJSON struct { + Type string `json:"type"` + Namespaces []string `json:"namespaces,omitempty"` +} + +type effectiveJSON struct { + Cluster string `json:"cluster,omitempty"` + Namespaces []nsLevelJSON `json:"namespaces,omitempty"` + AllowScale bool `json:"allowScale"` + PortForwarding bool `json:"portForwarding"` + AllowScaleImplicit bool `json:"allowScaleImplicit,omitempty"` + PortForwardingImplicit bool `json:"portForwardingImplicit,omitempty"` +} + +type nsLevelJSON struct { + AccessLevel string `json:"accessLevel"` + Namespaces []string `json:"namespaces"` +} + +type memberJSON struct { + Kind string `json:"kind"` + Name string `json:"name"` +} + +type groupSummaryJSON struct { + Name string `json:"name"` + Members int `json:"memberCount"` + Nested int `json:"nestedGroupCount"` + Grants int `json:"grantCount"` + Effective effectiveJSON `json:"effectiveSummary"` +} + +type groupDetailJSON struct { + Name string `json:"name"` + Members []memberJSON `json:"members"` + Grants []grantJSON `json:"grants"` + Effective effectiveJSON `json:"effectiveSummary"` + Warnings []string `json:"warnings"` +} + +// --- JSON output: build helpers --- + +func grantToJSON(g *normalizedGrant, via string) grantJSON { + return grantJSON{ + ViaGroup: via, + Source: sourceJSON{ + Kind: g.SourceKind, + Name: g.SourceName, + Namespace: g.SourceNamespace, + }, + AccessLevel: g.AccessLevel, + Scope: scopeJSON{Type: string(g.ScopeType), Namespaces: g.ScopeNamespaces}, + AllowScale: g.AllowScale, + PortForwarding: g.PortForwarding, + ManagedByD8: g.ManagedByD8, + } +} + +func summaryToJSON(s *effectiveSummary) effectiveJSON { + ej := effectiveJSON{ + Cluster: s.ClusterLevel, + AllowScale: s.AllowScale, + PortForwarding: s.PortForwarding, + AllowScaleImplicit: s.AllowScaleImplicit, + PortForwardingImplicit: s.PortForwardingImplicit, + } + + levelNS := make(map[string][]string) + for ns, level := range s.Namespaced { + levelNS[level] = append(levelNS[level], ns) + } + for level, nss := range levelNS { + sort.Strings(nss) + ej.Namespaces = append(ej.Namespaces, nsLevelJSON{AccessLevel: level, Namespaces: nss}) + } + + return ej +} + +func buildUsersJSON(inv *accessInventory) []userAccessJSON { + users := make([]string, 0, len(inv.Users)) + for u := range inv.Users { + users = append(users, u) + } + sort.Strings(users) + + items := make([]userAccessJSON, 0, len(users)) + for _, userName := range users { + items = append(items, buildUserAccessJSON(inv, userName)) + } + return items +} + +func buildUserAccessJSON(inv *accessInventory, userName string) userAccessJSON { + email := inv.Users[userName] + directGroups, transitiveGroups := inv.ResolveUserGroups(userName) + directGrants, inheritedGrants := inv.UserGrants(userName) + allGrants := make([]normalizedGrant, 0, len(directGrants)+len(inheritedGrants)) + allGrants = append(allGrants, directGrants...) + allGrants = append(allGrants, inheritedGrants...) + summary := computeEffectiveSummary(allGrants) + + directGrantsJSON := make([]grantJSON, 0, len(directGrants)) + for _, g := range directGrants { + directGrantsJSON = append(directGrantsJSON, grantToJSON(&g, "")) + } + inheritedGrantsJSON := make([]grantJSON, 0, len(inheritedGrants)) + for _, g := range inheritedGrants { + via := findViaGroup(inv, userName, g.SubjectPrincipal) + inheritedGrantsJSON = append(inheritedGrantsJSON, grantToJSON(&g, via)) + } + + return userAccessJSON{ + Kind: "AccessExplanation", + Subject: subjectJSONRef{ + Kind: string(iamtypes.KindUser), + RefName: userName, + Principal: email, + }, + Groups: groupsJSONRef{ + Direct: denil(directGroups), + Transitive: denil(transitiveGroups), + }, + DirectGrants: denil(directGrantsJSON), + InheritedGrants: denil(inheritedGrantsJSON), + Effective: summaryToJSON(summary), + Warnings: []string{}, + } +} + +func buildGroupsJSON(inv *accessInventory) []groupSummaryJSON { + groups := make([]string, 0, len(inv.GroupMembers)) + for g := range inv.GroupMembers { + groups = append(groups, g) + } + sort.Strings(groups) + + items := make([]groupSummaryJSON, 0, len(groups)) + for _, gName := range groups { + members := inv.GroupMembers[gName] + userCount, nestedCount := 0, 0 + for _, m := range members { + if m.Kind == iamtypes.KindGroup { + nestedCount++ + } else { + userCount++ + } + } + grants := inv.GroupGrants(gName) + summary := computeEffectiveSummary(grants) + items = append(items, groupSummaryJSON{ + Name: gName, + Members: userCount, + Nested: nestedCount, + Grants: len(grants), + Effective: summaryToJSON(summary), + }) + } + return items +} + +func buildGroupDetailJSON(inv *accessInventory, groupName string) groupDetailJSON { + members := inv.GroupMembers[groupName] + grants := inv.GroupGrants(groupName) + summary := computeEffectiveSummary(grants) + + memberItems := make([]memberJSON, 0, len(members)) + for _, m := range members { + memberItems = append(memberItems, memberJSON{Kind: string(m.Kind), Name: m.Name}) + } + grantItems := make([]grantJSON, 0, len(grants)) + for _, g := range grants { + grantItems = append(grantItems, grantToJSON(&g, "")) + } + + return groupDetailJSON{ + Name: groupName, + Members: denil(memberItems), + Grants: denil(grantItems), + Effective: summaryToJSON(summary), + Warnings: []string{}, + } +} diff --git a/internal/iam/access/cmd/naming.go b/internal/iam/access/cmd/naming.go new file mode 100644 index 00000000..b8e679db --- /dev/null +++ b/internal/iam/access/cmd/naming.go @@ -0,0 +1,125 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "crypto/sha256" + "fmt" + "sort" + "strings" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/version" +) + +// generateGrantName produces a deterministic name for a d8-managed grant object. +// Format: d8-access----- +func generateGrantName(spec *canonicalGrantSpec) (string, error) { + jsonStr, err := spec.JSON() + if err != nil { + return "", fmt.Errorf("canonical JSON: %w", err) + } + + h := sha256.Sum256([]byte(jsonStr)) + hash8 := fmt.Sprintf("%x", h[:4]) + + name := fmt.Sprintf("d8-access-%s-%s-%s-%s-%s", + strings.ToLower(string(spec.SubjectKind)), + sanitizeNamePart(spec.SubjectRef), + scopeNamePart(spec), + strings.ToLower(spec.AccessLevel), + hash8, + ) + + if len(name) > 253 { + name = name[:253] + } + + return name, nil +} + +// grantLabels returns the standard labels for a d8-managed grant object. +// Typed enum values are converted to plain strings here because Kubernetes +// label values are strings. +func grantLabels(spec *canonicalGrantSpec) map[string]string { + return map[string]string{ + iamtypes.LabelManagedBy: iamtypes.ManagedByValueCLI, + iamtypes.LabelAccessModel: string(iamtypes.ModelCurrent), + iamtypes.LabelAccessSubjectKind: strings.ToLower(string(spec.SubjectKind)), + iamtypes.LabelAccessScope: string(spec.ScopeType), + } +} + +// grantAnnotations returns the standard annotations for a d8-managed grant object. +func grantAnnotations(spec *canonicalGrantSpec) (map[string]string, error) { + jsonStr, err := spec.JSON() + if err != nil { + return nil, err + } + return map[string]string{ + iamtypes.AnnotationAccessSubjectRef: spec.SubjectRef, + iamtypes.AnnotationAccessSubjectPrincipal: spec.SubjectPrincipal, + iamtypes.AnnotationAccessCanonicalSpec: jsonStr, + iamtypes.AnnotationAccessCreatedByVersion: version.Version, + }, nil +} + +func sanitizeNamePart(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, "@", "-at-") + s = strings.ReplaceAll(s, ".", "-") + s = strings.ReplaceAll(s, "_", "-") + if len(s) > 40 { + s = s[:40] + } + s = strings.TrimRight(s, "-") + return s +} + +func scopeNamePart(spec *canonicalGrantSpec) string { + switch spec.ScopeType { + case iamtypes.ScopeNamespace: + if len(spec.Namespaces) == 1 { + return spec.Namespaces[0] + } + return "multi-ns" + case iamtypes.ScopeCluster: + return "cluster" + case iamtypes.ScopeAllNamespaces: + return "all" + case iamtypes.ScopeLabels: + // Stable middle segment so the object name self-documents as + // labels-scoped. Disambiguation against other label sets comes from + // the trailing hash8 of the full canonical spec. + keys := make([]string, 0, len(spec.LabelMatch)) + for k := range spec.LabelMatch { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(spec.LabelMatch[k]) + b.WriteByte(',') + } + h := sha256.Sum256([]byte(b.String())) + return fmt.Sprintf("labels-%x", h[:3]) + default: + return "unknown" + } +} diff --git a/internal/iam/access/cmd/naming_test.go b/internal/iam/access/cmd/naming_test.go new file mode 100644 index 00000000..f68934ec --- /dev/null +++ b/internal/iam/access/cmd/naming_test.go @@ -0,0 +1,272 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateGrantName(t *testing.T) { + tests := []struct { + name string + spec *canonicalGrantSpec + want string + }{ + { + name: "user namespaced", + spec: &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "Admin", + ScopeType: "namespace", + Namespaces: []string{"dev"}, + }, + want: "d8-access-user-anton-dev-admin-", + }, + { + name: "group cluster", + spec: &canonicalGrantSpec{ + Model: "current", + SubjectKind: "Group", + SubjectRef: "admins", + SubjectPrincipal: "admins", + AccessLevel: "ClusterAdmin", + ScopeType: "cluster", + }, + want: "d8-access-group-admins-cluster-clusteradmin-", + }, + { + name: "user all-namespaces", + spec: &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "SuperAdmin", + ScopeType: "all-namespaces", + }, + want: "d8-access-user-anton-all-superadmin-", + }, + { + name: "group labels", + spec: &canonicalGrantSpec{ + Model: "current", + SubjectKind: "Group", + SubjectRef: "admins", + SubjectPrincipal: "admins", + AccessLevel: "Editor", + ScopeType: "labels", + LabelMatch: map[string]string{"team": "platform", "tier": "prod"}, + }, + want: "d8-access-group-admins-labels-", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := generateGrantName(tt.spec) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(got, tt.want), + "expected name to start with %q, got %q", tt.want, got) + assert.LessOrEqual(t, len(got), 253, "name must be at most 253 chars") + }) + } +} + +// TestGenerateGrantName_StableForOldScopes locks down the names of grants +// that existed before the labels scope was introduced. Changing them would +// orphan previously created d8-managed CARs/ARs in upgraded clusters and +// silently break `d8 iam access revoke` (revoke locates the object by name). +func TestGenerateGrantName_StableForOldScopes(t *testing.T) { + tests := []struct { + name string + spec *canonicalGrantSpec + want string + }{ + { + name: "namespaced grant", + spec: &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "Admin", + ScopeType: "namespace", + Namespaces: []string{"dev"}, + }, + want: "d8-access-user-anton-dev-admin-4ca594ce", + }, + { + name: "cluster grant", + spec: &canonicalGrantSpec{ + Model: "current", + SubjectKind: "Group", + SubjectRef: "admins", + SubjectPrincipal: "admins", + AccessLevel: "ClusterAdmin", + ScopeType: "cluster", + }, + want: "d8-access-group-admins-cluster-clusteradmin-", + }, + { + name: "all-namespaces grant", + spec: &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "SuperAdmin", + ScopeType: "all-namespaces", + }, + want: "d8-access-user-anton-all-superadmin-", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := generateGrantName(tt.spec) + require.NoError(t, err) + // We only assert the human-readable prefix is intact; the trailing + // hash is a deterministic function of the canonical-spec JSON + // (also covered by TestGenerateGrantName_Deterministic) and we do + // not freeze it explicitly so unrelated future fields with + // `omitempty` defaults stay backwards-compatible. The only frozen + // hash above is the namespaced one because it happens to be the + // most fragile (subject email + namespace + level). + if strings.Contains(tt.want, "-4ca594ce") { + assert.Equal(t, tt.want, got) + } else { + assert.True(t, strings.HasPrefix(got, tt.want), + "want prefix %q, got %q", tt.want, got) + } + }) + } +} + +// TestGenerateGrantName_LabelsScopeDeterministic verifies that the same +// label set produces the same name across two invocations regardless of map +// iteration order, and that two different label sets produce different names. +func TestGenerateGrantName_LabelsScopeDeterministic(t *testing.T) { + spec1 := &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "Editor", + ScopeType: "labels", + LabelMatch: map[string]string{"team": "platform", "tier": "prod"}, + } + // Same data, different insertion order — Go does not guarantee the second + // map will iterate the same way as the first, so this guards against an + // implementation that accidentally sorts only one of them. + spec2 := &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "Editor", + ScopeType: "labels", + LabelMatch: map[string]string{"tier": "prod", "team": "platform"}, + } + + n1, err := generateGrantName(spec1) + require.NoError(t, err) + n2, err := generateGrantName(spec2) + require.NoError(t, err) + assert.Equal(t, n1, n2) + + // Different label set → different name (the hash differs even when the + // human-readable middle segment "labels-XXXXXX" coincidentally matches). + spec3 := *spec1 + spec3.LabelMatch = map[string]string{"team": "different"} + n3, err := generateGrantName(&spec3) + require.NoError(t, err) + assert.NotEqual(t, n1, n3) +} + +func TestGenerateGrantName_Deterministic(t *testing.T) { + spec := &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "Admin", + ScopeType: "namespace", + Namespaces: []string{"dev"}, + } + + name1, err := generateGrantName(spec) + require.NoError(t, err) + + name2, err := generateGrantName(spec) + require.NoError(t, err) + + assert.Equal(t, name1, name2, "same spec should produce same name") +} + +func TestSanitizeNamePart(t *testing.T) { + tests := []struct { + input string + want string + }{ + {input: "anton", want: "anton"}, + {input: "Anton", want: "anton"}, + {input: "user@example.com", want: "user-at-example-com"}, + {input: "my.name", want: "my-name"}, + {input: "my_name", want: "my-name"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, sanitizeNamePart(tt.input)) + }) + } +} + +func TestGrantLabels(t *testing.T) { + spec := &canonicalGrantSpec{ + SubjectKind: "User", + ScopeType: "cluster", + } + labels := grantLabels(spec) + assert.Equal(t, "d8-cli", labels["app.kubernetes.io/managed-by"]) + assert.Equal(t, "current", labels["deckhouse.io/access-model"]) + assert.Equal(t, "user", labels["deckhouse.io/access-subject-kind"]) + assert.Equal(t, "cluster", labels["deckhouse.io/access-scope"]) +} + +func TestGrantAnnotations(t *testing.T) { + spec := &canonicalGrantSpec{ + Model: "current", + SubjectKind: "User", + SubjectRef: "anton", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "Admin", + ScopeType: "namespace", + Namespaces: []string{"dev"}, + } + annotations, err := grantAnnotations(spec) + require.NoError(t, err) + assert.Equal(t, "anton", annotations["deckhouse.io/access-subject-ref"]) + assert.Equal(t, "anton@abc.com", annotations["deckhouse.io/access-subject-principal"]) + assert.Contains(t, annotations["deckhouse.io/access-canonical-spec"], `"accessLevel":"Admin"`) +} diff --git a/internal/iam/access/cmd/print.go b/internal/iam/access/cmd/print.go new file mode 100644 index 00000000..3947a607 --- /dev/null +++ b/internal/iam/access/cmd/print.go @@ -0,0 +1,68 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "encoding/json" + "errors" + "fmt" + "io" + + sigsyaml "sigs.k8s.io/yaml" +) + +// errUnsupportedFormat is returned for an unknown -o value. Callers wrap it +// with the per-command list of accepted formats; tests use errors.Is. +var errUnsupportedFormat = errors.New("unsupported output format") + +// printStructured renders v as JSON or YAML. YAML goes through json.Marshal + +// sigsyaml.JSONToYAML so json tags drive field names — our typed shapes +// declare only json tags. For Kubernetes manifests use utilk8s.PrintObject +// instead, which preserves the apiVersion/kind layout. +func printStructured(w io.Writer, v any, format string) error { + switch format { + case "json": + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("marshalling JSON: %w", err) + } + _, err = fmt.Fprintln(w, string(data)) + return err + case "yaml": + jsonData, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("marshalling JSON for YAML conversion: %w", err) + } + yamlData, err := sigsyaml.JSONToYAML(jsonData) + if err != nil { + return fmt.Errorf("converting JSON to YAML: %w", err) + } + _, err = fmt.Fprint(w, string(yamlData)) + return err + default: + return fmt.Errorf("%w %q", errUnsupportedFormat, format) + } +} + +// denil turns a nil slice into an empty one so JSON emits "[]" instead of +// "null". Returned by value to keep call sites one expression. +func denil[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/internal/iam/access/cmd/print_test.go b/internal/iam/access/cmd/print_test.go new file mode 100644 index 00000000..9ba6fc62 --- /dev/null +++ b/internal/iam/access/cmd/print_test.go @@ -0,0 +1,156 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// printStructuredFixture intentionally uses only json tags (matching every +// real type in this package) so the test exercises the same JSON-tagged +// path that production code does. +type printStructuredFixture struct { + Hello string `json:"hello"` + Numbers []int `json:"numbers"` + Nested []string `json:"nested"` +} + +func TestPrintStructured_JSON(t *testing.T) { + var buf bytes.Buffer + err := printStructured(&buf, printStructuredFixture{ + Hello: "world", + Numbers: []int{1, 2, 3}, + Nested: []string{}, + }, "json") + require.NoError(t, err) + + want := `{ + "hello": "world", + "numbers": [ + 1, + 2, + 3 + ], + "nested": [] +} +` + assert.Equal(t, want, buf.String(), "json output must keep 2-space indent + trailing newline") +} + +// TestPrintStructured_YAMLTagsAreJSON locks down the JSON-tag-first behaviour: +// printStructured marshals via JSON then converts to YAML, so YAML keys are +// the json-tag names (camelCase here), not the Go field names. +func TestPrintStructured_YAMLTagsAreJSON(t *testing.T) { + var buf bytes.Buffer + err := printStructured(&buf, printStructuredFixture{ + Hello: "world", + Numbers: []int{1}, + Nested: []string{"a"}, + }, "yaml") + require.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "hello: world", "yaml keys must come from json tags, not Go field names") + assert.Contains(t, out, "numbers:") + assert.Contains(t, out, "nested:") + assert.NotContains(t, out, "Hello:", "Go field name must not leak into yaml output") +} + +func TestPrintStructured_UnknownFormat(t *testing.T) { + var buf bytes.Buffer + err := printStructured(&buf, printStructuredFixture{}, "xml") + require.Error(t, err) + assert.True(t, errors.Is(err, errUnsupportedFormat), + "caller code uses errors.Is(err, errUnsupportedFormat) for branching; do not break that contract") + assert.Contains(t, err.Error(), `"xml"`, "error message must surface the offending format") + assert.Empty(t, buf.String(), "unknown format must not write partial output") +} + +// TestPrintStructured_NilValueIsNullJSON guards against accidentally treating +// a nil interface as an error. Callers may legitimately marshal a typed nil +// (e.g. an empty optional field) and we want them to get JSON "null" / YAML +// "null" rather than a hard error. +func TestPrintStructured_NilValueIsNullJSON(t *testing.T) { + var buf bytes.Buffer + err := printStructured(&buf, nil, "json") + require.NoError(t, err) + assert.Equal(t, "null\n", buf.String()) +} + +func TestDenil_NilSliceBecomesEmpty(t *testing.T) { + got := denil[string](nil) + require.NotNil(t, got, "denil must turn nil into a non-nil empty slice so JSON encodes []") + assert.Equal(t, 0, len(got)) +} + +func TestDenil_NonNilSliceIsReturnedUnchanged(t *testing.T) { + in := []int{1, 2, 3} + got := denil(in) + require.Equal(t, in, got) + // Same backing array — denil never copies, since the only purpose is to + // avoid the nil sentinel for JSON encoding. + in[0] = 99 + assert.Equal(t, 99, got[0], "denil must not allocate a new slice for non-nil inputs") +} + +// TestDenil_EmptyNonNilSliceStaysEmpty ensures we don't accidentally +// short-circuit on len == 0 (which would still allocate). The contract is +// purely about nil vs non-nil. +func TestDenil_EmptyNonNilSliceStaysEmpty(t *testing.T) { + in := []string{} + got := denil(in) + require.NotNil(t, got) + assert.Equal(t, 0, len(got)) +} + +// TestPrintStructured_GrantJSONShape sanity-checks that the package-level +// shared types (memberJSON, grantJSON, effectiveJSON, ...) round-trip +// through both formats with identical visible field names. This is the +// load-bearing assumption behind moving `iam access explain group -o json` +// off its private inline types onto the shared ones. +func TestPrintStructured_GrantJSONShape(t *testing.T) { + g := grantJSON{ + AccessLevel: "Admin", + Source: sourceJSON{ + Kind: "ClusterAuthorizationRule", + Name: "demo", + Namespace: "", + }, + Scope: scopeJSON{Type: "cluster"}, + AllowScale: true, + PortForwarding: false, + ManagedByD8: true, + } + + var jsonBuf, yamlBuf bytes.Buffer + require.NoError(t, printStructured(&jsonBuf, g, "json")) + require.NoError(t, printStructured(&yamlBuf, g, "yaml")) + + jsonOut := jsonBuf.String() + yamlOut := yamlBuf.String() + + for _, key := range []string{"accessLevel", "source", "scope", "allowScale", "portForwarding", "managedByD8"} { + assert.Truef(t, strings.Contains(jsonOut, key), "json output missing key %q: %s", key, jsonOut) + assert.Truef(t, strings.Contains(yamlOut, key), "yaml output missing key %q: %s", key, yamlOut) + } +} diff --git a/internal/iam/access/cmd/resolve.go b/internal/iam/access/cmd/resolve.go new file mode 100644 index 00000000..04ce4e33 --- /dev/null +++ b/internal/iam/access/cmd/resolve.go @@ -0,0 +1,400 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "context" + "fmt" + "sort" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/dynamic" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" +) + +// resolveUserEmail fetches a User CR and returns its spec.email. +func resolveUserEmail(ctx context.Context, dyn dynamic.Interface, userName string) (string, error) { + obj, err := dyn.Resource(iamtypes.UserGVR).Get(ctx, userName, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("getting User %q to resolve email: %w", userName, err) + } + email, found, _ := unstructured.NestedString(obj.Object, "spec", "email") + if !found || email == "" { + return "", fmt.Errorf("User %q has no spec.email", userName) + } + return email, nil +} + +// accessInventory holds the full in-memory model for access list/explain. +type accessInventory struct { + Users map[string]string // user CR name -> email + Emails map[string]string // email -> user CR name + + // GroupMembers: group name -> list of (kind, name) members + GroupMembers map[string][]memberRef + + // Grants: normalized grants from all authz rules + Grants []normalizedGrant +} + +// memberRef is a (kind, name) pair from Group.spec.members. +type memberRef struct { + Kind iamtypes.SubjectKind + Name string +} + +// buildInventory fetches all relevant CRs and builds the access inventory. +func buildInventory(ctx context.Context, dyn dynamic.Interface) (*accessInventory, error) { + inv := &accessInventory{ + Users: make(map[string]string), + Emails: make(map[string]string), + GroupMembers: make(map[string][]memberRef), + } + + // Fetch users + userList, err := dyn.Resource(iamtypes.UserGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing Users: %w", err) + } + for _, u := range userList.Items { + name := u.GetName() + email, _, _ := unstructured.NestedString(u.Object, "spec", "email") + if email != "" { + inv.Users[name] = email + inv.Emails[email] = name + } + } + + // Fetch groups + groupList, err := dyn.Resource(iamtypes.GroupGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing Groups: %w", err) + } + for _, g := range groupList.Items { + gName := g.GetName() + rawMembers, _, _ := unstructured.NestedSlice(g.Object, "spec", "members") + var members []memberRef + for _, item := range rawMembers { + m, ok := item.(map[string]any) + if !ok { + continue + } + members = append(members, memberRef{ + Kind: iamtypes.SubjectKind(fmt.Sprint(m["kind"])), + Name: fmt.Sprint(m["name"]), + }) + } + inv.GroupMembers[gName] = members + } + + // Fetch ClusterAuthorizationRules + carList, err := dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing ClusterAuthorizationRules: %w", err) + } + for _, car := range carList.Items { + inv.Grants = append(inv.Grants, normalizeAuthRule(&car)...) + } + + // Fetch AuthorizationRules from all namespaces + arList, err := dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace("").List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing AuthorizationRules: %w", err) + } + for _, ar := range arList.Items { + inv.Grants = append(inv.Grants, normalizeAuthRule(&ar)...) + } + + return inv, nil +} + +// normalizeAuthRule converts an AuthorizationRule or ClusterAuthorizationRule +// into a flat list of normalizedGrant rows (one per non-ServiceAccount subject). +// The CAR vs AR distinction lives entirely in the source kind and the scope: +// CARs default to ScopeCluster but switch to ScopeAllNamespaces or ScopeLabels +// based on namespaceSelector content; ARs are always ScopeNamespace. +func normalizeAuthRule(obj *unstructured.Unstructured) []normalizedGrant { + managedByD8 := obj.GetLabels()[iamtypes.LabelManagedBy] == iamtypes.ManagedByValueCLI + accessLevel, _, _ := unstructured.NestedString(obj.Object, "spec", "accessLevel") + allowScale, _, _ := unstructured.NestedBool(obj.Object, "spec", "allowScale") + portForwarding, _, _ := unstructured.NestedBool(obj.Object, "spec", "portForwarding") + + base := normalizedGrant{ + SourceName: obj.GetName(), + AccessLevel: accessLevel, + AllowScale: allowScale, + PortForwarding: portForwarding, + ManagedByD8: managedByD8, + } + + switch obj.GetKind() { + case iamtypes.KindClusterAuthorizationRule: + base.SourceKind = iamtypes.KindClusterAuthorizationRule + base.ScopeType = iamtypes.ScopeCluster + if matchAny, found, _ := unstructured.NestedBool(obj.Object, "spec", "namespaceSelector", "matchAny"); found && matchAny { + base.ScopeType = iamtypes.ScopeAllNamespaces + } else if matchLabels, found, _ := unstructured.NestedMap(obj.Object, "spec", "namespaceSelector", "labelSelector", "matchLabels"); found && len(matchLabels) > 0 { + base.ScopeType = iamtypes.ScopeLabels + } + case iamtypes.KindAuthorizationRule: + base.SourceKind = iamtypes.KindAuthorizationRule + base.SourceNamespace = obj.GetNamespace() + base.ScopeType = iamtypes.ScopeNamespace + base.ScopeNamespaces = []string{obj.GetNamespace()} + default: + return nil + } + + var grants []normalizedGrant + for _, sub := range readSubjectRefs(obj) { + if sub.Kind == iamtypes.KindServiceAccount { + continue + } + g := base + g.SubjectKind = sub.Kind + g.SubjectPrincipal = sub.Name + grants = append(grants, g) + } + return grants +} + +// ResolveUserGroups computes direct and transitive group memberships for a user. +func (inv *accessInventory) ResolveUserGroups(userName string) ([]string, []string) { + directSet := make(map[string]bool) + for gName, members := range inv.GroupMembers { + for _, m := range members { + if m.Kind == iamtypes.KindUser && m.Name == userName { + directSet[gName] = true + } + } + } + + visited := make(map[string]bool) + var walk func(string) + walk = func(g string) { + if visited[g] { + return + } + visited[g] = true + for parentGroup, members := range inv.GroupMembers { + for _, m := range members { + if m.Kind == iamtypes.KindGroup && m.Name == g { + walk(parentGroup) + } + } + } + } + + for g := range directSet { + walk(g) + } + + direct := make([]string, 0, len(directSet)) + for g := range directSet { + direct = append(direct, g) + } + sort.Strings(direct) + + transitive := make([]string, 0, len(visited)) + for g := range visited { + transitive = append(transitive, g) + } + sort.Strings(transitive) + return direct, transitive +} + +// UserGrants returns direct and inherited grants for a user. +func (inv *accessInventory) UserGrants(userName string) ([]normalizedGrant, []normalizedGrant) { + email := inv.Users[userName] + _, allGroups := inv.ResolveUserGroups(userName) + + groupSet := make(map[string]bool) + for _, g := range allGroups { + groupSet[g] = true + } + + var directGrants, inheritedGrants []normalizedGrant + for _, grant := range inv.Grants { + if grant.SubjectKind == iamtypes.KindUser && grant.SubjectPrincipal == email { + directGrants = append(directGrants, grant) + } else if grant.SubjectKind == iamtypes.KindGroup && groupSet[grant.SubjectPrincipal] { + inheritedGrants = append(inheritedGrants, grant) + } + } + return directGrants, inheritedGrants +} + +// GroupGrants returns grants assigned directly to a group. +func (inv *accessInventory) GroupGrants(groupName string) []normalizedGrant { + var grants []normalizedGrant + for _, grant := range inv.Grants { + if grant.SubjectKind == iamtypes.KindGroup && grant.SubjectPrincipal == groupName { + grants = append(grants, grant) + } + } + return grants +} + +// effectiveSummary computes the max access level per scope and OR-aggregated booleans. +type effectiveSummary struct { + ClusterLevel string + Namespaced map[string]string // namespace -> max level + AllowScale bool + PortForwarding bool + // *Implicit flags record that the capability comes from the SuperAdmin wildcard + // (apiGroups/*/*/*) in user-authz:super-admin ClusterRole, not from an explicit + // allowScale / portForwarding field on a CAR. This is critical because such a + // ClusterRole allows pods/portforward and */scale irrespective of CAR flags. + AllowScaleImplicit bool + PortForwardingImplicit bool +} + +func computeEffectiveSummary(grants []normalizedGrant) *effectiveSummary { + summary := &effectiveSummary{ + Namespaced: make(map[string]string), + } + + var clusterLevels []string + nsLevels := make(map[string][]string) + + for _, g := range grants { + if g.AllowScale { + summary.AllowScale = true + } + if g.PortForwarding { + summary.PortForwarding = true + } + switch g.ScopeType { + case iamtypes.ScopeCluster, iamtypes.ScopeAllNamespaces, iamtypes.ScopeLabels: + // ScopeLabels still creates a CAR; without resolving the + // labelSelector against the live namespace list we cannot + // attribute it to specific namespaces, so treat it as a + // cluster-level grant for the summary view. + clusterLevels = append(clusterLevels, g.AccessLevel) + case iamtypes.ScopeNamespace: + for _, ns := range g.ScopeNamespaces { + nsLevels[ns] = append(nsLevels[ns], g.AccessLevel) + } + } + } + + summary.ClusterLevel = maxAccessLevel(clusterLevels) + for ns, levels := range nsLevels { + summary.Namespaced[ns] = maxAccessLevel(levels) + } + + // SuperAdmin binds the user-authz:super-admin ClusterRole, which grants + // apiGroups/resources/verbs=* and nonResourceURLs=*. That implicitly covers + // pods/portforward and deployments|statefulsets/scale regardless of the CAR + // flags, so the effective answer must be true. + if summary.ClusterLevel == "SuperAdmin" { + if !summary.PortForwarding { + summary.PortForwarding = true + summary.PortForwardingImplicit = true + } + if !summary.AllowScale { + summary.AllowScale = true + summary.AllowScaleImplicit = true + } + } + + return summary +} + +// capabilityNote returns a human-readable source annotation used when a capability +// is present implicitly via the SuperAdmin wildcard rather than an explicit CAR flag. +func capabilityNote(implicit bool) string { + if implicit { + return " (implicit via SuperAdmin wildcard)" + } + return "" +} + +func (s *effectiveSummary) String() string { + var parts []string + if s.ClusterLevel != "" { + parts = append(parts, s.ClusterLevel+"[*]") + } + + // Group namespaces by access level + levelNS := make(map[string][]string) + for ns, level := range s.Namespaced { + levelNS[level] = append(levelNS[level], ns) + } + for level, nss := range levelNS { + sort.Strings(nss) + parts = append(parts, fmt.Sprintf("%s[%s]", level, strings.Join(nss, ","))) + } + + if len(parts) == 0 { + return "" + } + sort.Strings(parts) + return strings.Join(parts, ", ") +} + +// DetectGroupCycles finds cycles in the group membership graph. +func (inv *accessInventory) DetectGroupCycles() map[string][]string { + cycles := make(map[string][]string) + visited := make(map[string]int) // 0=unvisited, 1=visiting, 2=done + var path []string + + var dfs func(string) + dfs = func(g string) { + if visited[g] == 2 { + return + } + if visited[g] == 1 { + // Found cycle + cycleStart := -1 + for i, p := range path { + if p == g { + cycleStart = i + break + } + } + if cycleStart >= 0 { + cycle := append([]string{}, path[cycleStart:]...) + cycle = append(cycle, g) + cycles[g] = cycle + } + return + } + visited[g] = 1 + path = append(path, g) + + for _, m := range inv.GroupMembers[g] { + if m.Kind == iamtypes.KindGroup { + dfs(m.Name) + } + } + + path = path[:len(path)-1] + visited[g] = 2 + } + + for g := range inv.GroupMembers { + if visited[g] == 0 { + dfs(g) + } + } + + return cycles +} diff --git a/internal/iam/access/cmd/resolve_test.go b/internal/iam/access/cmd/resolve_test.go new file mode 100644 index 00000000..6d6f4eb3 --- /dev/null +++ b/internal/iam/access/cmd/resolve_test.go @@ -0,0 +1,329 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" +) + +func TestResolveUserGroups(t *testing.T) { + inv := &accessInventory{ + Users: map[string]string{"anton": "anton@abc.com"}, + Emails: map[string]string{"anton@abc.com": "anton"}, + GroupMembers: map[string][]memberRef{ + "admins": { + {Kind: "User", Name: "anton"}, + }, + "devs": { + {Kind: "User", Name: "anton"}, + }, + "platform": { + {Kind: "Group", Name: "admins"}, + }, + }, + } + + direct, transitive := inv.ResolveUserGroups("anton") + assert.ElementsMatch(t, []string{"admins", "devs"}, direct) + assert.ElementsMatch(t, []string{"admins", "devs", "platform"}, transitive) +} + +func TestResolveUserGroups_NoGroups(t *testing.T) { + inv := &accessInventory{ + Users: map[string]string{"anton": "anton@abc.com"}, + Emails: map[string]string{"anton@abc.com": "anton"}, + GroupMembers: map[string][]memberRef{}, + } + + direct, transitive := inv.ResolveUserGroups("anton") + assert.Empty(t, direct) + assert.Empty(t, transitive) +} + +func TestUserGrants(t *testing.T) { + inv := &accessInventory{ + Users: map[string]string{"anton": "anton@abc.com"}, + Emails: map[string]string{"anton@abc.com": "anton"}, + GroupMembers: map[string][]memberRef{ + "admins": { + {Kind: "User", Name: "anton"}, + }, + }, + Grants: []normalizedGrant{ + { + SourceKind: "AuthorizationRule", + SourceName: "direct-grant", + SourceNamespace: "dev", + SubjectKind: "User", + SubjectPrincipal: "anton@abc.com", + AccessLevel: "Admin", + ScopeType: "namespace", + ScopeNamespaces: []string{"dev"}, + }, + { + SourceKind: "ClusterAuthorizationRule", + SourceName: "group-grant", + SubjectKind: "Group", + SubjectPrincipal: "admins", + AccessLevel: "ClusterAdmin", + ScopeType: "cluster", + }, + { + SourceKind: "AuthorizationRule", + SourceName: "unrelated", + SourceNamespace: "prod", + SubjectKind: "User", + SubjectPrincipal: "other@abc.com", + AccessLevel: "Editor", + ScopeType: "namespace", + ScopeNamespaces: []string{"prod"}, + }, + }, + } + + directGrants, inheritedGrants := inv.UserGrants("anton") + assert.Len(t, directGrants, 1) + assert.Equal(t, "Admin", directGrants[0].AccessLevel) + assert.Len(t, inheritedGrants, 1) + assert.Equal(t, "ClusterAdmin", inheritedGrants[0].AccessLevel) +} + +func TestComputeEffectiveSummary(t *testing.T) { + grants := []normalizedGrant{ + {AccessLevel: "Admin", ScopeType: "namespace", ScopeNamespaces: []string{"dev"}}, + {AccessLevel: "Editor", ScopeType: "namespace", ScopeNamespaces: []string{"dev"}}, + {AccessLevel: "ClusterAdmin", ScopeType: "cluster"}, + {AccessLevel: "User", ScopeType: "namespace", ScopeNamespaces: []string{"prod"}, PortForwarding: true}, + } + + summary := computeEffectiveSummary(grants) + assert.Equal(t, "ClusterAdmin", summary.ClusterLevel) + assert.Equal(t, "Admin", summary.Namespaced["dev"]) + assert.Equal(t, "User", summary.Namespaced["prod"]) + assert.True(t, summary.PortForwarding) + assert.False(t, summary.AllowScale) +} + +func TestComputeEffectiveSummary_SuperAdminImplicitCapabilities(t *testing.T) { + // SuperAdmin binds user-authz:super-admin ClusterRole which carries */*/* rules; + // port-forward and scale are therefore always allowed regardless of CAR flags. + grants := []normalizedGrant{ + {AccessLevel: "SuperAdmin", ScopeType: "all-namespaces"}, + } + summary := computeEffectiveSummary(grants) + + assert.Equal(t, "SuperAdmin", summary.ClusterLevel) + assert.True(t, summary.PortForwarding) + assert.True(t, summary.AllowScale) + assert.True(t, summary.PortForwardingImplicit) + assert.True(t, summary.AllowScaleImplicit) +} + +func TestComputeEffectiveSummary_SuperAdminExplicitFlagsNotMarkedImplicit(t *testing.T) { + // When the CAR already sets the flags explicitly, the summary should not + // mark the capabilities as implicit — the explicit source wins. + grants := []normalizedGrant{ + {AccessLevel: "SuperAdmin", ScopeType: "cluster", PortForwarding: true, AllowScale: true}, + } + summary := computeEffectiveSummary(grants) + + assert.True(t, summary.PortForwarding) + assert.True(t, summary.AllowScale) + assert.False(t, summary.PortForwardingImplicit) + assert.False(t, summary.AllowScaleImplicit) +} + +func TestComputeEffectiveSummary_ClusterAdminDoesNotImplyPortForwardOrScale(t *testing.T) { + // ClusterAdmin is composed of scoped rules; it must NOT be treated as + // implicitly granting port-forwarding or scale. + grants := []normalizedGrant{ + {AccessLevel: "ClusterAdmin", ScopeType: "cluster"}, + } + summary := computeEffectiveSummary(grants) + + assert.Equal(t, "ClusterAdmin", summary.ClusterLevel) + assert.False(t, summary.PortForwarding) + assert.False(t, summary.AllowScale) + assert.False(t, summary.PortForwardingImplicit) + assert.False(t, summary.AllowScaleImplicit) +} + +func TestComputeEffectiveSummary_Empty(t *testing.T) { + summary := computeEffectiveSummary(nil) + assert.Equal(t, "", summary.ClusterLevel) + assert.Empty(t, summary.Namespaced) + assert.False(t, summary.PortForwarding) + assert.False(t, summary.AllowScale) +} + +func TestEffectiveSummary_String(t *testing.T) { + summary := &effectiveSummary{ + ClusterLevel: "ClusterAdmin", + Namespaced: map[string]string{"dev": "Admin"}, + PortForwarding: true, + } + s := summary.String() + assert.Contains(t, s, "ClusterAdmin[*]") + assert.Contains(t, s, "Admin[dev]") +} + +func TestEffectiveSummary_String_None(t *testing.T) { + summary := &effectiveSummary{ + Namespaced: map[string]string{}, + } + assert.Equal(t, "", summary.String()) +} + +func TestDetectGroupCycles(t *testing.T) { + t.Run("no cycles", func(t *testing.T) { + inv := &accessInventory{ + GroupMembers: map[string][]memberRef{ + "a": {{Kind: "Group", Name: "b"}}, + "b": {{Kind: "User", Name: "u1"}}, + }, + } + cycles := inv.DetectGroupCycles() + assert.Empty(t, cycles) + }) + + t.Run("simple cycle", func(t *testing.T) { + inv := &accessInventory{ + GroupMembers: map[string][]memberRef{ + "a": {{Kind: "Group", Name: "b"}}, + "b": {{Kind: "Group", Name: "a"}}, + }, + } + cycles := inv.DetectGroupCycles() + assert.NotEmpty(t, cycles) + }) +} + +func TestNormalizeAuthRule_CAR(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "kind": "ClusterAuthorizationRule", + "metadata": map[string]any{ + "name": "test-rule", + "labels": map[string]any{"app.kubernetes.io/managed-by": "d8-cli"}, + }, + "spec": map[string]any{ + "accessLevel": "ClusterAdmin", + "allowScale": true, + "portForwarding": false, + "subjects": []any{ + map[string]any{"kind": "User", "name": "anton@abc.com"}, + map[string]any{"kind": "Group", "name": "admins"}, + map[string]any{"kind": "ServiceAccount", "name": "sa1"}, + }, + }, + }, + } + + grants := normalizeAuthRule(obj) + assert.Len(t, grants, 2) // ServiceAccount filtered out + assert.Equal(t, iamtypes.KindClusterAuthorizationRule, grants[0].SourceKind) + assert.Equal(t, iamtypes.ScopeCluster, grants[0].ScopeType) + assert.Equal(t, iamtypes.KindUser, grants[0].SubjectKind) + assert.Equal(t, "anton@abc.com", grants[0].SubjectPrincipal) + assert.True(t, grants[0].ManagedByD8) + assert.Equal(t, iamtypes.KindGroup, grants[1].SubjectKind) +} + +func TestNormalizeAuthRule_AllNamespaces(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "kind": "ClusterAuthorizationRule", + "metadata": map[string]any{ + "name": "all-ns-rule", + }, + "spec": map[string]any{ + "accessLevel": "Admin", + "namespaceSelector": map[string]any{ + "matchAny": true, + }, + "subjects": []any{ + map[string]any{"kind": "User", "name": "test@test.com"}, + }, + }, + }, + } + + grants := normalizeAuthRule(obj) + assert.Len(t, grants, 1) + assert.Equal(t, iamtypes.ScopeAllNamespaces, grants[0].ScopeType) +} + +func TestNormalizeAuthRule_LabelSelector(t *testing.T) { + // CAR with namespaceSelector.labelSelector.matchLabels => ScopeLabels. + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "kind": "ClusterAuthorizationRule", + "metadata": map[string]any{ + "name": "labels-rule", + }, + "spec": map[string]any{ + "accessLevel": "Admin", + "namespaceSelector": map[string]any{ + "labelSelector": map[string]any{ + "matchLabels": map[string]any{ + "team": "platform", + "tier": "prod", + }, + }, + }, + "subjects": []any{ + map[string]any{"kind": "Group", "name": "platform"}, + }, + }, + }, + } + + grants := normalizeAuthRule(obj) + require.Len(t, grants, 1) + assert.Equal(t, iamtypes.ScopeLabels, grants[0].ScopeType) +} + +func TestNormalizeAuthRule_AR(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "kind": "AuthorizationRule", + "metadata": map[string]any{ + "name": "editors", + "namespace": "dev", + }, + "spec": map[string]any{ + "accessLevel": "Editor", + "subjects": []any{ + map[string]any{"kind": "User", "name": "anton@abc.com"}, + }, + }, + }, + } + + grants := normalizeAuthRule(obj) + require.Len(t, grants, 1) + assert.Equal(t, iamtypes.KindAuthorizationRule, grants[0].SourceKind) + assert.Equal(t, "dev", grants[0].SourceNamespace) + assert.Equal(t, iamtypes.ScopeNamespace, grants[0].ScopeType) + assert.Equal(t, []string{"dev"}, grants[0].ScopeNamespaces) +} diff --git a/internal/iam/access/cmd/revoke.go b/internal/iam/access/cmd/revoke.go new file mode 100644 index 00000000..840072d8 --- /dev/null +++ b/internal/iam/access/cmd/revoke.go @@ -0,0 +1,238 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "errors" + "fmt" + + "github.com/hashicorp/go-multierror" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + "k8s.io/kubectl/pkg/util/templates" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var revokeLong = templates.LongDesc(` +Revoke access previously granted with "d8 iam access grant". + +The flags must match the original grant's scope/level — the d8-managed +object name is derived from the canonical spec, so a mismatch yields a +"not found" error rather than removing an unrelated rule. + +Only d8-managed objects (label app.kubernetes.io/managed-by=d8-cli) are +affected. Manually maintained AuthorizationRules and +ClusterAuthorizationRules are never touched; edit them with kubectl. + +© Flant JSC 2026`) + +var revokeExample = templates.Examples(` + # Revoke a namespaced grant (mirrors the grant command) + d8 iam access revoke user anton --access-level Admin -n dev + + # Revoke a cluster-scoped grant + d8 iam access revoke user anton --access-level ClusterAdmin --scope cluster + + # Revoke a labels-scoped grant (must match the original --scope value) + d8 iam access revoke group admins --access-level Editor --scope labels=team=platform,tier=prod`) + +func newRevokeCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "revoke (user|group) NAME --access-level LEVEL [scope]", + Short: "Revoke a d8-managed access grant", + Long: revokeLong, + Example: revokeExample, + Args: cobra.ExactArgs(2), + ValidArgsFunction: completeSubjectAndName, + SilenceErrors: true, + SilenceUsage: true, + RunE: runRevoke, + } + + cmd.Flags().String("access-level", "", "Access level to revoke") + cmd.Flags().StringSliceP("namespace", "n", nil, "Target namespace(s). Mutually exclusive with --scope") + cmd.Flags().String("scope", "", "Cluster-wide scope: cluster | all-namespaces | labels=K=V[,K2=V2,...]. Mutually exclusive with -n/--namespace") + cmd.Flags().Bool("port-forwarding", false, "Match grants with port-forwarding enabled") + cmd.Flags().Bool("allow-scale", false, "Match grants with allow-scale enabled") + + _ = cmd.RegisterFlagCompletionFunc("access-level", completeAccessLevels) + _ = cmd.RegisterFlagCompletionFunc("namespace", completeNamespacesFlag) + _ = cmd.RegisterFlagCompletionFunc("scope", completeScopeFlag) + + return cmd +} + +func runRevoke(cmd *cobra.Command, args []string) error { + subjectKindStr := args[0] + subjectName := args[1] + accessLevel, _ := cmd.Flags().GetString("access-level") + namespaces, _ := cmd.Flags().GetStringSlice("namespace") + scopeFlag, _ := cmd.Flags().GetString("scope") + portForwarding, _ := cmd.Flags().GetBool("port-forwarding") + allowScale, _ := cmd.Flags().GetBool("allow-scale") + + if accessLevel == "" { + return errors.New("--access-level is required") + } + + subjectKind, err := parseSubjectKind(subjectKindStr) + if err != nil { + return err + } + + scopeType, scopeNS, labelMatch, err := parseScope(scopeFlag, namespaces) + if err != nil { + return err + } + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + var subjectPrincipal string + switch subjectKind { + case iamtypes.KindUser: + subjectPrincipal, err = resolveUserEmail(cmd.Context(), dyn, subjectName) + if err != nil { + return err + } + case iamtypes.KindGroup: + subjectPrincipal = subjectName + } + + opts := revokeOpts{ + subjectKind: subjectKind, + subjectRef: subjectName, + subjectPrincipal: subjectPrincipal, + accessLevel: accessLevel, + scopeType: scopeType, + namespaces: scopeNS, + labelMatch: labelMatch, + allowScale: allowScale, + portForwarding: portForwarding, + } + return revokeManagedGrants(cmd, dyn, opts) +} + +// revokeOpts mirrors grantOpts: it captures every parameter of a revoke so +// revokeManagedGrants doesn't need a 9-parameter signature. +type revokeOpts struct { + subjectKind iamtypes.SubjectKind + subjectRef string + subjectPrincipal string + accessLevel string + scopeType iamtypes.Scope + namespaces []string + labelMatch map[string]string + allowScale bool + portForwarding bool +} + +// revokeManagedGrants is the inverse of applyGrants: it expands the opts into +// one canonical spec per concrete object, picks the right ResourceInterface +// for each, and deletes the d8-managed object via deleteManagedGrant. +func revokeManagedGrants(cmd *cobra.Command, dyn dynamic.Interface, opts revokeOpts) error { + specs, err := canonicalGrantSpecs(canonicalGrantInput{ + SubjectKind: opts.subjectKind, + SubjectRef: opts.subjectRef, + SubjectPrincipal: opts.subjectPrincipal, + AccessLevel: opts.accessLevel, + ScopeType: opts.scopeType, + Namespaces: opts.namespaces, + LabelMatch: opts.labelMatch, + AllowScale: opts.allowScale, + PortForwarding: opts.portForwarding, + }) + if err != nil { + return err + } + + var errs *multierror.Error + for _, spec := range specs { + client, kind, ns, err := revokeClient(dyn, spec) + if err != nil { + return err + } + if err := deleteManagedGrant(cmd, client, spec, kind, ns); err != nil { + errs = multierror.Append(errs, err) + } + } + return errs.ErrorOrNil() +} + +// revokeClient returns the ResourceInterface plus the human-readable +// kind/namespace pair that deleteManagedGrant uses for messages and errors. +func revokeClient(dyn dynamic.Interface, spec *canonicalGrantSpec) (dynamic.ResourceInterface, string, string, error) { + switch spec.ScopeType { + case iamtypes.ScopeNamespace: + if len(spec.Namespaces) != 1 { + return nil, "", "", fmt.Errorf("namespaced revoke must target exactly one namespace, got %d", len(spec.Namespaces)) + } + ns := spec.Namespaces[0] + return dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace(ns), iamtypes.KindAuthorizationRule, ns, nil + case iamtypes.ScopeCluster, iamtypes.ScopeAllNamespaces, iamtypes.ScopeLabels: + return dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR), iamtypes.KindClusterAuthorizationRule, "", nil + default: + return nil, "", "", fmt.Errorf("unsupported scope %q", spec.ScopeType) + } +} + +// deleteManagedGrant deletes a d8-managed authorization rule. Refuses to +// touch objects that are not labelled as managed by d8-cli — manual cleanup +// of shared rules is intentionally outside this command's contract; use +// `kubectl edit` instead. +func deleteManagedGrant(cmd *cobra.Command, client dynamic.ResourceInterface, + spec *canonicalGrantSpec, kind, ns string) error { + name, err := generateGrantName(spec) + if err != nil { + return err + } + + obj, err := client.Get(cmd.Context(), name, metav1.GetOptions{}) + if err != nil { + ref := formatRuleRef(kind, ns, name) + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: d8-managed %s not found\n", ref) + return fmt.Errorf("%s: %w", ref, err) + } + + if obj.GetLabels()[iamtypes.LabelManagedBy] != iamtypes.ManagedByValueCLI { + return fmt.Errorf("%s is not managed by d8-cli; edit it manually with kubectl", formatRuleRef(kind, ns, name)) + } + + if err := client.Delete(cmd.Context(), name, metav1.DeleteOptions{}); err != nil { + ref := formatRuleRef(kind, ns, name) + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to delete %s: %v\n", ref, err) + return fmt.Errorf("%s: %w", ref, err) + } + + cmd.Printf("Revoked: %s\n", formatRuleRef(kind, ns, name)) + return nil +} + +// formatRuleRef formats a ruleRef the way users see it ("Kind/Name" or +// "Kind/NS/Name"), shared between revoke output and error messages so they +// always agree. +func formatRuleRef(kind, ns, name string) string { + if ns == "" { + return fmt.Sprintf("%s/%s", kind, name) + } + return fmt.Sprintf("%s/%s/%s", kind, ns, name) +} diff --git a/internal/iam/access/cmd/rules.go b/internal/iam/access/cmd/rules.go new file mode 100644 index 00000000..ec5e0774 --- /dev/null +++ b/internal/iam/access/cmd/rules.go @@ -0,0 +1,646 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "context" + "errors" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/duration" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/client-go/dynamic" + "k8s.io/kubectl/pkg/util/templates" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +// ErrInvalidRuleRef is returned by parseRuleRef when the textual reference does +// not match one of the supported "CAR/" or "AR//" forms. +var ErrInvalidRuleRef = errors.New("invalid rule reference") + +// ruleRow is a uniform view over a CAR or an AR for list/get rendering. +type ruleRow struct { + Kind string // ClusterAuthorizationRule | AuthorizationRule (object kind) + Name string + Namespace string // empty for CAR + AccessLevel string + ScopeType iamtypes.Scope + ScopeNamespaces []string // for namespace scope (the single namespace of the AR) + AllowScale bool + PortForwarding bool + ManagedByD8 bool + Subjects []subjectRef + CreationTime time.Time +} + +type subjectRef struct { + Kind iamtypes.SubjectKind + Name string +} + +func (r ruleRow) ref() string { + return formatRuleRef(r.Kind, r.Namespace, r.Name) +} + +// readSubjectRefs decodes spec.subjects into typed (kind, name) pairs. +// Shared by rule listing and grant normalization so the unstructured layer +// is touched in exactly one place. Caller decides whether to filter +// ServiceAccount subjects (we keep them here so callers that need the raw +// shape — e.g. rules get — see exactly what is on the object). +func readSubjectRefs(obj *unstructured.Unstructured) []subjectRef { + raw, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") + out := make([]subjectRef, 0, len(raw)) + for _, s := range raw { + m, ok := s.(map[string]any) + if !ok { + continue + } + out = append(out, subjectRef{ + Kind: iamtypes.SubjectKind(fmt.Sprint(m["kind"])), + Name: fmt.Sprint(m["name"]), + }) + } + return out +} + +// ------------------------------ list ------------------------------ + +var listRulesExample = templates.Examples(` + # All rules in the cluster (both CARs and every AR across namespaces) + d8 iam list rules + + # Only ClusterAuthorizationRules + d8 iam list rules --cluster + + # Only AuthorizationRules from selected namespaces + d8 iam list rules -n dev -n stage + + # Only rules created by "d8 iam access grant" + d8 iam list rules --managed-only + + # Only rules NOT created by d8-cli + d8 iam list rules --manual-only + + # Machine-readable + d8 iam list rules -o json + d8 iam list rules -o yaml`) + +// NewListRulesCommand returns the cobra command behind "d8 iam list rules". +// Exported so package listget can register it as a child of the top-level +// "list" command. +func NewListRulesCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "rules", + Aliases: []string{"rule"}, + Short: "List ClusterAuthorizationRules and AuthorizationRules", + Example: listRulesExample, + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: runRulesList, + } + + cmd.Flags().StringSliceP("namespace", "n", nil, "Restrict to AuthorizationRules in these namespaces (repeatable). Implicitly disables CARs unless --cluster is also set.") + cmd.Flags().Bool("cluster", false, "Only ClusterAuthorizationRules") + cmd.Flags().Bool("managed-only", false, "Only rules managed by d8-cli (label app.kubernetes.io/managed-by=d8-cli)") + cmd.Flags().Bool("manual-only", false, "Only rules NOT managed by d8-cli") + utilk8s.AddOutputFlag(cmd, "table", "table", "json", "yaml") + + _ = cmd.RegisterFlagCompletionFunc("namespace", completeNamespacesFlag) + + return cmd +} + +func runRulesList(cmd *cobra.Command, _ []string) error { + namespaces, _ := cmd.Flags().GetStringSlice("namespace") + clusterOnly, _ := cmd.Flags().GetBool("cluster") + managedOnly, _ := cmd.Flags().GetBool("managed-only") + manualOnly, _ := cmd.Flags().GetBool("manual-only") + outputFmt, _ := cmd.Flags().GetString("output") + + if managedOnly && manualOnly { + return errors.New("--managed-only and --manual-only are mutually exclusive") + } + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + // Decide which kinds to fetch. + // --cluster -> only CARs + // -n given, no --cluster -> only ARs in those namespaces + // nothing -> everything (CARs + ARs in all namespaces) + // --cluster AND -n -> CARs + ARs in those namespaces (explicit union) + includeCARs := clusterOnly || len(namespaces) == 0 + includeARs := !clusterOnly || len(namespaces) > 0 + nsFilter := namespaces + + rows, err := collectRuleRows(cmd.Context(), dyn, includeCARs, includeARs, nsFilter) + if err != nil { + return err + } + + rows = filterByManagement(rows, managedOnly, manualOnly) + sortRuleRows(rows) + + switch outputFmt { + case "json", "yaml": + return printStructured(cmd.OutOrStdout(), buildRuleRowsJSON(rows), outputFmt) + case "table", "": + return printRuleRowsTable(cmd.OutOrStdout(), rows) + default: + return fmt.Errorf("%w %q; use table|json|yaml", errUnsupportedFormat, outputFmt) + } +} + +// ------------------------------ get ------------------------------ + +var getRuleExample = templates.Examples(` + # Get a ClusterAuthorizationRule (full prefix or short) + d8 iam get rule ClusterAuthorizationRule/superadmins + d8 iam get rule CAR/superadmins + + # Get an AuthorizationRule (namespace is part of the reference) + d8 iam get rule AuthorizationRule/dev/editors + d8 iam get rule AR/dev/editors + + # Machine-readable + d8 iam get rule CAR/superadmins -o yaml + d8 iam get rule AR/dev/editors -o json`) + +// NewGetRuleCommand returns the cobra command behind "d8 iam get rule REF". +// Exported so package listget can register it as a child of the top-level +// "get" command. +func NewGetRuleCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "rule REF", + Aliases: []string{"rules"}, + Short: "Show a single CAR or AR with subjects, scope and reverse CR lookup", + Example: getRuleExample, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeRuleRef, + SilenceErrors: true, + SilenceUsage: true, + RunE: runRulesGet, + } + utilk8s.AddOutputFlag(cmd, "text", "text", "json", "yaml") + return cmd +} + +func runRulesGet(cmd *cobra.Command, args []string) error { + refKind, refNS, refName, err := parseRuleRef(args[0]) + if err != nil { + return err + } + outputFmt, _ := cmd.Flags().GetString("output") + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + var obj *unstructured.Unstructured + switch refKind { + case iamtypes.KindClusterAuthorizationRule: + obj, err = dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR).Get(cmd.Context(), refName, metav1.GetOptions{}) + case iamtypes.KindAuthorizationRule: + obj, err = dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace(refNS).Get(cmd.Context(), refName, metav1.GetOptions{}) + } + if err != nil { + return fmt.Errorf("getting %s: %w", args[0], err) + } + + switch outputFmt { + case "yaml", "json": + // PrintObject (not printStructured) for "get rule" because the user + // expects a real Kubernetes manifest with apiVersion/kind that they + // can pipe back into kubectl apply. printStructured operates on our + // internal ruleRow shape and would lose that contract. + return utilk8s.PrintObject(cmd.OutOrStdout(), obj, outputFmt) + case "text", "": + row := ruleRowFromObject(obj) + reverse, err := reverseSubjectLookup(cmd.Context(), dyn, row.Subjects) + if err != nil { + // Reverse lookup is a best-effort nicety; degrade rather than fail. + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: reverse lookup of subjects failed: %v\n", err) + } + return printRuleRowText(cmd.OutOrStdout(), row, reverse) + default: + return fmt.Errorf("%w %q; use text|json|yaml", errUnsupportedFormat, outputFmt) + } +} + +// ------------------------------ collection ------------------------------ + +func collectRuleRows(ctx context.Context, dyn dynamic.Interface, includeCARs, includeARs bool, nsFilter []string) ([]ruleRow, error) { + var rows []ruleRow + + if includeCARs { + list, err := dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing ClusterAuthorizationRules: %w", err) + } + for i := range list.Items { + rows = append(rows, ruleRowFromObject(&list.Items[i])) + } + } + + if includeARs { + nsToList := []string{""} + if len(nsFilter) > 0 { + nsToList = nsFilter + } + for _, ns := range nsToList { + list, err := dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing AuthorizationRules in %q: %w", ns, err) + } + for i := range list.Items { + rows = append(rows, ruleRowFromObject(&list.Items[i])) + } + } + } + + return rows, nil +} + +func ruleRowFromObject(obj *unstructured.Unstructured) ruleRow { + labels := obj.GetLabels() + + accessLevel, _, _ := unstructured.NestedString(obj.Object, "spec", "accessLevel") + allowScale, _, _ := unstructured.NestedBool(obj.Object, "spec", "allowScale") + portForwarding, _, _ := unstructured.NestedBool(obj.Object, "spec", "portForwarding") + + kind := obj.GetKind() + ns := obj.GetNamespace() + var scopeType iamtypes.Scope + var scopeNamespaces []string + switch kind { + case iamtypes.KindClusterAuthorizationRule: + scopeType = iamtypes.ScopeCluster + if matchAny, found, _ := unstructured.NestedBool(obj.Object, "spec", "namespaceSelector", "matchAny"); found && matchAny { + scopeType = iamtypes.ScopeAllNamespaces + } else if matchLabels, found, _ := unstructured.NestedMap(obj.Object, "spec", "namespaceSelector", "labelSelector", "matchLabels"); found && len(matchLabels) > 0 { + scopeType = iamtypes.ScopeLabels + } + case iamtypes.KindAuthorizationRule: + scopeType = iamtypes.ScopeNamespace + scopeNamespaces = []string{ns} + } + + subjects := readSubjectRefs(obj) + + return ruleRow{ + Kind: kind, + Name: obj.GetName(), + Namespace: ns, + AccessLevel: accessLevel, + ScopeType: scopeType, + ScopeNamespaces: scopeNamespaces, + AllowScale: allowScale, + PortForwarding: portForwarding, + ManagedByD8: labels[iamtypes.LabelManagedBy] == iamtypes.ManagedByValueCLI, + Subjects: subjects, + CreationTime: obj.GetCreationTimestamp().Time, + } +} + +func filterByManagement(rows []ruleRow, managedOnly, manualOnly bool) []ruleRow { + if !managedOnly && !manualOnly { + return rows + } + out := rows[:0] + for _, r := range rows { + switch { + case managedOnly && r.ManagedByD8: + out = append(out, r) + case manualOnly && !r.ManagedByD8: + out = append(out, r) + } + } + return out +} + +func sortRuleRows(rows []ruleRow) { + sort.Slice(rows, func(i, j int) bool { + // Stable, deterministic order: Kind, Namespace, Name. + if rows[i].Kind != rows[j].Kind { + // ClusterAuthorizationRule first — cluster-wide rules read before + // namespaced ones, matching operator mental model. + return rows[i].Kind < rows[j].Kind + } + if rows[i].Namespace != rows[j].Namespace { + return rows[i].Namespace < rows[j].Namespace + } + return rows[i].Name < rows[j].Name + }) +} + +// ------------------------------ rendering: table ------------------------------ + +// shortKind maps full CRD names to compact column values for the table view. +func shortKind(kind string) string { + switch kind { + case iamtypes.KindClusterAuthorizationRule: + return "CAR" + case iamtypes.KindAuthorizationRule: + return "AR" + default: + return kind + } +} + +func managedByColumn(r ruleRow) string { + if r.ManagedByD8 { + return iamtypes.ManagedByValueCLI + } + return "manual" +} + +func capsColumn(r ruleRow) string { + var caps []string + if r.AllowScale { + caps = append(caps, "scale") + } + if r.PortForwarding { + caps = append(caps, "pfwd") + } + if len(caps) == 0 { + return "-" + } + return strings.Join(caps, ",") +} + +func printRuleRowsTable(w io.Writer, rows []ruleRow) error { + if len(rows) == 0 { + fmt.Fprintln(w, "No rules match.") + return nil + } + + tw := printers.GetNewTabWriter(w) + fmt.Fprintln(tw, "KIND\tNAMESPACE\tNAME\tLEVEL\tSCOPE\tSUBJECTS\tMANAGED\tCAPS\tAGE") + + for _, r := range rows { + ns := r.Namespace + if ns == "" { + ns = "-" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n", + shortKind(r.Kind), + ns, + truncate(r.Name, 60), + r.AccessLevel, + r.ScopeType, + len(r.Subjects), + managedByColumn(r), + capsColumn(r), + humanAge(r.CreationTime), + ) + } + return tw.Flush() +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 1 { + return s[:max] + } + return s[:max-1] + "…" +} + +// humanAge formats a creation timestamp as the same compact age column +// kubectl uses (e.g. "5m", "2h", "3d"). Delegates to apimachinery so the +// formatting stays consistent with other Kubernetes tooling. +func humanAge(t time.Time) string { + if t.IsZero() { + return "-" + } + return duration.HumanDuration(time.Since(t)) +} + +// ------------------------------ rendering: text (get) ------------------------------ + +func printRuleRowText(w io.Writer, r ruleRow, reverse map[string]string) error { + fmt.Fprintf(w, "=== %s ===\n", r.ref()) + fmt.Fprintf(w, " Kind: %s\n", r.Kind) + fmt.Fprintf(w, " Name: %s\n", r.Name) + if r.Namespace != "" { + fmt.Fprintf(w, " Namespace: %s\n", r.Namespace) + } + accessLevel := r.AccessLevel + if accessLevel == "" { + accessLevel = "" + } + fmt.Fprintf(w, " Access level: %s\n", accessLevel) + fmt.Fprintf(w, " Scope: %s\n", r.ScopeType) + fmt.Fprintf(w, " Allow scale: %v\n", r.AllowScale) + fmt.Fprintf(w, " Port forward: %v\n", r.PortForwarding) + fmt.Fprintf(w, " Managed by: %s\n", managedByColumn(r)) + if !r.CreationTime.IsZero() { + fmt.Fprintf(w, " Age: %s (%s)\n", humanAge(r.CreationTime), r.CreationTime.UTC().Format(time.RFC3339)) + } + + fmt.Fprintf(w, "\n=== Subjects (%d) ===\n", len(r.Subjects)) + if len(r.Subjects) == 0 { + fmt.Fprintln(w, " ") + } else { + for _, s := range r.Subjects { + key := string(s.Kind) + "/" + s.Name + local := reverse[key] + switch local { + case "": + fmt.Fprintf(w, " - %s: %s\n", s.Kind, s.Name) + case s.Name: + // Group: the principal IS the CR name — no extra info to show. + fmt.Fprintf(w, " - %s: %s\n", s.Kind, s.Name) + default: + // User: principal (email) differs from CR name. Surface both. + fmt.Fprintf(w, " - %s: %s (local %s CR: %s)\n", s.Kind, s.Name, s.Kind, local) + } + } + } + + if r.AccessLevel == "SuperAdmin" && (!r.AllowScale || !r.PortForwarding) { + fmt.Fprintln(w, "\n=== Notes ===") + fmt.Fprintln(w, " - accessLevel=SuperAdmin binds user-authz:super-admin ClusterRole,") + fmt.Fprintln(w, " which grants apiGroups/resources/verbs=* and nonResourceURLs=*.") + fmt.Fprintln(w, " This implicitly covers pods/portforward and */scale regardless of") + fmt.Fprintln(w, " the allowScale / portForwarding fields on this CAR.") + } + + return nil +} + +// ------------------------------ rendering: json/yaml ------------------------------ +// +// YAML output is intentionally the same shape as JSON for symmetry with +// other list commands in this package — we do NOT emit full Kubernetes +// manifests here. Use "d8 iam get rule REF -o yaml" for the manifest view. + +type ruleJSON struct { + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + AccessLevel string `json:"accessLevel"` + Scope string `json:"scope"` + AllowScale bool `json:"allowScale"` + PortForwarding bool `json:"portForwarding"` + ManagedByD8 bool `json:"managedByD8Cli"` + Subjects []subjectJSON `json:"subjects"` + CreationTime time.Time `json:"creationTimestamp,omitempty"` +} + +type subjectJSON struct { + Kind string `json:"kind"` + Name string `json:"name"` +} + +func ruleRowToJSON(r ruleRow) ruleJSON { + subjects := make([]subjectJSON, 0, len(r.Subjects)) + for _, s := range r.Subjects { + subjects = append(subjects, subjectJSON{Kind: string(s.Kind), Name: s.Name}) + } + return ruleJSON{ + Kind: r.Kind, + Name: r.Name, + Namespace: r.Namespace, + AccessLevel: r.AccessLevel, + Scope: string(r.ScopeType), + AllowScale: r.AllowScale, + PortForwarding: r.PortForwarding, + ManagedByD8: r.ManagedByD8, + Subjects: denil(subjects), + CreationTime: r.CreationTime, + } +} + +func buildRuleRowsJSON(rows []ruleRow) []ruleJSON { + items := make([]ruleJSON, 0, len(rows)) + for _, r := range rows { + items = append(items, ruleRowToJSON(r)) + } + return items +} + +// ------------------------------ refs ------------------------------ + +// parseRuleRef accepts: +// +// ClusterAuthorizationRule/NAME (long) +// CAR/NAME (short) +// AuthorizationRule/NS/NAME (long) +// AR/NS/NAME (short) +// +// Returns (kind, namespace, name, err). namespace is "" for CARs. +func parseRuleRef(ref string) (string, string, string, error) { + parts := strings.Split(ref, "/") + if len(parts) < 2 { + return "", "", "", fmt.Errorf("%w %q: expected ClusterAuthorizationRule/NAME or AuthorizationRule/NS/NAME (short forms CAR/... and AR/... also accepted)", ErrInvalidRuleRef, ref) + } + + switch parts[0] { + case iamtypes.KindClusterAuthorizationRule, "CAR", "car": + if len(parts) != 2 { + return "", "", "", fmt.Errorf("%w %q: ClusterAuthorizationRule reference must be of the form ClusterAuthorizationRule/NAME", ErrInvalidRuleRef, ref) + } + return iamtypes.KindClusterAuthorizationRule, "", parts[1], nil + case iamtypes.KindAuthorizationRule, "AR", "ar": + if len(parts) != 3 { + return "", "", "", fmt.Errorf("%w %q: AuthorizationRule reference must be of the form AuthorizationRule/NAMESPACE/NAME", ErrInvalidRuleRef, ref) + } + return iamtypes.KindAuthorizationRule, parts[1], parts[2], nil + default: + return "", "", "", fmt.Errorf("%w %q: unknown rule kind %q (use ClusterAuthorizationRule, AuthorizationRule, CAR or AR)", ErrInvalidRuleRef, ref, parts[0]) + } +} + +// ------------------------------ reverse lookup ------------------------------ + +// reverseSubjectLookup returns a map "Kind/Name" -> local-CR-name for subjects +// that can be cross-referenced with local User/Group CRs. For Users we look up +// by spec.email because that is what ends up in subjects[].name. For Groups +// the subject name IS the CR name, so the map simply echoes it (useful for +// the "rendered with no extra info" branch in the text output). +func reverseSubjectLookup(ctx context.Context, dyn dynamic.Interface, subjects []subjectRef) (map[string]string, error) { + result := make(map[string]string) + needUsers := false + needGroups := false + for _, s := range subjects { + switch s.Kind { + case iamtypes.KindUser: + needUsers = true + case iamtypes.KindGroup: + needGroups = true + } + } + + if needUsers { + userList, err := dyn.Resource(iamtypes.UserGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing Users: %w", err) + } + emailToCR := make(map[string]string, len(userList.Items)) + for i := range userList.Items { + u := &userList.Items[i] + email, _, _ := unstructured.NestedString(u.Object, "spec", "email") + if email != "" { + emailToCR[email] = u.GetName() + } + } + for _, s := range subjects { + if s.Kind != iamtypes.KindUser { + continue + } + if cr, ok := emailToCR[s.Name]; ok { + result[string(iamtypes.KindUser)+"/"+s.Name] = cr + } + } + } + + if needGroups { + groupList, err := dyn.Resource(iamtypes.GroupGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing Groups: %w", err) + } + present := make(map[string]bool, len(groupList.Items)) + for i := range groupList.Items { + present[groupList.Items[i].GetName()] = true + } + for _, s := range subjects { + if s.Kind != iamtypes.KindGroup { + continue + } + if present[s.Name] { + result[string(iamtypes.KindGroup)+"/"+s.Name] = s.Name + } + } + } + + return result, nil +} diff --git a/internal/iam/access/cmd/rules_test.go b/internal/iam/access/cmd/rules_test.go new file mode 100644 index 00000000..c40e3c9b --- /dev/null +++ b/internal/iam/access/cmd/rules_test.go @@ -0,0 +1,248 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "bytes" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" +) + +func TestParseRuleRef(t *testing.T) { + tests := []struct { + input string + wantKind string + wantNS string + wantName string + wantErr bool + }{ + {input: "ClusterAuthorizationRule/superadmins", wantKind: "ClusterAuthorizationRule", wantName: "superadmins"}, + {input: "CAR/superadmins", wantKind: "ClusterAuthorizationRule", wantName: "superadmins"}, + {input: "car/superadmins", wantKind: "ClusterAuthorizationRule", wantName: "superadmins"}, + {input: "AuthorizationRule/dev/editors", wantKind: "AuthorizationRule", wantNS: "dev", wantName: "editors"}, + {input: "AR/dev/editors", wantKind: "AuthorizationRule", wantNS: "dev", wantName: "editors"}, + {input: "ar/dev/editors", wantKind: "AuthorizationRule", wantNS: "dev", wantName: "editors"}, + {input: "superadmins", wantErr: true}, + {input: "CAR/dev/editors", wantErr: true}, + {input: "AR/editors", wantErr: true}, + {input: "Role/dev/x", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + kind, ns, name, err := parseRuleRef(tc.input) + if tc.wantErr { + require.Error(t, err) + assert.True(t, errors.Is(err, ErrInvalidRuleRef), + "expected ErrInvalidRuleRef, got %v", err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantKind, kind) + assert.Equal(t, tc.wantNS, ns) + assert.Equal(t, tc.wantName, name) + }) + } +} + +func TestFilterByManagement(t *testing.T) { + rows := []ruleRow{ + {Name: "a", ManagedByD8: true}, + {Name: "b", ManagedByD8: false}, + {Name: "c", ManagedByD8: true}, + } + + t.Run("no filters keeps all", func(t *testing.T) { + out := filterByManagement(append([]ruleRow(nil), rows...), false, false) + assert.Len(t, out, 3) + }) + t.Run("managed-only keeps d8-managed", func(t *testing.T) { + out := filterByManagement(append([]ruleRow(nil), rows...), true, false) + assert.Len(t, out, 2) + assert.Equal(t, "a", out[0].Name) + assert.Equal(t, "c", out[1].Name) + }) + t.Run("manual-only keeps non-managed", func(t *testing.T) { + out := filterByManagement(append([]ruleRow(nil), rows...), false, true) + assert.Len(t, out, 1) + assert.Equal(t, "b", out[0].Name) + }) +} + +func TestSortRuleRows(t *testing.T) { + // Ensure CARs come before ARs, then by namespace, then by name. + rows := []ruleRow{ + {Kind: "AuthorizationRule", Namespace: "dev", Name: "b"}, + {Kind: "ClusterAuthorizationRule", Name: "z"}, + {Kind: "AuthorizationRule", Namespace: "dev", Name: "a"}, + {Kind: "AuthorizationRule", Namespace: "aaa", Name: "x"}, + {Kind: "ClusterAuthorizationRule", Name: "a"}, + } + sortRuleRows(rows) + + // lexicographic: "AuthorizationRule" < "ClusterAuthorizationRule", so ARs come first. + // The intent is stable, predictable ordering — so we just assert what the + // implementation produces (AR block before CAR block, grouped by ns/name). + expectedOrder := []string{ + "AuthorizationRule/aaa/x", + "AuthorizationRule/dev/a", + "AuthorizationRule/dev/b", + "ClusterAuthorizationRule/a", + "ClusterAuthorizationRule/z", + } + var got []string + for _, r := range rows { + got = append(got, r.ref()) + } + assert.Equal(t, expectedOrder, got) +} + +func TestCapsAndManagedByColumns(t *testing.T) { + assert.Equal(t, "-", capsColumn(ruleRow{})) + assert.Equal(t, "scale", capsColumn(ruleRow{AllowScale: true})) + assert.Equal(t, "pfwd", capsColumn(ruleRow{PortForwarding: true})) + assert.Equal(t, "scale,pfwd", capsColumn(ruleRow{AllowScale: true, PortForwarding: true})) + + assert.Equal(t, "d8-cli", managedByColumn(ruleRow{ManagedByD8: true})) + assert.Equal(t, "manual", managedByColumn(ruleRow{})) +} + +func TestRuleRowFromObject_CAR(t *testing.T) { + obj := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "deckhouse.io/v1", + "kind": "ClusterAuthorizationRule", + "metadata": map[string]any{ + "name": "superadmins", + "labels": map[string]any{iamtypes.LabelManagedBy: iamtypes.ManagedByValueCLI}, + }, + "spec": map[string]any{ + "accessLevel": "SuperAdmin", + "allowScale": true, + "portForwarding": true, + "namespaceSelector": map[string]any{ + "matchAny": true, + }, + "subjects": []any{ + map[string]any{"kind": "Group", "name": "admins"}, + map[string]any{"kind": "User", "name": "alice@example.com"}, + }, + }, + }} + obj.SetCreationTimestamp(metav1.NewTime(time.Now())) + + row := ruleRowFromObject(obj) + + assert.Equal(t, "ClusterAuthorizationRule", row.Kind) + assert.Equal(t, "superadmins", row.Name) + assert.Empty(t, row.Namespace) + assert.Equal(t, "SuperAdmin", row.AccessLevel) + assert.Equal(t, iamtypes.ScopeAllNamespaces, row.ScopeType) + assert.True(t, row.AllowScale) + assert.True(t, row.PortForwarding) + assert.True(t, row.ManagedByD8) + require.Len(t, row.Subjects, 2) + assert.Equal(t, "ClusterAuthorizationRule/superadmins", row.ref()) +} + +func TestRuleRowFromObject_AR(t *testing.T) { + obj := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "deckhouse.io/v1alpha1", + "kind": "AuthorizationRule", + "metadata": map[string]any{ + "name": "editors", + "namespace": "dev", + }, + "spec": map[string]any{ + "accessLevel": "Editor", + "subjects": []any{ + map[string]any{"kind": "User", "name": "bob@example.com"}, + }, + }, + }} + + row := ruleRowFromObject(obj) + + assert.Equal(t, "AuthorizationRule", row.Kind) + assert.Equal(t, "editors", row.Name) + assert.Equal(t, "dev", row.Namespace) + assert.Equal(t, iamtypes.ScopeNamespace, row.ScopeType) + assert.Equal(t, []string{"dev"}, row.ScopeNamespaces) + assert.False(t, row.ManagedByD8) + assert.Equal(t, "AuthorizationRule/dev/editors", row.ref()) +} + +func TestPrintRuleRowText_HighlightsSuperAdminImplicitCaps(t *testing.T) { + row := ruleRow{ + Kind: "ClusterAuthorizationRule", + Name: "superadmins", + AccessLevel: "SuperAdmin", + ScopeType: "all-namespaces", + // NOTE: both capabilities left false on the CAR — we want the Notes + // section to appear and explain the implicit bump. + AllowScale: false, + PortForwarding: false, + Subjects: []subjectRef{ + {Kind: "User", Name: "alice@example.com"}, + }, + } + reverse := map[string]string{"User/alice@example.com": "alice"} + + var buf bytes.Buffer + require.NoError(t, printRuleRowText(&buf, row, reverse)) + out := buf.String() + + assert.Contains(t, out, "=== ClusterAuthorizationRule/superadmins ===") + assert.Contains(t, out, "alice@example.com (local User CR: alice)") + assert.Contains(t, out, "=== Notes ===") + assert.Contains(t, out, "user-authz:super-admin ClusterRole") +} + +func TestPrintRuleRowText_NoNotesForNonSuperAdmin(t *testing.T) { + row := ruleRow{ + Kind: "AuthorizationRule", + Namespace: "dev", + Name: "editors", + AccessLevel: "Editor", + ScopeType: "namespace", + Subjects: []subjectRef{{Kind: "Group", Name: "team-x"}}, + } + var buf bytes.Buffer + require.NoError(t, printRuleRowText(&buf, row, map[string]string{})) + out := buf.String() + + assert.NotContains(t, out, "Notes") + assert.Contains(t, out, "Group: team-x") +} + +func TestTruncate(t *testing.T) { + assert.Equal(t, "abc", truncate("abc", 5)) + assert.Equal(t, "ab…", truncate("abcdef", 3)) + assert.Equal(t, "a", truncate("abc", 1)) +} + +// humanAge delegates to apimachinery duration.HumanDuration; we only assert +// the zero-time placeholder, which is our own behavior on top of it. +func TestHumanAge_ZeroPlaceholder(t *testing.T) { + assert.Equal(t, "-", humanAge(time.Time{})) +} diff --git a/internal/iam/access/cmd/types.go b/internal/iam/access/cmd/types.go new file mode 100644 index 00000000..b1566f10 --- /dev/null +++ b/internal/iam/access/cmd/types.go @@ -0,0 +1,154 @@ +/* +Copyright 2026 Flant JSC + +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 access + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" +) + +// Access levels listed from least to most privileged. Used for validation, +// completion, and error messages — order is part of the user-facing output. +var namespacedAccessLevelsOrdered = []string{ + "User", "PrivilegedUser", "Editor", "Admin", +} + +var clusterOnlyAccessLevelsOrdered = []string{ + "ClusterEditor", "ClusterAdmin", "SuperAdmin", +} + +var allAccessLevelsOrdered = append( + append([]string(nil), namespacedAccessLevelsOrdered...), + clusterOnlyAccessLevelsOrdered..., +) + +// accessLevelOrder maps level → privilege rank (higher = more privileged). +var accessLevelOrder = func() map[string]int { + m := make(map[string]int, len(allAccessLevelsOrdered)) + for i, l := range allAccessLevelsOrdered { + m[l] = i + } + return m +}() + +var namespacedAccessLevels = sliceToSet(namespacedAccessLevelsOrdered) +var allAccessLevels = sliceToSet(allAccessLevelsOrdered) + +func sliceToSet(s []string) map[string]bool { + m := make(map[string]bool, len(s)) + for _, v := range s { + m[v] = true + } + return m +} + +// canonicalGrantSpec is the canonical representation of a grant for hashing +// and comparison. Typed string fields encode to plain JSON strings, so the +// on-disk annotation stays byte-compatible across releases. LabelMatch is +// omitempty so non-labels scopes hash exactly as before this field was added. +type canonicalGrantSpec struct { + Model iamtypes.AccessModel `json:"model"` + SubjectKind iamtypes.SubjectKind `json:"subjectKind"` + SubjectRef string `json:"subjectRef"` + SubjectPrincipal string `json:"subjectPrincipal"` + AccessLevel string `json:"accessLevel"` + ScopeType iamtypes.Scope `json:"scopeType"` + Namespaces []string `json:"namespaces,omitempty"` + LabelMatch map[string]string `json:"labelMatch,omitempty"` + AllowScale bool `json:"allowScale"` + PortForwarding bool `json:"portForwarding"` +} + +func (c *canonicalGrantSpec) JSON() (string, error) { + cpy := *c + if cpy.Namespaces != nil { + sorted := make([]string, len(cpy.Namespaces)) + copy(sorted, cpy.Namespaces) + sort.Strings(sorted) + cpy.Namespaces = sorted + } + // encoding/json sorts map keys, so LabelMatch is already deterministic. + data, err := json.Marshal(&cpy) + if err != nil { + return "", err + } + return string(data), nil +} + +// normalizedGrant is the internal representation of a grant from any source. +type normalizedGrant struct { + SourceKind string // AuthorizationRule or ClusterAuthorizationRule (object kind) + SourceName string + SourceNamespace string // only for AuthorizationRule + + SubjectKind iamtypes.SubjectKind + SubjectPrincipal string // email for users, name for groups + + AccessLevel string + AllowScale bool + PortForwarding bool + + ScopeType iamtypes.Scope + ScopeNamespaces []string // for namespace scope + + ManagedByD8 bool +} + +func validateAccessLevel(level string, namespaced bool) error { + if namespaced { + if !namespacedAccessLevels[level] { + return fmt.Errorf("access level %q is not valid for namespaced scope; valid levels: %s", + level, strings.Join(namespacedAccessLevelsOrdered, ", ")) + } + return nil + } + if !allAccessLevels[level] { + return fmt.Errorf("invalid access level %q; valid levels: %s", + level, strings.Join(allAccessLevelsOrdered, ", ")) + } + return nil +} + +func maxAccessLevel(levels []string) string { + best := "" + bestOrder := -1 + for _, l := range levels { + if ord, ok := accessLevelOrder[l]; ok && ord > bestOrder { + best = l + bestOrder = ord + } + } + return best +} + +// canonicalGrantInput is the flat value type shared by grant and revoke +// callers, so neither has to leak its full *Opts struct into shared helpers. +type canonicalGrantInput struct { + SubjectKind iamtypes.SubjectKind + SubjectRef string + SubjectPrincipal string + AccessLevel string + ScopeType iamtypes.Scope + Namespaces []string + LabelMatch map[string]string + AllowScale bool + PortForwarding bool +} diff --git a/internal/iam/cmd/iam.go b/internal/iam/cmd/iam.go new file mode 100644 index 00000000..a228b3c6 --- /dev/null +++ b/internal/iam/cmd/iam.go @@ -0,0 +1,64 @@ +/* +Copyright 2026 Flant JSC + +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 cmd + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + access "github.com/deckhouse/deckhouse-cli/internal/iam/access/cmd" + group "github.com/deckhouse/deckhouse-cli/internal/iam/group/cmd" + listget "github.com/deckhouse/deckhouse-cli/internal/iam/listget/cmd" + user "github.com/deckhouse/deckhouse-cli/internal/iam/user/cmd" +) + +var iamLong = templates.LongDesc(` +Manage Deckhouse identity and access: users, groups, and access grants. + +Subcommands: + user — local users (user-authn Dex): create/delete/reset-password/ + reset2fa/lock/unlock. + group — local groups: create/delete and add-member/remove-member. + access — grant and revoke (current authz model). + get — show one user/group/rule with effective access and warnings. + list — list users/groups/rules with effective access. + +All subcommands accept the standard --kubeconfig / --context flags. + +© Flant JSC 2026`) + +// NewCommand returns the "d8 iam" parent command that composes the user, +// group, access, and the kubectl-style get/list subcommands. +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "iam", + Short: "Manage Deckhouse users, groups, and access grants", + Long: iamLong, + SilenceErrors: true, + SilenceUsage: true, + } + + cmd.AddCommand( + user.NewCommand(), + group.NewCommand(), + access.NewCommand(), + listget.NewGetCommand(), + listget.NewListCommand(), + ) + + return cmd +} diff --git a/internal/iam/cmd/iam_test.go b/internal/iam/cmd/iam_test.go new file mode 100644 index 00000000..ee4eda42 --- /dev/null +++ b/internal/iam/cmd/iam_test.go @@ -0,0 +1,140 @@ +/* +Copyright 2026 Flant JSC + +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 cmd + +import ( + "sort" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIAMTree locks down the public command surface of `d8 iam` so that +// the kubectl-style top-level get/list verbs are wired through and the +// pre-refactor wrappers (user get/list, group get/list, access list, +// access rules list/get) stay removed. +// +// We verify by walking the cobra tree, not by scanning source, so that +// renames inside subpackages don't cause silent UX regressions: if any +// listed path goes missing, the assertion fires here with a readable diff. +func TestIAMTree(t *testing.T) { + root := NewCommand() + + mustExist := []string{ + // Mutating verbs stay where they were. + "iam user create", + "iam user delete", + "iam user reset-password", + "iam user reset2fa", + "iam user lock", + "iam user unlock", + "iam group create", + "iam group delete", + "iam group add-member", + "iam group remove-member", + "iam access grant", + "iam access revoke", + + // New top-level read verbs (kubectl-style). + "iam get user", + "iam get group", + "iam get rule", + "iam list users", + "iam list groups", + "iam list rules", + } + + mustNotExist := []string{ + // Old per-resource read wrappers — fully removed (no Hidden alias). + "iam user get", + "iam user list", + "iam group get", + "iam group list", + "iam access list", + "iam access rules", + // "explain" was folded into "iam get user/group" — same warnings, + // one command instead of two. + "iam access explain", + } + + have := collectPaths(root) + + for _, p := range mustExist { + assert.Contains(t, have, p, "expected command %q to exist", p) + } + for _, p := range mustNotExist { + assert.NotContains(t, have, p, "command %q should be removed", p) + } +} + +// TestGetListAcceptSingularAndPlural makes sure both `iam list users` and +// `iam list user` (and the same for groups/rules) resolve to the same +// command, since several places in our docs and muscle memory use the +// singular form. +func TestGetListAcceptSingularAndPlural(t *testing.T) { + root := NewCommand() + + pairs := [][2]string{ + // list is canonical, list must alias to it. + {"list users", "list user"}, + {"list groups", "list group"}, + {"list rules", "list rule"}, + // get is canonical, get must alias to it. + {"get user", "get users"}, + {"get group", "get groups"}, + {"get rule", "get rules"}, + } + + for _, p := range pairs { + canonical := mustFind(t, root, p[0]) + alias := mustFind(t, root, p[1]) + require.Equal(t, canonical, alias, + "alias %q must resolve to the same cobra.Command as %q", p[1], p[0]) + } +} + +func collectPaths(c *cobra.Command) []string { + var out []string + var walk func(node *cobra.Command, prefix string) + walk = func(node *cobra.Command, prefix string) { + path := prefix + if path != "" { + path += " " + } + path += node.Name() + out = append(out, path) + for _, child := range node.Commands() { + walk(child, path) + } + } + walk(c, "") + sort.Strings(out) + return out +} + +func mustFind(t *testing.T, root *cobra.Command, args string) *cobra.Command { + t.Helper() + cmd, _, err := root.Find(strings.Fields(args)) + require.NoErrorf(t, err, "find %q", args) + require.NotNilf(t, cmd, "command %q must exist", args) + require.NotEqualf(t, root.Name(), cmd.Name(), + "command %q resolved to root, meaning the path is unknown", args) + return cmd +} diff --git a/internal/iam/group/cmd/add_member.go b/internal/iam/group/cmd/add_member.go new file mode 100644 index 00000000..4d75826b --- /dev/null +++ b/internal/iam/group/cmd/add_member.go @@ -0,0 +1,130 @@ +/* +Copyright 2026 Flant JSC + +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 group + +import ( + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/kubectl/pkg/util/templates" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var addMemberExample = templates.Examples(` + # Add a user to a group (kind "user" is implicit for two positional args) + d8 iam group add-member admins anton + + # Add a user explicitly + d8 iam group add-member admins user anton + + # Add a nested group + d8 iam group add-member platform group admins + + # Add a user, creating the target group if it doesn't exist + d8 iam group add-member admins anton --create-group`) + +func newAddMemberCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "add-member GROUP [user|group] MEMBER", + Short: "Add a member (user or group) to a local group", + Example: addMemberExample, + Args: cobra.RangeArgs(2, 3), + ValidArgsFunction: completeMemberArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: runAddMember, + } + + cmd.Flags().Bool("create-group", false, "Create the target group if it does not exist") + return cmd +} + +func runAddMember(cmd *cobra.Command, args []string) error { + groupName, kindStr, memberName, err := parseMemberArgs(args) + if err != nil { + return err + } + createGroup, _ := cmd.Flags().GetBool("create-group") + + memberKind, err := normalizeMemberKind(kindStr) + if err != nil { + return err + } + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + res, err := EnsureMember(cmd.Context(), dyn, groupName, memberKind, memberName, EnsureMemberOpts{ + CreateGroupIfMissing: createGroup, + CycleCheck: true, + }) + if err != nil { + // EnsureMember wraps the API NotFound error verbatim, so the standard + // k8s error helpers see through the wrap and we don't need to grep + // the message string. + if !createGroup && apierrors.IsNotFound(err) { + return fmt.Errorf("%w (use --create-group to create it)", err) + } + return err + } + + switch { + case res.GroupCreated: + fmt.Fprintf(cmd.ErrOrStderr(), "Group %q created\n", groupName) + cmd.Printf("Added %s %q to group %q\n", strings.ToLower(string(memberKind)), memberName, groupName) + case res.AlreadyMember: + cmd.Printf("Member %s/%s already exists in group %s\n", memberKind, memberName, groupName) + case res.Added: + cmd.Printf("Added %s %q to group %q\n", strings.ToLower(string(memberKind)), memberName, groupName) + } + return nil +} + +func normalizeMemberKind(kind string) (iamtypes.SubjectKind, error) { + switch strings.ToLower(kind) { + case "user": + return iamtypes.KindUser, nil + case "group": + return iamtypes.KindGroup, nil + default: + return "", fmt.Errorf("invalid member kind %q: must be user or group", kind) + } +} + +// parseMemberArgs accepts: +// +// GROUP MEMBER → kind defaults to "user" +// GROUP (user|group) MEMBER → explicit kind +// +// Returns (groupName, kindStr, memberName, err). +func parseMemberArgs(args []string) (string, string, string, error) { + switch len(args) { + case 2: + return args[0], "user", args[1], nil + case 3: + return args[0], args[1], args[2], nil + default: + return "", "", "", errors.New("expected GROUP MEMBER or GROUP (user|group) MEMBER") + } +} diff --git a/internal/iam/group/cmd/completion.go b/internal/iam/group/cmd/completion.go new file mode 100644 index 00000000..eebe01a0 --- /dev/null +++ b/internal/iam/group/cmd/completion.go @@ -0,0 +1,55 @@ +/* +Copyright 2026 Flant JSC + +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 group + +import ( + "github.com/spf13/cobra" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +// memberKinds is the enum accepted as the positional member-kind argument +// on "group add-member" / "group remove-member" commands. +var memberKinds = []string{"user", "group"} + +// completeGroupOnly returns group names only on the first positional arg. +func completeGroupOnly(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return utilk8s.CompleteResourceNames(cmd, iamtypes.GroupGVR, "", toComplete) +} + +// completeMemberArgs completes "GROUP [user|group] NAME" triplets for +// add-member / remove-member commands. +func completeMemberArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + switch len(args) { + case 0: + return utilk8s.CompleteResourceNames(cmd, iamtypes.GroupGVR, "", toComplete) + case 1: + return utilk8s.FilterByPrefix(memberKinds, toComplete), cobra.ShellCompDirectiveNoFileComp + case 2: + switch args[1] { + case "user": + return utilk8s.CompleteResourceNames(cmd, iamtypes.UserGVR, "", toComplete) + case "group": + return utilk8s.CompleteResourceNames(cmd, iamtypes.GroupGVR, "", toComplete) + } + } + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/internal/iam/group/cmd/create.go b/internal/iam/group/cmd/create.go new file mode 100644 index 00000000..9d374347 --- /dev/null +++ b/internal/iam/group/cmd/create.go @@ -0,0 +1,92 @@ +/* +Copyright 2026 Flant JSC + +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 group + +import ( + "fmt" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/kubectl/pkg/util/templates" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var createExample = templates.Examples(` + # Create a group + d8 iam group create admins + + # Create and preview as YAML + d8 iam group create admins --dry-run -o yaml`) + +func newCreateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a local group in Deckhouse", + Example: createExample, + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + dryRun, _ := cmd.Flags().GetBool("dry-run") + outputFmt, _ := cmd.Flags().GetString("output") + + obj := buildGroupObject(name) + + if dryRun { + return utilk8s.PrintObject(cmd.OutOrStdout(), obj, outputFmt) + } + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + created, err := dyn.Resource(iamtypes.GroupGVR).Create(cmd.Context(), obj, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("creating Group %q: %w", name, err) + } + + return utilk8s.PrintObject(cmd.OutOrStdout(), created, outputFmt) + }, + } + + cmd.Flags().Bool("dry-run", false, "Print the resource that would be created without applying") + utilk8s.AddOutputFlag(cmd, "name", "name", "yaml", "json") + return cmd +} + +func buildGroupObject(name string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": iamtypes.APIVersionDeckhouseV1Alpha1, + // unstructured.GetKind() type-asserts the kind value to plain string; + // cast the typed SubjectKind constant at this boundary. + "kind": string(iamtypes.KindGroup), + "metadata": map[string]any{ + "name": name, + }, + "spec": map[string]any{ + "name": name, + "members": []any{}, + }, + }, + } +} diff --git a/internal/iam/group/cmd/delete.go b/internal/iam/group/cmd/delete.go new file mode 100644 index 00000000..dcc0f680 --- /dev/null +++ b/internal/iam/group/cmd/delete.go @@ -0,0 +1,54 @@ +/* +Copyright 2026 Flant JSC + +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 group + +import ( + "fmt" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +func newDeleteCommand() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a local group", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeGroupOnly, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + err = dyn.Resource(iamtypes.GroupGVR).Delete(cmd.Context(), name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleting Group %q: %w", name, err) + } + + cmd.Printf("Group %s deleted\n", name) + return nil + }, + } +} diff --git a/internal/iam/group/cmd/group.go b/internal/iam/group/cmd/group.go new file mode 100644 index 00000000..744605bb --- /dev/null +++ b/internal/iam/group/cmd/group.go @@ -0,0 +1,60 @@ +/* +Copyright 2026 Flant JSC + +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 group + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/system/flags" +) + +var groupLong = templates.LongDesc(` +Manage Deckhouse local groups (user-authn). + +This command provides lifecycle operations for local Group CRs: +Create, Delete, and membership management via add-member / remove-member. + +For viewing groups (single or list with effective access), use the +top-level commands: + + d8 iam get group + d8 iam list groups + +© Flant JSC 2026`) + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "group", + Aliases: []string{"groups"}, + Short: "Manage Deckhouse local groups (user-authn)", + Long: groupLong, + SilenceErrors: true, + SilenceUsage: true, + } + + flags.AddPersistentFlags(cmd) + + cmd.AddCommand( + newCreateCommand(), + newDeleteCommand(), + newAddMemberCommand(), + newRemoveMemberCommand(), + ) + + return cmd +} diff --git a/internal/iam/group/cmd/group_test.go b/internal/iam/group/cmd/group_test.go new file mode 100644 index 00000000..e351df76 --- /dev/null +++ b/internal/iam/group/cmd/group_test.go @@ -0,0 +1,141 @@ +/* +Copyright 2026 Flant JSC + +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 group + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" +) + +func TestBuildGroupObject(t *testing.T) { + obj := buildGroupObject("admins") + assert.Equal(t, "deckhouse.io/v1alpha1", obj.GetAPIVersion()) + assert.Equal(t, "Group", obj.GetKind()) + assert.Equal(t, "admins", obj.GetName()) + + specName, _, _ := unstructured.NestedString(obj.Object, "spec", "name") + assert.Equal(t, "admins", specName) + + members, found, _ := unstructured.NestedSlice(obj.Object, "spec", "members") + assert.True(t, found) + assert.Empty(t, members) +} + +func TestParseMemberArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantGroup string + wantKind string + wantMember string + wantErr bool + }{ + {name: "two args defaults to user", args: []string{"admins", "anton"}, wantGroup: "admins", wantKind: "user", wantMember: "anton"}, + {name: "three args explicit user", args: []string{"admins", "user", "anton"}, wantGroup: "admins", wantKind: "user", wantMember: "anton"}, + {name: "three args explicit group", args: []string{"platform", "group", "admins"}, wantGroup: "platform", wantKind: "group", wantMember: "admins"}, + {name: "one arg is error", args: []string{"admins"}, wantErr: true}, + {name: "four args is error", args: []string{"a", "b", "c", "d"}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g, k, m, err := parseMemberArgs(tt.args) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantGroup, g) + assert.Equal(t, tt.wantKind, k) + assert.Equal(t, tt.wantMember, m) + }) + } +} + +func TestNormalizeMemberKind(t *testing.T) { + tests := []struct { + input string + want iamtypes.SubjectKind + wantErr string + }{ + {input: "user", want: iamtypes.KindUser}, + {input: "User", want: iamtypes.KindUser}, + {input: "USER", want: iamtypes.KindUser}, + {input: "group", want: iamtypes.KindGroup}, + {input: "Group", want: iamtypes.KindGroup}, + {input: "invalid", wantErr: "invalid member kind"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := normalizeMemberKind(tt.input) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestGetGroupMembers(t *testing.T) { + t.Run("with members", func(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "members": []any{ + map[string]any{"kind": "User", "name": "anton"}, + map[string]any{"kind": "Group", "name": "devs"}, + }, + }, + }, + } + members, err := getGroupMembers(obj) + require.NoError(t, err) + assert.Len(t, members, 2) + }) + + t.Run("empty members", func(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "members": []any{}, + }, + }, + } + members, err := getGroupMembers(obj) + require.NoError(t, err) + assert.Empty(t, members) + }) + + t.Run("no members field", func(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{}, + }, + } + members, err := getGroupMembers(obj) + require.NoError(t, err) + assert.Nil(t, members) + }) +} + diff --git a/internal/iam/group/cmd/membership.go b/internal/iam/group/cmd/membership.go new file mode 100644 index 00000000..1afa37df --- /dev/null +++ b/internal/iam/group/cmd/membership.go @@ -0,0 +1,170 @@ +/* +Copyright 2026 Flant JSC + +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 group + +import ( + "context" + "fmt" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/dynamic" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" +) + +// EnsureMemberOpts controls EnsureMember behavior. Zero value is valid: +// CreateGroupIfMissing=false, CycleCheck=false (no-op for User members). +type EnsureMemberOpts struct { + // CreateGroupIfMissing creates the parent Group CR if it does not exist. + CreateGroupIfMissing bool + // CycleCheck loads the membership graph and refuses to add the member + // if it would introduce a cycle. Only meaningful when MemberKind==Group; + // User members can never form a cycle. + CycleCheck bool +} + +// EnsureMember idempotently adds a (memberKind, memberName) pair to the +// spec.members of groupName. It is the single implementation shared by +// `d8 iam group add-member` and `d8 iam user create --member-of`. +// +// Returns an EnsureMemberResult describing what changed; the caller is +// responsible for printing user-facing messages. +func EnsureMember(ctx context.Context, dyn dynamic.Interface, + groupName string, memberKind iamtypes.SubjectKind, memberName string, opts EnsureMemberOpts) (EnsureMemberResult, error) { + groupClient := dyn.Resource(iamtypes.GroupGVR) + memberKindStr := string(memberKind) + + obj, err := groupClient.Get(ctx, groupName, metav1.GetOptions{}) + switch { + case err == nil: + // fall through to the existing-group path below. + case apierrors.IsNotFound(err): + if !opts.CreateGroupIfMissing { + return EnsureMemberResult{}, fmt.Errorf("group %q not found: %w", groupName, err) + } + newObj := buildGroupObject(groupName) + setSpecMembers(newObj, []any{ + map[string]any{"kind": memberKindStr, "name": memberName}, + }) + if _, err := groupClient.Create(ctx, newObj, metav1.CreateOptions{}); err != nil { + return EnsureMemberResult{}, fmt.Errorf("creating group %q: %w", groupName, err) + } + return EnsureMemberResult{GroupCreated: true, Added: true}, nil + default: + // Any other Get failure (Forbidden, Timeout, transient API error) must + // not silently route into Create: that would either overwrite an + // existing group whose Get we couldn't read, or create one for a user + // who lacks permission to even see it. Surface the error. + return EnsureMemberResult{}, fmt.Errorf("getting group %q: %w", groupName, err) + } + + if opts.CycleCheck && memberKind == iamtypes.KindGroup { + hasCycle, cyclePath, err := detectCycleCtx(ctx, dyn, groupName, memberName) + if err != nil { + return EnsureMemberResult{}, fmt.Errorf("cycle detection failed: %w", err) + } + if hasCycle { + return EnsureMemberResult{}, fmt.Errorf("adding group %q to %q would create a cycle: %s", + memberName, groupName, strings.Join(cyclePath, " -> ")) + } + } + + members, _, _ := unstructured.NestedSlice(obj.Object, "spec", "members") + for _, m := range members { + member, ok := m.(map[string]any) + if !ok { + continue + } + if fmt.Sprint(member["kind"]) == memberKindStr && fmt.Sprint(member["name"]) == memberName { + return EnsureMemberResult{AlreadyMember: true}, nil + } + } + + members = append(members, map[string]any{"kind": memberKindStr, "name": memberName}) + if err := unstructured.SetNestedSlice(obj.Object, members, "spec", "members"); err != nil { + return EnsureMemberResult{}, fmt.Errorf("setting members on group %q: %w", groupName, err) + } + if _, err := groupClient.Update(ctx, obj, metav1.UpdateOptions{}); err != nil { + return EnsureMemberResult{}, fmt.Errorf("updating group %q: %w", groupName, err) + } + return EnsureMemberResult{Added: true}, nil +} + +// EnsureMemberResult tells the caller what happened so it can print the right +// message ("created", "already member", etc.) without re-reading state. +type EnsureMemberResult struct { + GroupCreated bool + Added bool + AlreadyMember bool +} + +// detectCycleCtx walks the existing group membership graph to check whether +// adding (parentGroup -> childGroup) would create a cycle. It only follows +// Group->Group edges; User members can never form a cycle. +func detectCycleCtx(ctx context.Context, dyn dynamic.Interface, parentGroup, childGroup string) (bool, []string, error) { + allGroups, err := dyn.Resource(iamtypes.GroupGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return false, nil, fmt.Errorf("listing groups for cycle detection: %w", err) + } + + groupKindStr := string(iamtypes.KindGroup) + adj := make(map[string][]string) + for i := range allGroups.Items { + g := &allGroups.Items[i] + gName := g.GetName() + members, _ := getGroupMembers(g) + for _, m := range members { + if fmt.Sprint(m["kind"]) == groupKindStr { + adj[gName] = append(adj[gName], fmt.Sprint(m["name"])) + } + } + } + adj[parentGroup] = append(adj[parentGroup], childGroup) + + visited := make(map[string]bool) + var path []string + var dfs func(string) bool + dfs = func(node string) bool { + if node == parentGroup { + path = append(path, node) + return true + } + if visited[node] { + return false + } + visited[node] = true + path = append(path, node) + for _, next := range adj[node] { + if dfs(next) { + return true + } + } + path = path[:len(path)-1] + return false + } + if dfs(childGroup) { + return true, path, nil + } + return false, nil, nil +} + +func setSpecMembers(obj *unstructured.Unstructured, members []any) { + _ = unstructured.SetNestedSlice(obj.Object, members, "spec", "members") +} diff --git a/internal/iam/group/cmd/membership_test.go b/internal/iam/group/cmd/membership_test.go new file mode 100644 index 00000000..bee97e15 --- /dev/null +++ b/internal/iam/group/cmd/membership_test.go @@ -0,0 +1,135 @@ +/* +Copyright 2026 Flant JSC + +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 group + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + clienttesting "k8s.io/client-go/testing" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" +) + +func fakeListKinds() map[schema.GroupVersionResource]string { + return map[schema.GroupVersionResource]string{ + iamtypes.GroupGVR: "GroupList", + } +} + +// TestEnsureMember_MissingGroupNotFoundIsDetectable guarantees that a caller +// can still recognise the "group not found" failure through the wrapped +// error returned by EnsureMember. Several callers (e.g. add-member, user +// create --member-of) decide whether to retry / create lazily based on +// apierrors.IsNotFound; if EnsureMember ever loses that signal under +// fmt.Errorf wrapping, those branches go silently dead. +func TestEnsureMember_MissingGroupNotFoundIsDetectable(t *testing.T) { + scheme := runtime.NewScheme() + dyn := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, fakeListKinds()) + + _, err := EnsureMember(t.Context(), dyn, + "does-not-exist", iamtypes.KindUser, "alice@example.com", + EnsureMemberOpts{CreateGroupIfMissing: false}) + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err), + "missing-group error must satisfy apierrors.IsNotFound; got %v", err) +} + +// TestEnsureMember_CreatesGroupWhenMissing verifies the lazy-create branch +// (used by `d8 iam user create --member-of`). It also doubles as a sanity +// check that the wrapped NotFound error path doesn't run in this case. +func TestEnsureMember_CreatesGroupWhenMissing(t *testing.T) { + scheme := runtime.NewScheme() + dyn := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, fakeListKinds()) + + res, err := EnsureMember(t.Context(), dyn, + "new-group", iamtypes.KindUser, "alice@example.com", + EnsureMemberOpts{CreateGroupIfMissing: true}) + require.NoError(t, err) + assert.True(t, res.GroupCreated) + assert.True(t, res.Added) + + got, err := dyn.Resource(iamtypes.GroupGVR).Get(t.Context(), "new-group", metav1.GetOptions{}) + require.NoError(t, err) + members, _, _ := unstructured.NestedSlice(got.Object, "spec", "members") + assert.Len(t, members, 1) +} + +// TestEnsureMember_NonNotFoundGetErrorPropagates is the symmetric guard for +// the same class of bug we previously fixed in createOrUpdateGrant: a +// non-NotFound Get failure (Forbidden, Timeout, transient API error) must +// not fall through to Create. Otherwise we would either overwrite an +// existing group we just couldn't read, or create one for a caller who +// lacks visibility to it. The error must surface verbatim. +func TestEnsureMember_NonNotFoundGetErrorPropagates(t *testing.T) { + scheme := runtime.NewScheme() + dyn := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, fakeListKinds()) + + sentinel := errors.New("simulated etcd timeout") + dyn.PrependReactor("get", "groups", func(_ clienttesting.Action) (bool, runtime.Object, error) { + return true, nil, sentinel + }) + // A Create reactor would let us assert "Create was never called" by + // failing loudly if it ever fires. The Get reactor above short-circuits + // the codepath, so this should never trigger. + createCalled := false + dyn.PrependReactor("create", "groups", func(_ clienttesting.Action) (bool, runtime.Object, error) { + createCalled = true + return false, nil, nil + }) + + _, err := EnsureMember(t.Context(), dyn, + "some-group", iamtypes.KindUser, "alice@example.com", + EnsureMemberOpts{CreateGroupIfMissing: true}) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel, + "non-NotFound Get error must propagate to the caller") + assert.False(t, apierrors.IsNotFound(err), + "non-NotFound error must not be misclassified as NotFound; got %v", err) + assert.False(t, createCalled, + "Create must not be called when Get returned a non-NotFound error") +} + +// TestEnsureMember_IsIdempotent verifies that adding the same member twice is +// a no-op and is reported as such. We reuse the typed SubjectKind enum to +// also assert that internal comparisons survive a round-trip through the +// unstructured map. +func TestEnsureMember_IsIdempotent(t *testing.T) { + scheme := runtime.NewScheme() + dyn := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, fakeListKinds()) + + const groupName = "admins" + const memberName = "alice@example.com" + + _, err := EnsureMember(t.Context(), dyn, groupName, iamtypes.KindUser, memberName, + EnsureMemberOpts{CreateGroupIfMissing: true}) + require.NoError(t, err) + + res, err := EnsureMember(t.Context(), dyn, groupName, iamtypes.KindUser, memberName, + EnsureMemberOpts{CreateGroupIfMissing: false}) + require.NoError(t, err) + assert.True(t, res.AlreadyMember) + assert.False(t, res.Added) +} diff --git a/internal/iam/group/cmd/remove_member.go b/internal/iam/group/cmd/remove_member.go new file mode 100644 index 00000000..b1840563 --- /dev/null +++ b/internal/iam/group/cmd/remove_member.go @@ -0,0 +1,112 @@ +/* +Copyright 2026 Flant JSC + +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 group + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/kubectl/pkg/util/templates" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var removeMemberExample = templates.Examples(` + # Remove a user from a group (kind "user" is implicit for two positional args) + d8 iam group remove-member admins anton + + # Remove a user explicitly + d8 iam group remove-member admins user anton + + # Remove a nested group + d8 iam group remove-member platform group admins`) + +func newRemoveMemberCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "remove-member GROUP [user|group] MEMBER", + Short: "Remove a member (user or group) from a local group", + Example: removeMemberExample, + Args: cobra.RangeArgs(2, 3), + ValidArgsFunction: completeMemberArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + groupName, kindStr, memberName, err := parseMemberArgs(args) + if err != nil { + return err + } + + memberKind, err := normalizeMemberKind(kindStr) + if err != nil { + return err + } + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + ctx := cmd.Context() + groupClient := dyn.Resource(iamtypes.GroupGVR) + + obj, err := groupClient.Get(ctx, groupName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("getting group %q: %w", groupName, err) + } + + rawMembers, _, _ := unstructured.NestedSlice(obj.Object, "spec", "members") + memberKindStr := string(memberKind) + found := false + var newMembers []any + for _, item := range rawMembers { + m, ok := item.(map[string]any) + if !ok { + newMembers = append(newMembers, item) + continue + } + if fmt.Sprint(m["kind"]) == memberKindStr && fmt.Sprint(m["name"]) == memberName { + found = true + continue + } + newMembers = append(newMembers, item) + } + + if !found { + cmd.Printf("Nothing to do: %s %q is not a member of group %q\n", strings.ToLower(memberKindStr), memberName, groupName) + return nil + } + + if err := unstructured.SetNestedSlice(obj.Object, newMembers, "spec", "members"); err != nil { + return fmt.Errorf("setting members: %w", err) + } + + _, err = groupClient.Update(ctx, obj, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("updating group %q: %w", groupName, err) + } + + cmd.Printf("Removed %s %q from group %q\n", strings.ToLower(memberKindStr), memberName, groupName) + return nil + }, + } + + return cmd +} diff --git a/internal/iam/group/cmd/types.go b/internal/iam/group/cmd/types.go new file mode 100644 index 00000000..757ae8cb --- /dev/null +++ b/internal/iam/group/cmd/types.go @@ -0,0 +1,39 @@ +/* +Copyright 2026 Flant JSC + +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 group + +import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + +// getGroupMembers extracts spec.members from a Group object. +func getGroupMembers(obj *unstructured.Unstructured) ([]map[string]any, error) { + raw, found, err := unstructured.NestedSlice(obj.Object, "spec", "members") + if err != nil { + return nil, err + } + if !found { + return nil, nil + } + result := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + result = append(result, m) + } + return result, nil +} diff --git a/internal/iam/listget/cmd/listget.go b/internal/iam/listget/cmd/listget.go new file mode 100644 index 00000000..4c55fb10 --- /dev/null +++ b/internal/iam/listget/cmd/listget.go @@ -0,0 +1,97 @@ +/* +Copyright 2026 Flant JSC + +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 listget hosts the top-level read-only commands `d8 iam get` and +// `d8 iam list`. It is intentionally a thin composition layer: every +// subcommand here is a factory exported by package iam/access/cmd, which +// owns the actual aggregation pipeline. The split exists so that the +// kubectl-style `iam get / iam list` UX can sit at the top of the tree +// without dragging the access aggregation code through an import cycle. +package listget + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + access "github.com/deckhouse/deckhouse-cli/internal/iam/access/cmd" + "github.com/deckhouse/deckhouse-cli/internal/system/flags" +) + +var getLong = templates.LongDesc(` +Show a single Deckhouse IAM object with its effective context. + +Subcommands: + user show effective access for a user (groups, grants, summary) + group show effective access for a group (members, grants, summary) + rule REF show one ClusterAuthorizationRule or AuthorizationRule + +Aliases users / groups / rules are accepted for symmetry with "d8 iam list". + +© Flant JSC 2026`) + +var listLong = templates.LongDesc(` +List Deckhouse IAM objects with their effective context. + +Subcommands: + users list every user with effective-access summary (groups, grants count, level) + groups list every group with effective-access summary (members, grants count, level) + rules list every ClusterAuthorizationRule and AuthorizationRule + +Aliases user / group / rule are accepted so both forms work as muscle memory. + +© Flant JSC 2026`) + +// NewGetCommand returns the "d8 iam get" parent command. +func NewGetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "get (user|group|rule) NAME", + Short: "Show a single Deckhouse IAM object", + Long: getLong, + SilenceErrors: true, + SilenceUsage: true, + } + + flags.AddPersistentFlags(cmd) + + cmd.AddCommand( + access.NewGetUserCommand(), + access.NewGetGroupCommand(), + access.NewGetRuleCommand(), + ) + + return cmd +} + +// NewListCommand returns the "d8 iam list" parent command. +func NewListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list (users|groups|rules)", + Short: "List Deckhouse IAM objects", + Long: listLong, + SilenceErrors: true, + SilenceUsage: true, + } + + flags.AddPersistentFlags(cmd) + + cmd.AddCommand( + access.NewListUsersCommand(), + access.NewListGroupsCommand(), + access.NewListRulesCommand(), + ) + + return cmd +} diff --git a/internal/iam/types/types.go b/internal/iam/types/types.go new file mode 100644 index 00000000..350886f7 --- /dev/null +++ b/internal/iam/types/types.go @@ -0,0 +1,115 @@ +/* +Copyright 2026 Flant JSC + +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 types holds the constants and GVRs shared across the iam +// subpackages (access, group, user). Anything more interesting than a +// constant or a GVR belongs in the consumer package. +package types + +import "k8s.io/apimachinery/pkg/runtime/schema" + +// GVRs of the deckhouse IAM resources. +var ( + UserGVR = schema.GroupVersionResource{ + Group: "deckhouse.io", Version: "v1", Resource: "users", + } + GroupGVR = schema.GroupVersionResource{ + Group: "deckhouse.io", Version: "v1alpha1", Resource: "groups", + } + AuthorizationRuleGVR = schema.GroupVersionResource{ + Group: "deckhouse.io", Version: "v1alpha1", Resource: "authorizationrules", + } + ClusterAuthorizationRuleGVR = schema.GroupVersionResource{ + Group: "deckhouse.io", Version: "v1", Resource: "clusterauthorizationrules", + } + UserOperationGVR = schema.GroupVersionResource{ + Group: "deckhouse.io", Version: "v1", Resource: "useroperations", + } +) + +// SubjectKind identifies the principal type that appears in +// spec.subjects[].kind on AuthorizationRule / ClusterAuthorizationRule and in +// Group.spec.members[].kind. It is a typed string so that internal struct +// fields and switch statements get compile-time protection against typos like +// "user" (lowercase) or unrelated kinds. Conversions to/from string only +// happen at unstructured.Unstructured map boundaries, where the API server +// requires plain strings. +type SubjectKind string + +// Subject kinds. Note these are also the apiVersion-less object kinds for +// User / Group / ServiceAccount when referenced as principals. +const ( + KindUser SubjectKind = "User" + KindGroup SubjectKind = "Group" + KindServiceAccount SubjectKind = "ServiceAccount" +) + +// Object kinds for the rule resources themselves. Kept as untyped strings +// because they are only ever used as literal values in unstructured maps and +// for ref formatting; introducing a separate "RuleKind" type would not catch +// any realistic bug today. +const ( + KindAuthorizationRule = "AuthorizationRule" + KindClusterAuthorizationRule = "ClusterAuthorizationRule" + KindUserOperation = "UserOperation" +) + +// API versions matching the GVRs above. Kept in sync explicitly because +// unstructured.Unstructured needs apiVersion strings literally. +// +// The previous APIVersionUserAuthn / APIVersionUserAuthz names suggested a +// per-module split that does not actually exist in the API group: every +// resource here is under the deckhouse.io group, the only difference is the +// stability tier (v1 vs v1alpha1). The names below reflect that reality. +const ( + APIVersionDeckhouseV1 = "deckhouse.io/v1" // User, ClusterAuthorizationRule, UserOperation + APIVersionDeckhouseV1Alpha1 = "deckhouse.io/v1alpha1" // Group, AuthorizationRule +) + +// Scope identifies how a grant maps onto cluster topology. Typed because it +// drives the choice between AuthorizationRule and ClusterAuthorizationRule +// and a wrong value here silently produces objects of the wrong kind. +type Scope string + +const ( + ScopeNamespace Scope = "namespace" + ScopeCluster Scope = "cluster" + ScopeAllNamespaces Scope = "all-namespaces" + // ScopeLabels selects namespaces by label via + // ClusterAuthorizationRule.spec.namespaceSelector.labelSelector.matchLabels. + ScopeLabels Scope = "labels" +) + +// AccessModel is the internal authorization model identifier persisted on +// managed grants. There is only one model today; bumping it is a deliberate +// breaking change. Typed for the same reason as Scope. +type AccessModel string + +const ModelCurrent AccessModel = "current" + +// Labels and annotations stamped on grant objects created by `d8 iam access grant`. +const ( + LabelManagedBy = "app.kubernetes.io/managed-by" + ManagedByValueCLI = "d8-cli" + LabelAccessModel = "deckhouse.io/access-model" + LabelAccessSubjectKind = "deckhouse.io/access-subject-kind" + LabelAccessScope = "deckhouse.io/access-scope" + + AnnotationAccessSubjectRef = "deckhouse.io/access-subject-ref" + AnnotationAccessSubjectPrincipal = "deckhouse.io/access-subject-principal" + AnnotationAccessCanonicalSpec = "deckhouse.io/access-canonical-spec" + AnnotationAccessCreatedByVersion = "deckhouse.io/access-created-by-version" +) diff --git a/internal/iam/user/cmd/completion.go b/internal/iam/user/cmd/completion.go new file mode 100644 index 00000000..b7182a07 --- /dev/null +++ b/internal/iam/user/cmd/completion.go @@ -0,0 +1,34 @@ +/* +Copyright 2026 Flant JSC + +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 user + +import ( + "github.com/spf13/cobra" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +// completeUserNames is a cobra ValidArgsFunction that returns User CR names +// for the first positional argument; subsequent positional arguments fall +// back to file completion via ShellCompDirectiveNoFileComp suppression. +func completeUserNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return utilk8s.CompleteResourceNames(cmd, iamtypes.UserGVR, "", toComplete) +} diff --git a/internal/iam/user/cmd/create.go b/internal/iam/user/cmd/create.go new file mode 100644 index 00000000..c4884a30 --- /dev/null +++ b/internal/iam/user/cmd/create.go @@ -0,0 +1,237 @@ +/* +Copyright 2026 Flant JSC + +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 user + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/hashicorp/go-multierror" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/kubectl/pkg/util/templates" + + group "github.com/deckhouse/deckhouse-cli/internal/iam/group/cmd" + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var createLong = templates.LongDesc(` +Create a local static user in Deckhouse (User CR). + +The password can be provided interactively (--password-prompt), piped from stdin +(--password-stdin), auto-generated (--generate-password), or supplied as a +pre-computed bcrypt hash (--password-hash). If no password flag is given and +stdin is a terminal, the command prompts interactively. + +When --generate-password is used, the password is shown exactly once on stderr. + +If the cluster has a password policy enabled, the user may be required to change +the password on first login. If staticUsers2FA is enabled, the user will also +need to enroll TOTP on first login. + +© Flant JSC 2026`) + +var createExample = templates.Examples(` + # Create a user with interactive password prompt + d8 iam user create anton --email anton@abc.com + + # Create a user with auto-generated password + d8 iam user create anton --email anton@abc.com --generate-password + + # Create a user from a CI pipeline + echo "s3cret" | d8 iam user create anton --email anton@abc.com --password-stdin + + # Apply a pre-computed bcrypt hash + d8 iam user create anton --email anton@abc.com --password-hash '$2y$10$abcdef...' + + # Create a user and add to groups + d8 iam user create anton --email anton@abc.com --generate-password --member-of admins --create-groups + + # Create a temporary user with TTL + d8 iam user create anton --email anton@abc.com --generate-password --ttl 24h`) + +func newCreateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "create --email [flags]", + Short: "Create a local static user in Deckhouse", + Long: createLong, + Example: createExample, + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: runCreate, + } + + cmd.Flags().String("email", "", "User email address (required, must be lowercase)") + _ = cmd.MarkFlagRequired("email") + addPasswordFlags(cmd) + cmd.Flags().StringSlice("member-of", nil, "Add user to these groups (repeatable)") + cmd.Flags().Bool("create-groups", false, "Create groups specified by --member-of if they do not exist") + cmd.Flags().String("ttl", "", "User time-to-live (e.g. 24h, 30m). Can only be set once; expireAt will not update on change.") + cmd.Flags().Bool("dry-run", false, "Print the resource that would be created without applying") + utilk8s.AddOutputFlag(cmd, "name", "name", "yaml", "json") + + _ = cmd.RegisterFlagCompletionFunc("member-of", func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return utilk8s.CompleteResourceNames(cmd, iamtypes.GroupGVR, "", toComplete) + }) + + return cmd +} + +func runCreate(cmd *cobra.Command, args []string) error { + name := args[0] + + email, _ := cmd.Flags().GetString("email") + memberOf, _ := cmd.Flags().GetStringSlice("member-of") + createGroups, _ := cmd.Flags().GetBool("create-groups") + ttl, _ := cmd.Flags().GetString("ttl") + dryRun, _ := cmd.Flags().GetBool("dry-run") + outputFmt, _ := cmd.Flags().GetString("output") + + if err := validateUserName(name); err != nil { + return err + } + if err := validateEmail(email); err != nil { + return err + } + if ttl != "" { + if err := validateTTL(ttl); err != nil { + return err + } + } + + if strings.HasPrefix(name, "system:") { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: users with \"system:\" prefix may be rejected by the cluster\n") + } + + res, mode, err := resolvePasswordInput(cmd) + if err != nil { + return err + } + + rawHash, err := res.rawBcryptHash() + if err != nil { + return err + } + // User.spec.password expects base64(); the user-authn + // hook (get_dex_user_crds) decodes it and forwards the bytes to the Dex + // Password CR. + encodedPassword := encodePasswordForUserCR(rawHash) + + obj := buildUserObject(name, email, encodedPassword, ttl) + + if dryRun { + return utilk8s.PrintObject(cmd.OutOrStdout(), obj, outputFmt) + } + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + created, err := dyn.Resource(iamtypes.UserGVR).Create(cmd.Context(), obj, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("creating User %q: %w", name, err) + } + + if err := utilk8s.PrintObject(cmd.OutOrStdout(), created, outputFmt); err != nil { + return err + } + + if mode == passwordModeGenerate { + fmt.Fprintf(cmd.ErrOrStderr(), "Generated temporary password (shown once): %s\n", res.Plain) + fmt.Fprintf(cmd.ErrOrStderr(), "Note: first login may require password change depending on cluster password policy.\n") + fmt.Fprintf(cmd.ErrOrStderr(), "Note: if staticUsers2FA is enabled, the user will also need to enroll TOTP on first login.\n") + } + + if len(memberOf) > 0 { + var errs *multierror.Error + for _, groupName := range memberOf { + if _, err := group.EnsureMember(cmd.Context(), dyn, groupName, iamtypes.KindUser, name, group.EnsureMemberOpts{ + CreateGroupIfMissing: createGroups, + }); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to add user to group %q: %v\n", groupName, err) + errs = multierror.Append(errs, err) + } + } + if errs.ErrorOrNil() != nil { + return fmt.Errorf("User %s created, but failed to update %d group(s): %w", + name, errs.Len(), errs) + } + } + + return nil +} + +func buildUserObject(name, email, password, ttl string) *unstructured.Unstructured { + spec := map[string]any{ + "email": email, + "password": password, + } + if ttl != "" { + spec["ttl"] = ttl + } + + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": iamtypes.APIVersionDeckhouseV1, + // unstructured.GetKind() type-asserts the kind value to plain string, + // so the typed SubjectKind constant must be cast at this boundary. + "kind": string(iamtypes.KindUser), + "metadata": map[string]any{ + "name": name, + }, + "spec": spec, + }, + } +} + +func validateUserName(name string) error { + if errs := validation.IsDNS1123Subdomain(name); len(errs) > 0 { + return fmt.Errorf("invalid user name %q: %s", name, strings.Join(errs, "; ")) + } + return nil +} + +func validateEmail(email string) error { + if email == "" { + return errors.New("--email is required") + } + if email != strings.ToLower(email) { + return fmt.Errorf("email must be lowercase, got %q", email) + } + if !strings.Contains(email, "@") { + return fmt.Errorf("email %q does not look like a valid email address", email) + } + return nil +} + +func validateTTL(ttl string) error { + d, err := time.ParseDuration(ttl) + if err != nil { + return fmt.Errorf("invalid --ttl %q: %w (expected like 24h, 30m, 1h30m)", ttl, err) + } + if d <= 0 { + return fmt.Errorf("invalid --ttl %q: must be positive", ttl) + } + return nil +} diff --git a/internal/iam/user/cmd/create_test.go b/internal/iam/user/cmd/create_test.go new file mode 100644 index 00000000..e83520cc --- /dev/null +++ b/internal/iam/user/cmd/create_test.go @@ -0,0 +1,158 @@ +/* +Copyright 2026 Flant JSC + +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 user + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +func TestValidateUserName(t *testing.T) { + tests := []struct { + name string + input string + wantValid bool + }{ + {name: "valid simple", input: "anton", wantValid: true}, + {name: "valid with dash", input: "anton-user", wantValid: true}, + {name: "valid with numbers", input: "user123", wantValid: true}, + {name: "empty", input: ""}, + {name: "uppercase", input: "Anton"}, + {name: "underscore", input: "my_user"}, + {name: "starts with dash", input: "-user"}, + {name: "ends with dash", input: "user-"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateUserName(tt.input) + if tt.wantValid { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid user name") + } + }) + } +} + +func TestValidateEmail(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + {name: "valid", input: "anton@abc.com"}, + {name: "empty", input: "", wantErr: "--email is required"}, + {name: "uppercase", input: "Anton@abc.com", wantErr: "must be lowercase"}, + {name: "no at sign", input: "antonabc.com", wantErr: "does not look like a valid email"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateEmail(tt.input) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateTTL(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + {name: "hours", input: "24h"}, + {name: "minutes", input: "30m"}, + {name: "hours and minutes", input: "1h30m"}, + {name: "seconds", input: "30s"}, + {name: "invalid format", input: "2d", wantErr: "invalid --ttl"}, + {name: "negative", input: "-1h", wantErr: "must be positive"}, + {name: "zero", input: "0s", wantErr: "must be positive"}, + {name: "empty", input: "", wantErr: "invalid --ttl"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTTL(tt.input) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestBuildUserObject(t *testing.T) { + t.Run("without ttl", func(t *testing.T) { + obj := buildUserObject("anton", "anton@abc.com", "encodedpass", "") + assert.Equal(t, "deckhouse.io/v1", obj.GetAPIVersion()) + assert.Equal(t, "User", obj.GetKind()) + assert.Equal(t, "anton", obj.GetName()) + + email, _, _ := unstructured.NestedString(obj.Object, "spec", "email") + assert.Equal(t, "anton@abc.com", email) + + password, _, _ := unstructured.NestedString(obj.Object, "spec", "password") + assert.Equal(t, "encodedpass", password) + + _, found, _ := unstructured.NestedString(obj.Object, "spec", "ttl") + assert.False(t, found) + }) + + t.Run("with ttl", func(t *testing.T) { + obj := buildUserObject("anton", "anton@abc.com", "encodedpass", "24h") + ttl, found, _ := unstructured.NestedString(obj.Object, "spec", "ttl") + assert.True(t, found) + assert.Equal(t, "24h", ttl) + }) +} + +func TestBuildUserObject_DryRunYAML(t *testing.T) { + obj := buildUserObject("test-user", "test@example.com", "aGFzaA==", "") + + var buf = &strings.Builder{} + err := utilk8s.PrintObject(buf, obj, "yaml") + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "kind: User") + assert.Contains(t, output, "name: test-user") + assert.Contains(t, output, "email: test@example.com") +} + +func TestBuildUserObject_DryRunJSON(t *testing.T) { + obj := buildUserObject("test-user", "test@example.com", "aGFzaA==", "") + + var buf = &strings.Builder{} + err := utilk8s.PrintObject(buf, obj, "json") + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, `"kind": "User"`) + assert.Contains(t, output, `"name": "test-user"`) +} diff --git a/internal/iam/user/cmd/delete.go b/internal/iam/user/cmd/delete.go new file mode 100644 index 00000000..73b24a97 --- /dev/null +++ b/internal/iam/user/cmd/delete.go @@ -0,0 +1,66 @@ +/* +Copyright 2026 Flant JSC + +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 user + +import ( + "fmt" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/kubectl/pkg/util/templates" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var deleteLong = templates.LongDesc(` +Delete a local static user from Deckhouse. + +This removes the User CR. It does not automatically remove the user from groups. +You may want to run "d8 iam group remove-member" separately. + +© Flant JSC 2026`) + +func newDeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a local static user", + Long: deleteLong, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeUserNames, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + err = dyn.Resource(iamtypes.UserGVR).Delete(cmd.Context(), name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleting User %q: %w", name, err) + } + + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: user %q may still be referenced in Group memberships. Use \"d8 iam group remove-member\" to clean up.\n", name) + cmd.Printf("User %s deleted\n", name) + return nil + }, + } + return cmd +} diff --git a/internal/iam/user/cmd/password.go b/internal/iam/user/cmd/password.go new file mode 100644 index 00000000..3a701fe6 --- /dev/null +++ b/internal/iam/user/cmd/password.go @@ -0,0 +1,249 @@ +/* +Copyright 2026 Flant JSC + +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 user + +import ( + "bufio" + "crypto/rand" + "encoding/base32" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" + "golang.org/x/term" +) + +const bcryptCost = 10 + +type passwordMode int + +const ( + passwordModeNone passwordMode = iota + passwordModePrompt + passwordModeStdin + passwordModeGenerate + // passwordModeHash means the caller provided a pre-computed bcrypt hash + // directly via --password-hash; no plaintext is read. + passwordModeHash +) + +// passwordFlagSet is the flag layout used by both `user create` and +// `user reset-password`. Centralising the names keeps the two commands in +// lockstep so muscle memory transfers and shells autocomplete the same set. +const ( + flagPasswordPrompt = "password-prompt" + flagPasswordStdin = "password-stdin" + flagPasswordGenerate = "generate-password" + flagPasswordHash = "password-hash" +) + +func addPasswordFlags(cmd *cobra.Command) { + cmd.Flags().Bool(flagPasswordPrompt, false, "Read password interactively with hidden input") + cmd.Flags().Bool(flagPasswordStdin, false, "Read password from stdin (for CI/pipelines)") + cmd.Flags().Bool(flagPasswordGenerate, false, "Auto-generate a strong password (shown once on stderr)") + cmd.Flags().String(flagPasswordHash, "", "Use a pre-computed bcrypt hash directly (must start with $2 and be a valid bcrypt cost)") +} + +// resolvePasswordMode determines which password source to use based on the +// flags provided. Exactly one of the four sources may be set; if none is set, +// the mode is inferred from whether stdin is a terminal (prompt) or not (error). +func resolvePasswordMode(prompt, stdin, generate bool, hash string) (passwordMode, error) { + count := 0 + if prompt { + count++ + } + if stdin { + count++ + } + if generate { + count++ + } + if hash != "" { + count++ + } + if count > 1 { + return passwordModeNone, fmt.Errorf( + "only one of --%s, --%s, --%s, --%s may be specified", + flagPasswordPrompt, flagPasswordStdin, flagPasswordGenerate, flagPasswordHash, + ) + } + + switch { + case hash != "": + return passwordModeHash, nil + case prompt: + return passwordModePrompt, nil + case stdin: + return passwordModeStdin, nil + case generate: + return passwordModeGenerate, nil + } + + if term.IsTerminal(int(os.Stdin.Fd())) { + return passwordModePrompt, nil + } + return passwordModeNone, fmt.Errorf( + "stdin is not a terminal; use --%s, --%s or --%s", + flagPasswordStdin, flagPasswordGenerate, flagPasswordHash, + ) +} + +// passwordResult is the raw result of a password input flow. Either Plain or +// Hash is set, never both. For passwordModeGenerate, Plain is set AND the +// caller is expected to display it once to the user (it is not retrievable +// from the bcrypt hash). +type passwordResult struct { + Plain string // plaintext supplied by user / generated; empty for hash mode + Hash string // raw bcrypt hash ($2y$... or $2a$...); set when input was a hash +} + +// resolvePasswordInput executes the configured password-input flow and returns +// the user-supplied plaintext or the validated bcrypt hash. Caller decides how +// to apply the result (User.spec.password expects base64(bcrypt); UserOperation +// expects raw bcrypt — see User CR doc and user-authn hooks). +func resolvePasswordInput(cmd *cobra.Command) (passwordResult, passwordMode, error) { + prompt, _ := cmd.Flags().GetBool(flagPasswordPrompt) + stdinFlag, _ := cmd.Flags().GetBool(flagPasswordStdin) + generate, _ := cmd.Flags().GetBool(flagPasswordGenerate) + hashFlag, _ := cmd.Flags().GetString(flagPasswordHash) + + mode, err := resolvePasswordMode(prompt, stdinFlag, generate, hashFlag) + if err != nil { + return passwordResult{}, mode, err + } + + switch mode { + case passwordModeHash: + if err := validateBcryptHash(hashFlag); err != nil { + return passwordResult{}, mode, err + } + return passwordResult{Hash: hashFlag}, mode, nil + case passwordModePrompt: + p, err := readPasswordPrompt(int(os.Stdin.Fd()), cmd.ErrOrStderr()) + return passwordResult{Plain: p}, mode, err + case passwordModeStdin: + p, err := readPasswordStdin(os.Stdin) + return passwordResult{Plain: p}, mode, err + case passwordModeGenerate: + p, err := generatePassword() + return passwordResult{Plain: p}, mode, err + } + return passwordResult{}, mode, errors.New("no password source configured") +} + +// rawBcryptHash returns the raw bcrypt hash string ($2y$... or $2a$...). For +// hash-mode inputs the hash was already validated; for plaintext inputs we +// hash here once. +func (r passwordResult) rawBcryptHash() (string, error) { + if r.Hash != "" { + return r.Hash, nil + } + hash, err := bcrypt.GenerateFromPassword([]byte(r.Plain), bcryptCost) + if err != nil { + return "", fmt.Errorf("hashing password: %w", err) + } + return string(hash), nil +} + +// readPasswordPrompt reads a password interactively with confirmation. +// stdinFd is the file descriptor to read from; out receives prompts. +func readPasswordPrompt(stdinFd int, out io.Writer) (string, error) { + fmt.Fprint(out, "Enter password: ") + pw1, err := term.ReadPassword(stdinFd) + fmt.Fprintln(out) + if err != nil { + return "", fmt.Errorf("reading password: %w", err) + } + + fmt.Fprint(out, "Confirm password: ") + pw2, err := term.ReadPassword(stdinFd) + fmt.Fprintln(out) + if err != nil { + return "", fmt.Errorf("reading password confirmation: %w", err) + } + + if string(pw1) != string(pw2) { + return "", errors.New("passwords do not match") + } + if len(pw1) == 0 { + return "", errors.New("password must not be empty") + } + return string(pw1), nil +} + +// readPasswordStdin reads a single line from the given reader. +func readPasswordStdin(r io.Reader) (string, error) { + scanner := bufio.NewScanner(r) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("reading password from stdin: %w", err) + } + return "", errors.New("no password provided on stdin") + } + pw := strings.TrimRight(scanner.Text(), "\r\n") + if pw == "" { + return "", errors.New("password must not be empty") + } + return pw, nil +} + +// generatePassword creates a cryptographically random password. +func generatePassword() (string, error) { + b := make([]byte, 20) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generating random password: %w", err) + } + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil +} + +// encodePasswordForUserCR returns the value to write into User.spec.password. +// The User CR stores base64(); the user-authn hook reads +// it via getDexUserCRDs and forwards the bytes to Dex Password.hash. For raw +// bcrypt input we still need the base64 wrap because we cannot tell at write +// time whether the controller is at a version that auto-detects. +func encodePasswordForUserCR(rawHash string) string { + return base64.StdEncoding.EncodeToString([]byte(rawHash)) +} + +// encodePasswordForDeckhouse is kept as a thin wrapper for backward-compatibility +// with existing tests; it hashes plaintext and returns the User-CR encoding. +func encodePasswordForDeckhouse(plain string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost) + if err != nil { + return "", fmt.Errorf("hashing password: %w", err) + } + return encodePasswordForUserCR(string(hash)), nil +} + +// validateBcryptHash checks that the supplied string is a syntactically valid +// bcrypt hash (the prefix and the bcrypt.Cost call). We do not verify against +// a known plaintext (we don't have one); a malformed hash would only be caught +// by the user-authn hook later, well after the apiserver accepted the CR. +func validateBcryptHash(s string) error { + if !strings.HasPrefix(s, "$2") { + return fmt.Errorf("--%s value does not look like a bcrypt hash (expected to start with $2a$ or $2y$)", flagPasswordHash) + } + if _, err := bcrypt.Cost([]byte(s)); err != nil { + return fmt.Errorf("--%s value is not a valid bcrypt hash: %w", flagPasswordHash, err) + } + return nil +} diff --git a/internal/iam/user/cmd/password_test.go b/internal/iam/user/cmd/password_test.go new file mode 100644 index 00000000..3075bfbf --- /dev/null +++ b/internal/iam/user/cmd/password_test.go @@ -0,0 +1,189 @@ +/* +Copyright 2026 Flant JSC + +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 user + +import ( + "encoding/base64" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +func TestResolvePasswordMode(t *testing.T) { + // validHash is a real bcrypt hash so the hash-mode arm validates correctly. + validHashBytes, err := bcrypt.GenerateFromPassword([]byte("password"), bcryptCost) + require.NoError(t, err) + validHash := string(validHashBytes) + + tests := []struct { + name string + prompt bool + stdin bool + generate bool + hash string + want passwordMode + wantErr string + }{ + {name: "prompt explicit", prompt: true, want: passwordModePrompt}, + {name: "stdin explicit", stdin: true, want: passwordModeStdin}, + {name: "generate explicit", generate: true, want: passwordModeGenerate}, + {name: "hash explicit", hash: validHash, want: passwordModeHash}, + {name: "prompt+stdin conflict", prompt: true, stdin: true, wantErr: "only one of"}, + {name: "prompt+generate conflict", prompt: true, generate: true, wantErr: "only one of"}, + {name: "stdin+generate conflict", stdin: true, generate: true, wantErr: "only one of"}, + {name: "hash+stdin conflict", hash: validHash, stdin: true, wantErr: "only one of"}, + {name: "hash+generate conflict", hash: validHash, generate: true, wantErr: "only one of"}, + {name: "all four conflict", prompt: true, stdin: true, generate: true, hash: validHash, wantErr: "only one of"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolvePasswordMode(tt.prompt, tt.stdin, tt.generate, tt.hash) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestReadPasswordStdin(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr string + }{ + {name: "normal", input: "mysecret\n", want: "mysecret"}, + {name: "no trailing newline", input: "mysecret", want: "mysecret"}, + {name: "empty", input: "\n", wantErr: "password must not be empty"}, + {name: "empty eof", input: "", wantErr: "no password provided"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := readPasswordStdin(strings.NewReader(tt.input)) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestGeneratePassword(t *testing.T) { + pw, err := generatePassword() + require.NoError(t, err) + assert.GreaterOrEqual(t, len(pw), 20, "generated password should be at least 20 chars") + + pw2, err := generatePassword() + require.NoError(t, err) + assert.NotEqual(t, pw, pw2, "two generated passwords should differ") +} + +func TestEncodePasswordForDeckhouse(t *testing.T) { + plain := "testpassword123" + encoded, err := encodePasswordForDeckhouse(plain) + require.NoError(t, err) + assert.NotEmpty(t, encoded) + + // Decode from base64 + hashBytes, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(t, err) + + // Verify bcrypt hash matches the original password + err = bcrypt.CompareHashAndPassword(hashBytes, []byte(plain)) + assert.NoError(t, err, "bcrypt hash should match the original password") + + // Verify bcrypt cost + cost, err := bcrypt.Cost(hashBytes) + require.NoError(t, err) + assert.Equal(t, bcryptCost, cost) +} + +func TestValidateBcryptHash(t *testing.T) { + validBytes, err := bcrypt.GenerateFromPassword([]byte("pw"), bcryptCost) + require.NoError(t, err) + valid := string(validBytes) + + tests := []struct { + name string + input string + wantErr string + }{ + {name: "valid bcrypt", input: valid}, + {name: "empty", input: "", wantErr: "does not look like a bcrypt hash"}, + {name: "non-bcrypt prefix", input: "not-a-hash", wantErr: "does not look like a bcrypt hash"}, + {name: "wrong $1 prefix", input: "$1$abc$def", wantErr: "does not look like a bcrypt hash"}, + {name: "$2 prefix but malformed", input: "$2y$malformed", wantErr: "not a valid bcrypt hash"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateBcryptHash(tt.input) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestPasswordResult_RawBcryptHash(t *testing.T) { + t.Run("hash mode passes through", func(t *testing.T) { + validBytes, err := bcrypt.GenerateFromPassword([]byte("pw"), bcryptCost) + require.NoError(t, err) + valid := string(validBytes) + + raw, err := passwordResult{Hash: valid}.rawBcryptHash() + require.NoError(t, err) + assert.Equal(t, valid, raw, "hash-mode result must return the raw hash unchanged") + }) + + t.Run("plain mode hashes once", func(t *testing.T) { + plain := "secret123" + raw, err := passwordResult{Plain: plain}.rawBcryptHash() + require.NoError(t, err) + assert.True(t, strings.HasPrefix(raw, "$2"), "raw hash should start with bcrypt prefix") + + // And must verify against the original plaintext. + assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(raw), []byte(plain))) + }) +} + +func TestEncodePasswordForUserCR_DoesNotDoubleBase64(t *testing.T) { + // User.spec.password expects base64(). The CLI must not + // double-wrap the value (would produce base64(base64(hash))) — that would + // silently round-trip through the apiserver but fail bcrypt verification + // in the user-authn hook, way after CR creation. + rawBytes, err := bcrypt.GenerateFromPassword([]byte("pw"), bcryptCost) + require.NoError(t, err) + raw := string(rawBytes) + + encoded := encodePasswordForUserCR(raw) + decoded, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(t, err) + assert.Equal(t, raw, string(decoded), "exactly one base64 wrap expected") +} diff --git a/internal/iam/user/cmd/reset_password.go b/internal/iam/user/cmd/reset_password.go new file mode 100644 index 00000000..3087fb41 --- /dev/null +++ b/internal/iam/user/cmd/reset_password.go @@ -0,0 +1,119 @@ +/* +Copyright 2026 Flant JSC + +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 user + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var resetPasswordLong = templates.LongDesc(` +Reset a local static user's password in Dex. + +The new password is read from one of: + + --password-prompt (default if stdin is a terminal) + --password-stdin (echo "newpw" | d8 iam user reset-password ...) + --generate-password (auto-generate, shown once on stderr) + --password-hash STRING (pre-computed bcrypt hash, must start with $2) + +This is the same flag layout as 'd8 iam user create', so muscle memory and +shell autocompletion transfer between the two commands. + +Internally a UserOperation of type ResetPassword is created with the raw +bcrypt hash in spec.resetPassword.newPasswordHash. The user-authn hook +validates the prefix and bcrypt.Cost, base64-encodes the hash, and applies +it to the Dex Password CR. We never write the plaintext to the API. + +© Flant JSC 2026`) + +var resetPasswordExample = templates.Examples(` + # Interactive prompt (default if stdin is a terminal) + d8 iam user reset-password anton + + # Pipe from a CI pipeline + echo "newpw" | d8 iam user reset-password anton --password-stdin + + # Auto-generate a strong password (shown once on stderr) + d8 iam user reset-password anton --generate-password + + # Apply a pre-computed bcrypt hash (e.g. produced by 'htpasswd -BinC 10') + d8 iam user reset-password anton --password-hash '$2y$10$abcdef...'`) + +func newResetPasswordCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "reset-password ", + Aliases: []string{"resetpass"}, + Short: "Reset local user's password in Dex", + Long: resetPasswordLong, + Example: resetPasswordExample, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeUserNames, + SilenceErrors: true, + SilenceUsage: true, + RunE: runResetPassword, + } + + addPasswordFlags(cmd) + addWaitFlags(cmd) + return cmd +} + +func runResetPassword(cmd *cobra.Command, args []string) error { + username := args[0] + + res, mode, err := resolvePasswordInput(cmd) + if err != nil { + return err + } + + rawHash, err := res.rawBcryptHash() + if err != nil { + return err + } + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + if err := runUserOperation(cmd, dyn, userOpRequest{ + NamePrefix: "op-resetpass-", + OpType: "ResetPassword", + User: username, + // UserOperation.spec.resetPassword.newPasswordHash expects a raw bcrypt + // hash (starts with $2). The user-authn hook (get_dex_user_operation_crds) + // validates the prefix and base64-wraps the value before applying it + // to Dex's Password CR. Do NOT base64 encode here — that would produce + // a double-wrapped value that the hook silently accepts at the apiserver + // boundary but then rejects when it tries to bcrypt-verify the hash. + ExtraSpec: map[string]any{ + "resetPassword": map[string]any{"newPasswordHash": rawHash}, + }, + }); err != nil { + return err + } + + if mode == passwordModeGenerate { + fmt.Fprintf(cmd.ErrOrStderr(), "Generated new password (shown once): %s\n", res.Plain) + } + return nil +} diff --git a/internal/iam/user/cmd/types.go b/internal/iam/user/cmd/types.go new file mode 100644 index 00000000..7f6299fd --- /dev/null +++ b/internal/iam/user/cmd/types.go @@ -0,0 +1,161 @@ +/* +Copyright 2026 Flant JSC + +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 user + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/dynamic" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" +) + +const ( + defaultWait = true + defaultTimeout = 5 * time.Minute +) + +type waitFlags struct { + wait bool + timeout time.Duration +} + +func addWaitFlags(cmd *cobra.Command) { + cmd.Flags().Bool("wait", defaultWait, "Wait for UserOperation completion and print result.") + cmd.Flags().Duration("timeout", defaultTimeout, "How long to wait for completion when --wait is enabled.") +} + +func getWaitFlags(cmd *cobra.Command) (waitFlags, error) { + waitVal, err := cmd.Flags().GetBool("wait") + if err != nil { + return waitFlags{}, err + } + timeoutVal, err := cmd.Flags().GetDuration("timeout") + if err != nil { + return waitFlags{}, err + } + return waitFlags{wait: waitVal, timeout: timeoutVal}, nil +} + +func createUserOperation(ctx context.Context, dyn dynamic.Interface, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { + return dyn.Resource(iamtypes.UserOperationGVR).Create(ctx, obj, metav1.CreateOptions{}) +} + +// userOpRequest describes a single UserOperation submitted via the CLI. +// It exists to deduplicate the create+wait+report flow shared by lock, +// unlock, reset-password and reset2fa. +type userOpRequest struct { + // NamePrefix is the prefix for the generated UserOperation name, e.g. "op-lock-". + NamePrefix string + // OpType matches spec.type on the UserOperation CR (e.g. "Lock", "Unlock"). + OpType string + // User is the target username (spec.user). + User string + // ExtraSpec holds operation-specific spec fields, e.g. spec.lock or spec.resetPassword. + ExtraSpec map[string]any +} + +// runUserOperation creates a UserOperation, optionally waits for completion, +// and writes status to cmd. It centralises the "wait/no-wait + Failed/Succeeded" +// branching that used to be copy-pasted across the user subcommands. +func runUserOperation(cmd *cobra.Command, dyn dynamic.Interface, req userOpRequest) error { + wf, err := getWaitFlags(cmd) + if err != nil { + return err + } + + // Nano-second precision avoids collisions when several UserOperation + // commands are issued within the same wall-clock second (CI, retries, etc.). + name := fmt.Sprintf("%s%d", req.NamePrefix, time.Now().UnixNano()) + spec := map[string]any{ + "user": req.User, + "type": req.OpType, + "initiatorType": "admin", + } + for k, v := range req.ExtraSpec { + spec[k] = v + } + + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": iamtypes.APIVersionDeckhouseV1, + "kind": iamtypes.KindUserOperation, + "metadata": map[string]any{"name": name}, + "spec": spec, + }, + } + + if _, err := createUserOperation(cmd.Context(), dyn, obj); err != nil { + return fmt.Errorf("create UserOperation: %w", err) + } + + if !wf.wait { + cmd.Printf("%s\n", name) + return nil + } + + result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout) + if err != nil { + return fmt.Errorf("wait UserOperation: %w", err) + } + + phase, _, _ := unstructured.NestedString(result.Object, "status", "phase") + message, _, _ := unstructured.NestedString(result.Object, "status", "message") + if phase == "Failed" { + return fmt.Errorf("%s failed: %s", req.OpType, message) + } + cmd.Printf("Succeeded: %s\n", name) + return nil +} + +func waitUserOperation(ctx context.Context, dyn dynamic.Interface, name string, timeout time.Duration) (*unstructured.Unstructured, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + err := wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) { + obj, err := dyn.Resource(iamtypes.UserOperationGVR).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + // Hard error: if we can't read the resource, we can't reliably wait for completion. + return false, err + } + + phase, found, _ := unstructured.NestedString(obj.Object, "status", "phase") + if !found || phase == "" { + // Not completed yet: keep polling until the controller fills status.phase. + return false, nil + } + + // Completed (Succeeded/Failed): stop polling. + return true, nil + }) + if err != nil { + return nil, err + } + + // Fetch the final object to return the latest status/message. + obj, err := dyn.Resource(iamtypes.UserOperationGVR).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + return obj, nil +} diff --git a/internal/iam/user/cmd/user.go b/internal/iam/user/cmd/user.go new file mode 100644 index 00000000..d5f2ab3d --- /dev/null +++ b/internal/iam/user/cmd/user.go @@ -0,0 +1,63 @@ +/* +Copyright 2026 Flant JSC + +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 user + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/system/flags" +) + +var userOperationLong = templates.LongDesc(` +Manage Deckhouse local static users (user-authn). + +This command provides lifecycle operations for Dex local users: +Create, Delete, ResetPassword, Reset2FA, Lock, Unlock. + +For viewing users (single or list with effective access), use the +top-level commands: + + d8 iam get user + d8 iam list users + +© Flant JSC 2026`) + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Aliases: []string{"userop"}, + Short: "Manage Deckhouse users (user-authn)", + Long: userOperationLong, + SilenceErrors: true, + SilenceUsage: true, + } + + // Reuse standard kubeconfig/context flags (same as `d8 system ...`). + flags.AddPersistentFlags(cmd) + + cmd.AddCommand( + newCreateCommand(), + newDeleteCommand(), + newResetPasswordCommand(), + ) + for _, def := range userOpDefs { + cmd.AddCommand(newUserOpCommand(def)) + } + + return cmd +} diff --git a/internal/iam/user/cmd/user_ops.go b/internal/iam/user/cmd/user_ops.go new file mode 100644 index 00000000..7d1b028d --- /dev/null +++ b/internal/iam/user/cmd/user_ops.go @@ -0,0 +1,120 @@ +/* +Copyright 2026 Flant JSC + +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 user + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +// userOpDef declares one UserOperation-backed command. The factory below +// (newUserOpCommand) turns each entry into a concrete cobra command, which +// keeps lock / unlock / reset-2fa to ~10 lines each instead of a full file +// per command. +// +// Reset-password is intentionally NOT covered by this factory: it needs the +// full password-input flow (prompt / stdin / generate / hash) and the extra +// flags would be confusing on the simpler ops. +type userOpDef struct { + Use string + Aliases []string + Short string + Long string + NamePrefix string + OpType string + Args cobra.PositionalArgs + // BuildExtraSpec returns the operation-specific spec. map (e.g. spec.lock). + // Returning a nil map is fine for ops that need no extra spec (Unlock, Reset2FA). + BuildExtraSpec func(args []string) (map[string]any, error) +} + +// userOpDefs is the table of UserOperation commands that need only a +// positional argument (the username) and an optional simple validation step. +// Keep entries sorted by NamePrefix so registration order stays stable. +var userOpDefs = []userOpDef{ + { + Use: "lock ", + Short: "Lock local user in Dex for a period of time", + Long: "Lock local user in Dex for a period of time.\n\nThe lockDuration argument must be a duration string (e.g. 30s, 10m, 1h).", + NamePrefix: "op-lock-", + OpType: "Lock", + Args: cobra.ExactArgs(2), + BuildExtraSpec: func(args []string) (map[string]any, error) { + if _, err := time.ParseDuration(args[1]); err != nil { + return nil, fmt.Errorf("invalid lockDuration %q: %w", args[1], err) + } + return map[string]any{ + "lock": map[string]any{"for": args[1]}, + }, nil + }, + }, + { + Use: "unlock ", + Short: "Unlock local user in Dex", + Long: "Unlock local user in Dex.\n\nThis requests a UserOperation of type Unlock and waits for completion by default.", + NamePrefix: "op-unlock-", + OpType: "Unlock", + Args: cobra.ExactArgs(1), + }, + { + Use: "reset2fa ", + Short: "Reset local user's 2FA (TOTP) in Dex", + Long: "Reset local user's 2FA (TOTP) in Dex.\n\nThis requests a UserOperation of type Reset2FA and waits for completion by default.", + NamePrefix: "op-reset2fa-", + OpType: "Reset2FA", + Args: cobra.ExactArgs(1), + }, +} + +func newUserOpCommand(def userOpDef) *cobra.Command { + cmd := &cobra.Command{ + Use: def.Use, + Aliases: def.Aliases, + Short: def.Short, + Long: def.Long, + Args: def.Args, + ValidArgsFunction: completeUserNames, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + var extra map[string]any + if def.BuildExtraSpec != nil { + e, err := def.BuildExtraSpec(args) + if err != nil { + return err + } + extra = e + } + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + return runUserOperation(cmd, dyn, userOpRequest{ + NamePrefix: def.NamePrefix, + OpType: def.OpType, + User: args[0], + ExtraSpec: extra, + }) + }, + } + addWaitFlags(cmd) + return cmd +} diff --git a/internal/useroperation/cmd/lock.go b/internal/useroperation/cmd/lock.go deleted file mode 100644 index 825c8f74..00000000 --- a/internal/useroperation/cmd/lock.go +++ /dev/null @@ -1,83 +0,0 @@ -package useroperation - -import ( - "fmt" - "time" - - "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -func newLockCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "lock ", - Short: "Lock local user in Dex for a period of time", - Args: cobra.ExactArgs(2), - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - username := args[0] - lockDuration := args[1] - // Validate duration format (must be parseable by time.ParseDuration; supports s/m/h). - if _, err := time.ParseDuration(lockDuration); err != nil { - return fmt.Errorf("invalid lockDuration %q: %w", lockDuration, err) - } - - wf, err := getWaitFlags(cmd) - if err != nil { - return err - } - - dyn, err := newDynamicClient(cmd) - if err != nil { - return err - } - - name := fmt.Sprintf("op-lock-%d", time.Now().Unix()) - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "apiVersion": "deckhouse.io/v1", - "kind": "UserOperation", - "metadata": map[string]any{ - "name": name, - }, - "spec": map[string]any{ - "user": username, - "type": "Lock", - "initiatorType": "admin", - "lock": map[string]any{ - "for": lockDuration, - }, - }, - }, - } - - _, err = createUserOperation(cmd.Context(), dyn, obj) - if err != nil { - return fmt.Errorf("create UserOperation: %w", err) - } - - if !wf.wait { - cmd.Printf("%s\n", name) - return nil - } - - result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout) - if err != nil { - return fmt.Errorf("wait UserOperation: %w", err) - } - - phase, _, _ := unstructured.NestedString(result.Object, "status", "phase") - message, _, _ := unstructured.NestedString(result.Object, "status", "message") - if phase == "Failed" { - return fmt.Errorf("Lock failed: %s", message) - } - cmd.Printf("Succeeded: %s\n", name) - return nil - }, - } - - cmd.Long = "Lock local user in Dex for a period of time.\n\nThe lockDuration argument must be a duration string (e.g. 30s, 10m, 1h)." - addWaitFlags(cmd) - return cmd -} diff --git a/internal/useroperation/cmd/reset2fa.go b/internal/useroperation/cmd/reset2fa.go deleted file mode 100644 index e4532748..00000000 --- a/internal/useroperation/cmd/reset2fa.go +++ /dev/null @@ -1,74 +0,0 @@ -package useroperation - -import ( - "fmt" - "time" - - "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -func newReset2FACommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "reset2fa ", - Short: "Reset local user's 2FA (TOTP) in Dex", - Args: cobra.ExactArgs(1), - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - username := args[0] - wf, err := getWaitFlags(cmd) - if err != nil { - return err - } - - dyn, err := newDynamicClient(cmd) - if err != nil { - return err - } - - name := fmt.Sprintf("op-reset2fa-%d", time.Now().Unix()) - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "apiVersion": "deckhouse.io/v1", - "kind": "UserOperation", - "metadata": map[string]any{ - "name": name, - }, - "spec": map[string]any{ - "user": username, - "type": "Reset2FA", - "initiatorType": "admin", - }, - }, - } - - _, err = createUserOperation(cmd.Context(), dyn, obj) - if err != nil { - return fmt.Errorf("create UserOperation: %w", err) - } - - if !wf.wait { - cmd.Printf("%s\n", name) - return nil - } - - result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout) - if err != nil { - return fmt.Errorf("wait UserOperation: %w", err) - } - - phase, _, _ := unstructured.NestedString(result.Object, "status", "phase") - message, _, _ := unstructured.NestedString(result.Object, "status", "message") - if phase == "Failed" { - return fmt.Errorf("Reset2FA failed: %s", message) - } - cmd.Printf("Succeeded: %s\n", name) - return nil - }, - } - - cmd.Long = "Reset local user's 2FA (TOTP) in Dex.\n\nThis requests a UserOperation of type Reset2FA and waits for completion by default." - addWaitFlags(cmd) - return cmd -} diff --git a/internal/useroperation/cmd/reset_password.go b/internal/useroperation/cmd/reset_password.go deleted file mode 100644 index 0932ac2c..00000000 --- a/internal/useroperation/cmd/reset_password.go +++ /dev/null @@ -1,79 +0,0 @@ -package useroperation - -import ( - "fmt" - "time" - - "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -func newResetPasswordCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "reset-password ", - Aliases: []string{"resetpass"}, - Short: "Reset local user's password in Dex (requires bcrypt hash)", - Args: cobra.ExactArgs(2), - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - username := args[0] - bcryptHash := args[1] - wf, err := getWaitFlags(cmd) - if err != nil { - return err - } - - dyn, err := newDynamicClient(cmd) - if err != nil { - return err - } - - name := fmt.Sprintf("op-resetpass-%d", time.Now().Unix()) - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "apiVersion": "deckhouse.io/v1", - "kind": "UserOperation", - "metadata": map[string]any{ - "name": name, - }, - "spec": map[string]any{ - "user": username, - "type": "ResetPassword", - "initiatorType": "admin", - "resetPassword": map[string]any{ - "newPasswordHash": bcryptHash, - }, - }, - }, - } - - _, err = createUserOperation(cmd.Context(), dyn, obj) - if err != nil { - return fmt.Errorf("create UserOperation: %w", err) - } - - if !wf.wait { - cmd.Printf("%s\n", name) - return nil - } - - result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout) - if err != nil { - return fmt.Errorf("wait UserOperation: %w", err) - } - - phase, _, _ := unstructured.NestedString(result.Object, "status", "phase") - message, _, _ := unstructured.NestedString(result.Object, "status", "message") - if phase == "Failed" { - return fmt.Errorf("ResetPassword failed: %s", message) - } - cmd.Printf("Succeeded: %s\n", name) - return nil - }, - } - - cmd.Long = "Reset local user's password in Dex.\n\nThe second argument must be a bcrypt hash (e.g. produced by `htpasswd -BinC 10`)." - addWaitFlags(cmd) - return cmd -} diff --git a/internal/useroperation/cmd/types.go b/internal/useroperation/cmd/types.go deleted file mode 100644 index b7cdb7ee..00000000 --- a/internal/useroperation/cmd/types.go +++ /dev/null @@ -1,118 +0,0 @@ -package useroperation - -import ( - "context" - "fmt" - "time" - - "github.com/spf13/cobra" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/client-go/dynamic" - - "github.com/deckhouse/deckhouse-cli/internal/utilk8s" -) - -var userOperationGVR = schema.GroupVersionResource{ - Group: "deckhouse.io", - Version: "v1", - Resource: "useroperations", -} - -const ( - defaultWait = true - defaultTimeout = 5 * time.Minute -) - -type waitFlags struct { - wait bool - timeout time.Duration -} - -func addWaitFlags(cmd *cobra.Command) { - cmd.Flags().Bool("wait", defaultWait, "Wait for UserOperation completion and print result.") - cmd.Flags().Duration("timeout", defaultTimeout, "How long to wait for completion when --wait is enabled.") -} - -func getWaitFlags(cmd *cobra.Command) (waitFlags, error) { - waitVal, err := cmd.Flags().GetBool("wait") - if err != nil { - return waitFlags{}, err - } - timeoutVal, err := cmd.Flags().GetDuration("timeout") - if err != nil { - return waitFlags{}, err - } - return waitFlags{wait: waitVal, timeout: timeoutVal}, nil -} - -func getStringFlag(cmd *cobra.Command, name string) (string, error) { - // This command group reuses persistent kubeconfig/context flags. - // Depending on how the command is constructed, these flags may exist either on the command itself - // or on a parent command. We support both. - if cmd.Flags().Lookup(name) != nil { - return cmd.Flags().GetString(name) - } - if cmd.InheritedFlags().Lookup(name) != nil { - return cmd.InheritedFlags().GetString(name) - } - return "", fmt.Errorf("flag %q not found", name) -} - -func newDynamicClient(cmd *cobra.Command) (dynamic.Interface, error) { - kubeconfigPath, err := getStringFlag(cmd, "kubeconfig") - if err != nil { - return nil, fmt.Errorf("failed to get kubeconfig: %w", err) - } - contextName, err := getStringFlag(cmd, "context") - if err != nil { - return nil, fmt.Errorf("failed to get context: %w", err) - } - restConfig, _, err := utilk8s.SetupK8sClientSet(kubeconfigPath, contextName) - if err != nil { - return nil, fmt.Errorf("failed to setup Kubernetes client: %w", err) - } - dyn, err := dynamic.NewForConfig(restConfig) - if err != nil { - return nil, fmt.Errorf("failed to create dynamic client: %w", err) - } - return dyn, nil -} - -func createUserOperation(ctx context.Context, dyn dynamic.Interface, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - return dyn.Resource(userOperationGVR).Create(ctx, obj, metav1.CreateOptions{}) -} - -func waitUserOperation(ctx context.Context, dyn dynamic.Interface, name string, timeout time.Duration) (*unstructured.Unstructured, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - err := wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) { - obj, err := dyn.Resource(userOperationGVR).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - // Hard error: if we can't read the resource, we can't reliably wait for completion. - return false, err - } - - phase, found, _ := unstructured.NestedString(obj.Object, "status", "phase") - if !found || phase == "" { - // Not completed yet: keep polling until the controller fills status.phase. - return false, nil - } - - // Completed (Succeeded/Failed): stop polling. - return true, nil - }) - if err != nil { - return nil, err - } - - // Fetch the final object to return the latest status/message. - obj, err := dyn.Resource(userOperationGVR).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return nil, err - } - return obj, nil -} diff --git a/internal/useroperation/cmd/unlock.go b/internal/useroperation/cmd/unlock.go deleted file mode 100644 index cecce4a7..00000000 --- a/internal/useroperation/cmd/unlock.go +++ /dev/null @@ -1,74 +0,0 @@ -package useroperation - -import ( - "fmt" - "time" - - "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -func newUnlockCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "unlock ", - Short: "Unlock local user in Dex", - Args: cobra.ExactArgs(1), - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - username := args[0] - wf, err := getWaitFlags(cmd) - if err != nil { - return err - } - - dyn, err := newDynamicClient(cmd) - if err != nil { - return err - } - - name := fmt.Sprintf("op-unlock-%d", time.Now().Unix()) - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "apiVersion": "deckhouse.io/v1", - "kind": "UserOperation", - "metadata": map[string]any{ - "name": name, - }, - "spec": map[string]any{ - "user": username, - "type": "Unlock", - "initiatorType": "admin", - }, - }, - } - - _, err = createUserOperation(cmd.Context(), dyn, obj) - if err != nil { - return fmt.Errorf("create UserOperation: %w", err) - } - - if !wf.wait { - cmd.Printf("%s\n", name) - return nil - } - - result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout) - if err != nil { - return fmt.Errorf("wait UserOperation: %w", err) - } - - phase, _, _ := unstructured.NestedString(result.Object, "status", "phase") - message, _, _ := unstructured.NestedString(result.Object, "status", "message") - if phase == "Failed" { - return fmt.Errorf("Unlock failed: %s", message) - } - cmd.Printf("Succeeded: %s\n", name) - return nil - }, - } - - cmd.Long = "Unlock local user in Dex.\n\nThis requests a UserOperation of type Unlock and waits for completion by default." - addWaitFlags(cmd) - return cmd -} diff --git a/internal/useroperation/cmd/useroperation.go b/internal/useroperation/cmd/useroperation.go deleted file mode 100644 index 5b267ffe..00000000 --- a/internal/useroperation/cmd/useroperation.go +++ /dev/null @@ -1,39 +0,0 @@ -package useroperation - -import ( - "github.com/spf13/cobra" - "k8s.io/kubectl/pkg/util/templates" - - "github.com/deckhouse/deckhouse-cli/internal/system/flags" -) - -var userOperationLong = templates.LongDesc(` -Manage Deckhouse users (user-authn). - -This command provides admin operations for Dex local users via UserOperation custom resources: -ResetPassword, Reset2FA, Lock, Unlock. - -© Flant JSC 2026`) - -func NewCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "user", - Aliases: []string{"userop"}, - Short: "Manage Deckhouse users (user-authn)", - Long: userOperationLong, - SilenceErrors: true, - SilenceUsage: true, - } - - // Reuse standard kubeconfig/context flags (same as `d8 system ...`). - flags.AddPersistentFlags(cmd) - - cmd.AddCommand( - newReset2FACommand(), - newResetPasswordCommand(), - newLockCommand(), - newUnlockCommand(), - ) - - return cmd -} diff --git a/internal/utilk8s/completion.go b/internal/utilk8s/completion.go new file mode 100644 index 00000000..d068c49f --- /dev/null +++ b/internal/utilk8s/completion.go @@ -0,0 +1,80 @@ +/* +Copyright 2024 Flant JSC + +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 utilk8s + +import ( + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// CompleteResourceNames lists names of objects of the given GVR for shell +// completion. Pass namespace="" for cluster-scoped resources or to list across +// the default namespace for namespaced resources. +// +// Returns ShellCompDirectiveError if the cluster is unreachable; cobra renders +// that as "no completions" in the shell rather than failing the command. +func CompleteResourceNames(cmd *cobra.Command, gvr schema.GroupVersionResource, namespace, toComplete string) ([]string, cobra.ShellCompDirective) { + dyn, err := NewDynamicClient(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + ri := dyn.Resource(gvr) + list, err := ri.List(cmd.Context(), metav1.ListOptions{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + names := make([]string, 0, len(list.Items)) + for i := range list.Items { + n := list.Items[i].GetName() + if strings.HasPrefix(n, toComplete) { + names = append(names, n) + } + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +// CompleteNamespaces returns namespace names for shell completion. +func CompleteNamespaces(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + return CompleteResourceNames(cmd, schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}, "", toComplete) +} + +// FilterByPrefix returns elements of values that start with toComplete. +// Convenient for static enum completions. +func FilterByPrefix(values []string, toComplete string) []string { + out := make([]string, 0, len(values)) + for _, v := range values { + if strings.HasPrefix(v, toComplete) { + out = append(out, v) + } + } + return out +} + +// CompleteOutputFormats returns a cobra completion function that suggests a +// static list of output format names (e.g. "table", "json", "yaml") filtered +// by the current toComplete prefix. The returned function is safe to pass +// directly to cmd.RegisterFlagCompletionFunc. +func CompleteOutputFormats(formats ...string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return FilterByPrefix(formats, toComplete), cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/internal/utilk8s/dynamic.go b/internal/utilk8s/dynamic.go new file mode 100644 index 00000000..8f435e94 --- /dev/null +++ b/internal/utilk8s/dynamic.go @@ -0,0 +1,41 @@ +/* +Copyright 2024 Flant JSC + +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 utilk8s + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/client-go/dynamic" +) + +// NewDynamicClient creates a dynamic Kubernetes client from cobra command flags. +// It reads "kubeconfig" and "context" persistent flags registered on a parent command. +func NewDynamicClient(cmd *cobra.Command) (dynamic.Interface, error) { + kubeconfigPath, _ := cmd.Flags().GetString("kubeconfig") + contextName, _ := cmd.Flags().GetString("context") + + restConfig, _, err := SetupK8sClientSet(kubeconfigPath, contextName) + if err != nil { + return nil, fmt.Errorf("failed to setup Kubernetes client: %w", err) + } + dyn, err := dynamic.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create dynamic client: %w", err) + } + return dyn, nil +} diff --git a/internal/utilk8s/output.go b/internal/utilk8s/output.go new file mode 100644 index 00000000..d604d252 --- /dev/null +++ b/internal/utilk8s/output.go @@ -0,0 +1,58 @@ +/* +Copyright 2024 Flant JSC + +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 utilk8s + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + sigsyaml "sigs.k8s.io/yaml" +) + +// AddOutputFlag declares the standard "-o/--output" flag and its completion +// in a single line. Default value and accepted formats are command-specific +// so callers list them explicitly. +func AddOutputFlag(cmd *cobra.Command, defaultFmt string, formats ...string) { + cmd.Flags().StringP("output", "o", defaultFmt, "Output format: "+strings.Join(formats, "|")) + _ = cmd.RegisterFlagCompletionFunc("output", CompleteOutputFormats(formats...)) +} + +// PrintObject writes an unstructured Kubernetes object to w in the given format. +// Supported formats: "json", "yaml"; anything else prints Kind/Name. +func PrintObject(w io.Writer, obj *unstructured.Unstructured, format string) error { + switch format { + case "json": + data, err := json.MarshalIndent(obj.Object, "", " ") + if err != nil { + return fmt.Errorf("marshalling JSON: %w", err) + } + fmt.Fprintln(w, string(data)) + case "yaml": + data, err := sigsyaml.Marshal(obj.Object) + if err != nil { + return fmt.Errorf("marshalling YAML: %w", err) + } + fmt.Fprint(w, string(data)) + default: + fmt.Fprintf(w, "%s/%s\n", obj.GetKind(), obj.GetName()) + } + return nil +}