From cabfa610a915813ac357a4e11299f88061169fde Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 27 Apr 2026 10:53:32 +0300 Subject: [PATCH 1/5] Add commands for manage users groups and access Signed-off-by: Ivan Zvyagintsev --- cmd/d8/root.go | 4 +- go.mod | 2 +- go.sum | 2 - internal/iam/access/cmd/access.go | 57 ++ internal/iam/access/cmd/access_test.go | 195 +++++ internal/iam/access/cmd/completion.go | 115 +++ internal/iam/access/cmd/explain.go | 318 ++++++++ internal/iam/access/cmd/grant.go | 587 +++++++++++++++ internal/iam/access/cmd/grant_to_test.go | 130 ++++ internal/iam/access/cmd/list.go | 682 +++++++++++++++++ internal/iam/access/cmd/naming.go | 103 +++ internal/iam/access/cmd/naming_test.go | 148 ++++ internal/iam/access/cmd/resolve.go | 425 +++++++++++ internal/iam/access/cmd/resolve_test.go | 267 +++++++ internal/iam/access/cmd/revoke.go | 372 ++++++++++ internal/iam/access/cmd/rules.go | 685 ++++++++++++++++++ internal/iam/access/cmd/rules_test.go | 251 +++++++ internal/iam/access/cmd/types.go | 138 ++++ internal/iam/cmd/iam.go | 60 ++ internal/iam/group/cmd/add_member.go | 156 ++++ internal/iam/group/cmd/completion.go | 61 ++ internal/iam/group/cmd/create.go | 90 +++ internal/iam/group/cmd/delete.go | 53 ++ internal/iam/group/cmd/get.go | 172 +++++ internal/iam/group/cmd/group.go | 56 ++ internal/iam/group/cmd/group_test.go | 207 ++++++ internal/iam/group/cmd/list.go | 83 +++ internal/iam/group/cmd/remove_member.go | 110 +++ internal/iam/group/cmd/types.go | 103 +++ internal/iam/user/cmd/completion.go | 33 + internal/iam/user/cmd/create.go | 316 ++++++++ internal/iam/user/cmd/create_test.go | 158 ++++ internal/iam/user/cmd/delete.go | 65 ++ internal/iam/user/cmd/get.go | 155 ++++ internal/iam/user/cmd/list.go | 83 +++ .../{useroperation => iam/user}/cmd/lock.go | 33 +- internal/iam/user/cmd/password.go | 137 ++++ internal/iam/user/cmd/password_test.go | 114 +++ .../user}/cmd/reset2fa.go | 33 +- .../user}/cmd/reset_password.go | 35 +- .../{useroperation => iam/user}/cmd/types.go | 54 +- .../{useroperation => iam/user}/cmd/unlock.go | 33 +- internal/iam/user/cmd/user.go | 59 ++ internal/useroperation/cmd/useroperation.go | 39 - internal/utilk8s/completion.go | 80 ++ internal/utilk8s/dynamic.go | 41 ++ internal/utilk8s/output.go | 48 ++ 47 files changed, 7038 insertions(+), 110 deletions(-) create mode 100644 internal/iam/access/cmd/access.go create mode 100644 internal/iam/access/cmd/access_test.go create mode 100644 internal/iam/access/cmd/completion.go create mode 100644 internal/iam/access/cmd/explain.go create mode 100644 internal/iam/access/cmd/grant.go create mode 100644 internal/iam/access/cmd/grant_to_test.go create mode 100644 internal/iam/access/cmd/list.go create mode 100644 internal/iam/access/cmd/naming.go create mode 100644 internal/iam/access/cmd/naming_test.go create mode 100644 internal/iam/access/cmd/resolve.go create mode 100644 internal/iam/access/cmd/resolve_test.go create mode 100644 internal/iam/access/cmd/revoke.go create mode 100644 internal/iam/access/cmd/rules.go create mode 100644 internal/iam/access/cmd/rules_test.go create mode 100644 internal/iam/access/cmd/types.go create mode 100644 internal/iam/cmd/iam.go create mode 100644 internal/iam/group/cmd/add_member.go create mode 100644 internal/iam/group/cmd/completion.go create mode 100644 internal/iam/group/cmd/create.go create mode 100644 internal/iam/group/cmd/delete.go create mode 100644 internal/iam/group/cmd/get.go create mode 100644 internal/iam/group/cmd/group.go create mode 100644 internal/iam/group/cmd/group_test.go create mode 100644 internal/iam/group/cmd/list.go create mode 100644 internal/iam/group/cmd/remove_member.go create mode 100644 internal/iam/group/cmd/types.go create mode 100644 internal/iam/user/cmd/completion.go create mode 100644 internal/iam/user/cmd/create.go create mode 100644 internal/iam/user/cmd/create_test.go create mode 100644 internal/iam/user/cmd/delete.go create mode 100644 internal/iam/user/cmd/get.go create mode 100644 internal/iam/user/cmd/list.go rename internal/{useroperation => iam/user}/cmd/lock.go (67%) create mode 100644 internal/iam/user/cmd/password.go create mode 100644 internal/iam/user/cmd/password_test.go rename internal/{useroperation => iam/user}/cmd/reset2fa.go (63%) rename internal/{useroperation => iam/user}/cmd/reset_password.go (63%) rename internal/{useroperation => iam/user}/cmd/types.go (65%) rename internal/{useroperation => iam/user}/cmd/unlock.go (63%) create mode 100644 internal/iam/user/cmd/user.go delete mode 100644 internal/useroperation/cmd/useroperation.go create mode 100644 internal/utilk8s/completion.go create mode 100644 internal/utilk8s/dynamic.go create mode 100644 internal/utilk8s/output.go 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..da997be6 --- /dev/null +++ b/internal/iam/access/cmd/access.go @@ -0,0 +1,57 @@ +/* +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(` +Manage access grants in Deckhouse (current authz model). + +This command provides grant, revoke, list, and explain operations for +AuthorizationRule and ClusterAuthorizationRule custom resources. + +Only the current authorization model is supported in this version. +Experimental model support is planned for a future release. + +© Flant JSC 2026`) + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "access", + Short: "Manage access grants in Deckhouse (current authz model)", + Long: accessLong, + SilenceErrors: true, + SilenceUsage: true, + } + + flags.AddPersistentFlags(cmd) + + cmd.AddCommand( + newGrantCommand(), + newRevokeCommand(), + newListCommand(), + newExplainCommand(), + newRulesCommand(), + ) + + 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..a8532069 --- /dev/null +++ b/internal/iam/access/cmd/access_test.go @@ -0,0 +1,195 @@ +/* +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" +) + +func TestParseSubjectKind(t *testing.T) { + tests := []struct { + input string + want string + wantErr string + }{ + {input: "user", want: "User"}, + {input: "User", want: "User"}, + {input: "group", want: "Group"}, + {input: "Group", want: "Group"}, + {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 TestParseScopeFlags(t *testing.T) { + tests := []struct { + name string + namespaces []string + cluster bool + allNS bool + want string + wantErr string + }{ + {name: "namespace", namespaces: []string{"dev"}, want: "namespace"}, + {name: "cluster", cluster: true, want: "cluster"}, + {name: "all-namespaces", allNS: true, want: "all-namespaces"}, + {name: "none", wantErr: "one of"}, + {name: "namespace+cluster", namespaces: []string{"dev"}, cluster: true, wantErr: "mutually exclusive"}, + {name: "cluster+allNS", cluster: true, allNS: true, wantErr: "mutually exclusive"}, + {name: "all three", namespaces: []string{"dev"}, cluster: true, allNS: true, wantErr: "mutually exclusive"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseScopeFlags(tt.namespaces, tt.cluster, tt.allNS) + 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 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)) + }) + } +} + +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") +} + +func TestRemoveSubject(t *testing.T) { + t.Run("subject found", func(t *testing.T) { + obj := buildTestObj([]map[string]any{ + {"kind": "User", "name": "anton@abc.com"}, + {"kind": "Group", "name": "admins"}, + }) + newSubjects, removed := removeSubject(obj, "User", "anton@abc.com") + assert.True(t, removed) + assert.Len(t, newSubjects, 1) + }) + + t.Run("subject not found", func(t *testing.T) { + obj := buildTestObj([]map[string]any{ + {"kind": "Group", "name": "admins"}, + }) + newSubjects, removed := removeSubject(obj, "User", "anton@abc.com") + assert.False(t, removed) + assert.Len(t, newSubjects, 1) + }) +} + +func buildTestObj(subjects []map[string]any) *unstructured.Unstructured { + var rawSubjects []any + for _, s := range subjects { + rawSubjects = append(rawSubjects, s) + } + return &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "subjects": rawSubjects, + }, + }, + } +} diff --git a/internal/iam/access/cmd/completion.go b/internal/iam/access/cmd/completion.go new file mode 100644 index 00000000..4c80154f --- /dev/null +++ b/internal/iam/access/cmd/completion.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 access + +import ( + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +// subjectKinds is the enum accepted as the first positional arg on +// "d8 iam access grant", "d8 iam access revoke" and "d8 iam access explain". +var subjectKinds = []string{"user", "group"} + +// allAccessLevelsList is the static list of access levels for flag completion. +// Kept in stable admin-to-user order to be friendlier in shell completion UIs. +var allAccessLevelsList = []string{ + "User", "PrivilegedUser", "Editor", "Admin", + "ClusterEditor", "ClusterAdmin", "SuperAdmin", +} + +// 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, userGVR, "", toComplete) + case "group": + return utilk8s.CompleteResourceNames(cmd, 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) +} + +// completeRuleRef completes rule references in the form used by +// "d8 iam access rules get". It progressively offers: +// +// "CAR/" — full list of ClusterAuthorizationRules +// "AR//" — full list of AuthorizationRules from every namespace +// +// Short prefixes (CAR, AR) are preferred in completion output because they +// are less typing, but the long prefixes remain valid input. +func completeRuleRef(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Decide which branches to populate based on the prefix already committed + // to by the user. For a partially-typed prefix (e.g. "C", "CA") we still + // want both — cobra's downstream filtering will narrow things down. + 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(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(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/explain.go b/internal/iam/access/cmd/explain.go new file mode 100644 index 00000000..138ffaa4 --- /dev/null +++ b/internal/iam/access/cmd/explain.go @@ -0,0 +1,318 @@ +/* +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" + + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var explainLong = templates.LongDesc(` +Explain why a user or group has certain access. + +This command traces access through identity resolution, group memberships, +direct and inherited grants, and shows warnings about potential issues +like group cycles, orphaned references, and manual (non-d8-managed) objects. + +© Flant JSC 2026`) + +var explainExample = templates.Examples(` + # Explain access for a user + d8 iam access explain user anton + + # Explain access for a group (JSON output) + d8 iam access explain group admins -o json`) + +func newExplainCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "explain (user|group) ", + Short: "Explain why a user or group has certain access", + Long: explainLong, + Example: explainExample, + Args: cobra.ExactArgs(2), + ValidArgsFunction: completeSubjectAndName, + SilenceErrors: true, + SilenceUsage: true, + RunE: runExplain, + } + + cmd.Flags().StringP("output", "o", "text", "Output format: text|json") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("text", "json")) + return cmd +} + +func runExplain(cmd *cobra.Command, args []string) error { + kindStr := args[0] + name := args[1] + outputFmt, _ := cmd.Flags().GetString("output") + + subjectKind, err := parseSubjectKind(kindStr) + if err != nil { + return err + } + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + inv, err := buildInventory(cmd.Context(), dyn) + if err != nil { + return err + } + + switch subjectKind { + case "User": + return explainUser(cmd, inv, name, outputFmt) + case "Group": + return explainGroup(cmd, inv, name, outputFmt) + } + return nil +} + +func explainUser(cmd *cobra.Command, inv *accessInventory, userName, outputFmt string) error { + email, ok := inv.Users[userName] + if !ok { + return fmt.Errorf("user %q not found", 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) + cycles := inv.DetectGroupCycles() + warnings := collectUserWarnings(transitiveGroups, directGrants, inheritedGrants, cycles) + + if outputFmt == "json" { + item := buildUserAccessJSON(inv, userName) + item.Warnings = warnings + data, err := json.MarshalIndent(item, "", " ") + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil + } + + w := cmd.OutOrStdout() + + fmt.Fprintln(w, "=== Identity Resolution ===") + fmt.Fprintf(w, "User CR: %s\n", userName) + fmt.Fprintf(w, "Resolved principal (email): %s\n", email) + + fmt.Fprintln(w, "\n=== Group Memberships ===") + fmt.Fprintln(w, "Direct groups:") + printBulletList(w, directGroups) + if len(transitiveGroups) > len(directGroups) { + fmt.Fprintln(w, "Transitive groups (via nested membership):") + directSet := make(map[string]bool, len(directGroups)) + for _, g := range directGroups { + directSet[g] = true + } + var extra []string + for _, g := range transitiveGroups { + if !directSet[g] { + extra = append(extra, g) + } + } + sort.Strings(extra) + printBulletList(w, extra) + } + + fmt.Fprintln(w, "\n=== Direct Grants (matched by email) ===") + if len(directGrants) == 0 { + fmt.Fprintln(w, " ") + } + for _, g := range directGrants { + printGrantToWriter(w, &g, "") + } + + fmt.Fprintln(w, "\n=== Inherited Grants (via group membership) ===") + if len(inheritedGrants) == 0 { + fmt.Fprintln(w, " ") + } + for _, g := range inheritedGrants { + via := findViaGroup(inv, userName, g.SubjectPrincipal) + printGrantToWriter(w, &g, via) + } + + fmt.Fprintln(w, "\n=== Effective Access Summary ===") + printEffectiveSummary(w, summary) + + if len(warnings) > 0 { + fmt.Fprintln(w, "\n=== Warnings ===") + for _, warn := range warnings { + fmt.Fprintf(w, " ! %s\n", warn) + } + } + + return nil +} + +func explainGroup(cmd *cobra.Command, inv *accessInventory, groupName, outputFmt string) error { + if _, ok := inv.GroupMembers[groupName]; !ok { + return fmt.Errorf("group %q not found", groupName) + } + + grants := inv.GroupGrants(groupName) + summary := computeEffectiveSummary(grants) + cycles := inv.DetectGroupCycles() + warnings := collectGroupWarnings(inv, groupName, cycles) + + if outputFmt == "json" { + return printGroupExplainJSON(cmd, inv, groupName, warnings) + } + + w := cmd.OutOrStdout() + + fmt.Fprintln(w, "=== Group Identity ===") + fmt.Fprintf(w, "Group CR: %s\n", groupName) + + userMembers, groupMembers := partitionMembersByKind(inv.GroupMembers[groupName]) + + fmt.Fprintln(w, "\n=== Members ===") + fmt.Fprintf(w, "Users (%d):\n", len(userMembers)) + printBulletList(w, userMembers) + fmt.Fprintf(w, "Nested groups (%d):\n", len(groupMembers)) + printBulletList(w, groupMembers) + + fmt.Fprintln(w, "\n=== Grants ===") + if len(grants) == 0 { + fmt.Fprintln(w, " ") + } + for _, g := range grants { + printGrantToWriter(w, &g, "") + } + + fmt.Fprintln(w, "\n=== Effective Access Summary ===") + printEffectiveSummary(w, summary) + + if len(warnings) > 0 { + fmt.Fprintln(w, "\n=== Warnings ===") + for _, warn := range warnings { + fmt.Fprintf(w, " ! %s\n", warn) + } + } + + return nil +} + +func collectUserWarnings(groups []string, directGrants, inheritedGrants []normalizedGrant, cycles map[string][]string) []string { + var warnings []string + + // Check for cycles in user's groups + for _, g := range groups { + if cycle, ok := cycles[g]; ok { + warnings = append(warnings, fmt.Sprintf("group cycle detected: %s", strings.Join(cycle, " -> "))) + } + } + + for _, g := range directGrants { + if !g.ManagedByD8 { + warnings = append(warnings, fmt.Sprintf("direct grant from manual object: %s", formatGrantSource(&g))) + } + } + for _, g := range inheritedGrants { + if !g.ManagedByD8 { + warnings = append(warnings, fmt.Sprintf("inherited grant from manual object: %s (via group %s)", formatGrantSource(&g), g.SubjectPrincipal)) + } + } + + return warnings +} + +func collectGroupWarnings(inv *accessInventory, groupName string, cycles map[string][]string) []string { + var warnings []string + + if cycle, ok := cycles[groupName]; ok { + warnings = append(warnings, fmt.Sprintf("group cycle detected: %s", strings.Join(cycle, " -> "))) + } + + // Check if any user members are orphaned + members := inv.GroupMembers[groupName] + for _, m := range members { + if m.Kind == "User" { + if _, ok := inv.Users[m.Name]; !ok { + warnings = append(warnings, fmt.Sprintf("user member %q not found as a local User CR (may be orphaned)", m.Name)) + } + } + if m.Kind == "Group" { + if _, ok := inv.GroupMembers[m.Name]; !ok { + warnings = append(warnings, fmt.Sprintf("nested group %q not found as a local Group CR", m.Name)) + } + } + } + + return warnings +} + +func printGroupExplainJSON(cmd *cobra.Command, inv *accessInventory, groupName string, warnings []string) error { + members := inv.GroupMembers[groupName] + grants := inv.GroupGrants(groupName) + summary := computeEffectiveSummary(grants) + + type memberEntry struct { + Kind string `json:"kind"` + Name string `json:"name"` + } + type groupExplainJSON struct { + Kind string `json:"kind"` + Name string `json:"name"` + Members []memberEntry `json:"members"` + Grants []grantJSON `json:"grants"` + Effective effectiveJSON `json:"effectiveSummary"` + Warnings []string `json:"warnings"` + } + + item := groupExplainJSON{ + Kind: "GroupAccessExplanation", + Name: groupName, + Effective: summaryToJSON(summary), + Warnings: warnings, + } + for _, m := range members { + item.Members = append(item.Members, memberEntry(m)) + } + if item.Members == nil { + item.Members = []memberEntry{} + } + for _, g := range grants { + item.Grants = append(item.Grants, grantToJSON(&g, "")) + } + if item.Grants == nil { + item.Grants = []grantJSON{} + } + if item.Warnings == nil { + item.Warnings = []string{} + } + + data, err := json.MarshalIndent(item, "", " ") + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil +} diff --git a/internal/iam/access/cmd/grant.go b/internal/iam/access/cmd/grant.go new file mode 100644 index 00000000..5684a19e --- /dev/null +++ b/internal/iam/access/cmd/grant.go @@ -0,0 +1,587 @@ +/* +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" + "sort" + "strings" + + "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/client-go/dynamic" + "k8s.io/kubectl/pkg/util/templates" + + "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. + +Exactly one scope flag is required (they are mutually exclusive): + + -n/--namespace Creates an AuthorizationRule per namespace (namespaced scope). + Only User, PrivilegedUser, Editor, Admin levels are valid. + + --cluster Creates a ClusterAuthorizationRule. The grant applies to all + namespaces EXCEPT d8-* and kube-system (system namespaces). + All access levels are valid, including ClusterEditor/ClusterAdmin/SuperAdmin. + + --all-namespaces Creates a ClusterAuthorizationRule with namespaceSelector.matchAny. + The grant covers ALL namespaces including system namespaces. + Use with caution — grants access to d8-system, kube-system, etc. + +Modifier flags --allow-scale and --port-forwarding add additional capabilities +to the grant and can be combined with any scope. + +ADVANCED MODE (--to): + + Adds a subject to an existing authorization rule instead of creating a new + d8-managed object. Useful when you want to extend a rule maintained + manually or shared across teams. + + d8 iam access grant --to ClusterAuthorizationRule/RULE user PRINCIPAL + d8 iam access grant --to AuthorizationRule/NS/RULE group GROUPNAME + + PRINCIPAL is written literally into spec.subjects[].name — for users pass + the exact principal expected by the authz module (typically the email), + not the User CR name. No User CR resolution is performed in this mode. + + --to is strictly "add a subject". It never modifies accessLevel, scope, + allowScale or portForwarding of the target rule. Those fields apply to + ALL subjects of the rule, so changing them from CLI would silently affect + other principals — that is by design disallowed. Passing any of these + flags together with --to is an error. + + Because RBAC is additive, the normal way to extend a subject's capabilities + is a separate d8-managed grant next to the existing rule: + + d8 iam access grant user alice --access-level Admin -n dev --port-forwarding + + If you really need to change a shared rule's flags for everyone on it, edit + the rule directly (kubectl edit), since that is a policy change, not a + per-subject grant. + +© Flant JSC 2026`) + +var grantExample = templates.Examples(` + # Grant Admin in specific namespaces + d8 iam access grant user anton --access-level Admin -n dev -n stage + + # Grant ClusterAdmin cluster-wide (system namespaces excluded) + d8 iam access grant user anton --access-level ClusterAdmin --cluster + + # Grant ClusterAdmin to ALL namespaces including d8-system, kube-system + d8 iam access grant user anton --access-level ClusterAdmin --all-namespaces + + # Grant Editor to a group with port-forwarding enabled + d8 iam access grant group admins --access-level Editor -n dev --port-forwarding + + # Dry-run to preview the manifest before applying + d8 iam access grant user anton --access-level Admin -n dev --dry-run -o yaml + + # Advanced mode: add a user to an existing ClusterAuthorizationRule (literal principal) + d8 iam access grant --to ClusterAuthorizationRule/superadmins user new@example.com + + # Advanced mode: add a group to an existing namespaced AuthorizationRule + d8 iam access grant --to AuthorizationRule/dev/shared-editors group devs`) + +func newGrantCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "grant (user|group) NAME [--access-level LEVEL scope | --to SOURCE]", + 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)") + cmd.Flags().Bool("cluster", false, "Cluster scope: all namespaces EXCEPT system (d8-*, kube-system)") + cmd.Flags().Bool("all-namespaces", false, "Cluster scope: ALL namespaces INCLUDING system (d8-*, kube-system)") + 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") + cmd.Flags().StringP("output", "o", "name", "Output format: name|yaml|json") + cmd.Flags().String("to", "", "Existing rule to add the subject to: AuthorizationRule// or ClusterAuthorizationRule/") + + _ = cmd.RegisterFlagCompletionFunc("access-level", completeAccessLevels) + _ = cmd.RegisterFlagCompletionFunc("namespace", completeNamespacesFlag) + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("name", "yaml", "json")) + _ = cmd.RegisterFlagCompletionFunc("to", completeRuleRef) + + return cmd +} + +func runGrant(cmd *cobra.Command, args []string) error { + toFlag, _ := cmd.Flags().GetString("to") + if toFlag != "" { + return runSourceBasedGrant(cmd, args) + } + + subjectKindStr := args[0] + subjectName := args[1] + + accessLevel, _ := cmd.Flags().GetString("access-level") + namespaces, _ := cmd.Flags().GetStringSlice("namespace") + clusterFlag, _ := cmd.Flags().GetBool("cluster") + allNSFlag, _ := cmd.Flags().GetBool("all-namespaces") + 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 fmt.Errorf("--access-level is required (or use --to to add a subject to an existing rule)") + } + + subjectKind, err := parseSubjectKind(subjectKindStr) + if err != nil { + return err + } + + scopeType, err := parseScopeFlags(namespaces, clusterFlag, allNSFlag) + if err != nil { + return err + } + + isNamespaced := scopeType == "namespace" + 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 "User": + subjectPrincipal, err = resolveUserEmail(cmd.Context(), dyn, subjectName) + if err != nil { + return err + } + case "Group": + subjectPrincipal = subjectName + if _, err := dyn.Resource(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) + } + } + + switch scopeType { + case "namespace": + return grantNamespaced(cmd, dyn, subjectKind, subjectName, subjectPrincipal, accessLevel, namespaces, allowScale, portForwarding, dryRun, outputFmt) + case "cluster", "all-namespaces": + return grantCluster(cmd, dyn, subjectKind, subjectName, subjectPrincipal, accessLevel, scopeType, allowScale, portForwarding, dryRun, outputFmt) + } + return nil +} + +func grantNamespaced(cmd *cobra.Command, dyn dynamic.Interface, + subjectKind, subjectRef, subjectPrincipal, accessLevel string, + namespaces []string, allowScale, portForwarding, dryRun bool, outputFmt string) error { + var errs *multierror.Error + for _, ns := range namespaces { + spec := &canonicalGrantSpec{ + Model: "current", + SubjectKind: subjectKind, + SubjectRef: subjectRef, + SubjectPrincipal: subjectPrincipal, + AccessLevel: accessLevel, + ScopeType: "namespace", + Namespaces: []string{ns}, + AllowScale: allowScale, + PortForwarding: portForwarding, + } + + obj, err := buildAuthorizationRule(spec, ns) + if err != nil { + return err + } + + if dryRun { + if err := utilk8s.PrintObject(cmd.OutOrStdout(), obj, outputFmt); err != nil { + return err + } + continue + } + + result, err := createOrUpdateNamespacedGrant(cmd, dyn, obj, ns) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to create grant in namespace %q: %v\n", ns, err) + errs = multierror.Append(errs, fmt.Errorf("namespace %s: %w", ns, err)) + continue + } + if err := utilk8s.PrintObject(cmd.OutOrStdout(), result, outputFmt); err != nil { + return err + } + } + + return errs.ErrorOrNil() +} + +func grantCluster(cmd *cobra.Command, dyn dynamic.Interface, + subjectKind, subjectRef, subjectPrincipal, accessLevel, scopeType string, + allowScale, portForwarding, dryRun bool, outputFmt string) error { + spec := &canonicalGrantSpec{ + Model: "current", + SubjectKind: subjectKind, + SubjectRef: subjectRef, + SubjectPrincipal: subjectPrincipal, + AccessLevel: accessLevel, + ScopeType: scopeType, + AllowScale: allowScale, + PortForwarding: portForwarding, + } + + obj, err := buildClusterAuthorizationRule(spec) + if err != nil { + return err + } + + if dryRun { + return utilk8s.PrintObject(cmd.OutOrStdout(), obj, outputFmt) + } + + result, err := createOrUpdateClusterGrant(cmd, dyn, obj) + if err != nil { + return err + } + return utilk8s.PrintObject(cmd.OutOrStdout(), result, outputFmt) +} + +func buildAuthorizationRule(spec *canonicalGrantSpec, ns string) (*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": spec.SubjectKind, + "name": spec.SubjectPrincipal, + }, + }, + } + + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "deckhouse.io/v1alpha1", + "kind": "AuthorizationRule", + "metadata": map[string]any{ + "name": name, + "namespace": ns, + "labels": toAnyMap(labels), + "annotations": toAnyMap(annotations), + }, + "spec": ruleSpec, + }, + }, nil +} + +func buildClusterAuthorizationRule(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": spec.SubjectKind, + "name": spec.SubjectPrincipal, + }, + }, + } + + if spec.ScopeType == "all-namespaces" { + ruleSpec["namespaceSelector"] = map[string]any{ + "matchAny": true, + } + } + + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "deckhouse.io/v1", + "kind": "ClusterAuthorizationRule", + "metadata": map[string]any{ + "name": name, + "labels": toAnyMap(labels), + "annotations": toAnyMap(annotations), + }, + "spec": ruleSpec, + }, + }, nil +} + +func createOrUpdateNamespacedGrant(cmd *cobra.Command, dyn dynamic.Interface, obj *unstructured.Unstructured, ns string) (*unstructured.Unstructured, error) { + client := dyn.Resource(authorizationRuleGVR).Namespace(ns) + name := obj.GetName() + + existing, err := client.Get(cmd.Context(), name, metav1.GetOptions{}) + if err == nil { + // Check if it matches + existingAnnot := existing.GetAnnotations() + newAnnot := obj.GetAnnotations() + if existingAnnot["deckhouse.io/access-canonical-spec"] == newAnnot["deckhouse.io/access-canonical-spec"] { + cmd.PrintErrf("AuthorizationRule/%s/%s unchanged\n", ns, name) + return existing, nil + } + obj.SetResourceVersion(existing.GetResourceVersion()) + return client.Update(cmd.Context(), obj, metav1.UpdateOptions{}) + } + + return client.Create(cmd.Context(), obj, metav1.CreateOptions{}) +} + +func createOrUpdateClusterGrant(cmd *cobra.Command, dyn dynamic.Interface, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { + client := dyn.Resource(clusterAuthorizationRuleGVR) + name := obj.GetName() + + existing, err := client.Get(cmd.Context(), name, metav1.GetOptions{}) + if err == nil { + existingAnnot := existing.GetAnnotations() + newAnnot := obj.GetAnnotations() + if existingAnnot["deckhouse.io/access-canonical-spec"] == newAnnot["deckhouse.io/access-canonical-spec"] { + cmd.PrintErrf("ClusterAuthorizationRule/%s unchanged\n", name) + return existing, nil + } + obj.SetResourceVersion(existing.GetResourceVersion()) + return client.Update(cmd.Context(), obj, metav1.UpdateOptions{}) + } + + return client.Create(cmd.Context(), obj, metav1.CreateOptions{}) +} + +func parseSubjectKind(s string) (string, error) { + switch strings.ToLower(s) { + case "user": + return "User", nil + case "group": + return "Group", nil + default: + return "", fmt.Errorf("invalid subject kind %q: must be user or group", s) + } +} + +func parseScopeFlags(namespaces []string, cluster, allNamespaces bool) (string, error) { + count := 0 + if len(namespaces) > 0 { + count++ + } + if cluster { + count++ + } + if allNamespaces { + count++ + } + if count == 0 { + return "", fmt.Errorf("one of -n/--namespace, --cluster, or --all-namespaces must be specified") + } + if count > 1 { + return "", fmt.Errorf("-n/--namespace, --cluster, and --all-namespaces are mutually exclusive") + } + + if len(namespaces) > 0 { + return "namespace", nil + } + if cluster { + return "cluster", nil + } + return "all-namespaces", 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 +} + +// rejectFlagsInToMode fails fast if any flag incompatible with "--to add-subject" +// mode is provided. Each flag gets a specific hint so the user knows which tool +// to reach for instead. +func rejectFlagsInToMode(cmd *cobra.Command) error { + ruleSpecHint := "this field applies to ALL subjects of the rule; change it with 'kubectl edit' on the rule, not via --to" + perSubjectHint := "for a per-subject capability on top of the existing rule, create a separate grant without --to: 'd8 iam access grant --access-level ... [scope] --port-forwarding --allow-scale'" + + hints := map[string]string{ + "access-level": ruleSpecHint, + "namespace": ruleSpecHint, + "cluster": ruleSpecHint, + "all-namespaces": ruleSpecHint, + "port-forwarding": perSubjectHint + + " — note: setting it on a shared rule would affect every subject already on it", + "allow-scale": perSubjectHint + + " — note: setting it on a shared rule would affect every subject already on it", + "dry-run": "--dry-run is not supported with --to (subject add is applied via Update, not generated manifest)", + } + + var offenders []string + for f := range hints { + if cmd.Flags().Changed(f) { + offenders = append(offenders, f) + } + } + if len(offenders) == 0 { + return nil + } + sort.Strings(offenders) + + var b strings.Builder + fmt.Fprintf(&b, "the following flag(s) are not allowed with --to: --%s\n", strings.Join(offenders, ", --")) + for _, f := range offenders { + fmt.Fprintf(&b, " --%s: %s\n", f, hints[f]) + } + return errors.New(b.String()) +} + +func runSourceBasedGrant(cmd *cobra.Command, args []string) error { + toFlag, _ := cmd.Flags().GetString("to") + + // Hard-fail on any flag that would imply we are redefining the rule. + // port-forwarding and allow-scale in particular apply to ALL subjects of + // the target rule, so silently "applying" them via --to would silently + // mutate other principals' capabilities. That's unsafe and must be + // explicit — force the user to pick the right tool: + // - per-subject capability -> separate d8-managed grant (RBAC is additive) + // - rule-wide policy change -> kubectl edit on the rule itself + if err := rejectFlagsInToMode(cmd); err != nil { + return err + } + + if len(args) != 2 { + return fmt.Errorf("source-based grant requires: grant --to (user|group) ") + } + subjectKind, err := parseSubjectKind(args[0]) + if err != nil { + return err + } + // Principal is used literally — it must exactly match the value that will + // land in spec.subjects[].name. No User CR lookup here, for symmetry with + // `revoke --from` and because shared rules may already carry a specific + // principal format (email, LDAP DN, etc). + subjectPrincipal := args[1] + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + refKind, refNS, refName, err := parseRuleRef(toFlag) + if err != nil { + return fmt.Errorf("invalid --to: %w", err) + } + switch refKind { + case "ClusterAuthorizationRule": + return addSubjectToClusterRule(cmd, dyn, refName, subjectKind, subjectPrincipal) + case "AuthorizationRule": + return addSubjectToNamespacedRule(cmd, dyn, refNS, refName, subjectKind, subjectPrincipal) + default: + return fmt.Errorf("unsupported --to kind %q", refKind) + } +} + +func addSubjectToClusterRule(cmd *cobra.Command, dyn dynamic.Interface, ruleName, subjectKind, subjectPrincipal string) error { + client := dyn.Resource(clusterAuthorizationRuleGVR) + obj, err := client.Get(cmd.Context(), ruleName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("getting ClusterAuthorizationRule %q: %w", ruleName, err) + } + + newSubjects, added := addSubject(obj, subjectKind, subjectPrincipal) + if !added { + cmd.Printf("Subject %s/%s already present in ClusterAuthorizationRule/%s (no change)\n", subjectKind, subjectPrincipal, ruleName) + return nil + } + + if err := unstructured.SetNestedSlice(obj.Object, newSubjects, "spec", "subjects"); err != nil { + return fmt.Errorf("setting subjects: %w", err) + } + if _, err = client.Update(cmd.Context(), obj, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating ClusterAuthorizationRule %q: %w", ruleName, err) + } + cmd.Printf("Added subject %s/%s to ClusterAuthorizationRule/%s\n", subjectKind, subjectPrincipal, ruleName) + return nil +} + +func addSubjectToNamespacedRule(cmd *cobra.Command, dyn dynamic.Interface, ns, ruleName, subjectKind, subjectPrincipal string) error { + client := dyn.Resource(authorizationRuleGVR).Namespace(ns) + obj, err := client.Get(cmd.Context(), ruleName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("getting AuthorizationRule %q in namespace %q: %w", ruleName, ns, err) + } + + newSubjects, added := addSubject(obj, subjectKind, subjectPrincipal) + if !added { + cmd.Printf("Subject %s/%s already present in AuthorizationRule/%s/%s (no change)\n", subjectKind, subjectPrincipal, ns, ruleName) + return nil + } + + if err := unstructured.SetNestedSlice(obj.Object, newSubjects, "spec", "subjects"); err != nil { + return fmt.Errorf("setting subjects: %w", err) + } + if _, err = client.Update(cmd.Context(), obj, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating AuthorizationRule %s/%s: %w", ns, ruleName, err) + } + cmd.Printf("Added subject %s/%s to AuthorizationRule/%s/%s\n", subjectKind, subjectPrincipal, ns, ruleName) + return nil +} + +func addSubject(obj *unstructured.Unstructured, kind, name string) ([]any, bool) { + subjects, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") + for _, s := range subjects { + sub, ok := s.(map[string]any) + if !ok { + continue + } + if fmt.Sprint(sub["kind"]) == kind && fmt.Sprint(sub["name"]) == name { + return subjects, false + } + } + return append(subjects, map[string]any{"kind": kind, "name": name}), true +} diff --git a/internal/iam/access/cmd/grant_to_test.go b/internal/iam/access/cmd/grant_to_test.go new file mode 100644 index 00000000..cecf01d7 --- /dev/null +++ b/internal/iam/access/cmd/grant_to_test.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 access + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestRejectFlagsInToMode(t *testing.T) { + tests := []struct { + name string + setFlag string + setValue string + wantErr bool + wantHintSub string + }{ + {name: "no conflicting flags", wantErr: false}, + { + name: "access-level conflicts", + setFlag: "access-level", + setValue: "Admin", + wantErr: true, + wantHintSub: "kubectl edit", + }, + { + name: "port-forwarding conflicts with per-subject hint", + setFlag: "port-forwarding", + setValue: "true", + wantErr: true, + wantHintSub: "separate grant without --to", + }, + { + name: "allow-scale conflicts with shared-rule warning", + setFlag: "allow-scale", + setValue: "true", + wantErr: true, + wantHintSub: "affect every subject already on it", + }, + { + name: "dry-run is not supported", + setFlag: "dry-run", + setValue: "true", + wantErr: true, + wantHintSub: "--dry-run is not supported", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cmd := newGrantCommand() + if tc.setFlag != "" { + require.NoError(t, cmd.Flags().Set(tc.setFlag, tc.setValue)) + } + + err := rejectFlagsInToMode(cmd) + if !tc.wantErr { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), "not allowed with --to") + if tc.wantHintSub != "" { + assert.Contains(t, err.Error(), tc.wantHintSub) + } + }) + } +} + +func TestAddSubject(t *testing.T) { + build := func(subjects []any) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{"subjects": subjects}, + }, + } + } + + t.Run("adds to empty subjects", func(t *testing.T) { + obj := build(nil) + newSubs, added := addSubject(obj, "User", "alice@example.com") + assert.True(t, added) + assert.Len(t, newSubs, 1) + }) + + t.Run("appends when principal is new", func(t *testing.T) { + obj := build([]any{ + map[string]any{"kind": "User", "name": "bob@example.com"}, + }) + newSubs, added := addSubject(obj, "User", "alice@example.com") + assert.True(t, added) + assert.Len(t, newSubs, 2) + }) + + t.Run("is idempotent for exact duplicate", func(t *testing.T) { + obj := build([]any{ + map[string]any{"kind": "User", "name": "alice@example.com"}, + }) + newSubs, added := addSubject(obj, "User", "alice@example.com") + assert.False(t, added) + assert.Len(t, newSubs, 1) + }) + + t.Run("kind is part of identity", func(t *testing.T) { + // Group "alice" and User "alice" are not the same subject. + obj := build([]any{ + map[string]any{"kind": "Group", "name": "alice"}, + }) + newSubs, added := addSubject(obj, "User", "alice") + assert.True(t, added) + assert.Len(t, newSubs, 2) + }) +} diff --git a/internal/iam/access/cmd/list.go b/internal/iam/access/cmd/list.go new file mode 100644 index 00000000..499337a3 --- /dev/null +++ b/internal/iam/access/cmd/list.go @@ -0,0 +1,682 @@ +/* +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" + "io" + "sort" + "strings" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var listLong = templates.LongDesc(` +List effective access for users and groups. + +This is not a raw CR listing. It aggregates information from Users, Groups, +AuthorizationRules, and ClusterAuthorizationRules to show effective access. + +See the list of subcommands below or run "d8 iam access list SUBCOMMAND --help". + +© Flant JSC 2026`) + +func newListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list (users|groups|user |group )", + Short: "List effective access for users and groups", + Long: listLong, + } + + cmd.AddCommand( + newListUsersCommand(), + newListGroupsCommand(), + newListUserCommand(), + newListGroupCommand(), + ) + return cmd +} + +func newListUsersCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "users", + Short: "List all users with their effective access", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + outputFmt, _ := cmd.Flags().GetString("output") + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + inv, err := buildInventory(cmd.Context(), dyn) + if err != nil { + return err + } + + if outputFmt == "json" { + return printUsersJSON(cmd, inv) + } + + return printUsersTable(cmd, inv) + }, + } + cmd.Flags().StringP("output", "o", "table", "Output format: table|json") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json")) + return cmd +} + +func newListGroupsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "groups", + Short: "List all groups with their effective access", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + outputFmt, _ := cmd.Flags().GetString("output") + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + inv, err := buildInventory(cmd.Context(), dyn) + if err != nil { + return err + } + + if outputFmt == "json" { + return printGroupsJSON(cmd, inv) + } + + return printGroupsTable(cmd, inv) + }, + } + cmd.Flags().StringP("output", "o", "table", "Output format: table|json") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json")) + return cmd +} + +func newListUserCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "user ", + Short: "Show detailed access for a specific user", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return utilk8s.CompleteResourceNames(cmd, userGVR, "", toComplete) + }, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + userName := args[0] + outputFmt, _ := cmd.Flags().GetString("output") + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + inv, err := buildInventory(cmd.Context(), dyn) + if err != nil { + return err + } + + if _, ok := inv.Users[userName]; !ok { + return fmt.Errorf("user %q not found", userName) + } + + if outputFmt == "json" { + return printUserDetailJSON(cmd, inv, userName) + } + + return printUserDetail(cmd, inv, userName) + }, + } + cmd.Flags().StringP("output", "o", "table", "Output format: table|json") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json")) + return cmd +} + +func newListGroupCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "group ", + Short: "Show detailed access for a specific group", + Args: cobra.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return utilk8s.CompleteResourceNames(cmd, groupGVR, "", toComplete) + }, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + groupName := args[0] + outputFmt, _ := cmd.Flags().GetString("output") + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + inv, err := buildInventory(cmd.Context(), dyn) + if err != nil { + return err + } + + if _, ok := inv.GroupMembers[groupName]; !ok { + return fmt.Errorf("group %q not found", groupName) + } + + if outputFmt == "json" { + return printGroupDetailJSON(cmd, inv, groupName) + } + + return printGroupDetail(cmd, inv, groupName) + }, + } + cmd.Flags().StringP("output", "o", "table", "Output format: table|json") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json")) + 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 == "Group" { + 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) 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) + + return nil +} + +func printGroupDetail(cmd *cobra.Command, inv *accessInventory, groupName 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) + + return nil +} + +func printGrantToWriter(w io.Writer, g *normalizedGrant, via string) { + scope := g.ScopeType + if g.ScopeType == "namespace" && 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") + } +} + +// formatGrantSource renders the source CAR/AR reference as "Kind/Name" for +// cluster-scoped objects and "Kind/Namespace/Name" for namespaced ones. +// Used by text output in list, explain, and warning messages. +func formatGrantSource(g *normalizedGrant) string { + if g.SourceNamespace != "" { + return fmt.Sprintf("%s/%s/%s", g.SourceKind, g.SourceNamespace, g.SourceName) + } + return fmt.Sprintf("%s/%s", g.SourceKind, g.SourceName) +} + +// printEffectiveSummary renders the "cluster scope / namespaced scope / +// port-forwarding / allow-scale" block used by both "access list" and +// "access explain". The capability lines include the implicit-source note +// when the capability is inherited from the SuperAdmin wildcard rather than +// an explicit CAR 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)) +} + +// partitionMembersByKind splits Group.spec.members into user and nested-group +// name slices, preserving the original order in each bucket. +func partitionMembersByKind(members []memberRef) ([]string, []string) { + var users, groups []string + for _, m := range members { + if m.Kind == "Group" { + 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, _ := inv.ResolveUserGroups(userName) + for _, g := range directGroups { + if g == grantGroupName { + return g + } + } + // If not a direct group, find indirect path + _, transitiveGroups := inv.ResolveUserGroups(userName) + for _, g := range transitiveGroups { + if g == grantGroupName { + return g + " (transitive)" + } + } + return grantGroupName +} + +// --- JSON output --- + +type userAccessJSON struct { + Kind string `json:"kind"` + Subject struct { + Kind string `json:"kind"` + RefName string `json:"refName"` + Principal string `json:"principal"` + } `json:"subject"` + Groups struct { + Direct []string `json:"direct"` + Transitive []string `json:"transitive"` + } `json:"groups"` + DirectGrants []grantJSON `json:"directGrants"` + InheritedGrants []grantJSON `json:"inheritedGrants"` + Effective effectiveJSON `json:"effectiveSummary"` + Warnings []string `json:"warnings"` +} + +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"` +} + +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: 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 printUsersJSON(cmd *cobra.Command, inv *accessInventory) error { + items := make([]userAccessJSON, 0, len(inv.Users)) + users := make([]string, 0, len(inv.Users)) + for u := range inv.Users { + users = append(users, u) + } + sort.Strings(users) + + for _, userName := range users { + items = append(items, buildUserAccessJSON(inv, userName)) + } + + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil +} + +func printUserDetailJSON(cmd *cobra.Command, inv *accessInventory, userName string) error { + item := buildUserAccessJSON(inv, userName) + data, err := json.MarshalIndent(item, "", " ") + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil +} + +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) + + item := userAccessJSON{Kind: "AccessExplanation"} + item.Subject.Kind = "User" + item.Subject.RefName = userName + item.Subject.Principal = email + item.Groups.Direct = directGroups + item.Groups.Transitive = transitiveGroups + if item.Groups.Direct == nil { + item.Groups.Direct = []string{} + } + if item.Groups.Transitive == nil { + item.Groups.Transitive = []string{} + } + + for _, g := range directGrants { + item.DirectGrants = append(item.DirectGrants, grantToJSON(&g, "")) + } + for _, g := range inheritedGrants { + via := findViaGroup(inv, userName, g.SubjectPrincipal) + item.InheritedGrants = append(item.InheritedGrants, grantToJSON(&g, via)) + } + if item.DirectGrants == nil { + item.DirectGrants = []grantJSON{} + } + if item.InheritedGrants == nil { + item.InheritedGrants = []grantJSON{} + } + + item.Effective = summaryToJSON(summary) + item.Warnings = []string{} + return item +} + +func printGroupsJSON(cmd *cobra.Command, inv *accessInventory) error { + type groupJSON struct { + Name string `json:"name"` + Members int `json:"memberCount"` + Nested int `json:"nestedGroupCount"` + Grants int `json:"grantCount"` + Effective effectiveJSON `json:"effectiveSummary"` + } + + groups := make([]string, 0, len(inv.GroupMembers)) + for g := range inv.GroupMembers { + groups = append(groups, g) + } + sort.Strings(groups) + + items := make([]groupJSON, 0, len(groups)) + for _, gName := range groups { + members := inv.GroupMembers[gName] + userCount, nestedCount := 0, 0 + for _, m := range members { + if m.Kind == "Group" { + nestedCount++ + } else { + userCount++ + } + } + grants := inv.GroupGrants(gName) + summary := computeEffectiveSummary(grants) + items = append(items, groupJSON{ + Name: gName, + Members: userCount, + Nested: nestedCount, + Grants: len(grants), + Effective: summaryToJSON(summary), + }) + } + + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil +} + +func printGroupDetailJSON(cmd *cobra.Command, inv *accessInventory, groupName string) error { + members := inv.GroupMembers[groupName] + grants := inv.GroupGrants(groupName) + summary := computeEffectiveSummary(grants) + + type memberJSON struct { + Kind string `json:"kind"` + Name string `json:"name"` + } + + type detailJSON struct { + Name string `json:"name"` + Members []memberJSON `json:"members"` + Grants []grantJSON `json:"grants"` + Effective effectiveJSON `json:"effectiveSummary"` + } + + detail := detailJSON{Name: groupName, Effective: summaryToJSON(summary)} + for _, m := range members { + detail.Members = append(detail.Members, memberJSON(m)) + } + if detail.Members == nil { + detail.Members = []memberJSON{} + } + for _, g := range grants { + detail.Grants = append(detail.Grants, grantToJSON(&g, "")) + } + if detail.Grants == nil { + detail.Grants = []grantJSON{} + } + + data, err := json.MarshalIndent(detail, "", " ") + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return nil +} diff --git a/internal/iam/access/cmd/naming.go b/internal/iam/access/cmd/naming.go new file mode 100644 index 00000000..29cc8fb7 --- /dev/null +++ b/internal/iam/access/cmd/naming.go @@ -0,0 +1,103 @@ +/* +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" + "strings" + + "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(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. +func grantLabels(spec *canonicalGrantSpec) map[string]string { + return map[string]string{ + managedByLabel: managedByValue, + "deckhouse.io/access-model": "current", + "deckhouse.io/access-subject-kind": strings.ToLower(spec.SubjectKind), + "deckhouse.io/access-scope": 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{ + "deckhouse.io/access-subject-ref": spec.SubjectRef, + "deckhouse.io/access-subject-principal": spec.SubjectPrincipal, + "deckhouse.io/access-canonical-spec": jsonStr, + "deckhouse.io/access-created-by-version": 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 "namespace": + if len(spec.Namespaces) == 1 { + return spec.Namespaces[0] + } + return "multi-ns" + case "cluster": + return "cluster" + case "all-namespaces": + return "all" + 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..070ebeeb --- /dev/null +++ b/internal/iam/access/cmd/naming_test.go @@ -0,0 +1,148 @@ +/* +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-", + }, + } + + 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") + }) + } +} + +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/resolve.go b/internal/iam/access/cmd/resolve.go new file mode 100644 index 00000000..6f6c43cc --- /dev/null +++ b/internal/iam/access/cmd/resolve.go @@ -0,0 +1,425 @@ +/* +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" +) + +// 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(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 string + 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(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(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: fmt.Sprint(m["kind"]), + Name: fmt.Sprint(m["name"]), + }) + } + inv.GroupMembers[gName] = members + } + + // Fetch ClusterAuthorizationRules + carList, err := dyn.Resource(clusterAuthorizationRuleGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing ClusterAuthorizationRules: %w", err) + } + for _, car := range carList.Items { + grants := normalizeClusterAuthRule(&car) + inv.Grants = append(inv.Grants, grants...) + } + + // Fetch AuthorizationRules from all namespaces + arList, err := dyn.Resource(authorizationRuleGVR).Namespace("").List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing AuthorizationRules: %w", err) + } + for _, ar := range arList.Items { + grants := normalizeNamespacedAuthRule(&ar) + inv.Grants = append(inv.Grants, grants...) + } + + return inv, nil +} + +func normalizeClusterAuthRule(obj *unstructured.Unstructured) []normalizedGrant { + name := obj.GetName() + labels := obj.GetLabels() + managedByD8 := labels[managedByLabel] == managedByValue + + accessLevel, _, _ := unstructured.NestedString(obj.Object, "spec", "accessLevel") + allowScale, _, _ := unstructured.NestedBool(obj.Object, "spec", "allowScale") + portForwarding, _, _ := unstructured.NestedBool(obj.Object, "spec", "portForwarding") + + scopeType := "cluster" + matchAny, matchAnyFound, _ := unstructured.NestedBool(obj.Object, "spec", "namespaceSelector", "matchAny") + if matchAnyFound && matchAny { + scopeType = "all-namespaces" + } + + subjects, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") + var grants []normalizedGrant + for _, s := range subjects { + sub, ok := s.(map[string]any) + if !ok { + continue + } + kind := fmt.Sprint(sub["kind"]) + if kind == "ServiceAccount" { + continue + } + grants = append(grants, normalizedGrant{ + SourceKind: "ClusterAuthorizationRule", + SourceName: name, + SubjectKind: kind, + SubjectPrincipal: fmt.Sprint(sub["name"]), + AccessLevel: accessLevel, + AllowScale: allowScale, + PortForwarding: portForwarding, + ScopeType: scopeType, + ManagedByD8: managedByD8, + }) + } + return grants +} + +func normalizeNamespacedAuthRule(obj *unstructured.Unstructured) []normalizedGrant { + name := obj.GetName() + ns := obj.GetNamespace() + labels := obj.GetLabels() + managedByD8 := labels[managedByLabel] == managedByValue + + accessLevel, _, _ := unstructured.NestedString(obj.Object, "spec", "accessLevel") + allowScale, _, _ := unstructured.NestedBool(obj.Object, "spec", "allowScale") + portForwarding, _, _ := unstructured.NestedBool(obj.Object, "spec", "portForwarding") + + subjects, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") + var grants []normalizedGrant + for _, s := range subjects { + sub, ok := s.(map[string]any) + if !ok { + continue + } + kind := fmt.Sprint(sub["kind"]) + if kind == "ServiceAccount" { + continue + } + grants = append(grants, normalizedGrant{ + SourceKind: "AuthorizationRule", + SourceName: name, + SourceNamespace: ns, + SubjectKind: kind, + SubjectPrincipal: fmt.Sprint(sub["name"]), + AccessLevel: accessLevel, + AllowScale: allowScale, + PortForwarding: portForwarding, + ScopeType: "namespace", + ScopeNamespaces: []string{ns}, + ManagedByD8: managedByD8, + }) + } + 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 == "User" && 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 == "Group" && 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 == "User" && grant.SubjectPrincipal == email { + directGrants = append(directGrants, grant) + } else if grant.SubjectKind == "Group" && 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 == "Group" && 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 "cluster", "all-namespaces": + clusterLevels = append(clusterLevels, g.AccessLevel) + case "namespace": + 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 == "Group" { + 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..6d946e0b --- /dev/null +++ b/internal/iam/access/cmd/resolve_test.go @@ -0,0 +1,267 @@ +/* +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" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +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 TestNormalizeClusterAuthRule(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "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 := normalizeClusterAuthRule(obj) + assert.Len(t, grants, 2) // ServiceAccount filtered out + assert.Equal(t, "User", grants[0].SubjectKind) + assert.Equal(t, "anton@abc.com", grants[0].SubjectPrincipal) + assert.True(t, grants[0].ManagedByD8) + assert.Equal(t, "Group", grants[1].SubjectKind) +} + +func TestNormalizeClusterAuthRule_AllNamespaces(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "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 := normalizeClusterAuthRule(obj) + assert.Len(t, grants, 1) + assert.Equal(t, "all-namespaces", grants[0].ScopeType) +} diff --git a/internal/iam/access/cmd/revoke.go b/internal/iam/access/cmd/revoke.go new file mode 100644 index 00000000..85ba24a0 --- /dev/null +++ b/internal/iam/access/cmd/revoke.go @@ -0,0 +1,372 @@ +/* +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" + + "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/client-go/dynamic" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var revokeLong = templates.LongDesc(` +Revoke access from a user or group. + +There are two modes of operation: + +SIMPLE MODE (recommended for most users): + + Revokes access that was previously granted with "d8 iam access grant". + Uses the same flags to locate and delete the d8-managed object. + + d8 iam access revoke user NAME --access-level LEVEL [scope flags] + + Only d8-managed objects (label app.kubernetes.io/managed-by=d8-cli) + are affected. Manual objects are never modified in this mode. + +ADVANCED MODE (--from): + + Removes a subject from any existing authorization rule, even if it was + not created by d8. Useful for shared rules or manual cleanup. + + d8 iam access revoke --from ClusterAuthorizationRule/RULE user PRINCIPAL + d8 iam access revoke --from AuthorizationRule/NS/RULE group GROUPNAME + + PRINCIPAL is matched literally against spec.subjects[].name — use the + exact value from the rule (e.g. anton@example.com, not anton). No User + CR resolution is performed in this mode. + + The rule is patched, not deleted. Use --delete-empty to delete the rule + if no subjects remain after removal. + +© Flant JSC 2026`) + +var revokeExample = templates.Examples(` + # Simple mode: revoke a namespaced grant (mirrors the grant command) + d8 iam access revoke user anton --access-level Admin -n dev + + # Simple mode: revoke a cluster-scoped grant + d8 iam access revoke user anton --access-level ClusterAdmin --cluster + + # Simple mode: revoke from a group + d8 iam access revoke group admins --access-level Editor -n dev + + # Advanced mode: remove a user subject from a shared cluster rule (literal principal) + d8 iam access revoke --from ClusterAuthorizationRule/my-rule user anton@example.com + + # Advanced mode: remove a group subject and delete the rule if it becomes empty + d8 iam access revoke --from AuthorizationRule/dev/my-rule group admins --delete-empty`) + +func newRevokeCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "revoke (user|group) NAME [--access-level LEVEL scope | --from SOURCE]", + Short: "Revoke access from a user or group (current authz model)", + Long: revokeLong, + Example: revokeExample, + ValidArgsFunction: completeSubjectAndName, + SilenceErrors: true, + SilenceUsage: true, + RunE: runRevoke, + } + + cmd.Flags().String("access-level", "", "Access level to revoke (for intent-based revoke)") + cmd.Flags().StringSliceP("namespace", "n", nil, "Target namespace(s) (for intent-based revoke)") + cmd.Flags().Bool("cluster", false, "Cluster-scoped revoke") + cmd.Flags().Bool("all-namespaces", false, "All-namespaces scoped revoke") + 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.Flags().String("from", "", "Source object to revoke from: AuthorizationRule// or ClusterAuthorizationRule/") + cmd.Flags().Bool("delete-empty", false, "Delete the source object if subjects becomes empty (only with --from)") + + _ = cmd.RegisterFlagCompletionFunc("access-level", completeAccessLevels) + _ = cmd.RegisterFlagCompletionFunc("namespace", completeNamespacesFlag) + _ = cmd.RegisterFlagCompletionFunc("from", completeRuleRef) + + return cmd +} + +func runRevoke(cmd *cobra.Command, args []string) error { + fromFlag, _ := cmd.Flags().GetString("from") + + if fromFlag != "" { + return runSourceBasedRevoke(cmd, args) + } + return runIntentBasedRevoke(cmd, args) +} + +func runIntentBasedRevoke(cmd *cobra.Command, args []string) error { + if len(args) != 2 { + return fmt.Errorf("intent-based revoke requires: revoke (user|group) --access-level [scope]") + } + + subjectKindStr := args[0] + subjectName := args[1] + accessLevel, _ := cmd.Flags().GetString("access-level") + namespaces, _ := cmd.Flags().GetStringSlice("namespace") + clusterFlag, _ := cmd.Flags().GetBool("cluster") + allNSFlag, _ := cmd.Flags().GetBool("all-namespaces") + portForwarding, _ := cmd.Flags().GetBool("port-forwarding") + allowScale, _ := cmd.Flags().GetBool("allow-scale") + + if accessLevel == "" { + return fmt.Errorf("--access-level is required for intent-based revoke") + } + + subjectKind, err := parseSubjectKind(subjectKindStr) + if err != nil { + return err + } + + scopeType, err := parseScopeFlags(namespaces, clusterFlag, allNSFlag) + if err != nil { + return err + } + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + var subjectPrincipal string + switch subjectKind { + case "User": + subjectPrincipal, err = resolveUserEmail(cmd.Context(), dyn, subjectName) + if err != nil { + return err + } + case "Group": + subjectPrincipal = subjectName + } + + switch scopeType { + case "namespace": + return revokeNamespaced(cmd, dyn, subjectKind, subjectName, subjectPrincipal, accessLevel, namespaces, allowScale, portForwarding) + case "cluster", "all-namespaces": + return revokeCluster(cmd, dyn, subjectKind, subjectName, subjectPrincipal, accessLevel, scopeType, allowScale, portForwarding) + } + return nil +} + +func revokeNamespaced(cmd *cobra.Command, dyn dynamic.Interface, + subjectKind, subjectRef, subjectPrincipal, accessLevel string, + namespaces []string, allowScale, portForwarding bool) error { + var errs *multierror.Error + for _, ns := range namespaces { + spec := &canonicalGrantSpec{ + Model: "current", + SubjectKind: subjectKind, + SubjectRef: subjectRef, + SubjectPrincipal: subjectPrincipal, + AccessLevel: accessLevel, + ScopeType: "namespace", + Namespaces: []string{ns}, + AllowScale: allowScale, + PortForwarding: portForwarding, + } + + name, err := generateGrantName(spec) + if err != nil { + return err + } + + client := dyn.Resource(authorizationRuleGVR).Namespace(ns) + obj, err := client.Get(cmd.Context(), name, metav1.GetOptions{}) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: d8-managed AuthorizationRule %q not found in namespace %q\n", name, ns) + errs = multierror.Append(errs, fmt.Errorf("namespace %s: %w", ns, err)) + continue + } + + labels := obj.GetLabels() + if labels[managedByLabel] != managedByValue { + return fmt.Errorf("AuthorizationRule %q in namespace %q is not managed by d8-cli; use --from to revoke from it explicitly", name, ns) + } + + err = client.Delete(cmd.Context(), name, metav1.DeleteOptions{}) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to delete AuthorizationRule %q in %q: %v\n", name, ns, err) + errs = multierror.Append(errs, fmt.Errorf("namespace %s: %w", ns, err)) + continue + } + cmd.Printf("Revoked: AuthorizationRule/%s/%s\n", ns, name) + } + + return errs.ErrorOrNil() +} + +func revokeCluster(cmd *cobra.Command, dyn dynamic.Interface, + subjectKind, subjectRef, subjectPrincipal, accessLevel, scopeType string, + allowScale, portForwarding bool) error { + spec := &canonicalGrantSpec{ + Model: "current", + SubjectKind: subjectKind, + SubjectRef: subjectRef, + SubjectPrincipal: subjectPrincipal, + AccessLevel: accessLevel, + ScopeType: scopeType, + AllowScale: allowScale, + PortForwarding: portForwarding, + } + + name, err := generateGrantName(spec) + if err != nil { + return err + } + + client := dyn.Resource(clusterAuthorizationRuleGVR) + obj, err := client.Get(cmd.Context(), name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("d8-managed ClusterAuthorizationRule %q not found: %w", name, err) + } + + labels := obj.GetLabels() + if labels[managedByLabel] != managedByValue { + return fmt.Errorf("ClusterAuthorizationRule %q is not managed by d8-cli; use --from to revoke explicitly", name) + } + + err = client.Delete(cmd.Context(), name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleting ClusterAuthorizationRule %q: %w", name, err) + } + + cmd.Printf("Revoked: ClusterAuthorizationRule/%s\n", name) + return nil +} + +func runSourceBasedRevoke(cmd *cobra.Command, args []string) error { + fromFlag, _ := cmd.Flags().GetString("from") + deleteEmpty, _ := cmd.Flags().GetBool("delete-empty") + + if len(args) != 2 { + return fmt.Errorf("source-based revoke requires: revoke --from (user|group) ") + } + subjectKind, err := parseSubjectKind(args[0]) + if err != nil { + return err + } + // Principal is used literally — it must match spec.subjects[].name exactly. + // No User CR lookup here, because --from targets rules that may reference + // external identities or non-d8-managed subjects. + subjectName := args[1] + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + refKind, refNS, refName, err := parseRuleRef(fromFlag) + if err != nil { + return fmt.Errorf("invalid --from: %w", err) + } + switch refKind { + case "ClusterAuthorizationRule": + return revokeSubjectFromClusterRule(cmd, dyn, refName, subjectKind, subjectName, deleteEmpty) + case "AuthorizationRule": + return revokeSubjectFromNamespacedRule(cmd, dyn, refNS, refName, subjectKind, subjectName, deleteEmpty) + default: + return fmt.Errorf("unsupported --from kind %q", refKind) + } +} + +func revokeSubjectFromClusterRule(cmd *cobra.Command, dyn dynamic.Interface, ruleName, subjectKind, subjectPrincipal string, deleteEmpty bool) error { + client := dyn.Resource(clusterAuthorizationRuleGVR) + obj, err := client.Get(cmd.Context(), ruleName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("getting ClusterAuthorizationRule %q: %w", ruleName, err) + } + + newSubjects, removed := removeSubject(obj, subjectKind, subjectPrincipal) + if !removed { + return fmt.Errorf("subject %s/%s not found in ClusterAuthorizationRule %q", subjectKind, subjectPrincipal, ruleName) + } + + if len(newSubjects) == 0 && deleteEmpty { + err = client.Delete(cmd.Context(), ruleName, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleting empty ClusterAuthorizationRule %q: %w", ruleName, err) + } + cmd.Printf("Deleted empty ClusterAuthorizationRule/%s\n", ruleName) + return nil + } + + if err := unstructured.SetNestedSlice(obj.Object, newSubjects, "spec", "subjects"); err != nil { + return fmt.Errorf("setting subjects: %w", err) + } + _, err = client.Update(cmd.Context(), obj, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("updating ClusterAuthorizationRule %q: %w", ruleName, err) + } + cmd.Printf("Removed subject %s/%s from ClusterAuthorizationRule/%s\n", subjectKind, subjectPrincipal, ruleName) + return nil +} + +func revokeSubjectFromNamespacedRule(cmd *cobra.Command, dyn dynamic.Interface, ns, ruleName, subjectKind, subjectPrincipal string, deleteEmpty bool) error { + client := dyn.Resource(authorizationRuleGVR).Namespace(ns) + obj, err := client.Get(cmd.Context(), ruleName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("getting AuthorizationRule %q in namespace %q: %w", ruleName, ns, err) + } + + newSubjects, removed := removeSubject(obj, subjectKind, subjectPrincipal) + if !removed { + return fmt.Errorf("subject %s/%s not found in AuthorizationRule %s/%s", subjectKind, subjectPrincipal, ns, ruleName) + } + + if len(newSubjects) == 0 && deleteEmpty { + err = client.Delete(cmd.Context(), ruleName, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleting empty AuthorizationRule %s/%s: %w", ns, ruleName, err) + } + cmd.Printf("Deleted empty AuthorizationRule/%s/%s\n", ns, ruleName) + return nil + } + + if err := unstructured.SetNestedSlice(obj.Object, newSubjects, "spec", "subjects"); err != nil { + return fmt.Errorf("setting subjects: %w", err) + } + _, err = client.Update(cmd.Context(), obj, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("updating AuthorizationRule %s/%s: %w", ns, ruleName, err) + } + cmd.Printf("Removed subject %s/%s from AuthorizationRule/%s/%s\n", subjectKind, subjectPrincipal, ns, ruleName) + return nil +} + +func removeSubject(obj *unstructured.Unstructured, kind, name string) ([]any, bool) { + subjects, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") + var newSubjects []any + removed := false + for _, s := range subjects { + sub, ok := s.(map[string]any) + if !ok { + newSubjects = append(newSubjects, s) + continue + } + if fmt.Sprint(sub["kind"]) == kind && fmt.Sprint(sub["name"]) == name { + removed = true + continue + } + newSubjects = append(newSubjects, s) + } + return newSubjects, removed +} diff --git a/internal/iam/access/cmd/rules.go b/internal/iam/access/cmd/rules.go new file mode 100644 index 00000000..6f67f070 --- /dev/null +++ b/internal/iam/access/cmd/rules.go @@ -0,0 +1,685 @@ +/* +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" + "encoding/json" + "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/cli-runtime/pkg/printers" + "k8s.io/client-go/dynamic" + "k8s.io/kubectl/pkg/util/templates" + sigsyaml "sigs.k8s.io/yaml" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +// ruleRow is a uniform view over a CAR or an AR for list/get rendering. +type ruleRow struct { + Kind string // ClusterAuthorizationRule | AuthorizationRule + Name string + Namespace string // empty for CAR + AccessLevel string + ScopeType string // cluster | all-namespaces | namespace + 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 string // User | Group | ServiceAccount | ... + Name string +} + +func (r ruleRow) ref() string { + if r.Namespace == "" { + return r.Kind + "/" + r.Name + } + return r.Kind + "/" + r.Namespace + "/" + r.Name +} + +// ------------------------------ cobra ------------------------------ + +var rulesLong = templates.LongDesc(` +Inspect the raw authorization rules (ClusterAuthorizationRule and +AuthorizationRule) that back "d8 iam access grant/revoke/explain". + +Unlike "d8 iam access list users|groups" (which aggregates effective access +per subject), "d8 iam access rules list" shows the rules themselves — d8-managed +and manual alike — in a single view so you can see "what objects exist and +what they give". + +Use "d8 iam access rules get REF" for a detailed human view of one rule, with +a reverse lookup from spec.subjects to local User/Group CRs. + +© Flant JSC 2026`) + +func newRulesCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "rules", + Short: "Inspect raw ClusterAuthorizationRule / AuthorizationRule objects", + Long: rulesLong, + } + cmd.AddCommand(newRulesListCommand(), newRulesGetCommand()) + return cmd +} + +// ------------------------------ list ------------------------------ + +var rulesListExample = templates.Examples(` + # All rules in the cluster (both CARs and every AR across namespaces) + d8 iam access rules list + + # Only ClusterAuthorizationRules + d8 iam access rules list --cluster + + # Only AuthorizationRules from selected namespaces + d8 iam access rules list -n dev -n stage + + # Only rules created by "d8 iam access grant" + d8 iam access rules list --managed-only + + # Only rules NOT created by d8-cli + d8 iam access rules list --manual-only + + # Machine-readable + d8 iam access rules list -o json + d8 iam access rules list -o yaml`) + +func newRulesListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List ClusterAuthorizationRules and AuthorizationRules", + Example: rulesListExample, + 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") + cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") + + _ = cmd.RegisterFlagCompletionFunc("namespace", completeNamespacesFlag) + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) + + 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 fmt.Errorf("--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": + return printRuleRowsJSON(cmd.OutOrStdout(), rows) + case "yaml": + return printRuleRowsYAML(cmd.OutOrStdout(), rows) + case "table", "": + return printRuleRowsTable(cmd.OutOrStdout(), rows) + default: + return fmt.Errorf("unsupported output format %q; use table|json|yaml", outputFmt) + } +} + +// ------------------------------ get ------------------------------ + +var rulesGetExample = templates.Examples(` + # Get a ClusterAuthorizationRule (full prefix or short) + d8 iam access rules get ClusterAuthorizationRule/superadmins + d8 iam access rules get CAR/superadmins + + # Get an AuthorizationRule (namespace is part of the reference) + d8 iam access rules get AuthorizationRule/dev/editors + d8 iam access rules get AR/dev/editors + + # Machine-readable + d8 iam access rules get CAR/superadmins -o yaml + d8 iam access rules get AR/dev/editors -o json`) + +func newRulesGetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "get REF", + Short: "Show a single CAR or AR with subjects, scope and reverse CR lookup", + Example: rulesGetExample, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeRuleRef, + SilenceErrors: true, + SilenceUsage: true, + RunE: runRulesGet, + } + cmd.Flags().StringP("output", "o", "text", "Output format: text|json|yaml") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("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 "ClusterAuthorizationRule": + obj, err = dyn.Resource(clusterAuthorizationRuleGVR).Get(cmd.Context(), refName, metav1.GetOptions{}) + case "AuthorizationRule": + obj, err = dyn.Resource(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": + 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("unsupported output format %q; use text|json|yaml", 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(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(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() + scopeType := "" + var scopeNamespaces []string + switch kind { + case "ClusterAuthorizationRule": + scopeType = "cluster" + matchAny, found, _ := unstructured.NestedBool(obj.Object, "spec", "namespaceSelector", "matchAny") + if found && matchAny { + scopeType = "all-namespaces" + } + case "AuthorizationRule": + scopeType = "namespace" + scopeNamespaces = []string{ns} + } + + var subjects []subjectRef + raw, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") + for _, s := range raw { + m, ok := s.(map[string]any) + if !ok { + continue + } + subjects = append(subjects, subjectRef{ + Kind: fmt.Sprint(m["kind"]), + Name: fmt.Sprint(m["name"]), + }) + } + + return ruleRow{ + Kind: kind, + Name: obj.GetName(), + Namespace: ns, + AccessLevel: accessLevel, + ScopeType: scopeType, + ScopeNamespaces: scopeNamespaces, + AllowScale: allowScale, + PortForwarding: portForwarding, + ManagedByD8: labels[managedByLabel] == managedByValue, + 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 "ClusterAuthorizationRule": + return "CAR" + case "AuthorizationRule": + return "AR" + default: + return kind + } +} + +func managedByColumn(r ruleRow) string { + if r.ManagedByD8 { + return "d8-cli" + } + 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] + "…" +} + +func humanAge(t time.Time) string { + if t.IsZero() { + return "-" + } + d := time.Since(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + +// ------------------------------ 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) + } + fmt.Fprintf(w, " Access level: %s\n", firstNonEmpty(r.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 := 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 +} + +func firstNonEmpty(v, fallback string) string { + if v == "" { + return fallback + } + return v +} + +// ------------------------------ rendering: json/yaml ------------------------------ + +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 { + out := ruleJSON{ + Kind: r.Kind, + Name: r.Name, + Namespace: r.Namespace, + AccessLevel: r.AccessLevel, + Scope: r.ScopeType, + AllowScale: r.AllowScale, + PortForwarding: r.PortForwarding, + ManagedByD8: r.ManagedByD8, + CreationTime: r.CreationTime, + } + for _, s := range r.Subjects { + out.Subjects = append(out.Subjects, subjectJSON(s)) + } + if out.Subjects == nil { + out.Subjects = []subjectJSON{} + } + return out +} + +func printRuleRowsJSON(w io.Writer, rows []ruleRow) error { + items := make([]ruleJSON, 0, len(rows)) + for _, r := range rows { + items = append(items, ruleRowToJSON(r)) + } + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + fmt.Fprintln(w, string(data)) + return nil +} + +func printRuleRowsYAML(w io.Writer, rows []ruleRow) error { + // 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 "rules get -o yaml" for that). + items := make([]ruleJSON, 0, len(rows)) + for _, r := range rows { + items = append(items, ruleRowToJSON(r)) + } + data, err := json.Marshal(items) + if err != nil { + return err + } + // sigs.k8s.io/yaml keeps formatting consistent with utilk8s.PrintObject. + yamlBytes, err := sigsyaml.JSONToYAML(data) + if err != nil { + return err + } + fmt.Fprint(w, string(yamlBytes)) + return nil +} + +// ------------------------------ 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("invalid rule reference %q; expected ClusterAuthorizationRule/NAME or AuthorizationRule/NS/NAME (short forms CAR/... and AR/... also accepted)", ref) + } + + switch parts[0] { + case "ClusterAuthorizationRule", "CAR", "car": + if len(parts) != 2 { + return "", "", "", fmt.Errorf("ClusterAuthorizationRule reference must be of the form ClusterAuthorizationRule/NAME (got %q)", ref) + } + return "ClusterAuthorizationRule", "", parts[1], nil + case "AuthorizationRule", "AR", "ar": + if len(parts) != 3 { + return "", "", "", fmt.Errorf("AuthorizationRule reference must be of the form AuthorizationRule/NAMESPACE/NAME (got %q)", ref) + } + return "AuthorizationRule", parts[1], parts[2], nil + default: + return "", "", "", fmt.Errorf("unknown rule kind %q; use ClusterAuthorizationRule, AuthorizationRule, CAR or AR", 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 "User": + needUsers = true + case "Group": + needGroups = true + } + } + + if needUsers { + userList, err := dyn.Resource(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 != "User" { + continue + } + if cr, ok := emailToCR[s.Name]; ok { + result["User/"+s.Name] = cr + } + } + } + + if needGroups { + groupList, err := dyn.Resource(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 != "Group" { + continue + } + if present[s.Name] { + result["Group/"+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..e6ea305f --- /dev/null +++ b/internal/iam/access/cmd/rules_test.go @@ -0,0 +1,251 @@ +/* +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" + "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" +) + +func TestParseRuleRef(t *testing.T) { + tests := []struct { + input string + wantKind string + wantNS string + wantName string + wantErr bool + errContains string + }{ + {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, errContains: "invalid rule reference"}, + {input: "CAR/dev/editors", wantErr: true, errContains: "must be of the form ClusterAuthorizationRule/NAME"}, + {input: "AR/editors", wantErr: true, errContains: "must be of the form AuthorizationRule/NAMESPACE/NAME"}, + {input: "Role/dev/x", wantErr: true, errContains: "unknown rule kind"}, + } + 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) + if tc.errContains != "" { + assert.Contains(t, err.Error(), tc.errContains) + } + 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{managedByLabel: managedByValue}, + }, + "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, "all-namespaces", 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, "namespace", 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)) +} + +func TestHumanAge(t *testing.T) { + // Zero time returns a placeholder. + assert.Equal(t, "-", humanAge(time.Time{})) + + assert.Regexp(t, `^\d+s$`, humanAge(time.Now().Add(-10*time.Second))) + assert.Regexp(t, `^\d+m$`, humanAge(time.Now().Add(-10*time.Minute))) + assert.Regexp(t, `^\d+h$`, humanAge(time.Now().Add(-3*time.Hour))) + assert.Regexp(t, `^\d+d$`, humanAge(time.Now().Add(-48*time.Hour))) +} diff --git a/internal/iam/access/cmd/types.go b/internal/iam/access/cmd/types.go new file mode 100644 index 00000000..e46da5d8 --- /dev/null +++ b/internal/iam/access/cmd/types.go @@ -0,0 +1,138 @@ +/* +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" + + "k8s.io/apimachinery/pkg/runtime/schema" +) + +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", + } +) + +// accessLevelOrder defines the hierarchy: higher index = more privileged. +var accessLevelOrder = map[string]int{ + "User": 0, + "PrivilegedUser": 1, + "Editor": 2, + "Admin": 3, + "ClusterEditor": 4, + "ClusterAdmin": 5, + "SuperAdmin": 6, +} + +var namespacedAccessLevels = map[string]bool{ + "User": true, "PrivilegedUser": true, "Editor": true, "Admin": true, +} + +var allAccessLevels = map[string]bool{ + "User": true, "PrivilegedUser": true, "Editor": true, "Admin": true, + "ClusterEditor": true, "ClusterAdmin": true, "SuperAdmin": true, +} + +const managedByLabel = "app.kubernetes.io/managed-by" +const managedByValue = "d8-cli" + +// canonicalGrantSpec is the canonical representation of a grant for hashing and comparison. +type canonicalGrantSpec struct { + Model string `json:"model"` + SubjectKind string `json:"subjectKind"` + SubjectRef string `json:"subjectRef"` + SubjectPrincipal string `json:"subjectPrincipal"` + AccessLevel string `json:"accessLevel"` + ScopeType string `json:"scopeType"` + Namespaces []string `json:"namespaces,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 + } + 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 + SourceName string + SourceNamespace string // only for AuthorizationRule + + SubjectKind string // User or Group + SubjectPrincipal string // email for users, name for groups + + AccessLevel string + AllowScale bool + PortForwarding bool + + ScopeType string // namespace, cluster, all-namespaces + ScopeNamespaces []string // for namespace scope + + ManagedByD8 bool +} + +func validateAccessLevel(level string, namespaced bool) error { + if namespaced { + if !namespacedAccessLevels[level] { + valid := []string{"User", "PrivilegedUser", "Editor", "Admin"} + return fmt.Errorf("access level %q is not valid for namespaced scope; valid levels: %s", level, strings.Join(valid, ", ")) + } + } else { + if !allAccessLevels[level] { + valid := []string{"User", "PrivilegedUser", "Editor", "Admin", "ClusterEditor", "ClusterAdmin", "SuperAdmin"} + return fmt.Errorf("invalid access level %q; valid levels: %s", level, strings.Join(valid, ", ")) + } + } + 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 +} diff --git a/internal/iam/cmd/iam.go b/internal/iam/cmd/iam.go new file mode 100644 index 00000000..b6a62e85 --- /dev/null +++ b/internal/iam/cmd/iam.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 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" + 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 — manage local static users (user-authn Dex). + group — manage local groups and their membership (user-authn). + access — grant, revoke, list, and explain permissions backed by + AuthorizationRule and ClusterAuthorizationRule CRs (user-authz). + +Each subcommand accepts the standard --kubeconfig / --context flags +(short: -k / --context) inherited from its own persistent flag set. + +© Flant JSC 2026`) + +// NewCommand returns the "d8 iam" parent command that composes the user, +// group, and access subcommands under a single namespace. +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(), + ) + + 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..5ca8a017 --- /dev/null +++ b/internal/iam/group/cmd/add_member.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 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" + + "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 + } + + ctx := cmd.Context() + groupClient := dyn.Resource(groupGVR) + + obj, err := groupClient.Get(ctx, groupName, metav1.GetOptions{}) + if err != nil { + if !createGroup { + return fmt.Errorf("group %q not found (use --create-group to create it): %w", groupName, err) + } + obj = buildGroupObject(groupName) + obj, err = groupClient.Create(ctx, obj, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("creating group %q: %w", groupName, err) + } + fmt.Fprintf(cmd.ErrOrStderr(), "Group %q created\n", groupName) + } + + // Cycle detection for group->group membership + if memberKind == "Group" { + hasCycle, cyclePath, err := detectCycle(cmd, dyn, groupName, memberName) + if err != nil { + return fmt.Errorf("cycle detection failed: %w", err) + } + if hasCycle { + return fmt.Errorf("adding group %q to %q would create a cycle: %s", memberName, groupName, strings.Join(cyclePath, " -> ")) + } + } + + members, _ := getGroupMembers(obj) + for _, m := range members { + if fmt.Sprint(m["kind"]) == memberKind && fmt.Sprint(m["name"]) == memberName { + cmd.Printf("Member %s/%s already exists in group %s\n", memberKind, memberName, groupName) + return nil + } + } + + rawMembers, _, _ := unstructured.NestedSlice(obj.Object, "spec", "members") + rawMembers = append(rawMembers, map[string]any{ + "kind": memberKind, + "name": memberName, + }) + if err := unstructured.SetNestedSlice(obj.Object, rawMembers, "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("Added %s %q to group %q\n", strings.ToLower(memberKind), memberName, groupName) + return nil +} + +func normalizeMemberKind(kind string) (string, error) { + switch strings.ToLower(kind) { + case "user": + return "User", nil + case "group": + return "Group", 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 "", "", "", fmt.Errorf("expected GROUP MEMBER or GROUP (user|group) MEMBER, got %d arguments", len(args)) + } +} diff --git a/internal/iam/group/cmd/completion.go b/internal/iam/group/cmd/completion.go new file mode 100644 index 00000000..d71b8bed --- /dev/null +++ b/internal/iam/group/cmd/completion.go @@ -0,0 +1,61 @@ +/* +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/apimachinery/pkg/runtime/schema" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var userGVRForCompletion = schema.GroupVersionResource{ + Group: "deckhouse.io", + Version: "v1", + Resource: "users", +} + +// 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, 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, groupGVR, "", toComplete) + case 1: + return utilk8s.FilterByPrefix(memberKinds, toComplete), cobra.ShellCompDirectiveNoFileComp + case 2: + switch args[1] { + case "user": + return utilk8s.CompleteResourceNames(cmd, userGVRForCompletion, "", toComplete) + case "group": + return utilk8s.CompleteResourceNames(cmd, 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..ffa31dc0 --- /dev/null +++ b/internal/iam/group/cmd/create.go @@ -0,0 +1,90 @@ +/* +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" + + "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(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") + cmd.Flags().StringP("output", "o", "name", "Output format: name|yaml|json") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("name", "yaml", "json")) + return cmd +} + +func buildGroupObject(name string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "deckhouse.io/v1alpha1", + "kind": "Group", + "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..e87a1690 --- /dev/null +++ b/internal/iam/group/cmd/delete.go @@ -0,0 +1,53 @@ +/* +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" + + "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(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/get.go b/internal/iam/group/cmd/get.go new file mode 100644 index 00000000..514a95cc --- /dev/null +++ b/internal/iam/group/cmd/get.go @@ -0,0 +1,172 @@ +/* +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/cli-runtime/pkg/printers" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +func newGetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get details of a local group", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeGroupOnly, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + outputFmt, _ := cmd.Flags().GetString("output") + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + obj, err := dyn.Resource(groupGVR).Get(cmd.Context(), name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("getting Group %q: %w", name, err) + } + + switch outputFmt { + case "json", "yaml": + return utilk8s.PrintObject(cmd.OutOrStdout(), obj, outputFmt) + default: + return printGroupDetail(cmd, obj) + } + }, + } + + cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) + return cmd +} + +func printGroupDetail(cmd *cobra.Command, obj *unstructured.Unstructured) error { + w := cmd.OutOrStdout() + name := obj.GetName() + specName, _, _ := unstructured.NestedString(obj.Object, "spec", "name") + fmt.Fprintf(w, "Group: %s\n", name) + if specName != "" && specName != name { + fmt.Fprintf(w, "Display name: %s\n", specName) + } + + members, _ := getGroupMembers(obj) + var users, groups []string + for _, m := range members { + kind := fmt.Sprint(m["kind"]) + mName := fmt.Sprint(m["name"]) + switch kind { + case "Group": + groups = append(groups, mName) + default: + users = append(users, mName) + } + } + + fmt.Fprintf(w, "\nUser members (%d):\n", len(users)) + if len(users) == 0 { + fmt.Fprintln(w, " ") + } + for _, u := range users { + fmt.Fprintf(w, " - %s\n", u) + } + + fmt.Fprintf(w, "\nNested groups (%d):\n", len(groups)) + if len(groups) == 0 { + fmt.Fprintln(w, " ") + } + for _, g := range groups { + fmt.Fprintf(w, " - %s\n", g) + } + + // Show status errors if present (try both spec.status.errors and status.errors) + errors := getStatusErrors(obj) + if len(errors) > 0 { + fmt.Fprintf(w, "\nStatus errors (%d):\n", len(errors)) + for _, e := range errors { + fmt.Fprintf(w, " - %s\n", e) + } + } + + fmt.Fprintln(w, "\nTip: run \"d8 iam access explain group "+name+"\" to see effective grants.") + return nil +} + +func getStatusErrors(obj *unstructured.Unstructured) []string { + var result []string + + // Try spec.status.errors (as per CRD schema) + paths := [][]string{ + {"spec", "status", "errors"}, + {"status", "errors"}, + } + for _, path := range paths { + errs, found, _ := unstructured.NestedSlice(obj.Object, path...) + if !found { + continue + } + for _, e := range errs { + em, ok := e.(map[string]any) + if !ok { + continue + } + msg := fmt.Sprint(em["message"]) + if ref, ok := em["objectRef"].(map[string]any); ok { + msg = fmt.Sprintf("[%s/%s] %s", ref["kind"], ref["name"], msg) + } + result = append(result, msg) + } + if len(result) > 0 { + return result + } + } + return result +} + +func printGroupTable(cmd *cobra.Command, groups []*unstructured.Unstructured) error { + tw := printers.GetNewTabWriter(cmd.OutOrStdout()) + fmt.Fprintln(tw, "GROUP\tMEMBERS\tNESTED\tERRORS") + for _, g := range groups { + name := g.GetName() + members, _ := getGroupMembers(g) + userCount := 0 + nestedCount := 0 + for _, m := range members { + if fmt.Sprint(m["kind"]) == "Group" { + nestedCount++ + } else { + userCount++ + } + } + errorCount := len(getStatusErrors(g)) + errStr := "0" + if errorCount > 0 { + errStr = fmt.Sprintf("%d", errorCount) + } + + fmt.Fprintf(tw, "%s\t%d\t%d\t%s\n", name, userCount, nestedCount, errStr) + } + return tw.Flush() +} diff --git a/internal/iam/group/cmd/group.go b/internal/iam/group/cmd/group.go new file mode 100644 index 00000000..145f6cdd --- /dev/null +++ b/internal/iam/group/cmd/group.go @@ -0,0 +1,56 @@ +/* +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, Get, List, and membership management via add-member / remove-member. + +© 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(), + newGetCommand(), + newListCommand(), + 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..099af10f --- /dev/null +++ b/internal/iam/group/cmd/group_test.go @@ -0,0 +1,207 @@ +/* +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 ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +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 string + wantErr string + }{ + {input: "user", want: "User"}, + {input: "User", want: "User"}, + {input: "USER", want: "User"}, + {input: "group", want: "Group"}, + {input: "Group", want: "Group"}, + {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) + }) +} + +func TestGetStatusErrors(t *testing.T) { + t.Run("errors under spec.status.errors", func(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "status": map[string]any{ + "errors": []any{ + map[string]any{ + "message": "user not found", + "objectRef": map[string]any{ + "kind": "User", + "name": "deleted-user", + }, + }, + }, + }, + }, + }, + } + errors := getStatusErrors(obj) + require.Len(t, errors, 1) + assert.Contains(t, errors[0], "user not found") + assert.Contains(t, errors[0], "User/deleted-user") + }) + + t.Run("no errors", func(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{}, + }, + } + errors := getStatusErrors(obj) + assert.Empty(t, errors) + }) +} + +func TestPrintGroupDetail(t *testing.T) { + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "deckhouse.io/v1alpha1", + "kind": "Group", + "metadata": map[string]any{ + "name": "admins", + }, + "spec": map[string]any{ + "name": "admins", + "members": []any{ + map[string]any{"kind": "User", "name": "anton"}, + map[string]any{"kind": "Group", "name": "devs"}, + }, + }, + }, + } + + var buf strings.Builder + cmd := NewCommand() + cmd.SetOut(&buf) + err := printGroupDetail(cmd, obj) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "Group: admins") + assert.Contains(t, output, "anton") + assert.Contains(t, output, "devs") + assert.Contains(t, output, "User members (1)") + assert.Contains(t, output, "Nested groups (1)") +} diff --git a/internal/iam/group/cmd/list.go b/internal/iam/group/cmd/list.go new file mode 100644 index 00000000..4c64a6cf --- /dev/null +++ b/internal/iam/group/cmd/list.go @@ -0,0 +1,83 @@ +/* +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 ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + sigsyaml "sigs.k8s.io/yaml" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +func newListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all local groups", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + outputFmt, _ := cmd.Flags().GetString("output") + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + result, err := dyn.Resource(groupGVR).List(cmd.Context(), metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("listing Groups: %w", err) + } + + if len(result.Items) == 0 { + cmd.Println("No groups found") + return nil + } + + switch outputFmt { + case "json": + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("marshalling JSON: %w", err) + } + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + case "yaml": + data, err := sigsyaml.Marshal(result.UnstructuredContent()) + if err != nil { + return fmt.Errorf("marshalling YAML: %w", err) + } + fmt.Fprint(cmd.OutOrStdout(), string(data)) + default: + groups := make([]*unstructured.Unstructured, 0, len(result.Items)) + for i := range result.Items { + groups = append(groups, &result.Items[i]) + } + return printGroupTable(cmd, groups) + } + return nil + }, + } + + cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) + return cmd +} diff --git a/internal/iam/group/cmd/remove_member.go b/internal/iam/group/cmd/remove_member.go new file mode 100644 index 00000000..6117d466 --- /dev/null +++ b/internal/iam/group/cmd/remove_member.go @@ -0,0 +1,110 @@ +/* +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" + + "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(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") + 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"]) == memberKind && 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(memberKind), 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(memberKind), 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..afeae720 --- /dev/null +++ b/internal/iam/group/cmd/types.go @@ -0,0 +1,103 @@ +/* +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/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" +) + +var groupGVR = schema.GroupVersionResource{ + Group: "deckhouse.io", + Version: "v1alpha1", + Resource: "groups", +} + +// 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 +} + +// detectCycle checks if adding a group member (childGroup -> parentGroup) would create a cycle. +// It loads all groups from the cluster and walks the membership graph. +func detectCycle(cmd *cobra.Command, dyn dynamic.Interface, parentGroup, childGroup string) (bool, []string, error) { + ctx := cmd.Context() + allGroups, err := dyn.Resource(groupGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return false, nil, fmt.Errorf("listing groups for cycle detection: %w", err) + } + + adj := make(map[string][]string) + for _, g := range allGroups.Items { + gName := g.GetName() + members, _ := getGroupMembers(&g) + for _, m := range members { + if fmt.Sprint(m["kind"]) == "Group" { + 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 +} diff --git a/internal/iam/user/cmd/completion.go b/internal/iam/user/cmd/completion.go new file mode 100644 index 00000000..467de969 --- /dev/null +++ b/internal/iam/user/cmd/completion.go @@ -0,0 +1,33 @@ +/* +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" + + "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, userGVR, "", toComplete) +} diff --git a/internal/iam/user/cmd/create.go b/internal/iam/user/cmd/create.go new file mode 100644 index 00000000..df11fa7a --- /dev/null +++ b/internal/iam/user/cmd/create.go @@ -0,0 +1,316 @@ +/* +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" + "os" + "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/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/client-go/dynamic" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var userGVR = schema.GroupVersionResource{ + Group: "deckhouse.io", + Version: "v1", + Resource: "users", +} + +var groupGVR = schema.GroupVersionResource{ + Group: "deckhouse.io", + Version: "v1alpha1", + Resource: "groups", +} + +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), or auto-generated (--generate-password). 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 + + # 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") + cmd.Flags().Bool("password-prompt", false, "Read password interactively with hidden input") + cmd.Flags().Bool("password-stdin", false, "Read password from stdin (for CI/pipelines)") + cmd.Flags().Bool("generate-password", false, "Auto-generate a strong password (shown once on stderr)") + 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") + cmd.Flags().StringP("output", "o", "name", "Output format: name|yaml|json") + + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("name", "yaml", "json")) + _ = cmd.RegisterFlagCompletionFunc("member-of", func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return utilk8s.CompleteResourceNames(cmd, groupGVR, "", toComplete) + }) + + return cmd +} + +func runCreate(cmd *cobra.Command, args []string) error { + name := args[0] + + email, _ := cmd.Flags().GetString("email") + promptFlag, _ := cmd.Flags().GetBool("password-prompt") + stdinFlag, _ := cmd.Flags().GetBool("password-stdin") + generateFlag, _ := cmd.Flags().GetBool("generate-password") + 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") + } + + mode, err := resolvePasswordMode(promptFlag, stdinFlag, generateFlag) + if err != nil { + return err + } + + var plainPassword string + switch mode { + case passwordModePrompt: + plainPassword, err = readPasswordPrompt(int(os.Stdin.Fd()), cmd.ErrOrStderr()) + case passwordModeStdin: + plainPassword, err = readPasswordStdin(os.Stdin) + case passwordModeGenerate: + plainPassword, err = generatePassword() + } + if err != nil { + return err + } + + encodedPassword, err := encodePasswordForDeckhouse(plainPassword) + if err != nil { + return err + } + + 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(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", plainPassword) + 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 := ensureGroupMember(cmd, dyn, groupName, "User", name, 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": "deckhouse.io/v1", + "kind": "User", + "metadata": map[string]any{ + "name": name, + }, + "spec": spec, + }, + } +} + +func ensureGroupMember(cmd *cobra.Command, dyn dynamic.Interface, groupName, memberKind, memberName string, createIfMissing bool) error { + ctx := cmd.Context() + groupClient := dyn.Resource(groupGVR) + + obj, err := groupClient.Get(ctx, groupName, metav1.GetOptions{}) + if err != nil { + if !createIfMissing { + return fmt.Errorf("group %q not found: %w", groupName, err) + } + obj = &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "deckhouse.io/v1alpha1", + "kind": "Group", + "metadata": map[string]any{ + "name": groupName, + }, + "spec": map[string]any{ + "name": groupName, + "members": []any{ + map[string]any{ + "kind": memberKind, + "name": memberName, + }, + }, + }, + }, + } + _, err = groupClient.Create(ctx, obj, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("creating group %q: %w", groupName, err) + } + return nil + } + + members, _, _ := unstructured.NestedSlice(obj.Object, "spec", "members") + for _, m := range members { + member, ok := m.(map[string]any) + if !ok { + continue + } + if member["kind"] == memberKind && member["name"] == memberName { + return nil // already a member + } + } + + members = append(members, map[string]any{ + "kind": memberKind, + "name": memberName, + }) + if err := unstructured.SetNestedSlice(obj.Object, members, "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) + } + return nil +} + +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 fmt.Errorf("--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..dd2e171c --- /dev/null +++ b/internal/iam/user/cmd/delete.go @@ -0,0 +1,65 @@ +/* +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" + + "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(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/get.go b/internal/iam/user/cmd/get.go new file mode 100644 index 00000000..831a20cb --- /dev/null +++ b/internal/iam/user/cmd/get.go @@ -0,0 +1,155 @@ +/* +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" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/cli-runtime/pkg/printers" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +func newGetCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get details of a local static user", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeUserNames, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + outputFmt, _ := cmd.Flags().GetString("output") + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + obj, err := dyn.Resource(userGVR).Get(cmd.Context(), name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("getting User %q: %w", name, err) + } + + switch outputFmt { + case "json", "yaml": + return utilk8s.PrintObject(cmd.OutOrStdout(), obj, outputFmt) + default: + return printUserDetail(cmd, obj) + } + }, + } + + cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) + return cmd +} + +func printUserDetail(cmd *cobra.Command, u *unstructured.Unstructured) error { + w := cmd.OutOrStdout() + name := u.GetName() + email, _, _ := unstructured.NestedString(u.Object, "spec", "email") + ttl, _, _ := unstructured.NestedString(u.Object, "spec", "ttl") + userID, _, _ := unstructured.NestedString(u.Object, "spec", "userID") + + fmt.Fprintf(w, "User: %s\n", name) + if email != "" { + fmt.Fprintf(w, "Email: %s\n", email) + } + if userID != "" { + fmt.Fprintf(w, "UserID: %s\n", userID) + } + fmt.Fprintf(w, "Created: %s\n", u.GetCreationTimestamp().Format("2006-01-02 15:04:05")) + + fmt.Fprintln(w, "\nStatus:") + expireAt, _, _ := unstructured.NestedString(u.Object, "status", "expireAt") + if ttl != "" { + fmt.Fprintf(w, " TTL: %s\n", ttl) + } else { + fmt.Fprintln(w, " TTL: ") + } + if expireAt != "" { + fmt.Fprintf(w, " Expire at: %s\n", expireAt) + } else { + fmt.Fprintln(w, " Expire at: ") + } + + locked := false + if v, found, _ := unstructured.NestedBool(u.Object, "status", "lock", "state"); found { + locked = v + } + if locked { + fmt.Fprintln(w, " Lock: locked") + if until, _, _ := unstructured.NestedString(u.Object, "status", "lock", "until"); until != "" { + fmt.Fprintf(w, " Lock until: %s\n", until) + } + if reason, _, _ := unstructured.NestedString(u.Object, "status", "lock", "reason"); reason != "" { + fmt.Fprintf(w, " Lock reason: %s\n", reason) + } + if msg, _, _ := unstructured.NestedString(u.Object, "status", "lock", "message"); msg != "" { + fmt.Fprintf(w, " Lock message: %s\n", msg) + } + } else { + fmt.Fprintln(w, " Lock: unlocked") + } + + groups, _, _ := unstructured.NestedStringSlice(u.Object, "status", "groups") + fmt.Fprintf(w, "\nGroups (from status) (%d):\n", len(groups)) + if len(groups) == 0 { + fmt.Fprintln(w, " ") + } + for _, g := range groups { + fmt.Fprintf(w, " - %s\n", g) + } + + fmt.Fprintln(w, "\nTip: run \"d8 iam access explain user "+name+"\" to see effective access.") + return nil +} + +func printUserTable(cmd *cobra.Command, users []*unstructured.Unstructured) error { + tw := printers.GetNewTabWriter(cmd.OutOrStdout()) + fmt.Fprintln(tw, "NAME\tEMAIL\tGROUPS\tEXPIRE_AT\tLOCKED") + for _, u := range users { + name := u.GetName() + email, _, _ := unstructured.NestedString(u.Object, "spec", "email") + expireAt, _, _ := unstructured.NestedString(u.Object, "status", "expireAt") + + groups, _, _ := unstructured.NestedStringSlice(u.Object, "status", "groups") + groupStr := strings.Join(groups, ",") + if groupStr == "" { + groupStr = "" + } + + locked := "false" + lockState, found, _ := unstructured.NestedBool(u.Object, "status", "lock", "state") + if found && lockState { + locked = "true" + } + + if expireAt == "" { + expireAt = "" + } + + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", name, email, groupStr, expireAt, locked) + } + return tw.Flush() +} diff --git a/internal/iam/user/cmd/list.go b/internal/iam/user/cmd/list.go new file mode 100644 index 00000000..17114213 --- /dev/null +++ b/internal/iam/user/cmd/list.go @@ -0,0 +1,83 @@ +/* +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/json" + "fmt" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + sigsyaml "sigs.k8s.io/yaml" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +func newListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all local static users", + Args: cobra.NoArgs, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + outputFmt, _ := cmd.Flags().GetString("output") + + dyn, err := utilk8s.NewDynamicClient(cmd) + if err != nil { + return err + } + + result, err := dyn.Resource(userGVR).List(cmd.Context(), metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("listing Users: %w", err) + } + + if len(result.Items) == 0 { + cmd.Println("No users found") + return nil + } + + switch outputFmt { + case "json": + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("marshalling JSON: %w", err) + } + fmt.Fprintln(cmd.OutOrStdout(), string(data)) + case "yaml": + data, err := sigsyaml.Marshal(result.UnstructuredContent()) + if err != nil { + return fmt.Errorf("marshalling YAML: %w", err) + } + fmt.Fprint(cmd.OutOrStdout(), string(data)) + default: + users := make([]*unstructured.Unstructured, 0, len(result.Items)) + for i := range result.Items { + users = append(users, &result.Items[i]) + } + return printUserTable(cmd, users) + } + return nil + }, + } + + cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) + return cmd +} diff --git a/internal/useroperation/cmd/lock.go b/internal/iam/user/cmd/lock.go similarity index 67% rename from internal/useroperation/cmd/lock.go rename to internal/iam/user/cmd/lock.go index 825c8f74..30f1bd06 100644 --- a/internal/useroperation/cmd/lock.go +++ b/internal/iam/user/cmd/lock.go @@ -1,4 +1,20 @@ -package useroperation +/* +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" @@ -6,15 +22,18 @@ import ( "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) 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, + Use: "lock ", + Short: "Lock local user in Dex for a period of time", + Args: cobra.ExactArgs(2), + ValidArgsFunction: completeUserNames, + SilenceErrors: true, + SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { username := args[0] lockDuration := args[1] @@ -28,7 +47,7 @@ func newLockCommand() *cobra.Command { return err } - dyn, err := newDynamicClient(cmd) + dyn, err := utilk8s.NewDynamicClient(cmd) if err != nil { return err } diff --git a/internal/iam/user/cmd/password.go b/internal/iam/user/cmd/password.go new file mode 100644 index 00000000..92729ff7 --- /dev/null +++ b/internal/iam/user/cmd/password.go @@ -0,0 +1,137 @@ +/* +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" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/term" +) + +const bcryptCost = 10 + +type passwordMode int + +const ( + passwordModeNone passwordMode = iota + passwordModePrompt + passwordModeStdin + passwordModeGenerate +) + +// resolvePasswordMode determines which password source to use based on the flags provided. +// Exactly one of prompt, stdin, or generate must be true; if none is set, the mode is +// inferred from whether stdin is a terminal. +func resolvePasswordMode(prompt, stdin, generate bool) (passwordMode, error) { + count := 0 + if prompt { + count++ + } + if stdin { + count++ + } + if generate { + count++ + } + if count > 1 { + return passwordModeNone, fmt.Errorf("only one of --password-prompt, --password-stdin, --generate-password may be specified") + } + + if prompt { + return passwordModePrompt, nil + } + if stdin { + return passwordModeStdin, nil + } + if generate { + return passwordModeGenerate, nil + } + + if term.IsTerminal(int(os.Stdin.Fd())) { + return passwordModePrompt, nil + } + return passwordModeNone, fmt.Errorf("stdin is not a terminal; use --password-stdin or --generate-password") +} + +// 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 "", fmt.Errorf("passwords do not match") + } + if len(pw1) == 0 { + return "", fmt.Errorf("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 "", fmt.Errorf("no password provided on stdin") + } + pw := strings.TrimRight(scanner.Text(), "\r\n") + if pw == "" { + return "", fmt.Errorf("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 +} + +// encodePasswordForDeckhouse hashes the plaintext password with bcrypt cost 10 +// and returns a base64-encoded bcrypt hash suitable for User.spec.password. +func encodePasswordForDeckhouse(plain string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost) + if err != nil { + return "", fmt.Errorf("hashing password: %w", err) + } + return base64.StdEncoding.EncodeToString(hash), 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..774ae98c --- /dev/null +++ b/internal/iam/user/cmd/password_test.go @@ -0,0 +1,114 @@ +/* +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) { + tests := []struct { + name string + prompt bool + stdin bool + generate bool + 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: "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: "all three conflict", prompt: true, stdin: true, generate: true, 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) + 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) +} diff --git a/internal/useroperation/cmd/reset2fa.go b/internal/iam/user/cmd/reset2fa.go similarity index 63% rename from internal/useroperation/cmd/reset2fa.go rename to internal/iam/user/cmd/reset2fa.go index e4532748..88e5eb6a 100644 --- a/internal/useroperation/cmd/reset2fa.go +++ b/internal/iam/user/cmd/reset2fa.go @@ -1,4 +1,20 @@ -package useroperation +/* +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" @@ -6,15 +22,18 @@ import ( "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) 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, + Use: "reset2fa ", + Short: "Reset local user's 2FA (TOTP) in Dex", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeUserNames, + SilenceErrors: true, + SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { username := args[0] wf, err := getWaitFlags(cmd) @@ -22,7 +41,7 @@ func newReset2FACommand() *cobra.Command { return err } - dyn, err := newDynamicClient(cmd) + dyn, err := utilk8s.NewDynamicClient(cmd) if err != nil { return err } diff --git a/internal/useroperation/cmd/reset_password.go b/internal/iam/user/cmd/reset_password.go similarity index 63% rename from internal/useroperation/cmd/reset_password.go rename to internal/iam/user/cmd/reset_password.go index 0932ac2c..99c16791 100644 --- a/internal/useroperation/cmd/reset_password.go +++ b/internal/iam/user/cmd/reset_password.go @@ -1,4 +1,20 @@ -package useroperation +/* +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" @@ -6,16 +22,19 @@ import ( "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) 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, + Use: "reset-password ", + Aliases: []string{"resetpass"}, + Short: "Reset local user's password in Dex (requires bcrypt hash)", + Args: cobra.ExactArgs(2), + ValidArgsFunction: completeUserNames, + SilenceErrors: true, + SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { username := args[0] bcryptHash := args[1] @@ -24,7 +43,7 @@ func newResetPasswordCommand() *cobra.Command { return err } - dyn, err := newDynamicClient(cmd) + dyn, err := utilk8s.NewDynamicClient(cmd) if err != nil { return err } diff --git a/internal/useroperation/cmd/types.go b/internal/iam/user/cmd/types.go similarity index 65% rename from internal/useroperation/cmd/types.go rename to internal/iam/user/cmd/types.go index b7cdb7ee..b8d82048 100644 --- a/internal/useroperation/cmd/types.go +++ b/internal/iam/user/cmd/types.go @@ -1,8 +1,23 @@ -package useroperation +/* +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" @@ -11,8 +26,6 @@ import ( "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{ @@ -48,39 +61,6 @@ func getWaitFlags(cmd *cobra.Command) (waitFlags, error) { 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{}) } diff --git a/internal/useroperation/cmd/unlock.go b/internal/iam/user/cmd/unlock.go similarity index 63% rename from internal/useroperation/cmd/unlock.go rename to internal/iam/user/cmd/unlock.go index cecce4a7..a6b0d5e3 100644 --- a/internal/useroperation/cmd/unlock.go +++ b/internal/iam/user/cmd/unlock.go @@ -1,4 +1,20 @@ -package useroperation +/* +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" @@ -6,15 +22,18 @@ import ( "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) func newUnlockCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "unlock ", - Short: "Unlock local user in Dex", - Args: cobra.ExactArgs(1), - SilenceErrors: true, - SilenceUsage: true, + Use: "unlock ", + Short: "Unlock local user in Dex", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeUserNames, + SilenceErrors: true, + SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { username := args[0] wf, err := getWaitFlags(cmd) @@ -22,7 +41,7 @@ func newUnlockCommand() *cobra.Command { return err } - dyn, err := newDynamicClient(cmd) + dyn, err := utilk8s.NewDynamicClient(cmd) if err != nil { return err } diff --git a/internal/iam/user/cmd/user.go b/internal/iam/user/cmd/user.go new file mode 100644 index 00000000..a0ece436 --- /dev/null +++ b/internal/iam/user/cmd/user.go @@ -0,0 +1,59 @@ +/* +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, Get, List, 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( + newCreateCommand(), + newDeleteCommand(), + newGetCommand(), + newListCommand(), + newReset2FACommand(), + newResetPasswordCommand(), + newLockCommand(), + newUnlockCommand(), + ) + + 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..9155870b --- /dev/null +++ b/internal/utilk8s/output.go @@ -0,0 +1,48 @@ +/* +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" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + sigsyaml "sigs.k8s.io/yaml" +) + +// 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 +} From 352811294d4c01b104dd2ada90145ae9c507f74b Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Mon, 27 Apr 2026 12:34:50 +0300 Subject: [PATCH 2/5] Refactor Signed-off-by: Ivan Zvyagintsev --- internal/iam/access/cmd/access_test.go | 24 +- internal/iam/access/cmd/completion.go | 19 +- internal/iam/access/cmd/explain.go | 11 +- internal/iam/access/cmd/grant.go | 390 ++++++++++--------- internal/iam/access/cmd/grant_errors_test.go | 206 ++++++++++ internal/iam/access/cmd/list.go | 32 +- internal/iam/access/cmd/naming.go | 27 +- internal/iam/access/cmd/resolve.go | 74 ++-- internal/iam/access/cmd/resolve_test.go | 8 +- internal/iam/access/cmd/revoke.go | 240 ++++++------ internal/iam/access/cmd/rules.go | 150 +++---- internal/iam/access/cmd/rules_test.go | 43 +- internal/iam/access/cmd/types.go | 127 +++--- internal/iam/group/cmd/add_member.go | 76 ++-- internal/iam/group/cmd/completion.go | 16 +- internal/iam/group/cmd/create.go | 9 +- internal/iam/group/cmd/delete.go | 3 +- internal/iam/group/cmd/get.go | 8 +- internal/iam/group/cmd/group_test.go | 14 +- internal/iam/group/cmd/list.go | 3 +- internal/iam/group/cmd/membership.go | 160 ++++++++ internal/iam/group/cmd/membership_test.go | 98 +++++ internal/iam/group/cmd/remove_member.go | 10 +- internal/iam/group/cmd/types.go | 66 +--- internal/iam/types/types.go | 112 ++++++ internal/iam/user/cmd/completion.go | 3 +- internal/iam/user/cmd/create.go | 93 +---- internal/iam/user/cmd/delete.go | 3 +- internal/iam/user/cmd/get.go | 3 +- internal/iam/user/cmd/list.go | 3 +- internal/iam/user/cmd/lock.go | 54 +-- internal/iam/user/cmd/password.go | 13 +- internal/iam/user/cmd/reset2fa.go | 54 +-- internal/iam/user/cmd/reset_password.go | 59 +-- internal/iam/user/cmd/types.go | 83 +++- internal/iam/user/cmd/unlock.go | 54 +-- 36 files changed, 1359 insertions(+), 989 deletions(-) create mode 100644 internal/iam/access/cmd/grant_errors_test.go create mode 100644 internal/iam/group/cmd/membership.go create mode 100644 internal/iam/group/cmd/membership_test.go create mode 100644 internal/iam/types/types.go diff --git a/internal/iam/access/cmd/access_test.go b/internal/iam/access/cmd/access_test.go index a8532069..03c86ceb 100644 --- a/internal/iam/access/cmd/access_test.go +++ b/internal/iam/access/cmd/access_test.go @@ -22,18 +22,20 @@ import ( "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 TestParseSubjectKind(t *testing.T) { tests := []struct { input string - want string + want iamtypes.SubjectKind wantErr string }{ - {input: "user", want: "User"}, - {input: "User", want: "User"}, - {input: "group", want: "Group"}, - {input: "Group", want: "Group"}, + {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 { @@ -56,12 +58,12 @@ func TestParseScopeFlags(t *testing.T) { namespaces []string cluster bool allNS bool - want string + want iamtypes.Scope wantErr string }{ - {name: "namespace", namespaces: []string{"dev"}, want: "namespace"}, - {name: "cluster", cluster: true, want: "cluster"}, - {name: "all-namespaces", allNS: true, want: "all-namespaces"}, + {name: "namespace", namespaces: []string{"dev"}, want: iamtypes.ScopeNamespace}, + {name: "cluster", cluster: true, want: iamtypes.ScopeCluster}, + {name: "all-namespaces", allNS: true, want: iamtypes.ScopeAllNamespaces}, {name: "none", wantErr: "one of"}, {name: "namespace+cluster", namespaces: []string{"dev"}, cluster: true, wantErr: "mutually exclusive"}, {name: "cluster+allNS", cluster: true, allNS: true, wantErr: "mutually exclusive"}, @@ -165,7 +167,7 @@ func TestRemoveSubject(t *testing.T) { {"kind": "User", "name": "anton@abc.com"}, {"kind": "Group", "name": "admins"}, }) - newSubjects, removed := removeSubject(obj, "User", "anton@abc.com") + newSubjects, removed := removeSubject(obj, iamtypes.KindUser, "anton@abc.com") assert.True(t, removed) assert.Len(t, newSubjects, 1) }) @@ -174,7 +176,7 @@ func TestRemoveSubject(t *testing.T) { obj := buildTestObj([]map[string]any{ {"kind": "Group", "name": "admins"}, }) - newSubjects, removed := removeSubject(obj, "User", "anton@abc.com") + newSubjects, removed := removeSubject(obj, iamtypes.KindUser, "anton@abc.com") assert.False(t, removed) assert.Len(t, newSubjects, 1) }) diff --git a/internal/iam/access/cmd/completion.go b/internal/iam/access/cmd/completion.go index 4c80154f..520903fe 100644 --- a/internal/iam/access/cmd/completion.go +++ b/internal/iam/access/cmd/completion.go @@ -22,6 +22,7 @@ import ( "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" ) @@ -29,12 +30,10 @@ import ( // "d8 iam access grant", "d8 iam access revoke" and "d8 iam access explain". var subjectKinds = []string{"user", "group"} -// allAccessLevelsList is the static list of access levels for flag completion. -// Kept in stable admin-to-user order to be friendlier in shell completion UIs. -var allAccessLevelsList = []string{ - "User", "PrivilegedUser", "Editor", "Admin", - "ClusterEditor", "ClusterAdmin", "SuperAdmin", -} +// allAccessLevelsList is exposed for flag completion. It points at the same +// ordered slice that drives validation in types.go, so adding a new level +// updates both sites at once. +var allAccessLevelsList = allAccessLevelsOrdered // completeSubjectAndName completes "user|group NAME" positional pairs. func completeSubjectAndName(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -44,9 +43,9 @@ func completeSubjectAndName(cmd *cobra.Command, args []string, toComplete string case 1: switch args[0] { case "user": - return utilk8s.CompleteResourceNames(cmd, userGVR, "", toComplete) + return utilk8s.CompleteResourceNames(cmd, iamtypes.UserGVR, "", toComplete) case "group": - return utilk8s.CompleteResourceNames(cmd, groupGVR, "", toComplete) + return utilk8s.CompleteResourceNames(cmd, iamtypes.GroupGVR, "", toComplete) } } return nil, cobra.ShellCompDirectiveNoFileComp @@ -94,7 +93,7 @@ func completeRuleRef(cmd *cobra.Command, args []string, toComplete string) ([]st var suggestions []string if wantCAR { - list, err := dyn.Resource(clusterAuthorizationRuleGVR).List(cmd.Context(), metav1.ListOptions{}) + 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()) @@ -102,7 +101,7 @@ func completeRuleRef(cmd *cobra.Command, args []string, toComplete string) ([]st } } if wantAR { - list, err := dyn.Resource(authorizationRuleGVR).Namespace("").List(cmd.Context(), metav1.ListOptions{}) + list, err := dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace("").List(cmd.Context(), metav1.ListOptions{}) if err == nil { for i := range list.Items { obj := &list.Items[i] diff --git a/internal/iam/access/cmd/explain.go b/internal/iam/access/cmd/explain.go index 138ffaa4..08a1d93f 100644 --- a/internal/iam/access/cmd/explain.go +++ b/internal/iam/access/cmd/explain.go @@ -25,6 +25,7 @@ import ( "github.com/spf13/cobra" "k8s.io/kubectl/pkg/util/templates" + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -83,9 +84,9 @@ func runExplain(cmd *cobra.Command, args []string) error { } switch subjectKind { - case "User": + case iamtypes.KindUser: return explainUser(cmd, inv, name, outputFmt) - case "Group": + case iamtypes.KindGroup: return explainGroup(cmd, inv, name, outputFmt) } return nil @@ -254,12 +255,12 @@ func collectGroupWarnings(inv *accessInventory, groupName string, cycles map[str // Check if any user members are orphaned members := inv.GroupMembers[groupName] for _, m := range members { - if m.Kind == "User" { + if m.Kind == iamtypes.KindUser { if _, ok := inv.Users[m.Name]; !ok { warnings = append(warnings, fmt.Sprintf("user member %q not found as a local User CR (may be orphaned)", m.Name)) } } - if m.Kind == "Group" { + if m.Kind == iamtypes.KindGroup { if _, ok := inv.GroupMembers[m.Name]; !ok { warnings = append(warnings, fmt.Sprintf("nested group %q not found as a local Group CR", m.Name)) } @@ -294,7 +295,7 @@ func printGroupExplainJSON(cmd *cobra.Command, inv *accessInventory, groupName s Warnings: warnings, } for _, m := range members { - item.Members = append(item.Members, memberEntry(m)) + item.Members = append(item.Members, memberEntry{Kind: string(m.Kind), Name: m.Name}) } if item.Members == nil { item.Members = []memberEntry{} diff --git a/internal/iam/access/cmd/grant.go b/internal/iam/access/cmd/grant.go index 5684a19e..60db65ec 100644 --- a/internal/iam/access/cmd/grant.go +++ b/internal/iam/access/cmd/grant.go @@ -24,11 +24,13 @@ import ( "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" ) @@ -157,7 +159,7 @@ func runGrant(cmd *cobra.Command, args []string) error { outputFmt, _ := cmd.Flags().GetString("output") if accessLevel == "" { - return fmt.Errorf("--access-level is required (or use --to to add a subject to an existing rule)") + return errors.New("--access-level is required (or use --to to add a subject to an existing rule)") } subjectKind, err := parseSubjectKind(subjectKindStr) @@ -170,7 +172,7 @@ func runGrant(cmd *cobra.Command, args []string) error { return err } - isNamespaced := scopeType == "namespace" + isNamespaced := scopeType == iamtypes.ScopeNamespace if err := validateAccessLevel(accessLevel, isNamespaced); err != nil { return err } @@ -182,101 +184,159 @@ func runGrant(cmd *cobra.Command, args []string) error { var subjectPrincipal string switch subjectKind { - case "User": + case iamtypes.KindUser: subjectPrincipal, err = resolveUserEmail(cmd.Context(), dyn, subjectName) if err != nil { return err } - case "Group": + case iamtypes.KindGroup: subjectPrincipal = subjectName - if _, err := dyn.Resource(groupGVR).Get(cmd.Context(), subjectName, metav1.GetOptions{}); err != nil { + 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) } } - switch scopeType { - case "namespace": - return grantNamespaced(cmd, dyn, subjectKind, subjectName, subjectPrincipal, accessLevel, namespaces, allowScale, portForwarding, dryRun, outputFmt) - case "cluster", "all-namespaces": - return grantCluster(cmd, dyn, subjectKind, subjectName, subjectPrincipal, accessLevel, scopeType, allowScale, portForwarding, dryRun, outputFmt) + opts := grantOpts{ + subjectKind: subjectKind, + subjectRef: subjectName, + subjectPrincipal: subjectPrincipal, + accessLevel: accessLevel, + scopeType: scopeType, + namespaces: namespaces, + allowScale: allowScale, + portForwarding: portForwarding, + dryRun: dryRun, + outputFmt: outputFmt, } - return nil + 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 + allowScale bool + portForwarding bool + dryRun bool + outputFmt string } -func grantNamespaced(cmd *cobra.Command, dyn dynamic.Interface, - subjectKind, subjectRef, subjectPrincipal, accessLevel string, - namespaces []string, allowScale, portForwarding, dryRun bool, outputFmt string) error { +// 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, + AllowScale: opts.allowScale, + PortForwarding: opts.portForwarding, + }) + if err != nil { + return err + } + var errs *multierror.Error - for _, ns := range namespaces { - spec := &canonicalGrantSpec{ - Model: "current", - SubjectKind: subjectKind, - SubjectRef: subjectRef, - SubjectPrincipal: subjectPrincipal, - AccessLevel: accessLevel, - ScopeType: "namespace", - Namespaces: []string{ns}, - AllowScale: allowScale, - PortForwarding: portForwarding, + for _, spec := range specs { + client, err := grantClient(dyn, spec) + if err != nil { + return err } - - obj, err := buildAuthorizationRule(spec, ns) + obj, err := buildGrantObject(spec) if err != nil { return err } - if dryRun { - if err := utilk8s.PrintObject(cmd.OutOrStdout(), obj, outputFmt); err != nil { + if opts.dryRun { + if err := utilk8s.PrintObject(cmd.OutOrStdout(), obj, opts.outputFmt); err != nil { return err } continue } - result, err := createOrUpdateNamespacedGrant(cmd, dyn, obj, ns) + result, err := createOrUpdateGrant(cmd, client, obj, spec) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to create grant in namespace %q: %v\n", ns, err) - errs = multierror.Append(errs, fmt.Errorf("namespace %s: %w", ns, err)) + 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, outputFmt); err != nil { + if err := utilk8s.PrintObject(cmd.OutOrStdout(), result, opts.outputFmt); err != nil { return err } } - return errs.ErrorOrNil() } -func grantCluster(cmd *cobra.Command, dyn dynamic.Interface, - subjectKind, subjectRef, subjectPrincipal, accessLevel, scopeType string, - allowScale, portForwarding, dryRun bool, outputFmt string) error { - spec := &canonicalGrantSpec{ - Model: "current", - SubjectKind: subjectKind, - SubjectRef: subjectRef, - SubjectPrincipal: subjectPrincipal, - AccessLevel: accessLevel, - ScopeType: scopeType, - AllowScale: allowScale, - PortForwarding: portForwarding, - } - - obj, err := buildClusterAuthorizationRule(spec) - if err != nil { - return err - } - - if dryRun { - return utilk8s.PrintObject(cmd.OutOrStdout(), obj, outputFmt) +// 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 produces 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 + default: + return nil, fmt.Errorf("unsupported scope %q", in.ScopeType) } +} - result, err := createOrUpdateClusterGrant(cmd, dyn, obj) - if err != nil { - return err +// 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: + return dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR), nil + default: + return nil, fmt.Errorf("unsupported scope %q", spec.ScopeType) } - return utilk8s.PrintObject(cmd.OutOrStdout(), result, outputFmt) } -func buildAuthorizationRule(spec *canonicalGrantSpec, ns string) (*unstructured.Unstructured, error) { +// buildGrantObject produces the Unstructured for a grant — AR for namespaced, +// CAR for cluster/all-namespaces. 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 @@ -293,121 +353,106 @@ func buildAuthorizationRule(spec *canonicalGrantSpec, ns string) (*unstructured. "portForwarding": spec.PortForwarding, "subjects": []any{ map[string]any{ - "kind": spec.SubjectKind, + "kind": string(spec.SubjectKind), "name": spec.SubjectPrincipal, }, }, } - return &unstructured.Unstructured{ - Object: map[string]any{ - "apiVersion": "deckhouse.io/v1alpha1", - "kind": "AuthorizationRule", - "metadata": map[string]any{ - "name": name, - "namespace": ns, - "labels": toAnyMap(labels), - "annotations": toAnyMap(annotations), - }, - "spec": ruleSpec, - }, - }, nil -} - -func buildClusterAuthorizationRule(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": spec.SubjectKind, - "name": spec.SubjectPrincipal, - }, - }, + metadata := map[string]any{ + "name": name, + "labels": toAnyMap(labels), + "annotations": toAnyMap(annotations), } - if spec.ScopeType == "all-namespaces" { - ruleSpec["namespaceSelector"] = map[string]any{ - "matchAny": true, + 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} + default: + return nil, fmt.Errorf("unsupported scope %q", spec.ScopeType) } return &unstructured.Unstructured{ Object: map[string]any{ - "apiVersion": "deckhouse.io/v1", - "kind": "ClusterAuthorizationRule", - "metadata": map[string]any{ - "name": name, - "labels": toAnyMap(labels), - "annotations": toAnyMap(annotations), - }, - "spec": ruleSpec, + "apiVersion": apiVersion, + "kind": kind, + "metadata": metadata, + "spec": ruleSpec, }, }, nil } -func createOrUpdateNamespacedGrant(cmd *cobra.Command, dyn dynamic.Interface, obj *unstructured.Unstructured, ns string) (*unstructured.Unstructured, error) { - client := dyn.Resource(authorizationRuleGVR).Namespace(ns) +// 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{}) - if err == nil { - // Check if it matches + switch { + case err == nil: existingAnnot := existing.GetAnnotations() newAnnot := obj.GetAnnotations() - if existingAnnot["deckhouse.io/access-canonical-spec"] == newAnnot["deckhouse.io/access-canonical-spec"] { - cmd.PrintErrf("AuthorizationRule/%s/%s unchanged\n", ns, name) + 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) } - - return client.Create(cmd.Context(), obj, metav1.CreateOptions{}) } -func createOrUpdateClusterGrant(cmd *cobra.Command, dyn dynamic.Interface, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - client := dyn.Resource(clusterAuthorizationRuleGVR) - name := obj.GetName() - - existing, err := client.Get(cmd.Context(), name, metav1.GetOptions{}) - if err == nil { - existingAnnot := existing.GetAnnotations() - newAnnot := obj.GetAnnotations() - if existingAnnot["deckhouse.io/access-canonical-spec"] == newAnnot["deckhouse.io/access-canonical-spec"] { - cmd.PrintErrf("ClusterAuthorizationRule/%s unchanged\n", name) - return existing, nil - } - obj.SetResourceVersion(existing.GetResourceVersion()) - return client.Update(cmd.Context(), obj, metav1.UpdateOptions{}) +// 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 client.Create(cmd.Context(), obj, metav1.CreateOptions{}) + return iamtypes.KindClusterAuthorizationRule } -func parseSubjectKind(s string) (string, error) { +func parseSubjectKind(s string) (iamtypes.SubjectKind, error) { switch strings.ToLower(s) { case "user": - return "User", nil + return iamtypes.KindUser, nil case "group": - return "Group", nil + return iamtypes.KindGroup, nil default: return "", fmt.Errorf("invalid subject kind %q: must be user or group", s) } } -func parseScopeFlags(namespaces []string, cluster, allNamespaces bool) (string, error) { +func parseScopeFlags(namespaces []string, cluster, allNamespaces bool) (iamtypes.Scope, error) { count := 0 if len(namespaces) > 0 { count++ @@ -419,19 +464,19 @@ func parseScopeFlags(namespaces []string, cluster, allNamespaces bool) (string, count++ } if count == 0 { - return "", fmt.Errorf("one of -n/--namespace, --cluster, or --all-namespaces must be specified") + return "", errors.New("one of -n/--namespace, --cluster, or --all-namespaces must be specified") } if count > 1 { - return "", fmt.Errorf("-n/--namespace, --cluster, and --all-namespaces are mutually exclusive") + return "", errors.New("-n/--namespace, --cluster, and --all-namespaces are mutually exclusive") } if len(namespaces) > 0 { - return "namespace", nil + return iamtypes.ScopeNamespace, nil } if cluster { - return "cluster", nil + return iamtypes.ScopeCluster, nil } - return "all-namespaces", nil + return iamtypes.ScopeAllNamespaces, nil } func toAnyMap(m map[string]string) map[string]any { @@ -495,7 +540,7 @@ func runSourceBasedGrant(cmd *cobra.Command, args []string) error { } if len(args) != 2 { - return fmt.Errorf("source-based grant requires: grant --to (user|group) ") + return errors.New("source-based grant requires: grant --to (user|group) ") } subjectKind, err := parseSubjectKind(args[0]) if err != nil { @@ -516,72 +561,59 @@ func runSourceBasedGrant(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("invalid --to: %w", err) } + + var client dynamic.ResourceInterface switch refKind { - case "ClusterAuthorizationRule": - return addSubjectToClusterRule(cmd, dyn, refName, subjectKind, subjectPrincipal) - case "AuthorizationRule": - return addSubjectToNamespacedRule(cmd, dyn, refNS, refName, subjectKind, subjectPrincipal) + case iamtypes.KindClusterAuthorizationRule: + client = dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR) + case iamtypes.KindAuthorizationRule: + client = dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace(refNS) default: return fmt.Errorf("unsupported --to kind %q", refKind) } + return addSubjectToRule(cmd, client, refKind, refNS, refName, subjectKind, subjectPrincipal) } -func addSubjectToClusterRule(cmd *cobra.Command, dyn dynamic.Interface, ruleName, subjectKind, subjectPrincipal string) error { - client := dyn.Resource(clusterAuthorizationRuleGVR) - obj, err := client.Get(cmd.Context(), ruleName, metav1.GetOptions{}) - if err != nil { - return fmt.Errorf("getting ClusterAuthorizationRule %q: %w", ruleName, err) - } - - newSubjects, added := addSubject(obj, subjectKind, subjectPrincipal) - if !added { - cmd.Printf("Subject %s/%s already present in ClusterAuthorizationRule/%s (no change)\n", subjectKind, subjectPrincipal, ruleName) - return nil - } - - if err := unstructured.SetNestedSlice(obj.Object, newSubjects, "spec", "subjects"); err != nil { - return fmt.Errorf("setting subjects: %w", err) - } - if _, err = client.Update(cmd.Context(), obj, metav1.UpdateOptions{}); err != nil { - return fmt.Errorf("updating ClusterAuthorizationRule %q: %w", ruleName, err) - } - cmd.Printf("Added subject %s/%s to ClusterAuthorizationRule/%s\n", subjectKind, subjectPrincipal, ruleName) - return nil -} +// addSubjectToRule adds (kind,principal) to spec.subjects of the rule pointed +// to by client. The cluster vs namespaced difference lives entirely in how +// the caller built the dynamic.ResourceInterface; ref/ns are only used for +// human messages. +func addSubjectToRule(cmd *cobra.Command, client dynamic.ResourceInterface, + ruleKind, ns, ruleName string, subjectKind iamtypes.SubjectKind, subjectPrincipal string) error { + ref := formatRuleRef(ruleKind, ns, ruleName) -func addSubjectToNamespacedRule(cmd *cobra.Command, dyn dynamic.Interface, ns, ruleName, subjectKind, subjectPrincipal string) error { - client := dyn.Resource(authorizationRuleGVR).Namespace(ns) obj, err := client.Get(cmd.Context(), ruleName, metav1.GetOptions{}) if err != nil { - return fmt.Errorf("getting AuthorizationRule %q in namespace %q: %w", ruleName, ns, err) + return fmt.Errorf("getting %s: %w", ref, err) } newSubjects, added := addSubject(obj, subjectKind, subjectPrincipal) if !added { - cmd.Printf("Subject %s/%s already present in AuthorizationRule/%s/%s (no change)\n", subjectKind, subjectPrincipal, ns, ruleName) + cmd.Printf("Subject %s/%s already present in %s (no change)\n", subjectKind, subjectPrincipal, ref) return nil } if err := unstructured.SetNestedSlice(obj.Object, newSubjects, "spec", "subjects"); err != nil { - return fmt.Errorf("setting subjects: %w", err) + return fmt.Errorf("setting subjects on %s: %w", ref, err) } - if _, err = client.Update(cmd.Context(), obj, metav1.UpdateOptions{}); err != nil { - return fmt.Errorf("updating AuthorizationRule %s/%s: %w", ns, ruleName, err) + if _, err := client.Update(cmd.Context(), obj, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating %s: %w", ref, err) } - cmd.Printf("Added subject %s/%s to AuthorizationRule/%s/%s\n", subjectKind, subjectPrincipal, ns, ruleName) + cmd.Printf("Added subject %s/%s to %s\n", subjectKind, subjectPrincipal, ref) return nil } -func addSubject(obj *unstructured.Unstructured, kind, name string) ([]any, bool) { +func addSubject(obj *unstructured.Unstructured, kind iamtypes.SubjectKind, name string) ([]any, bool) { subjects, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") + kindStr := string(kind) for _, s := range subjects { sub, ok := s.(map[string]any) if !ok { continue } - if fmt.Sprint(sub["kind"]) == kind && fmt.Sprint(sub["name"]) == name { + if fmt.Sprint(sub["kind"]) == kindStr && fmt.Sprint(sub["name"]) == name { return subjects, false } } - return append(subjects, map[string]any{"kind": kind, "name": name}), true + return append(subjects, map[string]any{"kind": kindStr, "name": name}), true } 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 index 499337a3..cd5ffb8d 100644 --- a/internal/iam/access/cmd/list.go +++ b/internal/iam/access/cmd/list.go @@ -27,6 +27,7 @@ import ( "k8s.io/cli-runtime/pkg/printers" "k8s.io/kubectl/pkg/util/templates" + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -129,7 +130,7 @@ func newListUserCommand() *cobra.Command { if len(args) >= 1 { return nil, cobra.ShellCompDirectiveNoFileComp } - return utilk8s.CompleteResourceNames(cmd, userGVR, "", toComplete) + return utilk8s.CompleteResourceNames(cmd, iamtypes.UserGVR, "", toComplete) }, SilenceErrors: true, SilenceUsage: true, @@ -172,7 +173,7 @@ func newListGroupCommand() *cobra.Command { if len(args) >= 1 { return nil, cobra.ShellCompDirectiveNoFileComp } - return utilk8s.CompleteResourceNames(cmd, groupGVR, "", toComplete) + return utilk8s.CompleteResourceNames(cmd, iamtypes.GroupGVR, "", toComplete) }, SilenceErrors: true, SilenceUsage: true, @@ -254,7 +255,7 @@ func printGroupsTable(cmd *cobra.Command, inv *accessInventory) error { members := inv.GroupMembers[groupName] userCount, nestedCount := 0, 0 for _, m := range members { - if m.Kind == "Group" { + if m.Kind == iamtypes.KindGroup { nestedCount++ } else { userCount++ @@ -345,8 +346,8 @@ func printGroupDetail(cmd *cobra.Command, inv *accessInventory, groupName string } func printGrantToWriter(w io.Writer, g *normalizedGrant, via string) { - scope := g.ScopeType - if g.ScopeType == "namespace" && len(g.ScopeNamespaces) > 0 { + scope := string(g.ScopeType) + if g.ScopeType == iamtypes.ScopeNamespace && len(g.ScopeNamespaces) > 0 { scope = fmt.Sprintf("namespaces %s", strings.Join(g.ScopeNamespaces, ", ")) } managed := "" @@ -368,14 +369,11 @@ func printGrantToWriter(w io.Writer, g *normalizedGrant, via string) { } } -// formatGrantSource renders the source CAR/AR reference as "Kind/Name" for -// cluster-scoped objects and "Kind/Namespace/Name" for namespaced ones. -// Used by text output in list, explain, and warning messages. +// formatGrantSource renders the source CAR/AR reference. Delegates to +// formatRuleRef so list/explain/warning output and revoke output stay +// byte-identical for the same triple (Kind, NS, Name). func formatGrantSource(g *normalizedGrant) string { - if g.SourceNamespace != "" { - return fmt.Sprintf("%s/%s/%s", g.SourceKind, g.SourceNamespace, g.SourceName) - } - return fmt.Sprintf("%s/%s", g.SourceKind, g.SourceName) + return formatRuleRef(g.SourceKind, g.SourceNamespace, g.SourceName) } // printEffectiveSummary renders the "cluster scope / namespaced scope / @@ -399,7 +397,7 @@ func printEffectiveSummary(w io.Writer, summary *effectiveSummary) { func partitionMembersByKind(members []memberRef) ([]string, []string) { var users, groups []string for _, m := range members { - if m.Kind == "Group" { + if m.Kind == iamtypes.KindGroup { groups = append(groups, m.Name) } else { users = append(users, m.Name) @@ -498,7 +496,7 @@ func grantToJSON(g *normalizedGrant, via string) grantJSON { Namespace: g.SourceNamespace, }, AccessLevel: g.AccessLevel, - Scope: scopeJSON{Type: g.ScopeType, Namespaces: g.ScopeNamespaces}, + Scope: scopeJSON{Type: string(g.ScopeType), Namespaces: g.ScopeNamespaces}, AllowScale: g.AllowScale, PortForwarding: g.PortForwarding, ManagedByD8: g.ManagedByD8, @@ -566,7 +564,7 @@ func buildUserAccessJSON(inv *accessInventory, userName string) userAccessJSON { summary := computeEffectiveSummary(allGrants) item := userAccessJSON{Kind: "AccessExplanation"} - item.Subject.Kind = "User" + item.Subject.Kind = string(iamtypes.KindUser) item.Subject.RefName = userName item.Subject.Principal = email item.Groups.Direct = directGroups @@ -617,7 +615,7 @@ func printGroupsJSON(cmd *cobra.Command, inv *accessInventory) error { members := inv.GroupMembers[gName] userCount, nestedCount := 0, 0 for _, m := range members { - if m.Kind == "Group" { + if m.Kind == iamtypes.KindGroup { nestedCount++ } else { userCount++ @@ -661,7 +659,7 @@ func printGroupDetailJSON(cmd *cobra.Command, inv *accessInventory, groupName st detail := detailJSON{Name: groupName, Effective: summaryToJSON(summary)} for _, m := range members { - detail.Members = append(detail.Members, memberJSON(m)) + detail.Members = append(detail.Members, memberJSON{Kind: string(m.Kind), Name: m.Name}) } if detail.Members == nil { detail.Members = []memberJSON{} diff --git a/internal/iam/access/cmd/naming.go b/internal/iam/access/cmd/naming.go index 29cc8fb7..7c32bf6e 100644 --- a/internal/iam/access/cmd/naming.go +++ b/internal/iam/access/cmd/naming.go @@ -21,6 +21,7 @@ import ( "fmt" "strings" + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" "github.com/deckhouse/deckhouse-cli/internal/version" ) @@ -36,7 +37,7 @@ func generateGrantName(spec *canonicalGrantSpec) (string, error) { hash8 := fmt.Sprintf("%x", h[:4]) name := fmt.Sprintf("d8-access-%s-%s-%s-%s-%s", - strings.ToLower(spec.SubjectKind), + strings.ToLower(string(spec.SubjectKind)), sanitizeNamePart(spec.SubjectRef), scopeNamePart(spec), strings.ToLower(spec.AccessLevel), @@ -51,12 +52,14 @@ func generateGrantName(spec *canonicalGrantSpec) (string, error) { } // 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{ - managedByLabel: managedByValue, - "deckhouse.io/access-model": "current", - "deckhouse.io/access-subject-kind": strings.ToLower(spec.SubjectKind), - "deckhouse.io/access-scope": spec.ScopeType, + iamtypes.LabelManagedBy: iamtypes.ManagedByValueCLI, + iamtypes.LabelAccessModel: string(iamtypes.ModelCurrent), + iamtypes.LabelAccessSubjectKind: strings.ToLower(string(spec.SubjectKind)), + iamtypes.LabelAccessScope: string(spec.ScopeType), } } @@ -67,10 +70,10 @@ func grantAnnotations(spec *canonicalGrantSpec) (map[string]string, error) { return nil, err } return map[string]string{ - "deckhouse.io/access-subject-ref": spec.SubjectRef, - "deckhouse.io/access-subject-principal": spec.SubjectPrincipal, - "deckhouse.io/access-canonical-spec": jsonStr, - "deckhouse.io/access-created-by-version": version.Version, + iamtypes.AnnotationAccessSubjectRef: spec.SubjectRef, + iamtypes.AnnotationAccessSubjectPrincipal: spec.SubjectPrincipal, + iamtypes.AnnotationAccessCanonicalSpec: jsonStr, + iamtypes.AnnotationAccessCreatedByVersion: version.Version, }, nil } @@ -88,14 +91,14 @@ func sanitizeNamePart(s string) string { func scopeNamePart(spec *canonicalGrantSpec) string { switch spec.ScopeType { - case "namespace": + case iamtypes.ScopeNamespace: if len(spec.Namespaces) == 1 { return spec.Namespaces[0] } return "multi-ns" - case "cluster": + case iamtypes.ScopeCluster: return "cluster" - case "all-namespaces": + case iamtypes.ScopeAllNamespaces: return "all" default: return "unknown" diff --git a/internal/iam/access/cmd/resolve.go b/internal/iam/access/cmd/resolve.go index 6f6c43cc..02106be6 100644 --- a/internal/iam/access/cmd/resolve.go +++ b/internal/iam/access/cmd/resolve.go @@ -25,11 +25,13 @@ import ( 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(userGVR).Get(ctx, userName, metav1.GetOptions{}) + 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) } @@ -54,7 +56,7 @@ type accessInventory struct { // memberRef is a (kind, name) pair from Group.spec.members. type memberRef struct { - Kind string + Kind iamtypes.SubjectKind Name string } @@ -67,7 +69,7 @@ func buildInventory(ctx context.Context, dyn dynamic.Interface) (*accessInventor } // Fetch users - userList, err := dyn.Resource(userGVR).List(ctx, metav1.ListOptions{}) + userList, err := dyn.Resource(iamtypes.UserGVR).List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("listing Users: %w", err) } @@ -81,7 +83,7 @@ func buildInventory(ctx context.Context, dyn dynamic.Interface) (*accessInventor } // Fetch groups - groupList, err := dyn.Resource(groupGVR).List(ctx, metav1.ListOptions{}) + groupList, err := dyn.Resource(iamtypes.GroupGVR).List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("listing Groups: %w", err) } @@ -95,7 +97,7 @@ func buildInventory(ctx context.Context, dyn dynamic.Interface) (*accessInventor continue } members = append(members, memberRef{ - Kind: fmt.Sprint(m["kind"]), + Kind: iamtypes.SubjectKind(fmt.Sprint(m["kind"])), Name: fmt.Sprint(m["name"]), }) } @@ -103,7 +105,7 @@ func buildInventory(ctx context.Context, dyn dynamic.Interface) (*accessInventor } // Fetch ClusterAuthorizationRules - carList, err := dyn.Resource(clusterAuthorizationRuleGVR).List(ctx, metav1.ListOptions{}) + carList, err := dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR).List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("listing ClusterAuthorizationRules: %w", err) } @@ -113,7 +115,7 @@ func buildInventory(ctx context.Context, dyn dynamic.Interface) (*accessInventor } // Fetch AuthorizationRules from all namespaces - arList, err := dyn.Resource(authorizationRuleGVR).Namespace("").List(ctx, metav1.ListOptions{}) + arList, err := dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace("").List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("listing AuthorizationRules: %w", err) } @@ -128,34 +130,28 @@ func buildInventory(ctx context.Context, dyn dynamic.Interface) (*accessInventor func normalizeClusterAuthRule(obj *unstructured.Unstructured) []normalizedGrant { name := obj.GetName() labels := obj.GetLabels() - managedByD8 := labels[managedByLabel] == managedByValue + managedByD8 := labels[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") - scopeType := "cluster" + scopeType := iamtypes.ScopeCluster matchAny, matchAnyFound, _ := unstructured.NestedBool(obj.Object, "spec", "namespaceSelector", "matchAny") if matchAnyFound && matchAny { - scopeType = "all-namespaces" + scopeType = iamtypes.ScopeAllNamespaces } - subjects, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") var grants []normalizedGrant - for _, s := range subjects { - sub, ok := s.(map[string]any) - if !ok { - continue - } - kind := fmt.Sprint(sub["kind"]) - if kind == "ServiceAccount" { + for _, sub := range readSubjectRefs(obj) { + if sub.Kind == iamtypes.KindServiceAccount { continue } grants = append(grants, normalizedGrant{ - SourceKind: "ClusterAuthorizationRule", + SourceKind: iamtypes.KindClusterAuthorizationRule, SourceName: name, - SubjectKind: kind, - SubjectPrincipal: fmt.Sprint(sub["name"]), + SubjectKind: sub.Kind, + SubjectPrincipal: sub.Name, AccessLevel: accessLevel, AllowScale: allowScale, PortForwarding: portForwarding, @@ -170,33 +166,27 @@ func normalizeNamespacedAuthRule(obj *unstructured.Unstructured) []normalizedGra name := obj.GetName() ns := obj.GetNamespace() labels := obj.GetLabels() - managedByD8 := labels[managedByLabel] == managedByValue + managedByD8 := labels[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") - subjects, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") var grants []normalizedGrant - for _, s := range subjects { - sub, ok := s.(map[string]any) - if !ok { - continue - } - kind := fmt.Sprint(sub["kind"]) - if kind == "ServiceAccount" { + for _, sub := range readSubjectRefs(obj) { + if sub.Kind == iamtypes.KindServiceAccount { continue } grants = append(grants, normalizedGrant{ - SourceKind: "AuthorizationRule", + SourceKind: iamtypes.KindAuthorizationRule, SourceName: name, SourceNamespace: ns, - SubjectKind: kind, - SubjectPrincipal: fmt.Sprint(sub["name"]), + SubjectKind: sub.Kind, + SubjectPrincipal: sub.Name, AccessLevel: accessLevel, AllowScale: allowScale, PortForwarding: portForwarding, - ScopeType: "namespace", + ScopeType: iamtypes.ScopeNamespace, ScopeNamespaces: []string{ns}, ManagedByD8: managedByD8, }) @@ -209,7 +199,7 @@ func (inv *accessInventory) ResolveUserGroups(userName string) ([]string, []stri directSet := make(map[string]bool) for gName, members := range inv.GroupMembers { for _, m := range members { - if m.Kind == "User" && m.Name == userName { + if m.Kind == iamtypes.KindUser && m.Name == userName { directSet[gName] = true } } @@ -224,7 +214,7 @@ func (inv *accessInventory) ResolveUserGroups(userName string) ([]string, []stri visited[g] = true for parentGroup, members := range inv.GroupMembers { for _, m := range members { - if m.Kind == "Group" && m.Name == g { + if m.Kind == iamtypes.KindGroup && m.Name == g { walk(parentGroup) } } @@ -261,9 +251,9 @@ func (inv *accessInventory) UserGrants(userName string) ([]normalizedGrant, []no var directGrants, inheritedGrants []normalizedGrant for _, grant := range inv.Grants { - if grant.SubjectKind == "User" && grant.SubjectPrincipal == email { + if grant.SubjectKind == iamtypes.KindUser && grant.SubjectPrincipal == email { directGrants = append(directGrants, grant) - } else if grant.SubjectKind == "Group" && groupSet[grant.SubjectPrincipal] { + } else if grant.SubjectKind == iamtypes.KindGroup && groupSet[grant.SubjectPrincipal] { inheritedGrants = append(inheritedGrants, grant) } } @@ -274,7 +264,7 @@ func (inv *accessInventory) UserGrants(userName string) ([]normalizedGrant, []no func (inv *accessInventory) GroupGrants(groupName string) []normalizedGrant { var grants []normalizedGrant for _, grant := range inv.Grants { - if grant.SubjectKind == "Group" && grant.SubjectPrincipal == groupName { + if grant.SubjectKind == iamtypes.KindGroup && grant.SubjectPrincipal == groupName { grants = append(grants, grant) } } @@ -311,9 +301,9 @@ func computeEffectiveSummary(grants []normalizedGrant) *effectiveSummary { summary.PortForwarding = true } switch g.ScopeType { - case "cluster", "all-namespaces": + case iamtypes.ScopeCluster, iamtypes.ScopeAllNamespaces: clusterLevels = append(clusterLevels, g.AccessLevel) - case "namespace": + case iamtypes.ScopeNamespace: for _, ns := range g.ScopeNamespaces { nsLevels[ns] = append(nsLevels[ns], g.AccessLevel) } @@ -406,7 +396,7 @@ func (inv *accessInventory) DetectGroupCycles() map[string][]string { path = append(path, g) for _, m := range inv.GroupMembers[g] { - if m.Kind == "Group" { + if m.Kind == iamtypes.KindGroup { dfs(m.Name) } } diff --git a/internal/iam/access/cmd/resolve_test.go b/internal/iam/access/cmd/resolve_test.go index 6d946e0b..d24b3aa4 100644 --- a/internal/iam/access/cmd/resolve_test.go +++ b/internal/iam/access/cmd/resolve_test.go @@ -21,6 +21,8 @@ import ( "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" ) func TestResolveUserGroups(t *testing.T) { @@ -237,10 +239,10 @@ func TestNormalizeClusterAuthRule(t *testing.T) { grants := normalizeClusterAuthRule(obj) assert.Len(t, grants, 2) // ServiceAccount filtered out - assert.Equal(t, "User", grants[0].SubjectKind) + 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, "Group", grants[1].SubjectKind) + assert.Equal(t, iamtypes.KindGroup, grants[1].SubjectKind) } func TestNormalizeClusterAuthRule_AllNamespaces(t *testing.T) { @@ -263,5 +265,5 @@ func TestNormalizeClusterAuthRule_AllNamespaces(t *testing.T) { grants := normalizeClusterAuthRule(obj) assert.Len(t, grants, 1) - assert.Equal(t, "all-namespaces", grants[0].ScopeType) + assert.Equal(t, iamtypes.ScopeAllNamespaces, grants[0].ScopeType) } diff --git a/internal/iam/access/cmd/revoke.go b/internal/iam/access/cmd/revoke.go index 85ba24a0..67ca7a64 100644 --- a/internal/iam/access/cmd/revoke.go +++ b/internal/iam/access/cmd/revoke.go @@ -17,6 +17,7 @@ limitations under the License. package access import ( + "errors" "fmt" "github.com/hashicorp/go-multierror" @@ -26,6 +27,7 @@ import ( "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" ) @@ -116,7 +118,7 @@ func runRevoke(cmd *cobra.Command, args []string) error { func runIntentBasedRevoke(cmd *cobra.Command, args []string) error { if len(args) != 2 { - return fmt.Errorf("intent-based revoke requires: revoke (user|group) --access-level [scope]") + return errors.New("intent-based revoke requires: revoke (user|group) --access-level [scope]") } subjectKindStr := args[0] @@ -129,7 +131,7 @@ func runIntentBasedRevoke(cmd *cobra.Command, args []string) error { allowScale, _ := cmd.Flags().GetBool("allow-scale") if accessLevel == "" { - return fmt.Errorf("--access-level is required for intent-based revoke") + return errors.New("--access-level is required for intent-based revoke") } subjectKind, err := parseSubjectKind(subjectKindStr) @@ -149,116 +151,138 @@ func runIntentBasedRevoke(cmd *cobra.Command, args []string) error { var subjectPrincipal string switch subjectKind { - case "User": + case iamtypes.KindUser: subjectPrincipal, err = resolveUserEmail(cmd.Context(), dyn, subjectName) if err != nil { return err } - case "Group": + case iamtypes.KindGroup: subjectPrincipal = subjectName } - switch scopeType { - case "namespace": - return revokeNamespaced(cmd, dyn, subjectKind, subjectName, subjectPrincipal, accessLevel, namespaces, allowScale, portForwarding) - case "cluster", "all-namespaces": - return revokeCluster(cmd, dyn, subjectKind, subjectName, subjectPrincipal, accessLevel, scopeType, allowScale, portForwarding) + opts := revokeOpts{ + subjectKind: subjectKind, + subjectRef: subjectName, + subjectPrincipal: subjectPrincipal, + accessLevel: accessLevel, + scopeType: scopeType, + namespaces: namespaces, + allowScale: allowScale, + portForwarding: portForwarding, } - return nil + return revokeManagedGrants(cmd, dyn, opts) } -func revokeNamespaced(cmd *cobra.Command, dyn dynamic.Interface, - subjectKind, subjectRef, subjectPrincipal, accessLevel string, - namespaces []string, allowScale, portForwarding bool) error { - var errs *multierror.Error - for _, ns := range namespaces { - spec := &canonicalGrantSpec{ - Model: "current", - SubjectKind: subjectKind, - SubjectRef: subjectRef, - SubjectPrincipal: subjectPrincipal, - AccessLevel: accessLevel, - ScopeType: "namespace", - Namespaces: []string{ns}, - AllowScale: allowScale, - PortForwarding: portForwarding, - } +// revokeOpts mirrors grantOpts: it captures every parameter of an +// intent-based 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 + allowScale bool + portForwarding bool +} - name, err := generateGrantName(spec) - if err != nil { - return err - } +// 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, + AllowScale: opts.allowScale, + PortForwarding: opts.portForwarding, + }) + if err != nil { + return err + } - client := dyn.Resource(authorizationRuleGVR).Namespace(ns) - obj, err := client.Get(cmd.Context(), name, metav1.GetOptions{}) + var errs *multierror.Error + for _, spec := range specs { + client, kind, ns, err := revokeClient(dyn, spec) if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: d8-managed AuthorizationRule %q not found in namespace %q\n", name, ns) - errs = multierror.Append(errs, fmt.Errorf("namespace %s: %w", ns, err)) - continue - } - - labels := obj.GetLabels() - if labels[managedByLabel] != managedByValue { - return fmt.Errorf("AuthorizationRule %q in namespace %q is not managed by d8-cli; use --from to revoke from it explicitly", name, ns) + return err } - - err = client.Delete(cmd.Context(), name, metav1.DeleteOptions{}) - if err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to delete AuthorizationRule %q in %q: %v\n", name, ns, err) - errs = multierror.Append(errs, fmt.Errorf("namespace %s: %w", ns, err)) - continue + if err := deleteManagedGrant(cmd, client, spec, kind, ns); err != nil { + errs = multierror.Append(errs, err) } - cmd.Printf("Revoked: AuthorizationRule/%s/%s\n", ns, name) } - return errs.ErrorOrNil() } -func revokeCluster(cmd *cobra.Command, dyn dynamic.Interface, - subjectKind, subjectRef, subjectPrincipal, accessLevel, scopeType string, - allowScale, portForwarding bool) error { - spec := &canonicalGrantSpec{ - Model: "current", - SubjectKind: subjectKind, - SubjectRef: subjectRef, - SubjectPrincipal: subjectPrincipal, - AccessLevel: accessLevel, - ScopeType: scopeType, - AllowScale: allowScale, - PortForwarding: portForwarding, +// 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: + 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 (cluster or +// namespaced — the caller picks the dynamic.ResourceInterface). Refuses to +// touch objects that are not labelled as managed by d8-cli; that error path +// is what tells users to switch to --from for manual cleanup. +func deleteManagedGrant(cmd *cobra.Command, client dynamic.ResourceInterface, + spec *canonicalGrantSpec, kind, ns string) error { name, err := generateGrantName(spec) if err != nil { return err } - client := dyn.Resource(clusterAuthorizationRuleGVR) obj, err := client.Get(cmd.Context(), name, metav1.GetOptions{}) if err != nil { - return fmt.Errorf("d8-managed ClusterAuthorizationRule %q not found: %w", name, err) + ref := formatRuleRef(kind, ns, name) + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: d8-managed %s not found\n", ref) + return fmt.Errorf("%s: %w", ref, err) } - labels := obj.GetLabels() - if labels[managedByLabel] != managedByValue { - return fmt.Errorf("ClusterAuthorizationRule %q is not managed by d8-cli; use --from to revoke explicitly", name) + if obj.GetLabels()[iamtypes.LabelManagedBy] != iamtypes.ManagedByValueCLI { + return fmt.Errorf("%s is not managed by d8-cli; use --from to revoke from it explicitly", formatRuleRef(kind, ns, name)) } - err = client.Delete(cmd.Context(), name, metav1.DeleteOptions{}) - if err != nil { - return fmt.Errorf("deleting ClusterAuthorizationRule %q: %w", name, err) + 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: ClusterAuthorizationRule/%s\n", name) + 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) +} + func runSourceBasedRevoke(cmd *cobra.Command, args []string) error { fromFlag, _ := cmd.Flags().GetString("from") deleteEmpty, _ := cmd.Flags().GetBool("delete-empty") if len(args) != 2 { - return fmt.Errorf("source-based revoke requires: revoke --from (user|group) ") + return errors.New("source-based revoke requires: revoke --from (user|group) ") } subjectKind, err := parseSubjectKind(args[0]) if err != nil { @@ -278,82 +302,58 @@ func runSourceBasedRevoke(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("invalid --from: %w", err) } + + var client dynamic.ResourceInterface switch refKind { - case "ClusterAuthorizationRule": - return revokeSubjectFromClusterRule(cmd, dyn, refName, subjectKind, subjectName, deleteEmpty) - case "AuthorizationRule": - return revokeSubjectFromNamespacedRule(cmd, dyn, refNS, refName, subjectKind, subjectName, deleteEmpty) + case iamtypes.KindClusterAuthorizationRule: + client = dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR) + case iamtypes.KindAuthorizationRule: + client = dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace(refNS) default: return fmt.Errorf("unsupported --from kind %q", refKind) } + return revokeSubjectFromRule(cmd, client, refKind, refNS, refName, subjectKind, subjectName, deleteEmpty) } -func revokeSubjectFromClusterRule(cmd *cobra.Command, dyn dynamic.Interface, ruleName, subjectKind, subjectPrincipal string, deleteEmpty bool) error { - client := dyn.Resource(clusterAuthorizationRuleGVR) - obj, err := client.Get(cmd.Context(), ruleName, metav1.GetOptions{}) - if err != nil { - return fmt.Errorf("getting ClusterAuthorizationRule %q: %w", ruleName, err) - } - - newSubjects, removed := removeSubject(obj, subjectKind, subjectPrincipal) - if !removed { - return fmt.Errorf("subject %s/%s not found in ClusterAuthorizationRule %q", subjectKind, subjectPrincipal, ruleName) - } - - if len(newSubjects) == 0 && deleteEmpty { - err = client.Delete(cmd.Context(), ruleName, metav1.DeleteOptions{}) - if err != nil { - return fmt.Errorf("deleting empty ClusterAuthorizationRule %q: %w", ruleName, err) - } - cmd.Printf("Deleted empty ClusterAuthorizationRule/%s\n", ruleName) - return nil - } - - if err := unstructured.SetNestedSlice(obj.Object, newSubjects, "spec", "subjects"); err != nil { - return fmt.Errorf("setting subjects: %w", err) - } - _, err = client.Update(cmd.Context(), obj, metav1.UpdateOptions{}) - if err != nil { - return fmt.Errorf("updating ClusterAuthorizationRule %q: %w", ruleName, err) - } - cmd.Printf("Removed subject %s/%s from ClusterAuthorizationRule/%s\n", subjectKind, subjectPrincipal, ruleName) - return nil -} +// revokeSubjectFromRule removes a single subject from any AR or CAR. The +// dynamic.ResourceInterface argument is what makes the cluster vs namespaced +// difference disappear; the kind/ns are kept around purely for human-readable +// messages. +func revokeSubjectFromRule(cmd *cobra.Command, client dynamic.ResourceInterface, + ruleKind, ns, ruleName string, subjectKind iamtypes.SubjectKind, subjectPrincipal string, deleteEmpty bool) error { + ref := formatRuleRef(ruleKind, ns, ruleName) -func revokeSubjectFromNamespacedRule(cmd *cobra.Command, dyn dynamic.Interface, ns, ruleName, subjectKind, subjectPrincipal string, deleteEmpty bool) error { - client := dyn.Resource(authorizationRuleGVR).Namespace(ns) obj, err := client.Get(cmd.Context(), ruleName, metav1.GetOptions{}) if err != nil { - return fmt.Errorf("getting AuthorizationRule %q in namespace %q: %w", ruleName, ns, err) + return fmt.Errorf("getting %s: %w", ref, err) } newSubjects, removed := removeSubject(obj, subjectKind, subjectPrincipal) if !removed { - return fmt.Errorf("subject %s/%s not found in AuthorizationRule %s/%s", subjectKind, subjectPrincipal, ns, ruleName) + return fmt.Errorf("subject %s/%s not found in %s", subjectKind, subjectPrincipal, ref) } if len(newSubjects) == 0 && deleteEmpty { - err = client.Delete(cmd.Context(), ruleName, metav1.DeleteOptions{}) - if err != nil { - return fmt.Errorf("deleting empty AuthorizationRule %s/%s: %w", ns, ruleName, err) + if err := client.Delete(cmd.Context(), ruleName, metav1.DeleteOptions{}); err != nil { + return fmt.Errorf("deleting empty %s: %w", ref, err) } - cmd.Printf("Deleted empty AuthorizationRule/%s/%s\n", ns, ruleName) + cmd.Printf("Deleted empty %s\n", ref) return nil } if err := unstructured.SetNestedSlice(obj.Object, newSubjects, "spec", "subjects"); err != nil { - return fmt.Errorf("setting subjects: %w", err) + return fmt.Errorf("setting subjects on %s: %w", ref, err) } - _, err = client.Update(cmd.Context(), obj, metav1.UpdateOptions{}) - if err != nil { - return fmt.Errorf("updating AuthorizationRule %s/%s: %w", ns, ruleName, err) + if _, err := client.Update(cmd.Context(), obj, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating %s: %w", ref, err) } - cmd.Printf("Removed subject %s/%s from AuthorizationRule/%s/%s\n", subjectKind, subjectPrincipal, ns, ruleName) + cmd.Printf("Removed subject %s/%s from %s\n", subjectKind, subjectPrincipal, ref) return nil } -func removeSubject(obj *unstructured.Unstructured, kind, name string) ([]any, bool) { +func removeSubject(obj *unstructured.Unstructured, kind iamtypes.SubjectKind, name string) ([]any, bool) { subjects, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") + kindStr := string(kind) var newSubjects []any removed := false for _, s := range subjects { @@ -362,7 +362,7 @@ func removeSubject(obj *unstructured.Unstructured, kind, name string) ([]any, bo newSubjects = append(newSubjects, s) continue } - if fmt.Sprint(sub["kind"]) == kind && fmt.Sprint(sub["name"]) == name { + if fmt.Sprint(sub["kind"]) == kindStr && fmt.Sprint(sub["name"]) == name { removed = true continue } diff --git a/internal/iam/access/cmd/rules.go b/internal/iam/access/cmd/rules.go index 6f67f070..d9de0949 100644 --- a/internal/iam/access/cmd/rules.go +++ b/internal/iam/access/cmd/rules.go @@ -19,6 +19,7 @@ package access import ( "context" "encoding/json" + "errors" "fmt" "io" "sort" @@ -28,21 +29,27 @@ import ( "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" sigsyaml "sigs.k8s.io/yaml" + 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 + Kind string // ClusterAuthorizationRule | AuthorizationRule (object kind) Name string Namespace string // empty for CAR AccessLevel string - ScopeType string // cluster | all-namespaces | namespace + ScopeType iamtypes.Scope ScopeNamespaces []string // for namespace scope (the single namespace of the AR) AllowScale bool PortForwarding bool @@ -52,15 +59,33 @@ type ruleRow struct { } type subjectRef struct { - Kind string // User | Group | ServiceAccount | ... + Kind iamtypes.SubjectKind Name string } func (r ruleRow) ref() string { - if r.Namespace == "" { - return r.Kind + "/" + r.Name + 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 r.Kind + "/" + r.Namespace + "/" + r.Name + return out } // ------------------------------ cobra ------------------------------ @@ -142,7 +167,7 @@ func runRulesList(cmd *cobra.Command, _ []string) error { outputFmt, _ := cmd.Flags().GetString("output") if managedOnly && manualOnly { - return fmt.Errorf("--managed-only and --manual-only are mutually exclusive") + return errors.New("--managed-only and --manual-only are mutually exclusive") } dyn, err := utilk8s.NewDynamicClient(cmd) @@ -224,10 +249,10 @@ func runRulesGet(cmd *cobra.Command, args []string) error { var obj *unstructured.Unstructured switch refKind { - case "ClusterAuthorizationRule": - obj, err = dyn.Resource(clusterAuthorizationRuleGVR).Get(cmd.Context(), refName, metav1.GetOptions{}) - case "AuthorizationRule": - obj, err = dyn.Resource(authorizationRuleGVR).Namespace(refNS).Get(cmd.Context(), refName, metav1.GetOptions{}) + 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) @@ -255,7 +280,7 @@ func collectRuleRows(ctx context.Context, dyn dynamic.Interface, includeCARs, in var rows []ruleRow if includeCARs { - list, err := dyn.Resource(clusterAuthorizationRuleGVR).List(ctx, metav1.ListOptions{}) + list, err := dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR).List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("listing ClusterAuthorizationRules: %w", err) } @@ -270,7 +295,7 @@ func collectRuleRows(ctx context.Context, dyn dynamic.Interface, includeCARs, in nsToList = nsFilter } for _, ns := range nsToList { - list, err := dyn.Resource(authorizationRuleGVR).Namespace(ns).List(ctx, metav1.ListOptions{}) + 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) } @@ -292,32 +317,21 @@ func ruleRowFromObject(obj *unstructured.Unstructured) ruleRow { kind := obj.GetKind() ns := obj.GetNamespace() - scopeType := "" + var scopeType iamtypes.Scope var scopeNamespaces []string switch kind { - case "ClusterAuthorizationRule": - scopeType = "cluster" + case iamtypes.KindClusterAuthorizationRule: + scopeType = iamtypes.ScopeCluster matchAny, found, _ := unstructured.NestedBool(obj.Object, "spec", "namespaceSelector", "matchAny") if found && matchAny { - scopeType = "all-namespaces" + scopeType = iamtypes.ScopeAllNamespaces } - case "AuthorizationRule": - scopeType = "namespace" + case iamtypes.KindAuthorizationRule: + scopeType = iamtypes.ScopeNamespace scopeNamespaces = []string{ns} } - var subjects []subjectRef - raw, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") - for _, s := range raw { - m, ok := s.(map[string]any) - if !ok { - continue - } - subjects = append(subjects, subjectRef{ - Kind: fmt.Sprint(m["kind"]), - Name: fmt.Sprint(m["name"]), - }) - } + subjects := readSubjectRefs(obj) return ruleRow{ Kind: kind, @@ -328,7 +342,7 @@ func ruleRowFromObject(obj *unstructured.Unstructured) ruleRow { ScopeNamespaces: scopeNamespaces, AllowScale: allowScale, PortForwarding: portForwarding, - ManagedByD8: labels[managedByLabel] == managedByValue, + ManagedByD8: labels[iamtypes.LabelManagedBy] == iamtypes.ManagedByValueCLI, Subjects: subjects, CreationTime: obj.GetCreationTimestamp().Time, } @@ -370,9 +384,9 @@ func sortRuleRows(rows []ruleRow) { // shortKind maps full CRD names to compact column values for the table view. func shortKind(kind string) string { switch kind { - case "ClusterAuthorizationRule": + case iamtypes.KindClusterAuthorizationRule: return "CAR" - case "AuthorizationRule": + case iamtypes.KindAuthorizationRule: return "AR" default: return kind @@ -381,7 +395,7 @@ func shortKind(kind string) string { func managedByColumn(r ruleRow) string { if r.ManagedByD8 { - return "d8-cli" + return iamtypes.ManagedByValueCLI } return "manual" } @@ -439,21 +453,14 @@ func truncate(s string, max int) string { 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 "-" } - d := time.Since(t) - switch { - case d < time.Minute: - return fmt.Sprintf("%ds", int(d.Seconds())) - case d < time.Hour: - return fmt.Sprintf("%dm", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh", int(d.Hours())) - default: - return fmt.Sprintf("%dd", int(d.Hours()/24)) - } + return duration.HumanDuration(time.Since(t)) } // ------------------------------ rendering: text (get) ------------------------------ @@ -465,7 +472,11 @@ func printRuleRowText(w io.Writer, r ruleRow, reverse map[string]string) error { if r.Namespace != "" { fmt.Fprintf(w, " Namespace: %s\n", r.Namespace) } - fmt.Fprintf(w, " Access level: %s\n", firstNonEmpty(r.AccessLevel, "")) + 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) @@ -479,7 +490,7 @@ func printRuleRowText(w io.Writer, r ruleRow, reverse map[string]string) error { fmt.Fprintln(w, " ") } else { for _, s := range r.Subjects { - key := s.Kind + "/" + s.Name + key := string(s.Kind) + "/" + s.Name local := reverse[key] switch local { case "": @@ -505,13 +516,6 @@ func printRuleRowText(w io.Writer, r ruleRow, reverse map[string]string) error { return nil } -func firstNonEmpty(v, fallback string) string { - if v == "" { - return fallback - } - return v -} - // ------------------------------ rendering: json/yaml ------------------------------ type ruleJSON struct { @@ -538,14 +542,14 @@ func ruleRowToJSON(r ruleRow) ruleJSON { Name: r.Name, Namespace: r.Namespace, AccessLevel: r.AccessLevel, - Scope: r.ScopeType, + Scope: string(r.ScopeType), AllowScale: r.AllowScale, PortForwarding: r.PortForwarding, ManagedByD8: r.ManagedByD8, CreationTime: r.CreationTime, } for _, s := range r.Subjects { - out.Subjects = append(out.Subjects, subjectJSON(s)) + out.Subjects = append(out.Subjects, subjectJSON{Kind: string(s.Kind), Name: s.Name}) } if out.Subjects == nil { out.Subjects = []subjectJSON{} @@ -600,22 +604,22 @@ func printRuleRowsYAML(w io.Writer, rows []ruleRow) error { func parseRuleRef(ref string) (string, string, string, error) { parts := strings.Split(ref, "/") if len(parts) < 2 { - return "", "", "", fmt.Errorf("invalid rule reference %q; expected ClusterAuthorizationRule/NAME or AuthorizationRule/NS/NAME (short forms CAR/... and AR/... also accepted)", ref) + 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 "ClusterAuthorizationRule", "CAR", "car": + case iamtypes.KindClusterAuthorizationRule, "CAR", "car": if len(parts) != 2 { - return "", "", "", fmt.Errorf("ClusterAuthorizationRule reference must be of the form ClusterAuthorizationRule/NAME (got %q)", ref) + return "", "", "", fmt.Errorf("%w %q: ClusterAuthorizationRule reference must be of the form ClusterAuthorizationRule/NAME", ErrInvalidRuleRef, ref) } - return "ClusterAuthorizationRule", "", parts[1], nil - case "AuthorizationRule", "AR", "ar": + return iamtypes.KindClusterAuthorizationRule, "", parts[1], nil + case iamtypes.KindAuthorizationRule, "AR", "ar": if len(parts) != 3 { - return "", "", "", fmt.Errorf("AuthorizationRule reference must be of the form AuthorizationRule/NAMESPACE/NAME (got %q)", ref) + return "", "", "", fmt.Errorf("%w %q: AuthorizationRule reference must be of the form AuthorizationRule/NAMESPACE/NAME", ErrInvalidRuleRef, ref) } - return "AuthorizationRule", parts[1], parts[2], nil + return iamtypes.KindAuthorizationRule, parts[1], parts[2], nil default: - return "", "", "", fmt.Errorf("unknown rule kind %q; use ClusterAuthorizationRule, AuthorizationRule, CAR or AR", parts[0]) + return "", "", "", fmt.Errorf("%w %q: unknown rule kind %q (use ClusterAuthorizationRule, AuthorizationRule, CAR or AR)", ErrInvalidRuleRef, ref, parts[0]) } } @@ -632,15 +636,15 @@ func reverseSubjectLookup(ctx context.Context, dyn dynamic.Interface, subjects [ needGroups := false for _, s := range subjects { switch s.Kind { - case "User": + case iamtypes.KindUser: needUsers = true - case "Group": + case iamtypes.KindGroup: needGroups = true } } if needUsers { - userList, err := dyn.Resource(userGVR).List(ctx, metav1.ListOptions{}) + userList, err := dyn.Resource(iamtypes.UserGVR).List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("listing Users: %w", err) } @@ -653,17 +657,17 @@ func reverseSubjectLookup(ctx context.Context, dyn dynamic.Interface, subjects [ } } for _, s := range subjects { - if s.Kind != "User" { + if s.Kind != iamtypes.KindUser { continue } if cr, ok := emailToCR[s.Name]; ok { - result["User/"+s.Name] = cr + result[string(iamtypes.KindUser)+"/"+s.Name] = cr } } } if needGroups { - groupList, err := dyn.Resource(groupGVR).List(ctx, metav1.ListOptions{}) + groupList, err := dyn.Resource(iamtypes.GroupGVR).List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("listing Groups: %w", err) } @@ -672,11 +676,11 @@ func reverseSubjectLookup(ctx context.Context, dyn dynamic.Interface, subjects [ present[groupList.Items[i].GetName()] = true } for _, s := range subjects { - if s.Kind != "Group" { + if s.Kind != iamtypes.KindGroup { continue } if present[s.Name] { - result["Group/"+s.Name] = s.Name + result[string(iamtypes.KindGroup)+"/"+s.Name] = s.Name } } } diff --git a/internal/iam/access/cmd/rules_test.go b/internal/iam/access/cmd/rules_test.go index e6ea305f..c40e3c9b 100644 --- a/internal/iam/access/cmd/rules_test.go +++ b/internal/iam/access/cmd/rules_test.go @@ -18,6 +18,7 @@ package access import ( "bytes" + "errors" "testing" "time" @@ -25,16 +26,17 @@ import ( "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 - errContains string + input string + wantKind string + wantNS string + wantName string + wantErr bool }{ {input: "ClusterAuthorizationRule/superadmins", wantKind: "ClusterAuthorizationRule", wantName: "superadmins"}, {input: "CAR/superadmins", wantKind: "ClusterAuthorizationRule", wantName: "superadmins"}, @@ -42,19 +44,18 @@ func TestParseRuleRef(t *testing.T) { {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, errContains: "invalid rule reference"}, - {input: "CAR/dev/editors", wantErr: true, errContains: "must be of the form ClusterAuthorizationRule/NAME"}, - {input: "AR/editors", wantErr: true, errContains: "must be of the form AuthorizationRule/NAMESPACE/NAME"}, - {input: "Role/dev/x", wantErr: true, errContains: "unknown rule kind"}, + {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) - if tc.errContains != "" { - assert.Contains(t, err.Error(), tc.errContains) - } + assert.True(t, errors.Is(err, ErrInvalidRuleRef), + "expected ErrInvalidRuleRef, got %v", err) return } require.NoError(t, err) @@ -133,7 +134,7 @@ func TestRuleRowFromObject_CAR(t *testing.T) { "kind": "ClusterAuthorizationRule", "metadata": map[string]any{ "name": "superadmins", - "labels": map[string]any{managedByLabel: managedByValue}, + "labels": map[string]any{iamtypes.LabelManagedBy: iamtypes.ManagedByValueCLI}, }, "spec": map[string]any{ "accessLevel": "SuperAdmin", @@ -156,7 +157,7 @@ func TestRuleRowFromObject_CAR(t *testing.T) { assert.Equal(t, "superadmins", row.Name) assert.Empty(t, row.Namespace) assert.Equal(t, "SuperAdmin", row.AccessLevel) - assert.Equal(t, "all-namespaces", row.ScopeType) + assert.Equal(t, iamtypes.ScopeAllNamespaces, row.ScopeType) assert.True(t, row.AllowScale) assert.True(t, row.PortForwarding) assert.True(t, row.ManagedByD8) @@ -185,7 +186,7 @@ func TestRuleRowFromObject_AR(t *testing.T) { assert.Equal(t, "AuthorizationRule", row.Kind) assert.Equal(t, "editors", row.Name) assert.Equal(t, "dev", row.Namespace) - assert.Equal(t, "namespace", row.ScopeType) + 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()) @@ -240,12 +241,8 @@ func TestTruncate(t *testing.T) { assert.Equal(t, "a", truncate("abc", 1)) } -func TestHumanAge(t *testing.T) { - // Zero time returns a placeholder. +// 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{})) - - assert.Regexp(t, `^\d+s$`, humanAge(time.Now().Add(-10*time.Second))) - assert.Regexp(t, `^\d+m$`, humanAge(time.Now().Add(-10*time.Minute))) - assert.Regexp(t, `^\d+h$`, humanAge(time.Now().Add(-3*time.Hour))) - assert.Regexp(t, `^\d+d$`, humanAge(time.Now().Add(-48*time.Hour))) } diff --git a/internal/iam/access/cmd/types.go b/internal/iam/access/cmd/types.go index e46da5d8..e61fcca2 100644 --- a/internal/iam/access/cmd/types.go +++ b/internal/iam/access/cmd/types.go @@ -22,58 +22,66 @@ import ( "sort" "strings" - "k8s.io/apimachinery/pkg/runtime/schema" + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" ) -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", - } +// namespacedAccessLevelsOrdered is the source of truth for access levels +// allowed at namespace scope, listed from least to most privileged. +var namespacedAccessLevelsOrdered = []string{ + "User", "PrivilegedUser", "Editor", "Admin", +} + +// clusterOnlyAccessLevelsOrdered are the levels that only make sense at +// cluster scope. Combined with namespacedAccessLevelsOrdered they form the +// full set of access levels accepted by `d8 iam access grant`. +var clusterOnlyAccessLevelsOrdered = []string{ + "ClusterEditor", "ClusterAdmin", "SuperAdmin", +} + +// allAccessLevelsOrdered is the full ordered list of access levels in +// least-to-most privileged order. Used for completion and error messages +// so user-facing output stays in a predictable order. +var allAccessLevelsOrdered = append( + append([]string(nil), namespacedAccessLevelsOrdered...), + clusterOnlyAccessLevelsOrdered..., ) // accessLevelOrder defines the hierarchy: higher index = more privileged. -var accessLevelOrder = map[string]int{ - "User": 0, - "PrivilegedUser": 1, - "Editor": 2, - "Admin": 3, - "ClusterEditor": 4, - "ClusterAdmin": 5, - "SuperAdmin": 6, -} +// Built from allAccessLevelsOrdered so a single rename keeps everything in +// sync. +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 = map[string]bool{ - "User": true, "PrivilegedUser": true, "Editor": true, "Admin": true, -} +var namespacedAccessLevels = sliceToSet(namespacedAccessLevelsOrdered) +var allAccessLevels = sliceToSet(allAccessLevelsOrdered) -var allAccessLevels = map[string]bool{ - "User": true, "PrivilegedUser": true, "Editor": true, "Admin": true, - "ClusterEditor": true, "ClusterAdmin": true, "SuperAdmin": true, +func sliceToSet(s []string) map[string]bool { + m := make(map[string]bool, len(s)) + for _, v := range s { + m[v] = true + } + return m } -const managedByLabel = "app.kubernetes.io/managed-by" -const managedByValue = "d8-cli" - -// canonicalGrantSpec is the canonical representation of a grant for hashing and comparison. +// canonicalGrantSpec is the canonical representation of a grant for hashing +// and comparison. The typed Model/SubjectKind/ScopeType fields encode-decode +// to plain JSON strings (Go's encoding/json treats `type X string` as a +// string), so on-disk annotations stay byte-compatible with previous releases. type canonicalGrantSpec struct { - Model string `json:"model"` - SubjectKind string `json:"subjectKind"` - SubjectRef string `json:"subjectRef"` - SubjectPrincipal string `json:"subjectPrincipal"` - AccessLevel string `json:"accessLevel"` - ScopeType string `json:"scopeType"` - Namespaces []string `json:"namespaces,omitempty"` - AllowScale bool `json:"allowScale"` - PortForwarding bool `json:"portForwarding"` + 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"` + AllowScale bool `json:"allowScale"` + PortForwarding bool `json:"portForwarding"` } func (c *canonicalGrantSpec) JSON() (string, error) { @@ -93,18 +101,18 @@ func (c *canonicalGrantSpec) JSON() (string, error) { // normalizedGrant is the internal representation of a grant from any source. type normalizedGrant struct { - SourceKind string // AuthorizationRule or ClusterAuthorizationRule + SourceKind string // AuthorizationRule or ClusterAuthorizationRule (object kind) SourceName string SourceNamespace string // only for AuthorizationRule - SubjectKind string // User or Group + SubjectKind iamtypes.SubjectKind SubjectPrincipal string // email for users, name for groups AccessLevel string AllowScale bool PortForwarding bool - ScopeType string // namespace, cluster, all-namespaces + ScopeType iamtypes.Scope ScopeNamespaces []string // for namespace scope ManagedByD8 bool @@ -113,14 +121,14 @@ type normalizedGrant struct { func validateAccessLevel(level string, namespaced bool) error { if namespaced { if !namespacedAccessLevels[level] { - valid := []string{"User", "PrivilegedUser", "Editor", "Admin"} - return fmt.Errorf("access level %q is not valid for namespaced scope; valid levels: %s", level, strings.Join(valid, ", ")) - } - } else { - if !allAccessLevels[level] { - valid := []string{"User", "PrivilegedUser", "Editor", "Admin", "ClusterEditor", "ClusterAdmin", "SuperAdmin"} - return fmt.Errorf("invalid access level %q; valid levels: %s", level, strings.Join(valid, ", ")) + 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 } @@ -136,3 +144,18 @@ func maxAccessLevel(levels []string) string { } return best } + +// canonicalGrantInput captures the minimal subset of grant/revoke options +// needed to expand a user-facing intent into one canonicalGrantSpec per +// concrete object. It is intentionally a flat value type so callers don't +// have to leak grantOpts/revokeOpts into shared helpers. +type canonicalGrantInput struct { + SubjectKind iamtypes.SubjectKind + SubjectRef string + SubjectPrincipal string + AccessLevel string + ScopeType iamtypes.Scope + Namespaces []string + AllowScale bool + PortForwarding bool +} diff --git a/internal/iam/group/cmd/add_member.go b/internal/iam/group/cmd/add_member.go index 5ca8a017..4d75826b 100644 --- a/internal/iam/group/cmd/add_member.go +++ b/internal/iam/group/cmd/add_member.go @@ -17,14 +17,15 @@ limitations under the License. package group import ( + "errors" "fmt" "strings" "github.com/spf13/cobra" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + 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" ) @@ -74,65 +75,38 @@ func runAddMember(cmd *cobra.Command, args []string) error { return err } - ctx := cmd.Context() - groupClient := dyn.Resource(groupGVR) - - obj, err := groupClient.Get(ctx, groupName, metav1.GetOptions{}) + res, err := EnsureMember(cmd.Context(), dyn, groupName, memberKind, memberName, EnsureMemberOpts{ + CreateGroupIfMissing: createGroup, + CycleCheck: true, + }) if err != nil { - if !createGroup { - return fmt.Errorf("group %q not found (use --create-group to create it): %w", groupName, err) - } - obj = buildGroupObject(groupName) - obj, err = groupClient.Create(ctx, obj, metav1.CreateOptions{}) - if err != nil { - return fmt.Errorf("creating group %q: %w", groupName, err) - } - fmt.Fprintf(cmd.ErrOrStderr(), "Group %q created\n", groupName) - } - - // Cycle detection for group->group membership - if memberKind == "Group" { - hasCycle, cyclePath, err := detectCycle(cmd, dyn, groupName, memberName) - if err != nil { - return fmt.Errorf("cycle detection failed: %w", err) - } - if hasCycle { - return fmt.Errorf("adding group %q to %q would create a cycle: %s", memberName, groupName, strings.Join(cyclePath, " -> ")) - } - } - - members, _ := getGroupMembers(obj) - for _, m := range members { - if fmt.Sprint(m["kind"]) == memberKind && fmt.Sprint(m["name"]) == memberName { - cmd.Printf("Member %s/%s already exists in group %s\n", memberKind, memberName, groupName) - return 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 } - rawMembers, _, _ := unstructured.NestedSlice(obj.Object, "spec", "members") - rawMembers = append(rawMembers, map[string]any{ - "kind": memberKind, - "name": memberName, - }) - if err := unstructured.SetNestedSlice(obj.Object, rawMembers, "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) + 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) } - - cmd.Printf("Added %s %q to group %q\n", strings.ToLower(memberKind), memberName, groupName) return nil } -func normalizeMemberKind(kind string) (string, error) { +func normalizeMemberKind(kind string) (iamtypes.SubjectKind, error) { switch strings.ToLower(kind) { case "user": - return "User", nil + return iamtypes.KindUser, nil case "group": - return "Group", nil + return iamtypes.KindGroup, nil default: return "", fmt.Errorf("invalid member kind %q: must be user or group", kind) } @@ -151,6 +125,6 @@ func parseMemberArgs(args []string) (string, string, string, error) { case 3: return args[0], args[1], args[2], nil default: - return "", "", "", fmt.Errorf("expected GROUP MEMBER or GROUP (user|group) MEMBER, got %d arguments", len(args)) + 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 index d71b8bed..eebe01a0 100644 --- a/internal/iam/group/cmd/completion.go +++ b/internal/iam/group/cmd/completion.go @@ -18,17 +18,11 @@ package group import ( "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/runtime/schema" + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) -var userGVRForCompletion = schema.GroupVersionResource{ - Group: "deckhouse.io", - Version: "v1", - Resource: "users", -} - // memberKinds is the enum accepted as the positional member-kind argument // on "group add-member" / "group remove-member" commands. var memberKinds = []string{"user", "group"} @@ -38,7 +32,7 @@ func completeGroupOnly(cmd *cobra.Command, args []string, toComplete string) ([] if len(args) >= 1 { return nil, cobra.ShellCompDirectiveNoFileComp } - return utilk8s.CompleteResourceNames(cmd, groupGVR, "", toComplete) + return utilk8s.CompleteResourceNames(cmd, iamtypes.GroupGVR, "", toComplete) } // completeMemberArgs completes "GROUP [user|group] NAME" triplets for @@ -46,15 +40,15 @@ func completeGroupOnly(cmd *cobra.Command, args []string, toComplete string) ([] func completeMemberArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { switch len(args) { case 0: - return utilk8s.CompleteResourceNames(cmd, groupGVR, "", toComplete) + 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, userGVRForCompletion, "", toComplete) + return utilk8s.CompleteResourceNames(cmd, iamtypes.UserGVR, "", toComplete) case "group": - return utilk8s.CompleteResourceNames(cmd, groupGVR, "", toComplete) + 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 index ffa31dc0..fa960acd 100644 --- a/internal/iam/group/cmd/create.go +++ b/internal/iam/group/cmd/create.go @@ -24,6 +24,7 @@ import ( "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" ) @@ -58,7 +59,7 @@ func newCreateCommand() *cobra.Command { return err } - created, err := dyn.Resource(groupGVR).Create(cmd.Context(), obj, metav1.CreateOptions{}) + created, err := dyn.Resource(iamtypes.GroupGVR).Create(cmd.Context(), obj, metav1.CreateOptions{}) if err != nil { return fmt.Errorf("creating Group %q: %w", name, err) } @@ -76,8 +77,10 @@ func newCreateCommand() *cobra.Command { func buildGroupObject(name string) *unstructured.Unstructured { return &unstructured.Unstructured{ Object: map[string]any{ - "apiVersion": "deckhouse.io/v1alpha1", - "kind": "Group", + "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, }, diff --git a/internal/iam/group/cmd/delete.go b/internal/iam/group/cmd/delete.go index e87a1690..dcc0f680 100644 --- a/internal/iam/group/cmd/delete.go +++ b/internal/iam/group/cmd/delete.go @@ -22,6 +22,7 @@ import ( "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" ) @@ -41,7 +42,7 @@ func newDeleteCommand() *cobra.Command { return err } - err = dyn.Resource(groupGVR).Delete(cmd.Context(), name, metav1.DeleteOptions{}) + err = dyn.Resource(iamtypes.GroupGVR).Delete(cmd.Context(), name, metav1.DeleteOptions{}) if err != nil { return fmt.Errorf("deleting Group %q: %w", name, err) } diff --git a/internal/iam/group/cmd/get.go b/internal/iam/group/cmd/get.go index 514a95cc..108ff681 100644 --- a/internal/iam/group/cmd/get.go +++ b/internal/iam/group/cmd/get.go @@ -24,6 +24,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/cli-runtime/pkg/printers" + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -44,7 +45,7 @@ func newGetCommand() *cobra.Command { return err } - obj, err := dyn.Resource(groupGVR).Get(cmd.Context(), name, metav1.GetOptions{}) + obj, err := dyn.Resource(iamtypes.GroupGVR).Get(cmd.Context(), name, metav1.GetOptions{}) if err != nil { return fmt.Errorf("getting Group %q: %w", name, err) } @@ -73,12 +74,13 @@ func printGroupDetail(cmd *cobra.Command, obj *unstructured.Unstructured) error } members, _ := getGroupMembers(obj) + groupKindStr := string(iamtypes.KindGroup) var users, groups []string for _, m := range members { kind := fmt.Sprint(m["kind"]) mName := fmt.Sprint(m["name"]) switch kind { - case "Group": + case groupKindStr: groups = append(groups, mName) default: users = append(users, mName) @@ -154,7 +156,7 @@ func printGroupTable(cmd *cobra.Command, groups []*unstructured.Unstructured) er userCount := 0 nestedCount := 0 for _, m := range members { - if fmt.Sprint(m["kind"]) == "Group" { + if fmt.Sprint(m["kind"]) == string(iamtypes.KindGroup) { nestedCount++ } else { userCount++ diff --git a/internal/iam/group/cmd/group_test.go b/internal/iam/group/cmd/group_test.go index 099af10f..c51fd72d 100644 --- a/internal/iam/group/cmd/group_test.go +++ b/internal/iam/group/cmd/group_test.go @@ -23,6 +23,8 @@ import ( "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) { @@ -72,14 +74,14 @@ func TestParseMemberArgs(t *testing.T) { func TestNormalizeMemberKind(t *testing.T) { tests := []struct { input string - want string + want iamtypes.SubjectKind wantErr string }{ - {input: "user", want: "User"}, - {input: "User", want: "User"}, - {input: "USER", want: "User"}, - {input: "group", want: "Group"}, - {input: "Group", want: "Group"}, + {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 { diff --git a/internal/iam/group/cmd/list.go b/internal/iam/group/cmd/list.go index 4c64a6cf..9abafda8 100644 --- a/internal/iam/group/cmd/list.go +++ b/internal/iam/group/cmd/list.go @@ -25,6 +25,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" sigsyaml "sigs.k8s.io/yaml" + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -43,7 +44,7 @@ func newListCommand() *cobra.Command { return err } - result, err := dyn.Resource(groupGVR).List(cmd.Context(), metav1.ListOptions{}) + result, err := dyn.Resource(iamtypes.GroupGVR).List(cmd.Context(), metav1.ListOptions{}) if err != nil { return fmt.Errorf("listing Groups: %w", err) } diff --git a/internal/iam/group/cmd/membership.go b/internal/iam/group/cmd/membership.go new file mode 100644 index 00000000..b07cb588 --- /dev/null +++ b/internal/iam/group/cmd/membership.go @@ -0,0 +1,160 @@ +/* +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" + + 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{}) + if err != nil { + 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 + } + + 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..f50341dc --- /dev/null +++ b/internal/iam/group/cmd/membership_test.go @@ -0,0 +1,98 @@ +/* +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" + 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" + + 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_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 index 6117d466..b1840563 100644 --- a/internal/iam/group/cmd/remove_member.go +++ b/internal/iam/group/cmd/remove_member.go @@ -25,6 +25,7 @@ import ( "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" ) @@ -64,7 +65,7 @@ func newRemoveMemberCommand() *cobra.Command { } ctx := cmd.Context() - groupClient := dyn.Resource(groupGVR) + groupClient := dyn.Resource(iamtypes.GroupGVR) obj, err := groupClient.Get(ctx, groupName, metav1.GetOptions{}) if err != nil { @@ -72,6 +73,7 @@ func newRemoveMemberCommand() *cobra.Command { } rawMembers, _, _ := unstructured.NestedSlice(obj.Object, "spec", "members") + memberKindStr := string(memberKind) found := false var newMembers []any for _, item := range rawMembers { @@ -80,7 +82,7 @@ func newRemoveMemberCommand() *cobra.Command { newMembers = append(newMembers, item) continue } - if fmt.Sprint(m["kind"]) == memberKind && fmt.Sprint(m["name"]) == memberName { + if fmt.Sprint(m["kind"]) == memberKindStr && fmt.Sprint(m["name"]) == memberName { found = true continue } @@ -88,7 +90,7 @@ func newRemoveMemberCommand() *cobra.Command { } if !found { - cmd.Printf("Nothing to do: %s %q is not a member of group %q\n", strings.ToLower(memberKind), memberName, groupName) + cmd.Printf("Nothing to do: %s %q is not a member of group %q\n", strings.ToLower(memberKindStr), memberName, groupName) return nil } @@ -101,7 +103,7 @@ func newRemoveMemberCommand() *cobra.Command { return fmt.Errorf("updating group %q: %w", groupName, err) } - cmd.Printf("Removed %s %q from group %q\n", strings.ToLower(memberKind), memberName, groupName) + cmd.Printf("Removed %s %q from group %q\n", strings.ToLower(memberKindStr), memberName, groupName) return nil }, } diff --git a/internal/iam/group/cmd/types.go b/internal/iam/group/cmd/types.go index afeae720..757ae8cb 100644 --- a/internal/iam/group/cmd/types.go +++ b/internal/iam/group/cmd/types.go @@ -16,21 +16,7 @@ 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/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/dynamic" -) - -var groupGVR = schema.GroupVersionResource{ - Group: "deckhouse.io", - Version: "v1alpha1", - Resource: "groups", -} +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) { @@ -51,53 +37,3 @@ func getGroupMembers(obj *unstructured.Unstructured) ([]map[string]any, error) { } return result, nil } - -// detectCycle checks if adding a group member (childGroup -> parentGroup) would create a cycle. -// It loads all groups from the cluster and walks the membership graph. -func detectCycle(cmd *cobra.Command, dyn dynamic.Interface, parentGroup, childGroup string) (bool, []string, error) { - ctx := cmd.Context() - allGroups, err := dyn.Resource(groupGVR).List(ctx, metav1.ListOptions{}) - if err != nil { - return false, nil, fmt.Errorf("listing groups for cycle detection: %w", err) - } - - adj := make(map[string][]string) - for _, g := range allGroups.Items { - gName := g.GetName() - members, _ := getGroupMembers(&g) - for _, m := range members { - if fmt.Sprint(m["kind"]) == "Group" { - 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 -} diff --git a/internal/iam/types/types.go b/internal/iam/types/types.go new file mode 100644 index 00000000..bec7afff --- /dev/null +++ b/internal/iam/types/types.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 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" +) + +// 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 index 467de969..b7182a07 100644 --- a/internal/iam/user/cmd/completion.go +++ b/internal/iam/user/cmd/completion.go @@ -19,6 +19,7 @@ package user import ( "github.com/spf13/cobra" + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -29,5 +30,5 @@ func completeUserNames(cmd *cobra.Command, args []string, toComplete string) ([] if len(args) >= 1 { return nil, cobra.ShellCompDirectiveNoFileComp } - return utilk8s.CompleteResourceNames(cmd, userGVR, "", toComplete) + return utilk8s.CompleteResourceNames(cmd, iamtypes.UserGVR, "", toComplete) } diff --git a/internal/iam/user/cmd/create.go b/internal/iam/user/cmd/create.go index df11fa7a..b8d6ad2f 100644 --- a/internal/iam/user/cmd/create.go +++ b/internal/iam/user/cmd/create.go @@ -17,6 +17,7 @@ limitations under the License. package user import ( + "errors" "fmt" "os" "strings" @@ -26,26 +27,14 @@ import ( "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/validation" - "k8s.io/client-go/dynamic" "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 userGVR = schema.GroupVersionResource{ - Group: "deckhouse.io", - Version: "v1", - Resource: "users", -} - -var groupGVR = schema.GroupVersionResource{ - Group: "deckhouse.io", - Version: "v1alpha1", - Resource: "groups", -} - var createLong = templates.LongDesc(` Create a local static user in Deckhouse (User CR). @@ -102,7 +91,7 @@ func newCreateCommand() *cobra.Command { _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("name", "yaml", "json")) _ = cmd.RegisterFlagCompletionFunc("member-of", func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return utilk8s.CompleteResourceNames(cmd, groupGVR, "", toComplete) + return utilk8s.CompleteResourceNames(cmd, iamtypes.GroupGVR, "", toComplete) }) return cmd @@ -171,7 +160,7 @@ func runCreate(cmd *cobra.Command, args []string) error { return err } - created, err := dyn.Resource(userGVR).Create(cmd.Context(), obj, metav1.CreateOptions{}) + created, err := dyn.Resource(iamtypes.UserGVR).Create(cmd.Context(), obj, metav1.CreateOptions{}) if err != nil { return fmt.Errorf("creating User %q: %w", name, err) } @@ -189,7 +178,9 @@ func runCreate(cmd *cobra.Command, args []string) error { if len(memberOf) > 0 { var errs *multierror.Error for _, groupName := range memberOf { - if err := ensureGroupMember(cmd, dyn, groupName, "User", name, createGroups); err != nil { + 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) } @@ -214,8 +205,10 @@ func buildUserObject(name, email, password, ttl string) *unstructured.Unstructur return &unstructured.Unstructured{ Object: map[string]any{ - "apiVersion": "deckhouse.io/v1", - "kind": "User", + "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, }, @@ -224,66 +217,6 @@ func buildUserObject(name, email, password, ttl string) *unstructured.Unstructur } } -func ensureGroupMember(cmd *cobra.Command, dyn dynamic.Interface, groupName, memberKind, memberName string, createIfMissing bool) error { - ctx := cmd.Context() - groupClient := dyn.Resource(groupGVR) - - obj, err := groupClient.Get(ctx, groupName, metav1.GetOptions{}) - if err != nil { - if !createIfMissing { - return fmt.Errorf("group %q not found: %w", groupName, err) - } - obj = &unstructured.Unstructured{ - Object: map[string]any{ - "apiVersion": "deckhouse.io/v1alpha1", - "kind": "Group", - "metadata": map[string]any{ - "name": groupName, - }, - "spec": map[string]any{ - "name": groupName, - "members": []any{ - map[string]any{ - "kind": memberKind, - "name": memberName, - }, - }, - }, - }, - } - _, err = groupClient.Create(ctx, obj, metav1.CreateOptions{}) - if err != nil { - return fmt.Errorf("creating group %q: %w", groupName, err) - } - return nil - } - - members, _, _ := unstructured.NestedSlice(obj.Object, "spec", "members") - for _, m := range members { - member, ok := m.(map[string]any) - if !ok { - continue - } - if member["kind"] == memberKind && member["name"] == memberName { - return nil // already a member - } - } - - members = append(members, map[string]any{ - "kind": memberKind, - "name": memberName, - }) - if err := unstructured.SetNestedSlice(obj.Object, members, "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) - } - return nil -} - 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, "; ")) @@ -293,7 +226,7 @@ func validateUserName(name string) error { func validateEmail(email string) error { if email == "" { - return fmt.Errorf("--email is required") + return errors.New("--email is required") } if email != strings.ToLower(email) { return fmt.Errorf("email must be lowercase, got %q", email) diff --git a/internal/iam/user/cmd/delete.go b/internal/iam/user/cmd/delete.go index dd2e171c..73b24a97 100644 --- a/internal/iam/user/cmd/delete.go +++ b/internal/iam/user/cmd/delete.go @@ -23,6 +23,7 @@ import ( 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" ) @@ -51,7 +52,7 @@ func newDeleteCommand() *cobra.Command { return err } - err = dyn.Resource(userGVR).Delete(cmd.Context(), name, metav1.DeleteOptions{}) + err = dyn.Resource(iamtypes.UserGVR).Delete(cmd.Context(), name, metav1.DeleteOptions{}) if err != nil { return fmt.Errorf("deleting User %q: %w", name, err) } diff --git a/internal/iam/user/cmd/get.go b/internal/iam/user/cmd/get.go index 831a20cb..cae9af06 100644 --- a/internal/iam/user/cmd/get.go +++ b/internal/iam/user/cmd/get.go @@ -25,6 +25,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/cli-runtime/pkg/printers" + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -45,7 +46,7 @@ func newGetCommand() *cobra.Command { return err } - obj, err := dyn.Resource(userGVR).Get(cmd.Context(), name, metav1.GetOptions{}) + obj, err := dyn.Resource(iamtypes.UserGVR).Get(cmd.Context(), name, metav1.GetOptions{}) if err != nil { return fmt.Errorf("getting User %q: %w", name, err) } diff --git a/internal/iam/user/cmd/list.go b/internal/iam/user/cmd/list.go index 17114213..87e67350 100644 --- a/internal/iam/user/cmd/list.go +++ b/internal/iam/user/cmd/list.go @@ -25,6 +25,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" sigsyaml "sigs.k8s.io/yaml" + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -43,7 +44,7 @@ func newListCommand() *cobra.Command { return err } - result, err := dyn.Resource(userGVR).List(cmd.Context(), metav1.ListOptions{}) + result, err := dyn.Resource(iamtypes.UserGVR).List(cmd.Context(), metav1.ListOptions{}) if err != nil { return fmt.Errorf("listing Users: %w", err) } diff --git a/internal/iam/user/cmd/lock.go b/internal/iam/user/cmd/lock.go index 30f1bd06..d4d5ff57 100644 --- a/internal/iam/user/cmd/lock.go +++ b/internal/iam/user/cmd/lock.go @@ -21,7 +21,6 @@ import ( "time" "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -37,62 +36,23 @@ func newLockCommand() *cobra.Command { 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 := utilk8s.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, - }, - }, + return runUserOperation(cmd, dyn, userOpRequest{ + NamePrefix: "op-lock-", + OpType: "Lock", + User: username, + ExtraSpec: map[string]any{ + "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 + }) }, } diff --git a/internal/iam/user/cmd/password.go b/internal/iam/user/cmd/password.go index 92729ff7..10ea4a18 100644 --- a/internal/iam/user/cmd/password.go +++ b/internal/iam/user/cmd/password.go @@ -21,6 +21,7 @@ import ( "crypto/rand" "encoding/base32" "encoding/base64" + "errors" "fmt" "io" "os" @@ -56,7 +57,7 @@ func resolvePasswordMode(prompt, stdin, generate bool) (passwordMode, error) { count++ } if count > 1 { - return passwordModeNone, fmt.Errorf("only one of --password-prompt, --password-stdin, --generate-password may be specified") + return passwordModeNone, errors.New("only one of --password-prompt, --password-stdin, --generate-password may be specified") } if prompt { @@ -72,7 +73,7 @@ func resolvePasswordMode(prompt, stdin, generate bool) (passwordMode, error) { if term.IsTerminal(int(os.Stdin.Fd())) { return passwordModePrompt, nil } - return passwordModeNone, fmt.Errorf("stdin is not a terminal; use --password-stdin or --generate-password") + return passwordModeNone, errors.New("stdin is not a terminal; use --password-stdin or --generate-password") } // readPasswordPrompt reads a password interactively with confirmation. @@ -93,10 +94,10 @@ func readPasswordPrompt(stdinFd int, out io.Writer) (string, error) { } if string(pw1) != string(pw2) { - return "", fmt.Errorf("passwords do not match") + return "", errors.New("passwords do not match") } if len(pw1) == 0 { - return "", fmt.Errorf("password must not be empty") + return "", errors.New("password must not be empty") } return string(pw1), nil } @@ -108,11 +109,11 @@ func readPasswordStdin(r io.Reader) (string, error) { if err := scanner.Err(); err != nil { return "", fmt.Errorf("reading password from stdin: %w", err) } - return "", fmt.Errorf("no password provided on stdin") + return "", errors.New("no password provided on stdin") } pw := strings.TrimRight(scanner.Text(), "\r\n") if pw == "" { - return "", fmt.Errorf("password must not be empty") + return "", errors.New("password must not be empty") } return pw, nil } diff --git a/internal/iam/user/cmd/reset2fa.go b/internal/iam/user/cmd/reset2fa.go index 88e5eb6a..0ebbf52e 100644 --- a/internal/iam/user/cmd/reset2fa.go +++ b/internal/iam/user/cmd/reset2fa.go @@ -17,11 +17,7 @@ limitations under the License. package user import ( - "fmt" - "time" - "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -35,55 +31,15 @@ func newReset2FACommand() *cobra.Command { 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 := utilk8s.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 + return runUserOperation(cmd, dyn, userOpRequest{ + NamePrefix: "op-reset2fa-", + OpType: "Reset2FA", + User: args[0], + }) }, } diff --git a/internal/iam/user/cmd/reset_password.go b/internal/iam/user/cmd/reset_password.go index 99c16791..44b47778 100644 --- a/internal/iam/user/cmd/reset_password.go +++ b/internal/iam/user/cmd/reset_password.go @@ -17,11 +17,7 @@ limitations under the License. package user import ( - "fmt" - "time" - "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -36,59 +32,18 @@ func newResetPasswordCommand() *cobra.Command { 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 := utilk8s.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, - }, - }, + return runUserOperation(cmd, dyn, userOpRequest{ + NamePrefix: "op-resetpass-", + OpType: "ResetPassword", + User: args[0], + ExtraSpec: map[string]any{ + "resetPassword": map[string]any{"newPasswordHash": args[1]}, }, - } - - _, 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 + }) }, } diff --git a/internal/iam/user/cmd/types.go b/internal/iam/user/cmd/types.go index b8d82048..7f6299fd 100644 --- a/internal/iam/user/cmd/types.go +++ b/internal/iam/user/cmd/types.go @@ -18,21 +18,17 @@ 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/runtime/schema" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/dynamic" -) -var userOperationGVR = schema.GroupVersionResource{ - Group: "deckhouse.io", - Version: "v1", - Resource: "useroperations", -} + iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" +) const ( defaultWait = true @@ -62,7 +58,74 @@ func getWaitFlags(cmd *cobra.Command) (waitFlags, error) { } func createUserOperation(ctx context.Context, dyn dynamic.Interface, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { - return dyn.Resource(userOperationGVR).Create(ctx, obj, metav1.CreateOptions{}) + 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) { @@ -70,7 +133,7 @@ func waitUserOperation(ctx context.Context, dyn dynamic.Interface, name string, 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{}) + 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 @@ -90,7 +153,7 @@ func waitUserOperation(ctx context.Context, dyn dynamic.Interface, name string, } // Fetch the final object to return the latest status/message. - obj, err := dyn.Resource(userOperationGVR).Get(ctx, name, metav1.GetOptions{}) + obj, err := dyn.Resource(iamtypes.UserOperationGVR).Get(ctx, name, metav1.GetOptions{}) if err != nil { return nil, err } diff --git a/internal/iam/user/cmd/unlock.go b/internal/iam/user/cmd/unlock.go index a6b0d5e3..6d23c525 100644 --- a/internal/iam/user/cmd/unlock.go +++ b/internal/iam/user/cmd/unlock.go @@ -17,11 +17,7 @@ limitations under the License. package user import ( - "fmt" - "time" - "github.com/spf13/cobra" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -35,55 +31,15 @@ func newUnlockCommand() *cobra.Command { 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 := utilk8s.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 + return runUserOperation(cmd, dyn, userOpRequest{ + NamePrefix: "op-unlock-", + OpType: "Unlock", + User: args[0], + }) }, } From b409d79ab984132552c18532cd8c9c712ffef43b Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 28 Apr 2026 15:39:28 +0300 Subject: [PATCH 3/5] fix Signed-off-by: Ivan Zvyagintsev --- internal/iam/group/cmd/membership.go | 12 +++++++- internal/iam/group/cmd/membership_test.go | 37 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/iam/group/cmd/membership.go b/internal/iam/group/cmd/membership.go index b07cb588..1afa37df 100644 --- a/internal/iam/group/cmd/membership.go +++ b/internal/iam/group/cmd/membership.go @@ -21,6 +21,7 @@ import ( "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" @@ -51,7 +52,10 @@ func EnsureMember(ctx context.Context, dyn dynamic.Interface, memberKindStr := string(memberKind) obj, err := groupClient.Get(ctx, groupName, metav1.GetOptions{}) - if err != nil { + 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) } @@ -63,6 +67,12 @@ func EnsureMember(ctx context.Context, dyn dynamic.Interface, 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 { diff --git a/internal/iam/group/cmd/membership_test.go b/internal/iam/group/cmd/membership_test.go index f50341dc..bee97e15 100644 --- a/internal/iam/group/cmd/membership_test.go +++ b/internal/iam/group/cmd/membership_test.go @@ -17,6 +17,7 @@ limitations under the License. package group import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -27,6 +28,7 @@ import ( "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" ) @@ -75,6 +77,41 @@ func TestEnsureMember_CreatesGroupWhenMissing(t *testing.T) { 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 From 17d772b20dcd3e709600b2b15ed66e1fe56cd1c0 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 28 Apr 2026 17:59:14 +0300 Subject: [PATCH 4/5] refactor2 Signed-off-by: Ivan Zvyagintsev --- internal/iam/access/cmd/access.go | 7 +- internal/iam/access/cmd/access_test.go | 106 +++++++-- internal/iam/access/cmd/completion.go | 34 ++- internal/iam/access/cmd/explain.go | 68 ++---- internal/iam/access/cmd/grant.go | 167 ++++++++++---- internal/iam/access/cmd/list.go | 294 +++++++++++------------- internal/iam/access/cmd/naming.go | 22 ++ internal/iam/access/cmd/naming_test.go | 124 ++++++++++ internal/iam/access/cmd/print.go | 93 ++++++++ internal/iam/access/cmd/print_test.go | 156 +++++++++++++ internal/iam/access/cmd/resolve.go | 97 ++++---- internal/iam/access/cmd/resolve_test.go | 68 +++++- internal/iam/access/cmd/revoke.go | 31 ++- internal/iam/access/cmd/rules.go | 145 +++++------- internal/iam/access/cmd/types.go | 9 + internal/iam/cmd/iam.go | 16 +- internal/iam/cmd/iam_test.go | 138 +++++++++++ internal/iam/group/cmd/get.go | 174 -------------- internal/iam/group/cmd/group.go | 10 +- internal/iam/group/cmd/group_test.go | 68 ------ internal/iam/group/cmd/list.go | 84 ------- internal/iam/listget/cmd/listget.go | 97 ++++++++ internal/iam/types/types.go | 3 + internal/iam/user/cmd/create.go | 39 ++-- internal/iam/user/cmd/get.go | 156 ------------- internal/iam/user/cmd/list.go | 84 ------- internal/iam/user/cmd/lock.go | 62 ----- internal/iam/user/cmd/password.go | 139 +++++++++-- internal/iam/user/cmd/password_test.go | 79 ++++++- internal/iam/user/cmd/reset2fa.go | 49 ---- internal/iam/user/cmd/reset_password.go | 102 ++++++-- internal/iam/user/cmd/unlock.go | 49 ---- internal/iam/user/cmd/user.go | 16 +- internal/iam/user/cmd/user_ops.go | 120 ++++++++++ 34 files changed, 1664 insertions(+), 1242 deletions(-) create mode 100644 internal/iam/access/cmd/print.go create mode 100644 internal/iam/access/cmd/print_test.go create mode 100644 internal/iam/cmd/iam_test.go delete mode 100644 internal/iam/group/cmd/get.go delete mode 100644 internal/iam/group/cmd/list.go create mode 100644 internal/iam/listget/cmd/listget.go delete mode 100644 internal/iam/user/cmd/get.go delete mode 100644 internal/iam/user/cmd/list.go delete mode 100644 internal/iam/user/cmd/lock.go delete mode 100644 internal/iam/user/cmd/reset2fa.go delete mode 100644 internal/iam/user/cmd/unlock.go create mode 100644 internal/iam/user/cmd/user_ops.go diff --git a/internal/iam/access/cmd/access.go b/internal/iam/access/cmd/access.go index da997be6..2057be63 100644 --- a/internal/iam/access/cmd/access.go +++ b/internal/iam/access/cmd/access.go @@ -26,12 +26,15 @@ import ( var accessLong = templates.LongDesc(` Manage access grants in Deckhouse (current authz model). -This command provides grant, revoke, list, and explain operations for +This command provides grant, revoke, and explain operations for AuthorizationRule and ClusterAuthorizationRule custom resources. Only the current authorization model is supported in this version. Experimental model support is planned for a future release. +For inspecting existing rules and effective access, use the top-level +"d8 iam get" / "d8 iam list" commands. + © Flant JSC 2026`) func NewCommand() *cobra.Command { @@ -48,9 +51,7 @@ func NewCommand() *cobra.Command { cmd.AddCommand( newGrantCommand(), newRevokeCommand(), - newListCommand(), newExplainCommand(), - newRulesCommand(), ) return cmd diff --git a/internal/iam/access/cmd/access_test.go b/internal/iam/access/cmd/access_test.go index 03c86ceb..39753f90 100644 --- a/internal/iam/access/cmd/access_test.go +++ b/internal/iam/access/cmd/access_test.go @@ -52,37 +52,57 @@ func TestParseSubjectKind(t *testing.T) { } } -func TestParseScopeFlags(t *testing.T) { +func TestParseScope(t *testing.T) { tests := []struct { name string + scope string namespaces []string - cluster bool - allNS bool want iamtypes.Scope + wantNS []string + wantLabels map[string]string wantErr string }{ - {name: "namespace", namespaces: []string{"dev"}, want: iamtypes.ScopeNamespace}, - {name: "cluster", cluster: true, want: iamtypes.ScopeCluster}, - {name: "all-namespaces", allNS: true, want: iamtypes.ScopeAllNamespaces}, + {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+cluster", namespaces: []string{"dev"}, cluster: true, wantErr: "mutually exclusive"}, - {name: "cluster+allNS", cluster: true, allNS: true, wantErr: "mutually exclusive"}, - {name: "all three", namespaces: []string{"dev"}, cluster: true, allNS: true, wantErr: "mutually exclusive"}, + {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, err := parseScopeFlags(tt.namespaces, tt.cluster, tt.allNS) + got, ns, labels, err := parseScope(tt.scope, tt.namespaces) 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) + 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 @@ -131,6 +151,66 @@ func TestMaxAccessLevel(t *testing.T) { } } +// 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", diff --git a/internal/iam/access/cmd/completion.go b/internal/iam/access/cmd/completion.go index 520903fe..31dc646a 100644 --- a/internal/iam/access/cmd/completion.go +++ b/internal/iam/access/cmd/completion.go @@ -59,19 +59,31 @@ func completeNamespacesFlag(cmd *cobra.Command, _ []string, toComplete string) ( return utilk8s.CompleteNamespaces(cmd, toComplete) } -// completeRuleRef completes rule references in the form used by -// "d8 iam access rules get". It progressively offers: +// 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 rule references in the forms accepted by +// `d8 iam get rule REF` and the `--to`/`--from` flags of grant/revoke. // -// "CAR/" — full list of ClusterAuthorizationRules -// "AR//" — full list of AuthorizationRules from every namespace +// "CAR/" — full list of ClusterAuthorizationRules +// "AR//" — full list of AuthorizationRules across all namespaces // -// Short prefixes (CAR, AR) are preferred in completion output because they -// are less typing, but the long prefixes remain valid input. -func completeRuleRef(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) >= 1 { - return nil, cobra.ShellCompDirectiveNoFileComp - } - +// Short prefixes (CAR, AR) are preferred in completion output for less typing; +// long prefixes remain valid input. +// +// NB: this function is used both as a positional ValidArgsFunction (where args +// is empty when the REF arg is being typed) AND as a flag-value completer for +// --to/--from (where args may already contain command positionals like +// "user anton"). The previous implementation short-circuited on len(args)>=1, +// which silently disabled completion for the flag-value case. Cobra invokes +// the registered completer for the right slot regardless of unrelated args, +// so we ignore args here. +func completeRuleRef(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { dyn, err := utilk8s.NewDynamicClient(cmd) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/internal/iam/access/cmd/explain.go b/internal/iam/access/cmd/explain.go index 08a1d93f..f9654bd8 100644 --- a/internal/iam/access/cmd/explain.go +++ b/internal/iam/access/cmd/explain.go @@ -17,7 +17,6 @@ limitations under the License. package access import ( - "encoding/json" "fmt" "sort" "strings" @@ -110,12 +109,7 @@ func explainUser(cmd *cobra.Command, inv *accessInventory, userName, outputFmt s if outputFmt == "json" { item := buildUserAccessJSON(inv, userName) item.Warnings = warnings - data, err := json.MarshalIndent(item, "", " ") - if err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), string(data)) - return nil + return printStructured(cmd.OutOrStdout(), item, outputFmt) } w := cmd.OutOrStdout() @@ -184,7 +178,7 @@ func explainGroup(cmd *cobra.Command, inv *accessInventory, groupName, outputFmt warnings := collectGroupWarnings(inv, groupName, cycles) if outputFmt == "json" { - return printGroupExplainJSON(cmd, inv, groupName, warnings) + return printStructured(cmd.OutOrStdout(), buildGroupExplainJSON(inv, groupName, warnings), outputFmt) } w := cmd.OutOrStdout() @@ -270,50 +264,38 @@ func collectGroupWarnings(inv *accessInventory, groupName string, cycles map[str return warnings } -func printGroupExplainJSON(cmd *cobra.Command, inv *accessInventory, groupName string, warnings []string) error { +// groupExplainJSON is the payload for "d8 iam access explain group ... -o json". +// Lives here (not in list.go) because Members / Grants / Warnings are unique +// to the explain command's contract — get group does not surface warnings. +type groupExplainJSON struct { + Kind string `json:"kind"` + Name string `json:"name"` + Members []memberJSON `json:"members"` + Grants []grantJSON `json:"grants"` + Effective effectiveJSON `json:"effectiveSummary"` + Warnings []string `json:"warnings"` +} + +func buildGroupExplainJSON(inv *accessInventory, groupName string, warnings []string) groupExplainJSON { members := inv.GroupMembers[groupName] grants := inv.GroupGrants(groupName) summary := computeEffectiveSummary(grants) - type memberEntry struct { - Kind string `json:"kind"` - Name string `json:"name"` + memberItems := make([]memberJSON, 0, len(members)) + for _, m := range members { + memberItems = append(memberItems, memberJSON{Kind: string(m.Kind), Name: m.Name}) } - type groupExplainJSON struct { - Kind string `json:"kind"` - Name string `json:"name"` - Members []memberEntry `json:"members"` - Grants []grantJSON `json:"grants"` - Effective effectiveJSON `json:"effectiveSummary"` - Warnings []string `json:"warnings"` + grantItems := make([]grantJSON, 0, len(grants)) + for _, g := range grants { + grantItems = append(grantItems, grantToJSON(&g, "")) } - item := groupExplainJSON{ + return groupExplainJSON{ Kind: "GroupAccessExplanation", Name: groupName, + Members: denil(memberItems), + Grants: denil(grantItems), Effective: summaryToJSON(summary), - Warnings: warnings, + Warnings: denil(warnings), } - for _, m := range members { - item.Members = append(item.Members, memberEntry{Kind: string(m.Kind), Name: m.Name}) - } - if item.Members == nil { - item.Members = []memberEntry{} - } - for _, g := range grants { - item.Grants = append(item.Grants, grantToJSON(&g, "")) - } - if item.Grants == nil { - item.Grants = []grantJSON{} - } - if item.Warnings == nil { - item.Warnings = []string{} - } - - data, err := json.MarshalIndent(item, "", " ") - if err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), string(data)) - return nil } diff --git a/internal/iam/access/cmd/grant.go b/internal/iam/access/cmd/grant.go index 60db65ec..eb717c65 100644 --- a/internal/iam/access/cmd/grant.go +++ b/internal/iam/access/cmd/grant.go @@ -41,18 +41,27 @@ 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. -Exactly one scope flag is required (they are mutually exclusive): +Specify the scope via -n/--namespace OR --scope (mutually exclusive): - -n/--namespace Creates an AuthorizationRule per namespace (namespaced scope). - Only User, PrivilegedUser, Editor, Admin levels are valid. + -n/--namespace NS Creates an AuthorizationRule per namespace (namespaced scope). + Repeat to target several namespaces at once. + Only User, PrivilegedUser, Editor, Admin levels are valid. - --cluster Creates a ClusterAuthorizationRule. The grant applies to all - namespaces EXCEPT d8-* and kube-system (system namespaces). - All access levels are valid, including ClusterEditor/ClusterAdmin/SuperAdmin. + --scope cluster Creates a ClusterAuthorizationRule. The grant applies to + every namespace EXCEPT system ones (d8-*, kube-system). + All access levels are valid, including + ClusterEditor / ClusterAdmin / SuperAdmin. - --all-namespaces Creates a ClusterAuthorizationRule with namespaceSelector.matchAny. - The grant covers ALL namespaces including system namespaces. - Use with caution — grants access to d8-system, kube-system, etc. + --scope all-namespaces + Creates a ClusterAuthorizationRule with + namespaceSelector.matchAny: true. Covers ALL namespaces + including system ones (d8-system, kube-system, ...). + Use with caution. + + --scope labels=K=V[,K2=V2,...] + Creates a ClusterAuthorizationRule with + namespaceSelector.labelSelector.matchLabels = {K: V, ...}. + Targets every namespace matching all of the given labels. Modifier flags --allow-scale and --port-forwarding add additional capabilities to the grant and can be combined with any scope. @@ -92,10 +101,13 @@ var grantExample = templates.Examples(` d8 iam access grant user anton --access-level Admin -n dev -n stage # Grant ClusterAdmin cluster-wide (system namespaces excluded) - d8 iam access grant user anton --access-level ClusterAdmin --cluster + d8 iam access grant user anton --access-level ClusterAdmin --scope cluster # Grant ClusterAdmin to ALL namespaces including d8-system, kube-system - d8 iam access grant user anton --access-level ClusterAdmin --all-namespaces + d8 iam access grant user anton --access-level ClusterAdmin --scope all-namespaces + + # Grant Editor only in namespaces labelled team=platform,tier=prod + d8 iam access grant group admins --access-level Editor --scope labels=team=platform,tier=prod # Grant Editor to a group with port-forwarding enabled d8 iam access grant group admins --access-level Editor -n dev --port-forwarding @@ -123,9 +135,8 @@ func newGrantCommand() *cobra.Command { } 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)") - cmd.Flags().Bool("cluster", false, "Cluster scope: all namespaces EXCEPT system (d8-*, kube-system)") - cmd.Flags().Bool("all-namespaces", false, "Cluster scope: ALL namespaces INCLUDING system (d8-*, kube-system)") + 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") @@ -134,6 +145,7 @@ func newGrantCommand() *cobra.Command { _ = cmd.RegisterFlagCompletionFunc("access-level", completeAccessLevels) _ = cmd.RegisterFlagCompletionFunc("namespace", completeNamespacesFlag) + _ = cmd.RegisterFlagCompletionFunc("scope", completeScopeFlag) _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("name", "yaml", "json")) _ = cmd.RegisterFlagCompletionFunc("to", completeRuleRef) @@ -151,8 +163,7 @@ func runGrant(cmd *cobra.Command, args []string) error { accessLevel, _ := cmd.Flags().GetString("access-level") namespaces, _ := cmd.Flags().GetStringSlice("namespace") - clusterFlag, _ := cmd.Flags().GetBool("cluster") - allNSFlag, _ := cmd.Flags().GetBool("all-namespaces") + scopeFlag, _ := cmd.Flags().GetString("scope") portForwarding, _ := cmd.Flags().GetBool("port-forwarding") allowScale, _ := cmd.Flags().GetBool("allow-scale") dryRun, _ := cmd.Flags().GetBool("dry-run") @@ -167,7 +178,7 @@ func runGrant(cmd *cobra.Command, args []string) error { return err } - scopeType, err := parseScopeFlags(namespaces, clusterFlag, allNSFlag) + scopeType, scopeNS, labelMatch, err := parseScope(scopeFlag, namespaces) if err != nil { return err } @@ -202,7 +213,8 @@ func runGrant(cmd *cobra.Command, args []string) error { subjectPrincipal: subjectPrincipal, accessLevel: accessLevel, scopeType: scopeType, - namespaces: namespaces, + namespaces: scopeNS, + labelMatch: labelMatch, allowScale: allowScale, portForwarding: portForwarding, dryRun: dryRun, @@ -221,6 +233,7 @@ type grantOpts struct { accessLevel string scopeType iamtypes.Scope namespaces []string + labelMatch map[string]string allowScale bool portForwarding bool dryRun bool @@ -239,6 +252,7 @@ func applyGrants(cmd *cobra.Command, dyn dynamic.Interface, opts grantOpts) erro AccessLevel: opts.accessLevel, ScopeType: opts.scopeType, Namespaces: opts.namespaces, + LabelMatch: opts.labelMatch, AllowScale: opts.allowScale, PortForwarding: opts.portForwarding, }) @@ -279,9 +293,9 @@ func applyGrants(cmd *cobra.Command, dyn dynamic.Interface, opts grantOpts) erro // 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 produces 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). +// (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, @@ -311,6 +325,17 @@ func canonicalGrantSpecs(in canonicalGrantInput) ([]*canonicalGrantSpec, error) 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) } @@ -326,7 +351,7 @@ func grantClient(dyn dynamic.Interface, spec *canonicalGrantSpec) (dynamic.Resou 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: + case iamtypes.ScopeCluster, iamtypes.ScopeAllNamespaces, iamtypes.ScopeLabels: return dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR), nil default: return nil, fmt.Errorf("unsupported scope %q", spec.ScopeType) @@ -334,8 +359,8 @@ func grantClient(dyn dynamic.Interface, spec *canonicalGrantSpec) (dynamic.Resou } // buildGrantObject produces the Unstructured for a grant — AR for namespaced, -// CAR for cluster/all-namespaces. The object kind/apiVersion/namespace branch -// off ScopeType but the rest is shared. +// 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 { @@ -387,6 +412,21 @@ func buildGrantObject(spec *canonicalGrantSpec) (*unstructured.Unstructured, err 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) } @@ -452,31 +492,61 @@ func parseSubjectKind(s string) (iamtypes.SubjectKind, error) { } } -func parseScopeFlags(namespaces []string, cluster, allNamespaces bool) (iamtypes.Scope, error) { - count := 0 - if len(namespaces) > 0 { - count++ - } - if cluster { - count++ - } - if allNamespaces { - count++ - } - if count == 0 { - return "", errors.New("one of -n/--namespace, --cluster, or --all-namespaces must be specified") - } - if count > 1 { - return "", errors.New("-n/--namespace, --cluster, and --all-namespaces are mutually exclusive") +// 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 } - if len(namespaces) > 0 { - return iamtypes.ScopeNamespace, 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) } - if cluster { - return iamtypes.ScopeCluster, nil +} + +// 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 iamtypes.ScopeAllNamespaces, nil + return out, nil } func toAnyMap(m map[string]string) map[string]any { @@ -495,10 +565,9 @@ func rejectFlagsInToMode(cmd *cobra.Command) error { perSubjectHint := "for a per-subject capability on top of the existing rule, create a separate grant without --to: 'd8 iam access grant --access-level ... [scope] --port-forwarding --allow-scale'" hints := map[string]string{ - "access-level": ruleSpecHint, - "namespace": ruleSpecHint, - "cluster": ruleSpecHint, - "all-namespaces": ruleSpecHint, + "access-level": ruleSpecHint, + "namespace": ruleSpecHint, + "scope": ruleSpecHint, "port-forwarding": perSubjectHint + " — note: setting it on a shared rule would affect every subject already on it", "allow-scale": perSubjectHint + diff --git a/internal/iam/access/cmd/list.go b/internal/iam/access/cmd/list.go index cd5ffb8d..5d8b2a6f 100644 --- a/internal/iam/access/cmd/list.go +++ b/internal/iam/access/cmd/list.go @@ -17,7 +17,6 @@ limitations under the License. package access import ( - "encoding/json" "fmt" "io" "sort" @@ -25,41 +24,18 @@ import ( "github.com/spf13/cobra" "k8s.io/cli-runtime/pkg/printers" - "k8s.io/kubectl/pkg/util/templates" iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) -var listLong = templates.LongDesc(` -List effective access for users and groups. - -This is not a raw CR listing. It aggregates information from Users, Groups, -AuthorizationRules, and ClusterAuthorizationRules to show effective access. - -See the list of subcommands below or run "d8 iam access list SUBCOMMAND --help". - -© Flant JSC 2026`) - -func newListCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "list (users|groups|user |group )", - Short: "List effective access for users and groups", - Long: listLong, - } - - cmd.AddCommand( - newListUsersCommand(), - newListGroupsCommand(), - newListUserCommand(), - newListGroupCommand(), - ) - return cmd -} - -func newListUsersCommand() *cobra.Command { +// NewListUsersCommand returns the cobra command behind "d8 iam list users". +// It is exported so the top-level "iam list" parent (in package listget) can +// register it without re-implementing the aggregation pipeline. +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, @@ -78,7 +54,7 @@ func newListUsersCommand() *cobra.Command { } if outputFmt == "json" { - return printUsersJSON(cmd, inv) + return printStructured(cmd.OutOrStdout(), buildUsersJSON(inv), outputFmt) } return printUsersTable(cmd, inv) @@ -89,9 +65,11 @@ func newListUsersCommand() *cobra.Command { return cmd } -func newListGroupsCommand() *cobra.Command { +// 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, @@ -110,7 +88,7 @@ func newListGroupsCommand() *cobra.Command { } if outputFmt == "json" { - return printGroupsJSON(cmd, inv) + return printStructured(cmd.OutOrStdout(), buildGroupsJSON(inv), outputFmt) } return printGroupsTable(cmd, inv) @@ -121,15 +99,16 @@ func newListGroupsCommand() *cobra.Command { return cmd } -func newListUserCommand() *cobra.Command { +// NewGetUserCommand returns the cobra command behind "d8 iam get user ". +// Output is the aggregated access view: groups (direct + transitive), +// direct/inherited grants, and the effective summary. +func NewGetUserCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "user ", - Short: "Show detailed access for a specific user", - Args: cobra.ExactArgs(1), - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) >= 1 { - return nil, cobra.ShellCompDirectiveNoFileComp - } + 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, @@ -152,27 +131,29 @@ func newListUserCommand() *cobra.Command { return fmt.Errorf("user %q not found", userName) } - if outputFmt == "json" { - return printUserDetailJSON(cmd, inv, userName) + switch outputFmt { + case "json", "yaml": + return printStructured(cmd.OutOrStdout(), buildUserAccessJSON(inv, userName), outputFmt) + case "table", "": + return printUserDetail(cmd, inv, userName) + default: + return fmt.Errorf("%w %q; use table|json|yaml", errUnsupportedFormat, outputFmt) } - - return printUserDetail(cmd, inv, userName) }, } - cmd.Flags().StringP("output", "o", "table", "Output format: table|json") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json")) + cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) return cmd } -func newListGroupCommand() *cobra.Command { +// NewGetGroupCommand returns the cobra command behind "d8 iam get group ". +func NewGetGroupCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "group ", - Short: "Show detailed access for a specific group", - Args: cobra.ExactArgs(1), - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if len(args) >= 1 { - return nil, cobra.ShellCompDirectiveNoFileComp - } + 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, @@ -195,15 +176,18 @@ func newListGroupCommand() *cobra.Command { return fmt.Errorf("group %q not found", groupName) } - if outputFmt == "json" { - return printGroupDetailJSON(cmd, inv, groupName) + switch outputFmt { + case "json", "yaml": + return printStructured(cmd.OutOrStdout(), buildGroupDetailJSON(inv, groupName), outputFmt) + case "table", "": + return printGroupDetail(cmd, inv, groupName) + default: + return fmt.Errorf("%w %q; use table|json|yaml", errUnsupportedFormat, outputFmt) } - - return printGroupDetail(cmd, inv, groupName) }, } - cmd.Flags().StringP("output", "o", "table", "Output format: table|json") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json")) + cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") + _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) return cmd } @@ -433,25 +417,40 @@ func findViaGroup(inv *accessInventory, userName, grantGroupName string) string return grantGroupName } -// --- JSON output --- +// --- JSON output: shared types --- +// +// Every list/get JSON payload in this package is composed from the same +// building blocks. Defining them once at package level (instead of inline +// inside each printXxxJSON) keeps the wire format documented in one place +// and lets buildUserAccessJSON / buildGroupExplainJSON share concrete +// types like memberJSON and grantJSON. +// +// All slice fields go through denil() before being assigned so the JSON +// output emits "[]" instead of "null" — that contract is part of our CLI +// surface and tests pin it down. type userAccessJSON struct { - Kind string `json:"kind"` - Subject struct { - Kind string `json:"kind"` - RefName string `json:"refName"` - Principal string `json:"principal"` - } `json:"subject"` - Groups struct { - Direct []string `json:"direct"` - Transitive []string `json:"transitive"` - } `json:"groups"` + 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"` @@ -487,6 +486,34 @@ type nsLevelJSON struct { Namespaces []string `json:"namespaces"` } +// memberJSON is the shared shape for "kind/name" entries in group payloads. +// It is consumed both by buildGroupDetailJSON ("d8 iam get group") and +// buildGroupExplainJSON ("d8 iam access explain group"); having two +// identical inline types as we did before invited drift. +type memberJSON struct { + Kind string `json:"kind"` + Name string `json:"name"` +} + +// groupSummaryJSON is one entry in the "d8 iam list groups -o json" array. +type groupSummaryJSON struct { + Name string `json:"name"` + Members int `json:"memberCount"` + Nested int `json:"nestedGroupCount"` + Grants int `json:"grantCount"` + Effective effectiveJSON `json:"effectiveSummary"` +} + +// groupDetailJSON is the payload for "d8 iam get group -o json|yaml". +type groupDetailJSON struct { + Name string `json:"name"` + Members []memberJSON `json:"members"` + Grants []grantJSON `json:"grants"` + Effective effectiveJSON `json:"effectiveSummary"` +} + +// --- JSON output: build helpers --- + func grantToJSON(g *normalizedGrant, via string) grantJSON { return grantJSON{ ViaGroup: via, @@ -524,34 +551,18 @@ func summaryToJSON(s *effectiveSummary) effectiveJSON { return ej } -func printUsersJSON(cmd *cobra.Command, inv *accessInventory) error { - items := make([]userAccessJSON, 0, len(inv.Users)) +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)) } - - data, err := json.MarshalIndent(items, "", " ") - if err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), string(data)) - return nil -} - -func printUserDetailJSON(cmd *cobra.Command, inv *accessInventory, userName string) error { - item := buildUserAccessJSON(inv, userName) - data, err := json.MarshalIndent(item, "", " ") - if err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), string(data)) - return nil + return items } func buildUserAccessJSON(inv *accessInventory, userName string) userAccessJSON { @@ -563,54 +574,42 @@ func buildUserAccessJSON(inv *accessInventory, userName string) userAccessJSON { allGrants = append(allGrants, inheritedGrants...) summary := computeEffectiveSummary(allGrants) - item := userAccessJSON{Kind: "AccessExplanation"} - item.Subject.Kind = string(iamtypes.KindUser) - item.Subject.RefName = userName - item.Subject.Principal = email - item.Groups.Direct = directGroups - item.Groups.Transitive = transitiveGroups - if item.Groups.Direct == nil { - item.Groups.Direct = []string{} - } - if item.Groups.Transitive == nil { - item.Groups.Transitive = []string{} - } - + directGrantsJSON := make([]grantJSON, 0, len(directGrants)) for _, g := range directGrants { - item.DirectGrants = append(item.DirectGrants, grantToJSON(&g, "")) + directGrantsJSON = append(directGrantsJSON, grantToJSON(&g, "")) } + inheritedGrantsJSON := make([]grantJSON, 0, len(inheritedGrants)) for _, g := range inheritedGrants { via := findViaGroup(inv, userName, g.SubjectPrincipal) - item.InheritedGrants = append(item.InheritedGrants, grantToJSON(&g, via)) - } - if item.DirectGrants == nil { - item.DirectGrants = []grantJSON{} - } - if item.InheritedGrants == nil { - item.InheritedGrants = []grantJSON{} + inheritedGrantsJSON = append(inheritedGrantsJSON, grantToJSON(&g, via)) } - item.Effective = summaryToJSON(summary) - item.Warnings = []string{} - return item -} - -func printGroupsJSON(cmd *cobra.Command, inv *accessInventory) error { - type groupJSON struct { - Name string `json:"name"` - Members int `json:"memberCount"` - Nested int `json:"nestedGroupCount"` - Grants int `json:"grantCount"` - Effective effectiveJSON `json:"effectiveSummary"` + 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([]groupJSON, 0, len(groups)) + items := make([]groupSummaryJSON, 0, len(groups)) for _, gName := range groups { members := inv.GroupMembers[gName] userCount, nestedCount := 0, 0 @@ -623,7 +622,7 @@ func printGroupsJSON(cmd *cobra.Command, inv *accessInventory) error { } grants := inv.GroupGrants(gName) summary := computeEffectiveSummary(grants) - items = append(items, groupJSON{ + items = append(items, groupSummaryJSON{ Name: gName, Members: userCount, Nested: nestedCount, @@ -631,50 +630,27 @@ func printGroupsJSON(cmd *cobra.Command, inv *accessInventory) error { Effective: summaryToJSON(summary), }) } - - data, err := json.MarshalIndent(items, "", " ") - if err != nil { - return err - } - fmt.Fprintln(cmd.OutOrStdout(), string(data)) - return nil + return items } -func printGroupDetailJSON(cmd *cobra.Command, inv *accessInventory, groupName string) error { +func buildGroupDetailJSON(inv *accessInventory, groupName string) groupDetailJSON { members := inv.GroupMembers[groupName] grants := inv.GroupGrants(groupName) summary := computeEffectiveSummary(grants) - type memberJSON struct { - Kind string `json:"kind"` - Name string `json:"name"` - } - - type detailJSON struct { - Name string `json:"name"` - Members []memberJSON `json:"members"` - Grants []grantJSON `json:"grants"` - Effective effectiveJSON `json:"effectiveSummary"` - } - - detail := detailJSON{Name: groupName, Effective: summaryToJSON(summary)} + memberItems := make([]memberJSON, 0, len(members)) for _, m := range members { - detail.Members = append(detail.Members, memberJSON{Kind: string(m.Kind), Name: m.Name}) - } - if detail.Members == nil { - detail.Members = []memberJSON{} + memberItems = append(memberItems, memberJSON{Kind: string(m.Kind), Name: m.Name}) } + grantItems := make([]grantJSON, 0, len(grants)) for _, g := range grants { - detail.Grants = append(detail.Grants, grantToJSON(&g, "")) - } - if detail.Grants == nil { - detail.Grants = []grantJSON{} + grantItems = append(grantItems, grantToJSON(&g, "")) } - data, err := json.MarshalIndent(detail, "", " ") - if err != nil { - return err + return groupDetailJSON{ + Name: groupName, + Members: denil(memberItems), + Grants: denil(grantItems), + Effective: summaryToJSON(summary), } - fmt.Fprintln(cmd.OutOrStdout(), string(data)) - return nil } diff --git a/internal/iam/access/cmd/naming.go b/internal/iam/access/cmd/naming.go index 7c32bf6e..d8f0f20b 100644 --- a/internal/iam/access/cmd/naming.go +++ b/internal/iam/access/cmd/naming.go @@ -19,6 +19,7 @@ package access import ( "crypto/sha256" "fmt" + "sort" "strings" iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" @@ -100,6 +101,27 @@ func scopeNamePart(spec *canonicalGrantSpec) string { return "cluster" case iamtypes.ScopeAllNamespaces: return "all" + case iamtypes.ScopeLabels: + // The full hash8 suffix at the end of the name already disambiguates + // labels-scoped grants on different K=V sets, but we still want a + // stable, recognisable middle segment so the object name self-documents + // "this is a labels-scoped grant" without needing to read annotations. + // We hash the sorted K=V pairs (encoding/json normalises maps the same + // way for the canonical-spec annotation). + 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 index 070ebeeb..f68934ec 100644 --- a/internal/iam/access/cmd/naming_test.go +++ b/internal/iam/access/cmd/naming_test.go @@ -67,6 +67,19 @@ func TestGenerateGrantName(t *testing.T) { }, 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 { @@ -80,6 +93,117 @@ func TestGenerateGrantName(t *testing.T) { } } +// 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", diff --git a/internal/iam/access/cmd/print.go b/internal/iam/access/cmd/print.go new file mode 100644 index 00000000..1b447bdd --- /dev/null +++ b/internal/iam/access/cmd/print.go @@ -0,0 +1,93 @@ +/* +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 by printStructured when the format +// argument is neither "json" nor "yaml". Callers in this package wrap it +// with the per-command list of accepted values, e.g.: +// +// return fmt.Errorf("%w; use table|json|yaml", err) +// +// Tests assert via errors.Is. +var errUnsupportedFormat = errors.New("unsupported output format") + +// printStructured renders v as JSON or YAML on w. +// +// JSON path uses MarshalIndent with two-space indent and a trailing +// newline (via Fprintln). The historic call sites in this package emitted +// exactly that, so swapping them to printStructured stays byte-identical +// for scripted consumers. +// +// YAML path goes JSON -> sigsyaml.JSONToYAML so json tags drive field +// names. Every typed shape we print here (grantJSON, ruleJSON, +// userAccessJSON, ...) declares only json tags; dual-tagging every struct +// purely for one print path would be maintenance cost without benefit. +// utilk8s.PrintObject takes the same JSON-then-YAML approach for +// Unstructured manifests, keeping the two helpers symmetric. +// +// For Kubernetes manifests (user/group/grant create -o yaml) keep using +// utilk8s.PrintObject — it preserves the apiVersion/kind layout and +// supports the "name" format that is meaningful only for k8s objects. +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 normalises a nil slice to an empty (but non-nil) slice so JSON +// encoding emits "[]" instead of "null". Every iam list/get JSON payload +// in this package promises that contract so machine consumers can iterate +// the field unconditionally. +// +// Returned by value rather than mutating through a pointer because every +// call site in this package already assigns the field with the result of +// a build step — chaining with denil keeps the call site one expression +// instead of two statements. +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 index 02106be6..04ce4e33 100644 --- a/internal/iam/access/cmd/resolve.go +++ b/internal/iam/access/cmd/resolve.go @@ -110,8 +110,7 @@ func buildInventory(ctx context.Context, dyn dynamic.Interface) (*accessInventor return nil, fmt.Errorf("listing ClusterAuthorizationRules: %w", err) } for _, car := range carList.Items { - grants := normalizeClusterAuthRule(&car) - inv.Grants = append(inv.Grants, grants...) + inv.Grants = append(inv.Grants, normalizeAuthRule(&car)...) } // Fetch AuthorizationRules from all namespaces @@ -120,76 +119,58 @@ func buildInventory(ctx context.Context, dyn dynamic.Interface) (*accessInventor return nil, fmt.Errorf("listing AuthorizationRules: %w", err) } for _, ar := range arList.Items { - grants := normalizeNamespacedAuthRule(&ar) - inv.Grants = append(inv.Grants, grants...) + inv.Grants = append(inv.Grants, normalizeAuthRule(&ar)...) } return inv, nil } -func normalizeClusterAuthRule(obj *unstructured.Unstructured) []normalizedGrant { - name := obj.GetName() - labels := obj.GetLabels() - managedByD8 := labels[iamtypes.LabelManagedBy] == iamtypes.ManagedByValueCLI - +// 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") - scopeType := iamtypes.ScopeCluster - matchAny, matchAnyFound, _ := unstructured.NestedBool(obj.Object, "spec", "namespaceSelector", "matchAny") - if matchAnyFound && matchAny { - scopeType = iamtypes.ScopeAllNamespaces - } - - var grants []normalizedGrant - for _, sub := range readSubjectRefs(obj) { - if sub.Kind == iamtypes.KindServiceAccount { - continue + 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 } - grants = append(grants, normalizedGrant{ - SourceKind: iamtypes.KindClusterAuthorizationRule, - SourceName: name, - SubjectKind: sub.Kind, - SubjectPrincipal: sub.Name, - AccessLevel: accessLevel, - AllowScale: allowScale, - PortForwarding: portForwarding, - ScopeType: scopeType, - ManagedByD8: managedByD8, - }) + case iamtypes.KindAuthorizationRule: + base.SourceKind = iamtypes.KindAuthorizationRule + base.SourceNamespace = obj.GetNamespace() + base.ScopeType = iamtypes.ScopeNamespace + base.ScopeNamespaces = []string{obj.GetNamespace()} + default: + return nil } - return grants -} - -func normalizeNamespacedAuthRule(obj *unstructured.Unstructured) []normalizedGrant { - name := obj.GetName() - ns := obj.GetNamespace() - labels := obj.GetLabels() - managedByD8 := labels[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") var grants []normalizedGrant for _, sub := range readSubjectRefs(obj) { if sub.Kind == iamtypes.KindServiceAccount { continue } - grants = append(grants, normalizedGrant{ - SourceKind: iamtypes.KindAuthorizationRule, - SourceName: name, - SourceNamespace: ns, - SubjectKind: sub.Kind, - SubjectPrincipal: sub.Name, - AccessLevel: accessLevel, - AllowScale: allowScale, - PortForwarding: portForwarding, - ScopeType: iamtypes.ScopeNamespace, - ScopeNamespaces: []string{ns}, - ManagedByD8: managedByD8, - }) + g := base + g.SubjectKind = sub.Kind + g.SubjectPrincipal = sub.Name + grants = append(grants, g) } return grants } @@ -301,7 +282,11 @@ func computeEffectiveSummary(grants []normalizedGrant) *effectiveSummary { summary.PortForwarding = true } switch g.ScopeType { - case iamtypes.ScopeCluster, iamtypes.ScopeAllNamespaces: + 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 { diff --git a/internal/iam/access/cmd/resolve_test.go b/internal/iam/access/cmd/resolve_test.go index d24b3aa4..6d6f4eb3 100644 --- a/internal/iam/access/cmd/resolve_test.go +++ b/internal/iam/access/cmd/resolve_test.go @@ -20,6 +20,7 @@ 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" @@ -217,9 +218,10 @@ func TestDetectGroupCycles(t *testing.T) { }) } -func TestNormalizeClusterAuthRule(t *testing.T) { +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"}, @@ -237,17 +239,20 @@ func TestNormalizeClusterAuthRule(t *testing.T) { }, } - grants := normalizeClusterAuthRule(obj) + 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 TestNormalizeClusterAuthRule_AllNamespaces(t *testing.T) { +func TestNormalizeAuthRule_AllNamespaces(t *testing.T) { obj := &unstructured.Unstructured{ Object: map[string]any{ + "kind": "ClusterAuthorizationRule", "metadata": map[string]any{ "name": "all-ns-rule", }, @@ -263,7 +268,62 @@ func TestNormalizeClusterAuthRule_AllNamespaces(t *testing.T) { }, } - grants := normalizeClusterAuthRule(obj) + 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 index 67ca7a64..2e3ca87f 100644 --- a/internal/iam/access/cmd/revoke.go +++ b/internal/iam/access/cmd/revoke.go @@ -41,10 +41,12 @@ SIMPLE MODE (recommended for most users): Revokes access that was previously granted with "d8 iam access grant". Uses the same flags to locate and delete the d8-managed object. - d8 iam access revoke user NAME --access-level LEVEL [scope flags] + d8 iam access revoke user NAME --access-level LEVEL [-n NS... | --scope ...] Only d8-managed objects (label app.kubernetes.io/managed-by=d8-cli) - are affected. Manual objects are never modified in this mode. + are affected. Manual objects are never modified in this mode. The scope + flags must match the original grant exactly (the d8-managed object name + is derived from the canonical spec). ADVANCED MODE (--from): @@ -68,14 +70,17 @@ var revokeExample = templates.Examples(` d8 iam access revoke user anton --access-level Admin -n dev # Simple mode: revoke a cluster-scoped grant - d8 iam access revoke user anton --access-level ClusterAdmin --cluster + d8 iam access revoke user anton --access-level ClusterAdmin --scope cluster + + # Simple mode: 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 # Simple mode: revoke from a group d8 iam access revoke group admins --access-level Editor -n dev # Advanced mode: remove a user subject from a shared cluster rule (literal principal) d8 iam access revoke --from ClusterAuthorizationRule/my-rule user anton@example.com - + # Advanced mode: remove a group subject and delete the rule if it becomes empty d8 iam access revoke --from AuthorizationRule/dev/my-rule group admins --delete-empty`) @@ -92,9 +97,8 @@ func newRevokeCommand() *cobra.Command { } cmd.Flags().String("access-level", "", "Access level to revoke (for intent-based revoke)") - cmd.Flags().StringSliceP("namespace", "n", nil, "Target namespace(s) (for intent-based revoke)") - cmd.Flags().Bool("cluster", false, "Cluster-scoped revoke") - cmd.Flags().Bool("all-namespaces", false, "All-namespaces scoped revoke") + cmd.Flags().StringSliceP("namespace", "n", nil, "Target namespace(s) (for intent-based revoke). 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.Flags().String("from", "", "Source object to revoke from: AuthorizationRule// or ClusterAuthorizationRule/") @@ -102,6 +106,7 @@ func newRevokeCommand() *cobra.Command { _ = cmd.RegisterFlagCompletionFunc("access-level", completeAccessLevels) _ = cmd.RegisterFlagCompletionFunc("namespace", completeNamespacesFlag) + _ = cmd.RegisterFlagCompletionFunc("scope", completeScopeFlag) _ = cmd.RegisterFlagCompletionFunc("from", completeRuleRef) return cmd @@ -125,8 +130,7 @@ func runIntentBasedRevoke(cmd *cobra.Command, args []string) error { subjectName := args[1] accessLevel, _ := cmd.Flags().GetString("access-level") namespaces, _ := cmd.Flags().GetStringSlice("namespace") - clusterFlag, _ := cmd.Flags().GetBool("cluster") - allNSFlag, _ := cmd.Flags().GetBool("all-namespaces") + scopeFlag, _ := cmd.Flags().GetString("scope") portForwarding, _ := cmd.Flags().GetBool("port-forwarding") allowScale, _ := cmd.Flags().GetBool("allow-scale") @@ -139,7 +143,7 @@ func runIntentBasedRevoke(cmd *cobra.Command, args []string) error { return err } - scopeType, err := parseScopeFlags(namespaces, clusterFlag, allNSFlag) + scopeType, scopeNS, labelMatch, err := parseScope(scopeFlag, namespaces) if err != nil { return err } @@ -166,7 +170,8 @@ func runIntentBasedRevoke(cmd *cobra.Command, args []string) error { subjectPrincipal: subjectPrincipal, accessLevel: accessLevel, scopeType: scopeType, - namespaces: namespaces, + namespaces: scopeNS, + labelMatch: labelMatch, allowScale: allowScale, portForwarding: portForwarding, } @@ -183,6 +188,7 @@ type revokeOpts struct { accessLevel string scopeType iamtypes.Scope namespaces []string + labelMatch map[string]string allowScale bool portForwarding bool } @@ -198,6 +204,7 @@ func revokeManagedGrants(cmd *cobra.Command, dyn dynamic.Interface, opts revokeO AccessLevel: opts.accessLevel, ScopeType: opts.scopeType, Namespaces: opts.namespaces, + LabelMatch: opts.labelMatch, AllowScale: opts.allowScale, PortForwarding: opts.portForwarding, }) @@ -228,7 +235,7 @@ func revokeClient(dyn dynamic.Interface, spec *canonicalGrantSpec) (dynamic.Reso } ns := spec.Namespaces[0] return dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace(ns), iamtypes.KindAuthorizationRule, ns, nil - case iamtypes.ScopeCluster, iamtypes.ScopeAllNamespaces: + 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) diff --git a/internal/iam/access/cmd/rules.go b/internal/iam/access/cmd/rules.go index d9de0949..c926fe5d 100644 --- a/internal/iam/access/cmd/rules.go +++ b/internal/iam/access/cmd/rules.go @@ -18,7 +18,6 @@ package access import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -33,7 +32,6 @@ import ( "k8s.io/cli-runtime/pkg/printers" "k8s.io/client-go/dynamic" "k8s.io/kubectl/pkg/util/templates" - sigsyaml "sigs.k8s.io/yaml" iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" @@ -88,59 +86,37 @@ func readSubjectRefs(obj *unstructured.Unstructured) []subjectRef { return out } -// ------------------------------ cobra ------------------------------ - -var rulesLong = templates.LongDesc(` -Inspect the raw authorization rules (ClusterAuthorizationRule and -AuthorizationRule) that back "d8 iam access grant/revoke/explain". - -Unlike "d8 iam access list users|groups" (which aggregates effective access -per subject), "d8 iam access rules list" shows the rules themselves — d8-managed -and manual alike — in a single view so you can see "what objects exist and -what they give". - -Use "d8 iam access rules get REF" for a detailed human view of one rule, with -a reverse lookup from spec.subjects to local User/Group CRs. - -© Flant JSC 2026`) - -func newRulesCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "rules", - Short: "Inspect raw ClusterAuthorizationRule / AuthorizationRule objects", - Long: rulesLong, - } - cmd.AddCommand(newRulesListCommand(), newRulesGetCommand()) - return cmd -} - // ------------------------------ list ------------------------------ -var rulesListExample = templates.Examples(` +var listRulesExample = templates.Examples(` # All rules in the cluster (both CARs and every AR across namespaces) - d8 iam access rules list + d8 iam list rules # Only ClusterAuthorizationRules - d8 iam access rules list --cluster + d8 iam list rules --cluster # Only AuthorizationRules from selected namespaces - d8 iam access rules list -n dev -n stage + d8 iam list rules -n dev -n stage # Only rules created by "d8 iam access grant" - d8 iam access rules list --managed-only + d8 iam list rules --managed-only # Only rules NOT created by d8-cli - d8 iam access rules list --manual-only + d8 iam list rules --manual-only # Machine-readable - d8 iam access rules list -o json - d8 iam access rules list -o yaml`) + d8 iam list rules -o json + d8 iam list rules -o yaml`) -func newRulesListCommand() *cobra.Command { +// 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: "list", + Use: "rules", + Aliases: []string{"rule"}, Short: "List ClusterAuthorizationRules and AuthorizationRules", - Example: rulesListExample, + Example: listRulesExample, Args: cobra.NoArgs, SilenceErrors: true, SilenceUsage: true, @@ -193,37 +169,39 @@ func runRulesList(cmd *cobra.Command, _ []string) error { sortRuleRows(rows) switch outputFmt { - case "json": - return printRuleRowsJSON(cmd.OutOrStdout(), rows) - case "yaml": - return printRuleRowsYAML(cmd.OutOrStdout(), rows) + case "json", "yaml": + return printStructured(cmd.OutOrStdout(), buildRuleRowsJSON(rows), outputFmt) case "table", "": return printRuleRowsTable(cmd.OutOrStdout(), rows) default: - return fmt.Errorf("unsupported output format %q; use table|json|yaml", outputFmt) + return fmt.Errorf("%w %q; use table|json|yaml", errUnsupportedFormat, outputFmt) } } // ------------------------------ get ------------------------------ -var rulesGetExample = templates.Examples(` +var getRuleExample = templates.Examples(` # Get a ClusterAuthorizationRule (full prefix or short) - d8 iam access rules get ClusterAuthorizationRule/superadmins - d8 iam access rules get CAR/superadmins + d8 iam get rule ClusterAuthorizationRule/superadmins + d8 iam get rule CAR/superadmins # Get an AuthorizationRule (namespace is part of the reference) - d8 iam access rules get AuthorizationRule/dev/editors - d8 iam access rules get AR/dev/editors + d8 iam get rule AuthorizationRule/dev/editors + d8 iam get rule AR/dev/editors # Machine-readable - d8 iam access rules get CAR/superadmins -o yaml - d8 iam access rules get AR/dev/editors -o json`) + d8 iam get rule CAR/superadmins -o yaml + d8 iam get rule AR/dev/editors -o json`) -func newRulesGetCommand() *cobra.Command { +// 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: "get REF", + Use: "rule REF", + Aliases: []string{"rules"}, Short: "Show a single CAR or AR with subjects, scope and reverse CR lookup", - Example: rulesGetExample, + Example: getRuleExample, Args: cobra.ExactArgs(1), ValidArgsFunction: completeRuleRef, SilenceErrors: true, @@ -260,6 +238,10 @@ func runRulesGet(cmd *cobra.Command, args []string) error { 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) @@ -270,7 +252,7 @@ func runRulesGet(cmd *cobra.Command, args []string) error { } return printRuleRowText(cmd.OutOrStdout(), row, reverse) default: - return fmt.Errorf("unsupported output format %q; use text|json|yaml", outputFmt) + return fmt.Errorf("%w %q; use text|json|yaml", errUnsupportedFormat, outputFmt) } } @@ -322,9 +304,10 @@ func ruleRowFromObject(obj *unstructured.Unstructured) ruleRow { switch kind { case iamtypes.KindClusterAuthorizationRule: scopeType = iamtypes.ScopeCluster - matchAny, found, _ := unstructured.NestedBool(obj.Object, "spec", "namespaceSelector", "matchAny") - if found && matchAny { + 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 @@ -517,6 +500,10 @@ func printRuleRowText(w io.Writer, r ruleRow, reverse map[string]string) error { } // ------------------------------ 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"` @@ -537,7 +524,11 @@ type subjectJSON struct { } func ruleRowToJSON(r ruleRow) ruleJSON { - out := 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, @@ -546,49 +537,17 @@ func ruleRowToJSON(r ruleRow) ruleJSON { AllowScale: r.AllowScale, PortForwarding: r.PortForwarding, ManagedByD8: r.ManagedByD8, + Subjects: denil(subjects), CreationTime: r.CreationTime, } - for _, s := range r.Subjects { - out.Subjects = append(out.Subjects, subjectJSON{Kind: string(s.Kind), Name: s.Name}) - } - if out.Subjects == nil { - out.Subjects = []subjectJSON{} - } - return out -} - -func printRuleRowsJSON(w io.Writer, rows []ruleRow) error { - items := make([]ruleJSON, 0, len(rows)) - for _, r := range rows { - items = append(items, ruleRowToJSON(r)) - } - data, err := json.MarshalIndent(items, "", " ") - if err != nil { - return err - } - fmt.Fprintln(w, string(data)) - return nil } -func printRuleRowsYAML(w io.Writer, rows []ruleRow) error { - // 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 "rules get -o yaml" for that). +func buildRuleRowsJSON(rows []ruleRow) []ruleJSON { items := make([]ruleJSON, 0, len(rows)) for _, r := range rows { items = append(items, ruleRowToJSON(r)) } - data, err := json.Marshal(items) - if err != nil { - return err - } - // sigs.k8s.io/yaml keeps formatting consistent with utilk8s.PrintObject. - yamlBytes, err := sigsyaml.JSONToYAML(data) - if err != nil { - return err - } - fmt.Fprint(w, string(yamlBytes)) - return nil + return items } // ------------------------------ refs ------------------------------ diff --git a/internal/iam/access/cmd/types.go b/internal/iam/access/cmd/types.go index e61fcca2..71aff17e 100644 --- a/internal/iam/access/cmd/types.go +++ b/internal/iam/access/cmd/types.go @@ -72,6 +72,11 @@ func sliceToSet(s []string) map[string]bool { // and comparison. The typed Model/SubjectKind/ScopeType fields encode-decode // to plain JSON strings (Go's encoding/json treats `type X string` as a // string), so on-disk annotations stay byte-compatible with previous releases. +// +// LabelMatch is omitempty so cluster/all-namespaces/namespace specs hash to +// the exact same JSON they did before this field was added (keeping +// d8-managed object names stable across upgrades). It is only populated for +// ScopeLabels. type canonicalGrantSpec struct { Model iamtypes.AccessModel `json:"model"` SubjectKind iamtypes.SubjectKind `json:"subjectKind"` @@ -80,6 +85,7 @@ type canonicalGrantSpec struct { 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"` } @@ -92,6 +98,8 @@ func (c *canonicalGrantSpec) JSON() (string, error) { sort.Strings(sorted) cpy.Namespaces = sorted } + // encoding/json sorts map keys alphabetically, so LabelMatch is already + // emitted deterministically — no extra normalisation needed here. data, err := json.Marshal(&cpy) if err != nil { return "", err @@ -156,6 +164,7 @@ type canonicalGrantInput struct { 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 index b6a62e85..213739d5 100644 --- a/internal/iam/cmd/iam.go +++ b/internal/iam/cmd/iam.go @@ -22,6 +22,7 @@ import ( 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" ) @@ -29,10 +30,13 @@ var iamLong = templates.LongDesc(` Manage Deckhouse identity and access: users, groups, and access grants. Subcommands: - user — manage local static users (user-authn Dex). - group — manage local groups and their membership (user-authn). - access — grant, revoke, list, and explain permissions backed by - AuthorizationRule and ClusterAuthorizationRule CRs (user-authz). + user — manage local static users (user-authn Dex): create / delete / + reset-password / reset2fa / lock / unlock. + group — manage local groups: create / delete and add-member / remove-member. + access — grant, revoke, and explain permissions backed by AuthorizationRule + and ClusterAuthorizationRule CRs (user-authz). + get — show one user / group / rule with its effective context. + list — list users / groups / rules with effective context. Each subcommand accepts the standard --kubeconfig / --context flags (short: -k / --context) inherited from its own persistent flag set. @@ -40,7 +44,7 @@ Each subcommand accepts the standard --kubeconfig / --context flags © Flant JSC 2026`) // NewCommand returns the "d8 iam" parent command that composes the user, -// group, and access subcommands under a single namespace. +// group, access, and the kubectl-style get/list subcommands. func NewCommand() *cobra.Command { cmd := &cobra.Command{ Use: "iam", @@ -54,6 +58,8 @@ func NewCommand() *cobra.Command { 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..2c237df1 --- /dev/null +++ b/internal/iam/cmd/iam_test.go @@ -0,0 +1,138 @@ +/* +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", + "iam access explain", + + // 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", + } + + 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/get.go b/internal/iam/group/cmd/get.go deleted file mode 100644 index 108ff681..00000000 --- a/internal/iam/group/cmd/get.go +++ /dev/null @@ -1,174 +0,0 @@ -/* -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/cli-runtime/pkg/printers" - - iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" - "github.com/deckhouse/deckhouse-cli/internal/utilk8s" -) - -func newGetCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "get ", - Short: "Get details of a local group", - Args: cobra.ExactArgs(1), - ValidArgsFunction: completeGroupOnly, - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - name := args[0] - outputFmt, _ := cmd.Flags().GetString("output") - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - obj, err := dyn.Resource(iamtypes.GroupGVR).Get(cmd.Context(), name, metav1.GetOptions{}) - if err != nil { - return fmt.Errorf("getting Group %q: %w", name, err) - } - - switch outputFmt { - case "json", "yaml": - return utilk8s.PrintObject(cmd.OutOrStdout(), obj, outputFmt) - default: - return printGroupDetail(cmd, obj) - } - }, - } - - cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) - return cmd -} - -func printGroupDetail(cmd *cobra.Command, obj *unstructured.Unstructured) error { - w := cmd.OutOrStdout() - name := obj.GetName() - specName, _, _ := unstructured.NestedString(obj.Object, "spec", "name") - fmt.Fprintf(w, "Group: %s\n", name) - if specName != "" && specName != name { - fmt.Fprintf(w, "Display name: %s\n", specName) - } - - members, _ := getGroupMembers(obj) - groupKindStr := string(iamtypes.KindGroup) - var users, groups []string - for _, m := range members { - kind := fmt.Sprint(m["kind"]) - mName := fmt.Sprint(m["name"]) - switch kind { - case groupKindStr: - groups = append(groups, mName) - default: - users = append(users, mName) - } - } - - fmt.Fprintf(w, "\nUser members (%d):\n", len(users)) - if len(users) == 0 { - fmt.Fprintln(w, " ") - } - for _, u := range users { - fmt.Fprintf(w, " - %s\n", u) - } - - fmt.Fprintf(w, "\nNested groups (%d):\n", len(groups)) - if len(groups) == 0 { - fmt.Fprintln(w, " ") - } - for _, g := range groups { - fmt.Fprintf(w, " - %s\n", g) - } - - // Show status errors if present (try both spec.status.errors and status.errors) - errors := getStatusErrors(obj) - if len(errors) > 0 { - fmt.Fprintf(w, "\nStatus errors (%d):\n", len(errors)) - for _, e := range errors { - fmt.Fprintf(w, " - %s\n", e) - } - } - - fmt.Fprintln(w, "\nTip: run \"d8 iam access explain group "+name+"\" to see effective grants.") - return nil -} - -func getStatusErrors(obj *unstructured.Unstructured) []string { - var result []string - - // Try spec.status.errors (as per CRD schema) - paths := [][]string{ - {"spec", "status", "errors"}, - {"status", "errors"}, - } - for _, path := range paths { - errs, found, _ := unstructured.NestedSlice(obj.Object, path...) - if !found { - continue - } - for _, e := range errs { - em, ok := e.(map[string]any) - if !ok { - continue - } - msg := fmt.Sprint(em["message"]) - if ref, ok := em["objectRef"].(map[string]any); ok { - msg = fmt.Sprintf("[%s/%s] %s", ref["kind"], ref["name"], msg) - } - result = append(result, msg) - } - if len(result) > 0 { - return result - } - } - return result -} - -func printGroupTable(cmd *cobra.Command, groups []*unstructured.Unstructured) error { - tw := printers.GetNewTabWriter(cmd.OutOrStdout()) - fmt.Fprintln(tw, "GROUP\tMEMBERS\tNESTED\tERRORS") - for _, g := range groups { - name := g.GetName() - members, _ := getGroupMembers(g) - userCount := 0 - nestedCount := 0 - for _, m := range members { - if fmt.Sprint(m["kind"]) == string(iamtypes.KindGroup) { - nestedCount++ - } else { - userCount++ - } - } - errorCount := len(getStatusErrors(g)) - errStr := "0" - if errorCount > 0 { - errStr = fmt.Sprintf("%d", errorCount) - } - - fmt.Fprintf(tw, "%s\t%d\t%d\t%s\n", name, userCount, nestedCount, errStr) - } - return tw.Flush() -} diff --git a/internal/iam/group/cmd/group.go b/internal/iam/group/cmd/group.go index 145f6cdd..744605bb 100644 --- a/internal/iam/group/cmd/group.go +++ b/internal/iam/group/cmd/group.go @@ -27,7 +27,13 @@ var groupLong = templates.LongDesc(` Manage Deckhouse local groups (user-authn). This command provides lifecycle operations for local Group CRs: -Create, Delete, Get, List, and membership management via add-member / remove-member. +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`) @@ -46,8 +52,6 @@ func NewCommand() *cobra.Command { cmd.AddCommand( newCreateCommand(), newDeleteCommand(), - newGetCommand(), - newListCommand(), newAddMemberCommand(), newRemoveMemberCommand(), ) diff --git a/internal/iam/group/cmd/group_test.go b/internal/iam/group/cmd/group_test.go index c51fd72d..e351df76 100644 --- a/internal/iam/group/cmd/group_test.go +++ b/internal/iam/group/cmd/group_test.go @@ -17,7 +17,6 @@ limitations under the License. package group import ( - "strings" "testing" "github.com/stretchr/testify/assert" @@ -140,70 +139,3 @@ func TestGetGroupMembers(t *testing.T) { }) } -func TestGetStatusErrors(t *testing.T) { - t.Run("errors under spec.status.errors", func(t *testing.T) { - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "spec": map[string]any{ - "status": map[string]any{ - "errors": []any{ - map[string]any{ - "message": "user not found", - "objectRef": map[string]any{ - "kind": "User", - "name": "deleted-user", - }, - }, - }, - }, - }, - }, - } - errors := getStatusErrors(obj) - require.Len(t, errors, 1) - assert.Contains(t, errors[0], "user not found") - assert.Contains(t, errors[0], "User/deleted-user") - }) - - t.Run("no errors", func(t *testing.T) { - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "spec": map[string]any{}, - }, - } - errors := getStatusErrors(obj) - assert.Empty(t, errors) - }) -} - -func TestPrintGroupDetail(t *testing.T) { - obj := &unstructured.Unstructured{ - Object: map[string]any{ - "apiVersion": "deckhouse.io/v1alpha1", - "kind": "Group", - "metadata": map[string]any{ - "name": "admins", - }, - "spec": map[string]any{ - "name": "admins", - "members": []any{ - map[string]any{"kind": "User", "name": "anton"}, - map[string]any{"kind": "Group", "name": "devs"}, - }, - }, - }, - } - - var buf strings.Builder - cmd := NewCommand() - cmd.SetOut(&buf) - err := printGroupDetail(cmd, obj) - require.NoError(t, err) - - output := buf.String() - assert.Contains(t, output, "Group: admins") - assert.Contains(t, output, "anton") - assert.Contains(t, output, "devs") - assert.Contains(t, output, "User members (1)") - assert.Contains(t, output, "Nested groups (1)") -} diff --git a/internal/iam/group/cmd/list.go b/internal/iam/group/cmd/list.go deleted file mode 100644 index 9abafda8..00000000 --- a/internal/iam/group/cmd/list.go +++ /dev/null @@ -1,84 +0,0 @@ -/* -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 ( - "encoding/json" - "fmt" - - "github.com/spf13/cobra" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - sigsyaml "sigs.k8s.io/yaml" - - iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" - "github.com/deckhouse/deckhouse-cli/internal/utilk8s" -) - -func newListCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "list", - Short: "List all local groups", - Args: cobra.NoArgs, - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, _ []string) error { - outputFmt, _ := cmd.Flags().GetString("output") - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - result, err := dyn.Resource(iamtypes.GroupGVR).List(cmd.Context(), metav1.ListOptions{}) - if err != nil { - return fmt.Errorf("listing Groups: %w", err) - } - - if len(result.Items) == 0 { - cmd.Println("No groups found") - return nil - } - - switch outputFmt { - case "json": - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("marshalling JSON: %w", err) - } - fmt.Fprintln(cmd.OutOrStdout(), string(data)) - case "yaml": - data, err := sigsyaml.Marshal(result.UnstructuredContent()) - if err != nil { - return fmt.Errorf("marshalling YAML: %w", err) - } - fmt.Fprint(cmd.OutOrStdout(), string(data)) - default: - groups := make([]*unstructured.Unstructured, 0, len(result.Items)) - for i := range result.Items { - groups = append(groups, &result.Items[i]) - } - return printGroupTable(cmd, groups) - } - return nil - }, - } - - cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) - return cmd -} 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 index bec7afff..350886f7 100644 --- a/internal/iam/types/types.go +++ b/internal/iam/types/types.go @@ -88,6 +88,9 @@ 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 diff --git a/internal/iam/user/cmd/create.go b/internal/iam/user/cmd/create.go index b8d6ad2f..20890aa7 100644 --- a/internal/iam/user/cmd/create.go +++ b/internal/iam/user/cmd/create.go @@ -19,7 +19,6 @@ package user import ( "errors" "fmt" - "os" "strings" "time" @@ -39,8 +38,9 @@ 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), or auto-generated (--generate-password). If no password flag -is given and stdin is a terminal, the command prompts interactively. +(--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. @@ -60,6 +60,9 @@ var createExample = templates.Examples(` # 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 @@ -80,9 +83,7 @@ func newCreateCommand() *cobra.Command { cmd.Flags().String("email", "", "User email address (required, must be lowercase)") _ = cmd.MarkFlagRequired("email") - cmd.Flags().Bool("password-prompt", false, "Read password interactively with hidden input") - cmd.Flags().Bool("password-stdin", false, "Read password from stdin (for CI/pipelines)") - cmd.Flags().Bool("generate-password", false, "Auto-generate a strong password (shown once on stderr)") + 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.") @@ -101,9 +102,6 @@ func runCreate(cmd *cobra.Command, args []string) error { name := args[0] email, _ := cmd.Flags().GetString("email") - promptFlag, _ := cmd.Flags().GetBool("password-prompt") - stdinFlag, _ := cmd.Flags().GetBool("password-stdin") - generateFlag, _ := cmd.Flags().GetBool("generate-password") memberOf, _ := cmd.Flags().GetStringSlice("member-of") createGroups, _ := cmd.Flags().GetBool("create-groups") ttl, _ := cmd.Flags().GetString("ttl") @@ -126,28 +124,19 @@ func runCreate(cmd *cobra.Command, args []string) error { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: users with \"system:\" prefix may be rejected by the cluster\n") } - mode, err := resolvePasswordMode(promptFlag, stdinFlag, generateFlag) - if err != nil { - return err - } - - var plainPassword string - switch mode { - case passwordModePrompt: - plainPassword, err = readPasswordPrompt(int(os.Stdin.Fd()), cmd.ErrOrStderr()) - case passwordModeStdin: - plainPassword, err = readPasswordStdin(os.Stdin) - case passwordModeGenerate: - plainPassword, err = generatePassword() - } + res, mode, err := resolvePasswordInput(cmd) if err != nil { return err } - encodedPassword, err := encodePasswordForDeckhouse(plainPassword) + 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) @@ -170,7 +159,7 @@ func runCreate(cmd *cobra.Command, args []string) error { } if mode == passwordModeGenerate { - fmt.Fprintf(cmd.ErrOrStderr(), "Generated temporary password (shown once): %s\n", plainPassword) + 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") } diff --git a/internal/iam/user/cmd/get.go b/internal/iam/user/cmd/get.go deleted file mode 100644 index cae9af06..00000000 --- a/internal/iam/user/cmd/get.go +++ /dev/null @@ -1,156 +0,0 @@ -/* -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" - "strings" - - "github.com/spf13/cobra" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/cli-runtime/pkg/printers" - - iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" - "github.com/deckhouse/deckhouse-cli/internal/utilk8s" -) - -func newGetCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "get ", - Short: "Get details of a local static user", - Args: cobra.ExactArgs(1), - ValidArgsFunction: completeUserNames, - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - name := args[0] - outputFmt, _ := cmd.Flags().GetString("output") - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - obj, err := dyn.Resource(iamtypes.UserGVR).Get(cmd.Context(), name, metav1.GetOptions{}) - if err != nil { - return fmt.Errorf("getting User %q: %w", name, err) - } - - switch outputFmt { - case "json", "yaml": - return utilk8s.PrintObject(cmd.OutOrStdout(), obj, outputFmt) - default: - return printUserDetail(cmd, obj) - } - }, - } - - cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) - return cmd -} - -func printUserDetail(cmd *cobra.Command, u *unstructured.Unstructured) error { - w := cmd.OutOrStdout() - name := u.GetName() - email, _, _ := unstructured.NestedString(u.Object, "spec", "email") - ttl, _, _ := unstructured.NestedString(u.Object, "spec", "ttl") - userID, _, _ := unstructured.NestedString(u.Object, "spec", "userID") - - fmt.Fprintf(w, "User: %s\n", name) - if email != "" { - fmt.Fprintf(w, "Email: %s\n", email) - } - if userID != "" { - fmt.Fprintf(w, "UserID: %s\n", userID) - } - fmt.Fprintf(w, "Created: %s\n", u.GetCreationTimestamp().Format("2006-01-02 15:04:05")) - - fmt.Fprintln(w, "\nStatus:") - expireAt, _, _ := unstructured.NestedString(u.Object, "status", "expireAt") - if ttl != "" { - fmt.Fprintf(w, " TTL: %s\n", ttl) - } else { - fmt.Fprintln(w, " TTL: ") - } - if expireAt != "" { - fmt.Fprintf(w, " Expire at: %s\n", expireAt) - } else { - fmt.Fprintln(w, " Expire at: ") - } - - locked := false - if v, found, _ := unstructured.NestedBool(u.Object, "status", "lock", "state"); found { - locked = v - } - if locked { - fmt.Fprintln(w, " Lock: locked") - if until, _, _ := unstructured.NestedString(u.Object, "status", "lock", "until"); until != "" { - fmt.Fprintf(w, " Lock until: %s\n", until) - } - if reason, _, _ := unstructured.NestedString(u.Object, "status", "lock", "reason"); reason != "" { - fmt.Fprintf(w, " Lock reason: %s\n", reason) - } - if msg, _, _ := unstructured.NestedString(u.Object, "status", "lock", "message"); msg != "" { - fmt.Fprintf(w, " Lock message: %s\n", msg) - } - } else { - fmt.Fprintln(w, " Lock: unlocked") - } - - groups, _, _ := unstructured.NestedStringSlice(u.Object, "status", "groups") - fmt.Fprintf(w, "\nGroups (from status) (%d):\n", len(groups)) - if len(groups) == 0 { - fmt.Fprintln(w, " ") - } - for _, g := range groups { - fmt.Fprintf(w, " - %s\n", g) - } - - fmt.Fprintln(w, "\nTip: run \"d8 iam access explain user "+name+"\" to see effective access.") - return nil -} - -func printUserTable(cmd *cobra.Command, users []*unstructured.Unstructured) error { - tw := printers.GetNewTabWriter(cmd.OutOrStdout()) - fmt.Fprintln(tw, "NAME\tEMAIL\tGROUPS\tEXPIRE_AT\tLOCKED") - for _, u := range users { - name := u.GetName() - email, _, _ := unstructured.NestedString(u.Object, "spec", "email") - expireAt, _, _ := unstructured.NestedString(u.Object, "status", "expireAt") - - groups, _, _ := unstructured.NestedStringSlice(u.Object, "status", "groups") - groupStr := strings.Join(groups, ",") - if groupStr == "" { - groupStr = "" - } - - locked := "false" - lockState, found, _ := unstructured.NestedBool(u.Object, "status", "lock", "state") - if found && lockState { - locked = "true" - } - - if expireAt == "" { - expireAt = "" - } - - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", name, email, groupStr, expireAt, locked) - } - return tw.Flush() -} diff --git a/internal/iam/user/cmd/list.go b/internal/iam/user/cmd/list.go deleted file mode 100644 index 87e67350..00000000 --- a/internal/iam/user/cmd/list.go +++ /dev/null @@ -1,84 +0,0 @@ -/* -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/json" - "fmt" - - "github.com/spf13/cobra" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - sigsyaml "sigs.k8s.io/yaml" - - iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" - "github.com/deckhouse/deckhouse-cli/internal/utilk8s" -) - -func newListCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "list", - Short: "List all local static users", - Args: cobra.NoArgs, - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, _ []string) error { - outputFmt, _ := cmd.Flags().GetString("output") - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - result, err := dyn.Resource(iamtypes.UserGVR).List(cmd.Context(), metav1.ListOptions{}) - if err != nil { - return fmt.Errorf("listing Users: %w", err) - } - - if len(result.Items) == 0 { - cmd.Println("No users found") - return nil - } - - switch outputFmt { - case "json": - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return fmt.Errorf("marshalling JSON: %w", err) - } - fmt.Fprintln(cmd.OutOrStdout(), string(data)) - case "yaml": - data, err := sigsyaml.Marshal(result.UnstructuredContent()) - if err != nil { - return fmt.Errorf("marshalling YAML: %w", err) - } - fmt.Fprint(cmd.OutOrStdout(), string(data)) - default: - users := make([]*unstructured.Unstructured, 0, len(result.Items)) - for i := range result.Items { - users = append(users, &result.Items[i]) - } - return printUserTable(cmd, users) - } - return nil - }, - } - - cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) - return cmd -} diff --git a/internal/iam/user/cmd/lock.go b/internal/iam/user/cmd/lock.go deleted file mode 100644 index d4d5ff57..00000000 --- a/internal/iam/user/cmd/lock.go +++ /dev/null @@ -1,62 +0,0 @@ -/* -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" -) - -func newLockCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "lock ", - Short: "Lock local user in Dex for a period of time", - Args: cobra.ExactArgs(2), - ValidArgsFunction: completeUserNames, - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - username := args[0] - lockDuration := args[1] - if _, err := time.ParseDuration(lockDuration); err != nil { - return fmt.Errorf("invalid lockDuration %q: %w", lockDuration, err) - } - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - return runUserOperation(cmd, dyn, userOpRequest{ - NamePrefix: "op-lock-", - OpType: "Lock", - User: username, - ExtraSpec: map[string]any{ - "lock": map[string]any{"for": lockDuration}, - }, - }) - }, - } - - 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/iam/user/cmd/password.go b/internal/iam/user/cmd/password.go index 10ea4a18..3a701fe6 100644 --- a/internal/iam/user/cmd/password.go +++ b/internal/iam/user/cmd/password.go @@ -27,6 +27,7 @@ import ( "os" "strings" + "github.com/spf13/cobra" "golang.org/x/crypto/bcrypt" "golang.org/x/term" ) @@ -40,12 +41,32 @@ const ( passwordModePrompt passwordModeStdin passwordModeGenerate + // passwordModeHash means the caller provided a pre-computed bcrypt hash + // directly via --password-hash; no plaintext is read. + passwordModeHash ) -// resolvePasswordMode determines which password source to use based on the flags provided. -// Exactly one of prompt, stdin, or generate must be true; if none is set, the mode is -// inferred from whether stdin is a terminal. -func resolvePasswordMode(prompt, stdin, generate bool) (passwordMode, error) { +// 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++ @@ -56,24 +77,91 @@ func resolvePasswordMode(prompt, stdin, generate bool) (passwordMode, error) { if generate { count++ } + if hash != "" { + count++ + } if count > 1 { - return passwordModeNone, errors.New("only one of --password-prompt, --password-stdin, --generate-password may be specified") + return passwordModeNone, fmt.Errorf( + "only one of --%s, --%s, --%s, --%s may be specified", + flagPasswordPrompt, flagPasswordStdin, flagPasswordGenerate, flagPasswordHash, + ) } - if prompt { + switch { + case hash != "": + return passwordModeHash, nil + case prompt: return passwordModePrompt, nil - } - if stdin { + case stdin: return passwordModeStdin, nil - } - if generate { + case generate: return passwordModeGenerate, nil } if term.IsTerminal(int(os.Stdin.Fd())) { return passwordModePrompt, nil } - return passwordModeNone, errors.New("stdin is not a terminal; use --password-stdin or --generate-password") + 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. @@ -127,12 +215,35 @@ func generatePassword() (string, error) { return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil } -// encodePasswordForDeckhouse hashes the plaintext password with bcrypt cost 10 -// and returns a base64-encoded bcrypt hash suitable for User.spec.password. +// 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 base64.StdEncoding.EncodeToString(hash), nil + 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 index 774ae98c..3075bfbf 100644 --- a/internal/iam/user/cmd/password_test.go +++ b/internal/iam/user/cmd/password_test.go @@ -27,25 +27,34 @@ import ( ) 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: "all three conflict", prompt: true, 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) + 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) @@ -112,3 +121,69 @@ func TestEncodePasswordForDeckhouse(t *testing.T) { 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/reset2fa.go b/internal/iam/user/cmd/reset2fa.go deleted file mode 100644 index 0ebbf52e..00000000 --- a/internal/iam/user/cmd/reset2fa.go +++ /dev/null @@ -1,49 +0,0 @@ -/* -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" - - "github.com/deckhouse/deckhouse-cli/internal/utilk8s" -) - -func newReset2FACommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "reset2fa ", - Short: "Reset local user's 2FA (TOTP) in Dex", - Args: cobra.ExactArgs(1), - ValidArgsFunction: completeUserNames, - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - return runUserOperation(cmd, dyn, userOpRequest{ - NamePrefix: "op-reset2fa-", - OpType: "Reset2FA", - User: args[0], - }) - }, - } - - 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/iam/user/cmd/reset_password.go b/internal/iam/user/cmd/reset_password.go index 44b47778..3087fb41 100644 --- a/internal/iam/user/cmd/reset_password.go +++ b/internal/iam/user/cmd/reset_password.go @@ -17,37 +17,103 @@ 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 ", + Use: "reset-password ", Aliases: []string{"resetpass"}, - Short: "Reset local user's password in Dex (requires bcrypt hash)", - Args: cobra.ExactArgs(2), + Short: "Reset local user's password in Dex", + Long: resetPasswordLong, + Example: resetPasswordExample, + Args: cobra.ExactArgs(1), ValidArgsFunction: completeUserNames, SilenceErrors: true, SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - return runUserOperation(cmd, dyn, userOpRequest{ - NamePrefix: "op-resetpass-", - OpType: "ResetPassword", - User: args[0], - ExtraSpec: map[string]any{ - "resetPassword": map[string]any{"newPasswordHash": args[1]}, - }, - }) - }, + RunE: runResetPassword, } - 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`)." + 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/unlock.go b/internal/iam/user/cmd/unlock.go deleted file mode 100644 index 6d23c525..00000000 --- a/internal/iam/user/cmd/unlock.go +++ /dev/null @@ -1,49 +0,0 @@ -/* -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" - - "github.com/deckhouse/deckhouse-cli/internal/utilk8s" -) - -func newUnlockCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "unlock ", - Short: "Unlock local user in Dex", - Args: cobra.ExactArgs(1), - ValidArgsFunction: completeUserNames, - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - return runUserOperation(cmd, dyn, userOpRequest{ - NamePrefix: "op-unlock-", - OpType: "Unlock", - User: args[0], - }) - }, - } - - 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/iam/user/cmd/user.go b/internal/iam/user/cmd/user.go index a0ece436..d5f2ab3d 100644 --- a/internal/iam/user/cmd/user.go +++ b/internal/iam/user/cmd/user.go @@ -27,7 +27,13 @@ var userOperationLong = templates.LongDesc(` Manage Deckhouse local static users (user-authn). This command provides lifecycle operations for Dex local users: -Create, Delete, Get, List, ResetPassword, Reset2FA, Lock, Unlock. +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`) @@ -47,13 +53,11 @@ func NewCommand() *cobra.Command { cmd.AddCommand( newCreateCommand(), newDeleteCommand(), - newGetCommand(), - newListCommand(), - newReset2FACommand(), newResetPasswordCommand(), - newLockCommand(), - newUnlockCommand(), ) + 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 +} From 41aa31c022d2dadf32245aaffa8a58779c551234 Mon Sep 17 00:00:00 2001 From: Ivan Zvyagintsev Date: Tue, 28 Apr 2026 18:30:15 +0300 Subject: [PATCH 5/5] refactor3 Signed-off-by: Ivan Zvyagintsev --- internal/iam/access/cmd/access.go | 15 +- internal/iam/access/cmd/access_test.go | 35 --- internal/iam/access/cmd/completion.go | 33 +-- internal/iam/access/cmd/explain.go | 301 ----------------------- internal/iam/access/cmd/grant.go | 230 ++--------------- internal/iam/access/cmd/grant_to_test.go | 130 ---------- internal/iam/access/cmd/list.go | 279 +++++++++++---------- internal/iam/access/cmd/naming.go | 9 +- internal/iam/access/cmd/print.go | 41 +-- internal/iam/access/cmd/revoke.go | 189 ++------------ internal/iam/access/cmd/rules.go | 6 +- internal/iam/access/cmd/types.go | 34 +-- internal/iam/cmd/iam.go | 16 +- internal/iam/cmd/iam_test.go | 4 +- internal/iam/group/cmd/create.go | 3 +- internal/iam/user/cmd/create.go | 3 +- internal/utilk8s/output.go | 10 + 17 files changed, 259 insertions(+), 1079 deletions(-) delete mode 100644 internal/iam/access/cmd/explain.go delete mode 100644 internal/iam/access/cmd/grant_to_test.go diff --git a/internal/iam/access/cmd/access.go b/internal/iam/access/cmd/access.go index 2057be63..c5f7cea9 100644 --- a/internal/iam/access/cmd/access.go +++ b/internal/iam/access/cmd/access.go @@ -24,23 +24,17 @@ import ( ) var accessLong = templates.LongDesc(` -Manage access grants in Deckhouse (current authz model). +Grant and revoke access in Deckhouse (current authz model). -This command provides grant, revoke, and explain operations for -AuthorizationRule and ClusterAuthorizationRule custom resources. - -Only the current authorization model is supported in this version. -Experimental model support is planned for a future release. - -For inspecting existing rules and effective access, use the top-level -"d8 iam get" / "d8 iam list" commands. +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: "Manage access grants in Deckhouse (current authz model)", + Short: "Grant or revoke access (current authz model)", Long: accessLong, SilenceErrors: true, SilenceUsage: true, @@ -51,7 +45,6 @@ func NewCommand() *cobra.Command { cmd.AddCommand( newGrantCommand(), newRevokeCommand(), - newExplainCommand(), ) return cmd diff --git a/internal/iam/access/cmd/access_test.go b/internal/iam/access/cmd/access_test.go index 39753f90..3c41221e 100644 --- a/internal/iam/access/cmd/access_test.go +++ b/internal/iam/access/cmd/access_test.go @@ -21,7 +21,6 @@ import ( "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" ) @@ -241,37 +240,3 @@ func TestCanonicalGrantSpec_JSON_Deterministic(t *testing.T) { assert.Equal(t, j1, j2, "namespace order should not affect canonical JSON") } -func TestRemoveSubject(t *testing.T) { - t.Run("subject found", func(t *testing.T) { - obj := buildTestObj([]map[string]any{ - {"kind": "User", "name": "anton@abc.com"}, - {"kind": "Group", "name": "admins"}, - }) - newSubjects, removed := removeSubject(obj, iamtypes.KindUser, "anton@abc.com") - assert.True(t, removed) - assert.Len(t, newSubjects, 1) - }) - - t.Run("subject not found", func(t *testing.T) { - obj := buildTestObj([]map[string]any{ - {"kind": "Group", "name": "admins"}, - }) - newSubjects, removed := removeSubject(obj, iamtypes.KindUser, "anton@abc.com") - assert.False(t, removed) - assert.Len(t, newSubjects, 1) - }) -} - -func buildTestObj(subjects []map[string]any) *unstructured.Unstructured { - var rawSubjects []any - for _, s := range subjects { - rawSubjects = append(rawSubjects, s) - } - return &unstructured.Unstructured{ - Object: map[string]any{ - "spec": map[string]any{ - "subjects": rawSubjects, - }, - }, - } -} diff --git a/internal/iam/access/cmd/completion.go b/internal/iam/access/cmd/completion.go index 31dc646a..7cf319e6 100644 --- a/internal/iam/access/cmd/completion.go +++ b/internal/iam/access/cmd/completion.go @@ -26,13 +26,11 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) -// subjectKinds is the enum accepted as the first positional arg on -// "d8 iam access grant", "d8 iam access revoke" and "d8 iam access explain". +// subjectKinds is the first positional arg of grant/revoke. var subjectKinds = []string{"user", "group"} -// allAccessLevelsList is exposed for flag completion. It points at the same -// ordered slice that drives validation in types.go, so adding a new level -// updates both sites at once. +// 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. @@ -67,31 +65,18 @@ func completeScopeFlag(_ *cobra.Command, _ []string, toComplete string) ([]strin return utilk8s.FilterByPrefix(candidates, toComplete), cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace } -// completeRuleRef completes rule references in the forms accepted by -// `d8 iam get rule REF` and the `--to`/`--from` flags of grant/revoke. -// -// "CAR/" — full list of ClusterAuthorizationRules -// "AR//" — full list of AuthorizationRules across all namespaces -// -// Short prefixes (CAR, AR) are preferred in completion output for less typing; -// long prefixes remain valid input. -// -// NB: this function is used both as a positional ValidArgsFunction (where args -// is empty when the REF arg is being typed) AND as a flag-value completer for -// --to/--from (where args may already contain command positionals like -// "user anton"). The previous implementation short-circuited on len(args)>=1, -// which silently disabled completion for the flag-value case. Cobra invokes -// the registered completer for the right slot regardless of unrelated args, -// so we ignore args here. +// 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 } - // Decide which branches to populate based on the prefix already committed - // to by the user. For a partially-typed prefix (e.g. "C", "CA") we still - // want both — cobra's downstream filtering will narrow things down. + // 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/"), diff --git a/internal/iam/access/cmd/explain.go b/internal/iam/access/cmd/explain.go deleted file mode 100644 index f9654bd8..00000000 --- a/internal/iam/access/cmd/explain.go +++ /dev/null @@ -1,301 +0,0 @@ -/* -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" - "sort" - "strings" - - "github.com/spf13/cobra" - "k8s.io/kubectl/pkg/util/templates" - - iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" - "github.com/deckhouse/deckhouse-cli/internal/utilk8s" -) - -var explainLong = templates.LongDesc(` -Explain why a user or group has certain access. - -This command traces access through identity resolution, group memberships, -direct and inherited grants, and shows warnings about potential issues -like group cycles, orphaned references, and manual (non-d8-managed) objects. - -© Flant JSC 2026`) - -var explainExample = templates.Examples(` - # Explain access for a user - d8 iam access explain user anton - - # Explain access for a group (JSON output) - d8 iam access explain group admins -o json`) - -func newExplainCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "explain (user|group) ", - Short: "Explain why a user or group has certain access", - Long: explainLong, - Example: explainExample, - Args: cobra.ExactArgs(2), - ValidArgsFunction: completeSubjectAndName, - SilenceErrors: true, - SilenceUsage: true, - RunE: runExplain, - } - - cmd.Flags().StringP("output", "o", "text", "Output format: text|json") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("text", "json")) - return cmd -} - -func runExplain(cmd *cobra.Command, args []string) error { - kindStr := args[0] - name := args[1] - outputFmt, _ := cmd.Flags().GetString("output") - - subjectKind, err := parseSubjectKind(kindStr) - if err != nil { - return err - } - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - inv, err := buildInventory(cmd.Context(), dyn) - if err != nil { - return err - } - - switch subjectKind { - case iamtypes.KindUser: - return explainUser(cmd, inv, name, outputFmt) - case iamtypes.KindGroup: - return explainGroup(cmd, inv, name, outputFmt) - } - return nil -} - -func explainUser(cmd *cobra.Command, inv *accessInventory, userName, outputFmt string) error { - email, ok := inv.Users[userName] - if !ok { - return fmt.Errorf("user %q not found", 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) - cycles := inv.DetectGroupCycles() - warnings := collectUserWarnings(transitiveGroups, directGrants, inheritedGrants, cycles) - - if outputFmt == "json" { - item := buildUserAccessJSON(inv, userName) - item.Warnings = warnings - return printStructured(cmd.OutOrStdout(), item, outputFmt) - } - - w := cmd.OutOrStdout() - - fmt.Fprintln(w, "=== Identity Resolution ===") - fmt.Fprintf(w, "User CR: %s\n", userName) - fmt.Fprintf(w, "Resolved principal (email): %s\n", email) - - fmt.Fprintln(w, "\n=== Group Memberships ===") - fmt.Fprintln(w, "Direct groups:") - printBulletList(w, directGroups) - if len(transitiveGroups) > len(directGroups) { - fmt.Fprintln(w, "Transitive groups (via nested membership):") - directSet := make(map[string]bool, len(directGroups)) - for _, g := range directGroups { - directSet[g] = true - } - var extra []string - for _, g := range transitiveGroups { - if !directSet[g] { - extra = append(extra, g) - } - } - sort.Strings(extra) - printBulletList(w, extra) - } - - fmt.Fprintln(w, "\n=== Direct Grants (matched by email) ===") - if len(directGrants) == 0 { - fmt.Fprintln(w, " ") - } - for _, g := range directGrants { - printGrantToWriter(w, &g, "") - } - - fmt.Fprintln(w, "\n=== Inherited Grants (via group membership) ===") - if len(inheritedGrants) == 0 { - fmt.Fprintln(w, " ") - } - for _, g := range inheritedGrants { - via := findViaGroup(inv, userName, g.SubjectPrincipal) - printGrantToWriter(w, &g, via) - } - - fmt.Fprintln(w, "\n=== Effective Access Summary ===") - printEffectiveSummary(w, summary) - - if len(warnings) > 0 { - fmt.Fprintln(w, "\n=== Warnings ===") - for _, warn := range warnings { - fmt.Fprintf(w, " ! %s\n", warn) - } - } - - return nil -} - -func explainGroup(cmd *cobra.Command, inv *accessInventory, groupName, outputFmt string) error { - if _, ok := inv.GroupMembers[groupName]; !ok { - return fmt.Errorf("group %q not found", groupName) - } - - grants := inv.GroupGrants(groupName) - summary := computeEffectiveSummary(grants) - cycles := inv.DetectGroupCycles() - warnings := collectGroupWarnings(inv, groupName, cycles) - - if outputFmt == "json" { - return printStructured(cmd.OutOrStdout(), buildGroupExplainJSON(inv, groupName, warnings), outputFmt) - } - - w := cmd.OutOrStdout() - - fmt.Fprintln(w, "=== Group Identity ===") - fmt.Fprintf(w, "Group CR: %s\n", groupName) - - userMembers, groupMembers := partitionMembersByKind(inv.GroupMembers[groupName]) - - fmt.Fprintln(w, "\n=== Members ===") - fmt.Fprintf(w, "Users (%d):\n", len(userMembers)) - printBulletList(w, userMembers) - fmt.Fprintf(w, "Nested groups (%d):\n", len(groupMembers)) - printBulletList(w, groupMembers) - - fmt.Fprintln(w, "\n=== Grants ===") - if len(grants) == 0 { - fmt.Fprintln(w, " ") - } - for _, g := range grants { - printGrantToWriter(w, &g, "") - } - - fmt.Fprintln(w, "\n=== Effective Access Summary ===") - printEffectiveSummary(w, summary) - - if len(warnings) > 0 { - fmt.Fprintln(w, "\n=== Warnings ===") - for _, warn := range warnings { - fmt.Fprintf(w, " ! %s\n", warn) - } - } - - return nil -} - -func collectUserWarnings(groups []string, directGrants, inheritedGrants []normalizedGrant, cycles map[string][]string) []string { - var warnings []string - - // Check for cycles in user's groups - for _, g := range groups { - if cycle, ok := cycles[g]; ok { - warnings = append(warnings, fmt.Sprintf("group cycle detected: %s", strings.Join(cycle, " -> "))) - } - } - - for _, g := range directGrants { - if !g.ManagedByD8 { - warnings = append(warnings, fmt.Sprintf("direct grant from manual object: %s", formatGrantSource(&g))) - } - } - for _, g := range inheritedGrants { - if !g.ManagedByD8 { - warnings = append(warnings, fmt.Sprintf("inherited grant from manual object: %s (via group %s)", formatGrantSource(&g), g.SubjectPrincipal)) - } - } - - return warnings -} - -func collectGroupWarnings(inv *accessInventory, groupName string, cycles map[string][]string) []string { - var warnings []string - - if cycle, ok := cycles[groupName]; ok { - warnings = append(warnings, fmt.Sprintf("group cycle detected: %s", strings.Join(cycle, " -> "))) - } - - // Check if any user members are orphaned - members := inv.GroupMembers[groupName] - for _, m := range members { - if m.Kind == iamtypes.KindUser { - if _, ok := inv.Users[m.Name]; !ok { - warnings = append(warnings, fmt.Sprintf("user member %q not found as a local User CR (may be orphaned)", m.Name)) - } - } - if m.Kind == iamtypes.KindGroup { - if _, ok := inv.GroupMembers[m.Name]; !ok { - warnings = append(warnings, fmt.Sprintf("nested group %q not found as a local Group CR", m.Name)) - } - } - } - - return warnings -} - -// groupExplainJSON is the payload for "d8 iam access explain group ... -o json". -// Lives here (not in list.go) because Members / Grants / Warnings are unique -// to the explain command's contract — get group does not surface warnings. -type groupExplainJSON struct { - Kind string `json:"kind"` - Name string `json:"name"` - Members []memberJSON `json:"members"` - Grants []grantJSON `json:"grants"` - Effective effectiveJSON `json:"effectiveSummary"` - Warnings []string `json:"warnings"` -} - -func buildGroupExplainJSON(inv *accessInventory, groupName string, warnings []string) groupExplainJSON { - 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 groupExplainJSON{ - Kind: "GroupAccessExplanation", - Name: groupName, - Members: denil(memberItems), - Grants: denil(grantItems), - Effective: summaryToJSON(summary), - Warnings: denil(warnings), - } -} diff --git a/internal/iam/access/cmd/grant.go b/internal/iam/access/cmd/grant.go index eb717c65..97a6f05e 100644 --- a/internal/iam/access/cmd/grant.go +++ b/internal/iam/access/cmd/grant.go @@ -19,7 +19,6 @@ package access import ( "errors" "fmt" - "sort" "strings" "github.com/hashicorp/go-multierror" @@ -37,93 +36,48 @@ import ( 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. +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 Creates an AuthorizationRule per namespace (namespaced scope). - Repeat to target several namespaces at once. - Only User, PrivilegedUser, Editor, Admin levels are valid. + -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. - --scope cluster Creates a ClusterAuthorizationRule. The grant applies to - every namespace EXCEPT system ones (d8-*, kube-system). - All access levels are valid, including - ClusterEditor / ClusterAdmin / SuperAdmin. - - --scope all-namespaces - Creates a ClusterAuthorizationRule with - namespaceSelector.matchAny: true. Covers ALL namespaces - including system ones (d8-system, kube-system, ...). - Use with caution. - - --scope labels=K=V[,K2=V2,...] - Creates a ClusterAuthorizationRule with - namespaceSelector.labelSelector.matchLabels = {K: V, ...}. - Targets every namespace matching all of the given labels. - -Modifier flags --allow-scale and --port-forwarding add additional capabilities -to the grant and can be combined with any scope. - -ADVANCED MODE (--to): - - Adds a subject to an existing authorization rule instead of creating a new - d8-managed object. Useful when you want to extend a rule maintained - manually or shared across teams. - - d8 iam access grant --to ClusterAuthorizationRule/RULE user PRINCIPAL - d8 iam access grant --to AuthorizationRule/NS/RULE group GROUPNAME - - PRINCIPAL is written literally into spec.subjects[].name — for users pass - the exact principal expected by the authz module (typically the email), - not the User CR name. No User CR resolution is performed in this mode. - - --to is strictly "add a subject". It never modifies accessLevel, scope, - allowScale or portForwarding of the target rule. Those fields apply to - ALL subjects of the rule, so changing them from CLI would silently affect - other principals — that is by design disallowed. Passing any of these - flags together with --to is an error. - - Because RBAC is additive, the normal way to extend a subject's capabilities - is a separate d8-managed grant next to the existing rule: - - d8 iam access grant user alice --access-level Admin -n dev --port-forwarding - - If you really need to change a shared rule's flags for everyone on it, edit - the rule directly (kubectl edit), since that is a policy change, not a - per-subject grant. +--allow-scale and --port-forwarding add capabilities to any scope. © Flant JSC 2026`) var grantExample = templates.Examples(` - # Grant Admin in specific namespaces + # Namespaced grants (one AR per --namespace) d8 iam access grant user anton --access-level Admin -n dev -n stage - # Grant ClusterAdmin cluster-wide (system namespaces excluded) + # Cluster-wide (no system namespaces) d8 iam access grant user anton --access-level ClusterAdmin --scope cluster - # Grant ClusterAdmin to ALL namespaces including d8-system, kube-system + # Cluster-wide including system namespaces d8 iam access grant user anton --access-level ClusterAdmin --scope all-namespaces - # Grant Editor only in namespaces labelled team=platform,tier=prod + # Match by namespace labels d8 iam access grant group admins --access-level Editor --scope labels=team=platform,tier=prod - # Grant Editor to a group with port-forwarding enabled + # Add port-forwarding capability d8 iam access grant group admins --access-level Editor -n dev --port-forwarding - # Dry-run to preview the manifest before applying - d8 iam access grant user anton --access-level Admin -n dev --dry-run -o yaml - - # Advanced mode: add a user to an existing ClusterAuthorizationRule (literal principal) - d8 iam access grant --to ClusterAuthorizationRule/superadmins user new@example.com - - # Advanced mode: add a group to an existing namespaced AuthorizationRule - d8 iam access grant --to AuthorizationRule/dev/shared-editors group devs`) + # 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 | --to SOURCE]", + Use: "grant (user|group) NAME --access-level LEVEL [scope]", Short: "Grant access to a user or group (current authz model)", Long: grantLong, Example: grantExample, @@ -140,24 +94,16 @@ func newGrantCommand() *cobra.Command { 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") - cmd.Flags().StringP("output", "o", "name", "Output format: name|yaml|json") - cmd.Flags().String("to", "", "Existing rule to add the subject to: AuthorizationRule// or ClusterAuthorizationRule/") + utilk8s.AddOutputFlag(cmd, "name", "name", "yaml", "json") _ = cmd.RegisterFlagCompletionFunc("access-level", completeAccessLevels) _ = cmd.RegisterFlagCompletionFunc("namespace", completeNamespacesFlag) _ = cmd.RegisterFlagCompletionFunc("scope", completeScopeFlag) - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("name", "yaml", "json")) - _ = cmd.RegisterFlagCompletionFunc("to", completeRuleRef) return cmd } func runGrant(cmd *cobra.Command, args []string) error { - toFlag, _ := cmd.Flags().GetString("to") - if toFlag != "" { - return runSourceBasedGrant(cmd, args) - } - subjectKindStr := args[0] subjectName := args[1] @@ -170,7 +116,7 @@ func runGrant(cmd *cobra.Command, args []string) error { outputFmt, _ := cmd.Flags().GetString("output") if accessLevel == "" { - return errors.New("--access-level is required (or use --to to add a subject to an existing rule)") + return errors.New("--access-level is required") } subjectKind, err := parseSubjectKind(subjectKindStr) @@ -556,133 +502,3 @@ func toAnyMap(m map[string]string) map[string]any { } return result } - -// rejectFlagsInToMode fails fast if any flag incompatible with "--to add-subject" -// mode is provided. Each flag gets a specific hint so the user knows which tool -// to reach for instead. -func rejectFlagsInToMode(cmd *cobra.Command) error { - ruleSpecHint := "this field applies to ALL subjects of the rule; change it with 'kubectl edit' on the rule, not via --to" - perSubjectHint := "for a per-subject capability on top of the existing rule, create a separate grant without --to: 'd8 iam access grant --access-level ... [scope] --port-forwarding --allow-scale'" - - hints := map[string]string{ - "access-level": ruleSpecHint, - "namespace": ruleSpecHint, - "scope": ruleSpecHint, - "port-forwarding": perSubjectHint + - " — note: setting it on a shared rule would affect every subject already on it", - "allow-scale": perSubjectHint + - " — note: setting it on a shared rule would affect every subject already on it", - "dry-run": "--dry-run is not supported with --to (subject add is applied via Update, not generated manifest)", - } - - var offenders []string - for f := range hints { - if cmd.Flags().Changed(f) { - offenders = append(offenders, f) - } - } - if len(offenders) == 0 { - return nil - } - sort.Strings(offenders) - - var b strings.Builder - fmt.Fprintf(&b, "the following flag(s) are not allowed with --to: --%s\n", strings.Join(offenders, ", --")) - for _, f := range offenders { - fmt.Fprintf(&b, " --%s: %s\n", f, hints[f]) - } - return errors.New(b.String()) -} - -func runSourceBasedGrant(cmd *cobra.Command, args []string) error { - toFlag, _ := cmd.Flags().GetString("to") - - // Hard-fail on any flag that would imply we are redefining the rule. - // port-forwarding and allow-scale in particular apply to ALL subjects of - // the target rule, so silently "applying" them via --to would silently - // mutate other principals' capabilities. That's unsafe and must be - // explicit — force the user to pick the right tool: - // - per-subject capability -> separate d8-managed grant (RBAC is additive) - // - rule-wide policy change -> kubectl edit on the rule itself - if err := rejectFlagsInToMode(cmd); err != nil { - return err - } - - if len(args) != 2 { - return errors.New("source-based grant requires: grant --to (user|group) ") - } - subjectKind, err := parseSubjectKind(args[0]) - if err != nil { - return err - } - // Principal is used literally — it must exactly match the value that will - // land in spec.subjects[].name. No User CR lookup here, for symmetry with - // `revoke --from` and because shared rules may already carry a specific - // principal format (email, LDAP DN, etc). - subjectPrincipal := args[1] - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - refKind, refNS, refName, err := parseRuleRef(toFlag) - if err != nil { - return fmt.Errorf("invalid --to: %w", err) - } - - var client dynamic.ResourceInterface - switch refKind { - case iamtypes.KindClusterAuthorizationRule: - client = dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR) - case iamtypes.KindAuthorizationRule: - client = dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace(refNS) - default: - return fmt.Errorf("unsupported --to kind %q", refKind) - } - return addSubjectToRule(cmd, client, refKind, refNS, refName, subjectKind, subjectPrincipal) -} - -// addSubjectToRule adds (kind,principal) to spec.subjects of the rule pointed -// to by client. The cluster vs namespaced difference lives entirely in how -// the caller built the dynamic.ResourceInterface; ref/ns are only used for -// human messages. -func addSubjectToRule(cmd *cobra.Command, client dynamic.ResourceInterface, - ruleKind, ns, ruleName string, subjectKind iamtypes.SubjectKind, subjectPrincipal string) error { - ref := formatRuleRef(ruleKind, ns, ruleName) - - obj, err := client.Get(cmd.Context(), ruleName, metav1.GetOptions{}) - if err != nil { - return fmt.Errorf("getting %s: %w", ref, err) - } - - newSubjects, added := addSubject(obj, subjectKind, subjectPrincipal) - if !added { - cmd.Printf("Subject %s/%s already present in %s (no change)\n", subjectKind, subjectPrincipal, ref) - return nil - } - - if err := unstructured.SetNestedSlice(obj.Object, newSubjects, "spec", "subjects"); err != nil { - return fmt.Errorf("setting subjects on %s: %w", ref, err) - } - if _, err := client.Update(cmd.Context(), obj, metav1.UpdateOptions{}); err != nil { - return fmt.Errorf("updating %s: %w", ref, err) - } - cmd.Printf("Added subject %s/%s to %s\n", subjectKind, subjectPrincipal, ref) - return nil -} - -func addSubject(obj *unstructured.Unstructured, kind iamtypes.SubjectKind, name string) ([]any, bool) { - subjects, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") - kindStr := string(kind) - for _, s := range subjects { - sub, ok := s.(map[string]any) - if !ok { - continue - } - if fmt.Sprint(sub["kind"]) == kindStr && fmt.Sprint(sub["name"]) == name { - return subjects, false - } - } - return append(subjects, map[string]any{"kind": kindStr, "name": name}), true -} diff --git a/internal/iam/access/cmd/grant_to_test.go b/internal/iam/access/cmd/grant_to_test.go deleted file mode 100644 index cecf01d7..00000000 --- a/internal/iam/access/cmd/grant_to_test.go +++ /dev/null @@ -1,130 +0,0 @@ -/* -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" -) - -func TestRejectFlagsInToMode(t *testing.T) { - tests := []struct { - name string - setFlag string - setValue string - wantErr bool - wantHintSub string - }{ - {name: "no conflicting flags", wantErr: false}, - { - name: "access-level conflicts", - setFlag: "access-level", - setValue: "Admin", - wantErr: true, - wantHintSub: "kubectl edit", - }, - { - name: "port-forwarding conflicts with per-subject hint", - setFlag: "port-forwarding", - setValue: "true", - wantErr: true, - wantHintSub: "separate grant without --to", - }, - { - name: "allow-scale conflicts with shared-rule warning", - setFlag: "allow-scale", - setValue: "true", - wantErr: true, - wantHintSub: "affect every subject already on it", - }, - { - name: "dry-run is not supported", - setFlag: "dry-run", - setValue: "true", - wantErr: true, - wantHintSub: "--dry-run is not supported", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - cmd := newGrantCommand() - if tc.setFlag != "" { - require.NoError(t, cmd.Flags().Set(tc.setFlag, tc.setValue)) - } - - err := rejectFlagsInToMode(cmd) - if !tc.wantErr { - assert.NoError(t, err) - return - } - require.Error(t, err) - assert.Contains(t, err.Error(), "not allowed with --to") - if tc.wantHintSub != "" { - assert.Contains(t, err.Error(), tc.wantHintSub) - } - }) - } -} - -func TestAddSubject(t *testing.T) { - build := func(subjects []any) *unstructured.Unstructured { - return &unstructured.Unstructured{ - Object: map[string]any{ - "spec": map[string]any{"subjects": subjects}, - }, - } - } - - t.Run("adds to empty subjects", func(t *testing.T) { - obj := build(nil) - newSubs, added := addSubject(obj, "User", "alice@example.com") - assert.True(t, added) - assert.Len(t, newSubs, 1) - }) - - t.Run("appends when principal is new", func(t *testing.T) { - obj := build([]any{ - map[string]any{"kind": "User", "name": "bob@example.com"}, - }) - newSubs, added := addSubject(obj, "User", "alice@example.com") - assert.True(t, added) - assert.Len(t, newSubs, 2) - }) - - t.Run("is idempotent for exact duplicate", func(t *testing.T) { - obj := build([]any{ - map[string]any{"kind": "User", "name": "alice@example.com"}, - }) - newSubs, added := addSubject(obj, "User", "alice@example.com") - assert.False(t, added) - assert.Len(t, newSubs, 1) - }) - - t.Run("kind is part of identity", func(t *testing.T) { - // Group "alice" and User "alice" are not the same subject. - obj := build([]any{ - map[string]any{"kind": "Group", "name": "alice"}, - }) - newSubs, added := addSubject(obj, "User", "alice") - assert.True(t, added) - assert.Len(t, newSubs, 2) - }) -} diff --git a/internal/iam/access/cmd/list.go b/internal/iam/access/cmd/list.go index 5d8b2a6f..0afb54d2 100644 --- a/internal/iam/access/cmd/list.go +++ b/internal/iam/access/cmd/list.go @@ -29,9 +29,25 @@ import ( "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". -// It is exported so the top-level "iam list" parent (in package listget) can -// register it without re-implementing the aggregation pipeline. +// Exported so package listget can register it under "d8 iam list". func NewListUsersCommand() *cobra.Command { cmd := &cobra.Command{ Use: "users", @@ -41,27 +57,15 @@ func NewListUsersCommand() *cobra.Command { SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - outputFmt, _ := cmd.Flags().GetString("output") - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - inv, err := buildInventory(cmd.Context(), dyn) - if err != nil { - return err - } - - if outputFmt == "json" { - return printStructured(cmd.OutOrStdout(), buildUsersJSON(inv), outputFmt) - } - - return printUsersTable(cmd, inv) + return runInventory(cmd, func(inv *accessInventory, outputFmt string) error { + if outputFmt == "json" { + return printStructured(cmd.OutOrStdout(), buildUsersJSON(inv), outputFmt) + } + return printUsersTable(cmd, inv) + }) }, } - cmd.Flags().StringP("output", "o", "table", "Output format: table|json") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json")) + utilk8s.AddOutputFlag(cmd, "table", "table", "json") return cmd } @@ -75,33 +79,22 @@ func NewListGroupsCommand() *cobra.Command { SilenceErrors: true, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - outputFmt, _ := cmd.Flags().GetString("output") - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - inv, err := buildInventory(cmd.Context(), dyn) - if err != nil { - return err - } - - if outputFmt == "json" { - return printStructured(cmd.OutOrStdout(), buildGroupsJSON(inv), outputFmt) - } - - return printGroupsTable(cmd, inv) + return runInventory(cmd, func(inv *accessInventory, outputFmt string) error { + if outputFmt == "json" { + return printStructured(cmd.OutOrStdout(), buildGroupsJSON(inv), outputFmt) + } + return printGroupsTable(cmd, inv) + }) }, } - cmd.Flags().StringP("output", "o", "table", "Output format: table|json") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json")) + utilk8s.AddOutputFlag(cmd, "table", "table", "json") return cmd } // NewGetUserCommand returns the cobra command behind "d8 iam get user ". -// Output is the aggregated access view: groups (direct + transitive), -// direct/inherited grants, and the effective summary. +// 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 ", @@ -115,34 +108,25 @@ func NewGetUserCommand() *cobra.Command { SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { userName := args[0] - outputFmt, _ := cmd.Flags().GetString("output") - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - inv, err := buildInventory(cmd.Context(), dyn) - if err != nil { - return err - } - - if _, ok := inv.Users[userName]; !ok { - return fmt.Errorf("user %q not found", userName) - } - - switch outputFmt { - case "json", "yaml": - return printStructured(cmd.OutOrStdout(), buildUserAccessJSON(inv, userName), outputFmt) - case "table", "": - return printUserDetail(cmd, inv, userName) - default: - return fmt.Errorf("%w %q; use table|json|yaml", errUnsupportedFormat, outputFmt) - } + 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) + } + }) }, } - cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) + utilk8s.AddOutputFlag(cmd, "table", "table", "json", "yaml") return cmd } @@ -160,34 +144,25 @@ func NewGetGroupCommand() *cobra.Command { SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { groupName := args[0] - outputFmt, _ := cmd.Flags().GetString("output") - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - inv, err := buildInventory(cmd.Context(), dyn) - if err != nil { - return err - } - - if _, ok := inv.GroupMembers[groupName]; !ok { - return fmt.Errorf("group %q not found", groupName) - } - - switch outputFmt { - case "json", "yaml": - return printStructured(cmd.OutOrStdout(), buildGroupDetailJSON(inv, groupName), outputFmt) - case "table", "": - return printGroupDetail(cmd, inv, groupName) - default: - return fmt.Errorf("%w %q; use table|json|yaml", errUnsupportedFormat, outputFmt) - } + 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) + } + }) }, } - cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) + utilk8s.AddOutputFlag(cmd, "table", "table", "json", "yaml") return cmd } @@ -255,7 +230,7 @@ func printGroupsTable(cmd *cobra.Command, inv *accessInventory) error { return tw.Flush() } -func printUserDetail(cmd *cobra.Command, inv *accessInventory, userName string) error { +func printUserDetail(cmd *cobra.Command, inv *accessInventory, userName string, warnings []string) error { w := cmd.OutOrStdout() email := inv.Users[userName] directGroups, transitiveGroups := inv.ResolveUserGroups(userName) @@ -295,11 +270,12 @@ func printUserDetail(cmd *cobra.Command, inv *accessInventory, userName string) fmt.Fprintf(w, "\nEffective access summary:\n") printEffectiveSummary(w, summary) + printWarnings(w, warnings) return nil } -func printGroupDetail(cmd *cobra.Command, inv *accessInventory, groupName string) error { +func printGroupDetail(cmd *cobra.Command, inv *accessInventory, groupName string, warnings []string) error { w := cmd.OutOrStdout() members := inv.GroupMembers[groupName] grants := inv.GroupGrants(groupName) @@ -325,6 +301,7 @@ func printGroupDetail(cmd *cobra.Command, inv *accessInventory, groupName string fmt.Fprintf(w, "\nEffective access summary:\n") printEffectiveSummary(w, summary) + printWarnings(w, warnings) return nil } @@ -353,18 +330,13 @@ func printGrantToWriter(w io.Writer, g *normalizedGrant, via string) { } } -// formatGrantSource renders the source CAR/AR reference. Delegates to -// formatRuleRef so list/explain/warning output and revoke output stay -// byte-identical for the same triple (Kind, NS, Name). func formatGrantSource(g *normalizedGrant) string { return formatRuleRef(g.SourceKind, g.SourceNamespace, g.SourceName) } -// printEffectiveSummary renders the "cluster scope / namespaced scope / -// port-forwarding / allow-scale" block used by both "access list" and -// "access explain". The capability lines include the implicit-source note -// when the capability is inherited from the SuperAdmin wildcard rather than -// an explicit CAR flag. +// 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) @@ -376,8 +348,16 @@ func printEffectiveSummary(w io.Writer, summary *effectiveSummary) { fmt.Fprintf(w, " allow-scale: %v%s\n", summary.AllowScale, capabilityNote(summary.AllowScaleImplicit)) } -// partitionMembersByKind splits Group.spec.members into user and nested-group -// name slices, preserving the original order in each bucket. +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 { @@ -401,14 +381,12 @@ func printBulletList(w io.Writer, items []string) { } func findViaGroup(inv *accessInventory, userName, grantGroupName string) string { - directGroups, _ := inv.ResolveUserGroups(userName) + directGroups, transitiveGroups := inv.ResolveUserGroups(userName) for _, g := range directGroups { if g == grantGroupName { return g } } - // If not a direct group, find indirect path - _, transitiveGroups := inv.ResolveUserGroups(userName) for _, g := range transitiveGroups { if g == grantGroupName { return g + " (transitive)" @@ -417,17 +395,74 @@ func findViaGroup(inv *accessInventory, userName, grantGroupName string) string 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 --- // -// Every list/get JSON payload in this package is composed from the same -// building blocks. Defining them once at package level (instead of inline -// inside each printXxxJSON) keeps the wire format documented in one place -// and lets buildUserAccessJSON / buildGroupExplainJSON share concrete -// types like memberJSON and grantJSON. -// -// All slice fields go through denil() before being assigned so the JSON -// output emits "[]" instead of "null" — that contract is part of our CLI -// surface and tests pin it down. +// 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"` @@ -486,16 +521,11 @@ type nsLevelJSON struct { Namespaces []string `json:"namespaces"` } -// memberJSON is the shared shape for "kind/name" entries in group payloads. -// It is consumed both by buildGroupDetailJSON ("d8 iam get group") and -// buildGroupExplainJSON ("d8 iam access explain group"); having two -// identical inline types as we did before invited drift. type memberJSON struct { Kind string `json:"kind"` Name string `json:"name"` } -// groupSummaryJSON is one entry in the "d8 iam list groups -o json" array. type groupSummaryJSON struct { Name string `json:"name"` Members int `json:"memberCount"` @@ -504,12 +534,12 @@ type groupSummaryJSON struct { Effective effectiveJSON `json:"effectiveSummary"` } -// groupDetailJSON is the payload for "d8 iam get group -o json|yaml". 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 --- @@ -652,5 +682,6 @@ func buildGroupDetailJSON(inv *accessInventory, groupName string) groupDetailJSO 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 index d8f0f20b..b8e679db 100644 --- a/internal/iam/access/cmd/naming.go +++ b/internal/iam/access/cmd/naming.go @@ -102,12 +102,9 @@ func scopeNamePart(spec *canonicalGrantSpec) string { case iamtypes.ScopeAllNamespaces: return "all" case iamtypes.ScopeLabels: - // The full hash8 suffix at the end of the name already disambiguates - // labels-scoped grants on different K=V sets, but we still want a - // stable, recognisable middle segment so the object name self-documents - // "this is a labels-scoped grant" without needing to read annotations. - // We hash the sorted K=V pairs (encoding/json normalises maps the same - // way for the canonical-spec annotation). + // 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) diff --git a/internal/iam/access/cmd/print.go b/internal/iam/access/cmd/print.go index 1b447bdd..3947a607 100644 --- a/internal/iam/access/cmd/print.go +++ b/internal/iam/access/cmd/print.go @@ -25,32 +25,14 @@ import ( sigsyaml "sigs.k8s.io/yaml" ) -// errUnsupportedFormat is returned by printStructured when the format -// argument is neither "json" nor "yaml". Callers in this package wrap it -// with the per-command list of accepted values, e.g.: -// -// return fmt.Errorf("%w; use table|json|yaml", err) -// -// Tests assert via errors.Is. +// 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 on w. -// -// JSON path uses MarshalIndent with two-space indent and a trailing -// newline (via Fprintln). The historic call sites in this package emitted -// exactly that, so swapping them to printStructured stays byte-identical -// for scripted consumers. -// -// YAML path goes JSON -> sigsyaml.JSONToYAML so json tags drive field -// names. Every typed shape we print here (grantJSON, ruleJSON, -// userAccessJSON, ...) declares only json tags; dual-tagging every struct -// purely for one print path would be maintenance cost without benefit. -// utilk8s.PrintObject takes the same JSON-then-YAML approach for -// Unstructured manifests, keeping the two helpers symmetric. -// -// For Kubernetes manifests (user/group/grant create -o yaml) keep using -// utilk8s.PrintObject — it preserves the apiVersion/kind layout and -// supports the "name" format that is meaningful only for k8s objects. +// 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": @@ -76,15 +58,8 @@ func printStructured(w io.Writer, v any, format string) error { } } -// denil normalises a nil slice to an empty (but non-nil) slice so JSON -// encoding emits "[]" instead of "null". Every iam list/get JSON payload -// in this package promises that contract so machine consumers can iterate -// the field unconditionally. -// -// Returned by value rather than mutating through a pointer because every -// call site in this package already assigns the field with the result of -// a build step — chaining with denil keeps the call site one expression -// instead of two statements. +// 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{} diff --git a/internal/iam/access/cmd/revoke.go b/internal/iam/access/cmd/revoke.go index 2e3ca87f..840072d8 100644 --- a/internal/iam/access/cmd/revoke.go +++ b/internal/iam/access/cmd/revoke.go @@ -23,7 +23,6 @@ import ( "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/client-go/dynamic" "k8s.io/kubectl/pkg/util/templates" @@ -32,100 +31,55 @@ import ( ) var revokeLong = templates.LongDesc(` -Revoke access from a user or group. +Revoke access previously granted with "d8 iam access grant". -There are two modes of operation: +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. -SIMPLE MODE (recommended for most users): - - Revokes access that was previously granted with "d8 iam access grant". - Uses the same flags to locate and delete the d8-managed object. - - d8 iam access revoke user NAME --access-level LEVEL [-n NS... | --scope ...] - - Only d8-managed objects (label app.kubernetes.io/managed-by=d8-cli) - are affected. Manual objects are never modified in this mode. The scope - flags must match the original grant exactly (the d8-managed object name - is derived from the canonical spec). - -ADVANCED MODE (--from): - - Removes a subject from any existing authorization rule, even if it was - not created by d8. Useful for shared rules or manual cleanup. - - d8 iam access revoke --from ClusterAuthorizationRule/RULE user PRINCIPAL - d8 iam access revoke --from AuthorizationRule/NS/RULE group GROUPNAME - - PRINCIPAL is matched literally against spec.subjects[].name — use the - exact value from the rule (e.g. anton@example.com, not anton). No User - CR resolution is performed in this mode. - - The rule is patched, not deleted. Use --delete-empty to delete the rule - if no subjects remain after removal. +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(` - # Simple mode: revoke a namespaced grant (mirrors the grant command) + # Revoke a namespaced grant (mirrors the grant command) d8 iam access revoke user anton --access-level Admin -n dev - # Simple mode: revoke a cluster-scoped grant + # Revoke a cluster-scoped grant d8 iam access revoke user anton --access-level ClusterAdmin --scope cluster - # Simple mode: 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 - - # Simple mode: revoke from a group - d8 iam access revoke group admins --access-level Editor -n dev - - # Advanced mode: remove a user subject from a shared cluster rule (literal principal) - d8 iam access revoke --from ClusterAuthorizationRule/my-rule user anton@example.com - - # Advanced mode: remove a group subject and delete the rule if it becomes empty - d8 iam access revoke --from AuthorizationRule/dev/my-rule group admins --delete-empty`) + # 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 | --from SOURCE]", - Short: "Revoke access from a user or group (current authz model)", + 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 (for intent-based revoke)") - cmd.Flags().StringSliceP("namespace", "n", nil, "Target namespace(s) (for intent-based revoke). Mutually exclusive with --scope") + 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.Flags().String("from", "", "Source object to revoke from: AuthorizationRule// or ClusterAuthorizationRule/") - cmd.Flags().Bool("delete-empty", false, "Delete the source object if subjects becomes empty (only with --from)") _ = cmd.RegisterFlagCompletionFunc("access-level", completeAccessLevels) _ = cmd.RegisterFlagCompletionFunc("namespace", completeNamespacesFlag) _ = cmd.RegisterFlagCompletionFunc("scope", completeScopeFlag) - _ = cmd.RegisterFlagCompletionFunc("from", completeRuleRef) return cmd } func runRevoke(cmd *cobra.Command, args []string) error { - fromFlag, _ := cmd.Flags().GetString("from") - - if fromFlag != "" { - return runSourceBasedRevoke(cmd, args) - } - return runIntentBasedRevoke(cmd, args) -} - -func runIntentBasedRevoke(cmd *cobra.Command, args []string) error { - if len(args) != 2 { - return errors.New("intent-based revoke requires: revoke (user|group) --access-level [scope]") - } - subjectKindStr := args[0] subjectName := args[1] accessLevel, _ := cmd.Flags().GetString("access-level") @@ -135,7 +89,7 @@ func runIntentBasedRevoke(cmd *cobra.Command, args []string) error { allowScale, _ := cmd.Flags().GetBool("allow-scale") if accessLevel == "" { - return errors.New("--access-level is required for intent-based revoke") + return errors.New("--access-level is required") } subjectKind, err := parseSubjectKind(subjectKindStr) @@ -178,9 +132,8 @@ func runIntentBasedRevoke(cmd *cobra.Command, args []string) error { return revokeManagedGrants(cmd, dyn, opts) } -// revokeOpts mirrors grantOpts: it captures every parameter of an -// intent-based revoke so revokeManagedGrants doesn't need a 9-parameter -// signature. +// 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 @@ -242,10 +195,10 @@ func revokeClient(dyn dynamic.Interface, spec *canonicalGrantSpec) (dynamic.Reso } } -// deleteManagedGrant deletes a d8-managed authorization rule (cluster or -// namespaced — the caller picks the dynamic.ResourceInterface). Refuses to -// touch objects that are not labelled as managed by d8-cli; that error path -// is what tells users to switch to --from for manual cleanup. +// 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) @@ -261,7 +214,7 @@ func deleteManagedGrant(cmd *cobra.Command, client dynamic.ResourceInterface, } if obj.GetLabels()[iamtypes.LabelManagedBy] != iamtypes.ManagedByValueCLI { - return fmt.Errorf("%s is not managed by d8-cli; use --from to revoke from it explicitly", formatRuleRef(kind, ns, name)) + 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 { @@ -283,97 +236,3 @@ func formatRuleRef(kind, ns, name string) string { } return fmt.Sprintf("%s/%s/%s", kind, ns, name) } - -func runSourceBasedRevoke(cmd *cobra.Command, args []string) error { - fromFlag, _ := cmd.Flags().GetString("from") - deleteEmpty, _ := cmd.Flags().GetBool("delete-empty") - - if len(args) != 2 { - return errors.New("source-based revoke requires: revoke --from (user|group) ") - } - subjectKind, err := parseSubjectKind(args[0]) - if err != nil { - return err - } - // Principal is used literally — it must match spec.subjects[].name exactly. - // No User CR lookup here, because --from targets rules that may reference - // external identities or non-d8-managed subjects. - subjectName := args[1] - - dyn, err := utilk8s.NewDynamicClient(cmd) - if err != nil { - return err - } - - refKind, refNS, refName, err := parseRuleRef(fromFlag) - if err != nil { - return fmt.Errorf("invalid --from: %w", err) - } - - var client dynamic.ResourceInterface - switch refKind { - case iamtypes.KindClusterAuthorizationRule: - client = dyn.Resource(iamtypes.ClusterAuthorizationRuleGVR) - case iamtypes.KindAuthorizationRule: - client = dyn.Resource(iamtypes.AuthorizationRuleGVR).Namespace(refNS) - default: - return fmt.Errorf("unsupported --from kind %q", refKind) - } - return revokeSubjectFromRule(cmd, client, refKind, refNS, refName, subjectKind, subjectName, deleteEmpty) -} - -// revokeSubjectFromRule removes a single subject from any AR or CAR. The -// dynamic.ResourceInterface argument is what makes the cluster vs namespaced -// difference disappear; the kind/ns are kept around purely for human-readable -// messages. -func revokeSubjectFromRule(cmd *cobra.Command, client dynamic.ResourceInterface, - ruleKind, ns, ruleName string, subjectKind iamtypes.SubjectKind, subjectPrincipal string, deleteEmpty bool) error { - ref := formatRuleRef(ruleKind, ns, ruleName) - - obj, err := client.Get(cmd.Context(), ruleName, metav1.GetOptions{}) - if err != nil { - return fmt.Errorf("getting %s: %w", ref, err) - } - - newSubjects, removed := removeSubject(obj, subjectKind, subjectPrincipal) - if !removed { - return fmt.Errorf("subject %s/%s not found in %s", subjectKind, subjectPrincipal, ref) - } - - if len(newSubjects) == 0 && deleteEmpty { - if err := client.Delete(cmd.Context(), ruleName, metav1.DeleteOptions{}); err != nil { - return fmt.Errorf("deleting empty %s: %w", ref, err) - } - cmd.Printf("Deleted empty %s\n", ref) - return nil - } - - if err := unstructured.SetNestedSlice(obj.Object, newSubjects, "spec", "subjects"); err != nil { - return fmt.Errorf("setting subjects on %s: %w", ref, err) - } - if _, err := client.Update(cmd.Context(), obj, metav1.UpdateOptions{}); err != nil { - return fmt.Errorf("updating %s: %w", ref, err) - } - cmd.Printf("Removed subject %s/%s from %s\n", subjectKind, subjectPrincipal, ref) - return nil -} - -func removeSubject(obj *unstructured.Unstructured, kind iamtypes.SubjectKind, name string) ([]any, bool) { - subjects, _, _ := unstructured.NestedSlice(obj.Object, "spec", "subjects") - kindStr := string(kind) - var newSubjects []any - removed := false - for _, s := range subjects { - sub, ok := s.(map[string]any) - if !ok { - newSubjects = append(newSubjects, s) - continue - } - if fmt.Sprint(sub["kind"]) == kindStr && fmt.Sprint(sub["name"]) == name { - removed = true - continue - } - newSubjects = append(newSubjects, s) - } - return newSubjects, removed -} diff --git a/internal/iam/access/cmd/rules.go b/internal/iam/access/cmd/rules.go index c926fe5d..ec5e0774 100644 --- a/internal/iam/access/cmd/rules.go +++ b/internal/iam/access/cmd/rules.go @@ -127,10 +127,9 @@ func NewListRulesCommand() *cobra.Command { 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") - cmd.Flags().StringP("output", "o", "table", "Output format: table|json|yaml") + utilk8s.AddOutputFlag(cmd, "table", "table", "json", "yaml") _ = cmd.RegisterFlagCompletionFunc("namespace", completeNamespacesFlag) - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("table", "json", "yaml")) return cmd } @@ -208,8 +207,7 @@ func NewGetRuleCommand() *cobra.Command { SilenceUsage: true, RunE: runRulesGet, } - cmd.Flags().StringP("output", "o", "text", "Output format: text|json|yaml") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("text", "json", "yaml")) + utilk8s.AddOutputFlag(cmd, "text", "text", "json", "yaml") return cmd } diff --git a/internal/iam/access/cmd/types.go b/internal/iam/access/cmd/types.go index 71aff17e..b1566f10 100644 --- a/internal/iam/access/cmd/types.go +++ b/internal/iam/access/cmd/types.go @@ -25,30 +25,22 @@ import ( iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types" ) -// namespacedAccessLevelsOrdered is the source of truth for access levels -// allowed at namespace scope, listed from least to most privileged. +// 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", } -// clusterOnlyAccessLevelsOrdered are the levels that only make sense at -// cluster scope. Combined with namespacedAccessLevelsOrdered they form the -// full set of access levels accepted by `d8 iam access grant`. var clusterOnlyAccessLevelsOrdered = []string{ "ClusterEditor", "ClusterAdmin", "SuperAdmin", } -// allAccessLevelsOrdered is the full ordered list of access levels in -// least-to-most privileged order. Used for completion and error messages -// so user-facing output stays in a predictable order. var allAccessLevelsOrdered = append( append([]string(nil), namespacedAccessLevelsOrdered...), clusterOnlyAccessLevelsOrdered..., ) -// accessLevelOrder defines the hierarchy: higher index = more privileged. -// Built from allAccessLevelsOrdered so a single rename keeps everything in -// sync. +// 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 { @@ -69,14 +61,9 @@ func sliceToSet(s []string) map[string]bool { } // canonicalGrantSpec is the canonical representation of a grant for hashing -// and comparison. The typed Model/SubjectKind/ScopeType fields encode-decode -// to plain JSON strings (Go's encoding/json treats `type X string` as a -// string), so on-disk annotations stay byte-compatible with previous releases. -// -// LabelMatch is omitempty so cluster/all-namespaces/namespace specs hash to -// the exact same JSON they did before this field was added (keeping -// d8-managed object names stable across upgrades). It is only populated for -// ScopeLabels. +// 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"` @@ -98,8 +85,7 @@ func (c *canonicalGrantSpec) JSON() (string, error) { sort.Strings(sorted) cpy.Namespaces = sorted } - // encoding/json sorts map keys alphabetically, so LabelMatch is already - // emitted deterministically — no extra normalisation needed here. + // encoding/json sorts map keys, so LabelMatch is already deterministic. data, err := json.Marshal(&cpy) if err != nil { return "", err @@ -153,10 +139,8 @@ func maxAccessLevel(levels []string) string { return best } -// canonicalGrantInput captures the minimal subset of grant/revoke options -// needed to expand a user-facing intent into one canonicalGrantSpec per -// concrete object. It is intentionally a flat value type so callers don't -// have to leak grantOpts/revokeOpts into shared helpers. +// 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 diff --git a/internal/iam/cmd/iam.go b/internal/iam/cmd/iam.go index 213739d5..a228b3c6 100644 --- a/internal/iam/cmd/iam.go +++ b/internal/iam/cmd/iam.go @@ -30,16 +30,14 @@ var iamLong = templates.LongDesc(` Manage Deckhouse identity and access: users, groups, and access grants. Subcommands: - user — manage local static users (user-authn Dex): create / delete / - reset-password / reset2fa / lock / unlock. - group — manage local groups: create / delete and add-member / remove-member. - access — grant, revoke, and explain permissions backed by AuthorizationRule - and ClusterAuthorizationRule CRs (user-authz). - get — show one user / group / rule with its effective context. - list — list users / groups / rules with effective context. + 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. -Each subcommand accepts the standard --kubeconfig / --context flags -(short: -k / --context) inherited from its own persistent flag set. +All subcommands accept the standard --kubeconfig / --context flags. © Flant JSC 2026`) diff --git a/internal/iam/cmd/iam_test.go b/internal/iam/cmd/iam_test.go index 2c237df1..ee4eda42 100644 --- a/internal/iam/cmd/iam_test.go +++ b/internal/iam/cmd/iam_test.go @@ -51,7 +51,6 @@ func TestIAMTree(t *testing.T) { "iam group remove-member", "iam access grant", "iam access revoke", - "iam access explain", // New top-level read verbs (kubectl-style). "iam get user", @@ -70,6 +69,9 @@ func TestIAMTree(t *testing.T) { "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) diff --git a/internal/iam/group/cmd/create.go b/internal/iam/group/cmd/create.go index fa960acd..9d374347 100644 --- a/internal/iam/group/cmd/create.go +++ b/internal/iam/group/cmd/create.go @@ -69,8 +69,7 @@ func newCreateCommand() *cobra.Command { } cmd.Flags().Bool("dry-run", false, "Print the resource that would be created without applying") - cmd.Flags().StringP("output", "o", "name", "Output format: name|yaml|json") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("name", "yaml", "json")) + utilk8s.AddOutputFlag(cmd, "name", "name", "yaml", "json") return cmd } diff --git a/internal/iam/user/cmd/create.go b/internal/iam/user/cmd/create.go index 20890aa7..c4884a30 100644 --- a/internal/iam/user/cmd/create.go +++ b/internal/iam/user/cmd/create.go @@ -88,9 +88,8 @@ func newCreateCommand() *cobra.Command { 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") - cmd.Flags().StringP("output", "o", "name", "Output format: name|yaml|json") + utilk8s.AddOutputFlag(cmd, "name", "name", "yaml", "json") - _ = cmd.RegisterFlagCompletionFunc("output", utilk8s.CompleteOutputFormats("name", "yaml", "json")) _ = cmd.RegisterFlagCompletionFunc("member-of", func(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { return utilk8s.CompleteResourceNames(cmd, iamtypes.GroupGVR, "", toComplete) }) diff --git a/internal/utilk8s/output.go b/internal/utilk8s/output.go index 9155870b..d604d252 100644 --- a/internal/utilk8s/output.go +++ b/internal/utilk8s/output.go @@ -20,11 +20,21 @@ 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 {