Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/d8/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
51 changes: 51 additions & 0 deletions internal/iam/access/cmd/access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
Copyright 2026 Flant JSC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package access

import (
"github.com/spf13/cobra"
"k8s.io/kubectl/pkg/util/templates"

"github.com/deckhouse/deckhouse-cli/internal/system/flags"
)

var accessLong = templates.LongDesc(`
Grant and revoke access in Deckhouse (current authz model).

For inspecting existing rules and effective access (warnings, group
cycles, manual rules), use the top-level "d8 iam get" / "d8 iam list".

© Flant JSC 2026`)

func NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "access",
Short: "Grant or revoke access (current authz model)",
Long: accessLong,
SilenceErrors: true,
SilenceUsage: true,
}

flags.AddPersistentFlags(cmd)

cmd.AddCommand(
newGrantCommand(),
newRevokeCommand(),
)

return cmd
}
242 changes: 242 additions & 0 deletions internal/iam/access/cmd/access_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/*
Copyright 2026 Flant JSC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package access

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

iamtypes "github.com/deckhouse/deckhouse-cli/internal/iam/types"
)

func TestParseSubjectKind(t *testing.T) {
tests := []struct {
input string
want iamtypes.SubjectKind
wantErr string
}{
{input: "user", want: iamtypes.KindUser},
{input: "User", want: iamtypes.KindUser},
{input: "group", want: iamtypes.KindGroup},
{input: "Group", want: iamtypes.KindGroup},
{input: "serviceaccount", wantErr: "invalid subject kind"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parseSubjectKind(tt.input)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
} else {
require.NoError(t, err)
assert.Equal(t, tt.want, got)
}
})
}
}

func TestParseScope(t *testing.T) {
tests := []struct {
name string
scope string
namespaces []string
want iamtypes.Scope
wantNS []string
wantLabels map[string]string
wantErr string
}{
{name: "namespace", namespaces: []string{"dev"}, want: iamtypes.ScopeNamespace, wantNS: []string{"dev"}},
{name: "namespace multi", namespaces: []string{"dev", "stage"}, want: iamtypes.ScopeNamespace, wantNS: []string{"dev", "stage"}},
{name: "scope cluster", scope: "cluster", want: iamtypes.ScopeCluster},
{name: "scope all-namespaces", scope: "all-namespaces", want: iamtypes.ScopeAllNamespaces},
{name: "scope all alias", scope: "all", want: iamtypes.ScopeAllNamespaces},
{name: "scope labels single", scope: "labels=team=platform", want: iamtypes.ScopeLabels, wantLabels: map[string]string{"team": "platform"}},
{name: "scope labels multi", scope: "labels=team=platform,tier=prod", want: iamtypes.ScopeLabels, wantLabels: map[string]string{"team": "platform", "tier": "prod"}},
{name: "none", wantErr: "one of"},
{name: "namespace + scope mutually exclusive", scope: "cluster", namespaces: []string{"dev"}, wantErr: "mutually exclusive"},
{name: "invalid scope", scope: "global", wantErr: "invalid --scope"},
{name: "labels empty", scope: "labels=", wantErr: "labels=... must contain"},
{name: "labels malformed pair", scope: "labels=team", wantErr: "expected key=value"},
{name: "labels empty key", scope: "labels==prod", wantErr: "key and value must be non-empty"},
{name: "labels duplicate key", scope: "labels=team=a,team=b", wantErr: "duplicate label key"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ns, labels, err := parseScope(tt.scope, tt.namespaces)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
assert.Equal(t, tt.wantNS, ns)
assert.Equal(t, tt.wantLabels, labels)
})
}
}

func TestParseLabelMatch(t *testing.T) {
got, err := parseLabelMatch("team=platform,tier=prod")
require.NoError(t, err)
assert.Equal(t, map[string]string{"team": "platform", "tier": "prod"}, got)

got, err = parseLabelMatch("k=v")
require.NoError(t, err)
assert.Equal(t, map[string]string{"k": "v"}, got)
}

func TestValidateAccessLevel(t *testing.T) {
tests := []struct {
name string
level string
namespaced bool
wantErr string
}{
{name: "User namespaced", level: "User", namespaced: true},
{name: "Admin namespaced", level: "Admin", namespaced: true},
{name: "ClusterAdmin namespaced", level: "ClusterAdmin", namespaced: true, wantErr: "not valid for namespaced scope"},
{name: "SuperAdmin namespaced", level: "SuperAdmin", namespaced: true, wantErr: "not valid for namespaced scope"},
{name: "User cluster", level: "User", namespaced: false},
{name: "ClusterAdmin cluster", level: "ClusterAdmin", namespaced: false},
{name: "SuperAdmin cluster", level: "SuperAdmin", namespaced: false},
{name: "Invalid", level: "NotALevel", namespaced: false, wantErr: "invalid access level"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAccessLevel(tt.level, tt.namespaced)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
} else {
assert.NoError(t, err)
}
})
}
}

func TestMaxAccessLevel(t *testing.T) {
tests := []struct {
name string
levels []string
want string
}{
{name: "single", levels: []string{"Admin"}, want: "Admin"},
{name: "multiple ascending", levels: []string{"User", "Admin"}, want: "Admin"},
{name: "with cluster levels", levels: []string{"Admin", "ClusterAdmin", "Editor"}, want: "ClusterAdmin"},
{name: "super admin", levels: []string{"SuperAdmin", "User"}, want: "SuperAdmin"},
{name: "empty", levels: nil, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, maxAccessLevel(tt.levels))
})
}
}

// TestCanonicalGrantSpec_OldScopesJSONStable freezes the on-disk shape of
// the canonical-spec annotation for the three pre-labels scopes. The field
// is what `createOrUpdateGrant` uses to detect "same spec → no-op", and a
// silent shape change (e.g. losing omitempty on LabelMatch, or changing the
// field order Go's encoding/json emits for these exact fields) would treat
// every previously-applied grant as needing an Update. That would break
// idempotency on upgrade.
func TestCanonicalGrantSpec_OldScopesJSONStable(t *testing.T) {
tests := []struct {
name string
spec *canonicalGrantSpec
want string
}{
{
name: "namespaced",
spec: &canonicalGrantSpec{
Model: "current",
SubjectKind: "User",
SubjectRef: "anton",
SubjectPrincipal: "anton@abc.com",
AccessLevel: "Admin",
ScopeType: "namespace",
Namespaces: []string{"dev"},
},
want: `{"model":"current","subjectKind":"User","subjectRef":"anton","subjectPrincipal":"anton@abc.com","accessLevel":"Admin","scopeType":"namespace","namespaces":["dev"],"allowScale":false,"portForwarding":false}`,
},
{
name: "cluster",
spec: &canonicalGrantSpec{
Model: "current",
SubjectKind: "Group",
SubjectRef: "admins",
SubjectPrincipal: "admins",
AccessLevel: "ClusterAdmin",
ScopeType: "cluster",
},
want: `{"model":"current","subjectKind":"Group","subjectRef":"admins","subjectPrincipal":"admins","accessLevel":"ClusterAdmin","scopeType":"cluster","allowScale":false,"portForwarding":false}`,
},
{
name: "all-namespaces",
spec: &canonicalGrantSpec{
Model: "current",
SubjectKind: "User",
SubjectRef: "anton",
SubjectPrincipal: "anton@abc.com",
AccessLevel: "SuperAdmin",
ScopeType: "all-namespaces",
},
want: `{"model":"current","subjectKind":"User","subjectRef":"anton","subjectPrincipal":"anton@abc.com","accessLevel":"SuperAdmin","scopeType":"all-namespaces","allowScale":false,"portForwarding":false}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.spec.JSON()
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestCanonicalGrantSpec_JSON_Deterministic(t *testing.T) {
spec := &canonicalGrantSpec{
Model: "current",
SubjectKind: "User",
SubjectRef: "anton",
SubjectPrincipal: "anton@abc.com",
AccessLevel: "Admin",
ScopeType: "namespace",
Namespaces: []string{"stage", "dev"},
}

j1, err := spec.JSON()
require.NoError(t, err)

// Namespaces in different order should produce same JSON (sorted)
spec2 := &canonicalGrantSpec{
Model: "current",
SubjectKind: "User",
SubjectRef: "anton",
SubjectPrincipal: "anton@abc.com",
AccessLevel: "Admin",
ScopeType: "namespace",
Namespaces: []string{"dev", "stage"},
}
j2, err := spec2.JSON()
require.NoError(t, err)

assert.Equal(t, j1, j2, "namespace order should not affect canonical JSON")
}

Loading