diff --git a/Makefile b/Makefile index 0d25eb2..72eb0ab 100644 --- a/Makefile +++ b/Makefile @@ -19,4 +19,4 @@ test: .PHONY: integration integration: - go test -run Integration ./integration/... + go test -v -run Integration ./integration/... diff --git a/example-rbac.yaml b/example-rbac.yaml index e54e672..d024a42 100644 --- a/example-rbac.yaml +++ b/example-rbac.yaml @@ -15,6 +15,12 @@ metadata: namespace: test-namespace name: invalid-sa --- +apiVersion: v1 +kind: ServiceAccount +metadata: + namespace: test-namespace + name: sa-with-no-bindings +--- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: diff --git a/go.mod b/go.mod index cf96012..8d7b7e9 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/onsi/gomega v1.24.1 github.com/spf13/cobra v1.6.1 github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.8.0 k8s.io/api v0.26.0 k8s.io/apimachinery v0.26.0 k8s.io/cli-runtime v0.26.0 @@ -17,7 +18,7 @@ require ( require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.16 // indirect - github.com/stretchr/objx v0.4.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 8772b27..60c2eb6 100644 --- a/go.sum +++ b/go.sum @@ -210,6 +210,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/xlab/treeprint v1.1.0 h1:G/1DjNkPpfZCFt9CSh6b5/nY4VimlbHF3Rh4obvtzDk= github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/integration/expect_test.go b/integration/expect_test.go index daeec1f..3cf77b4 100644 --- a/integration/expect_test.go +++ b/integration/expect_test.go @@ -1,6 +1,7 @@ package integration import ( + "github.com/stretchr/testify/assert" "os" "os/exec" "strings" @@ -12,40 +13,101 @@ import ( . "github.com/onsi/gomega/gexec" ) -func TestPluginIntegration(t *testing.T) { +func TestIntegration(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - RegisterTestingT(t) + type test struct { + name string + sa string + flags []string - command := exec.Command("kubectl", "permissions", "sa-under-test", "-n", "test-namespace") - session, err := Start(command, GinkgoWriter, GinkgoWriter) - if err != nil { - t.Errorf("unable to exec command %s", err) + expectedOut string + expectedErr string } - response := string(session.Wait(10 * time.Second).Out.Contents()) + tests := []test{ + { + name: "sa-under-test", + sa: "sa-under-test", + flags: []string{"-n", "test-namespace"}, + + expectedOut: "ServiceAccount/sa-under-test (test-namespace)\n" + + "\x1b[0;94;40m├\x1b[0m ClusterRoleBinding/cluster-roles\n" + + "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m└\x1b[0m ClusterRole/cluster-level-role\n" + + "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m apps\n" + + "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m deployments verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n" + + "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m└\x1b[0m replicasets verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n" + + "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m core.k8s.io\n" + + "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m configmaps verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n" + + "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m pods verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n" + + "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m pods/log verbs=[get] \x1b[0;32m✔ \x1b[0m\n" + + "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m└\x1b[0m services verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n" + + "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m└\x1b[0m networking.k8s.io\n\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m└\x1b[0m ingresses verbs=[get] \x1b[0;32m✔ \x1b[0m\n" + + "\x1b[0;94;40m└\x1b[0m RoleBinding/namespaced-roles (test-namespace)\n" + + " \x1b[0;94;40m└\x1b[0m Role/namespaced-role (test-namespace)\n" + + " \x1b[0;94;40m└\x1b[0m core.k8s.io\n" + + " \x1b[0;94;40m└\x1b[0m secrets verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n", + }, + + { + name: "monitoring", + sa: "monitoring", + flags: []string{"-n", "test-namespace"}, + + expectedOut: "ServiceAccount/monitoring (test-namespace)\n" + + "\x1b[0;94;40m└\x1b[0m ClusterRoleBinding/monitoring\n" + + " \x1b[0;94;40m└\x1b[0m ClusterRole/monitoring\n" + + " \x1b[0;94;40m└\x1b[0m core.k8s.io\n" + + " \x1b[0;94;40m├\x1b[0m endpoints verbs=[create] \x1b[0;32m✔ \x1b[0m\n" + + " \x1b[0;94;40m├\x1b[0m endpoints verbs=[get list watch] \x1b[0;32m✔ \x1b[0m\n" + + " \x1b[0;94;40m├\x1b[0m pods verbs=[create] \x1b[0;32m✔ \x1b[0m\n" + + " \x1b[0;94;40m├\x1b[0m pods verbs=[get list watch] \x1b[0;32m✔ \x1b[0m\n" + + " \x1b[0;94;40m├\x1b[0m services verbs=[create] \x1b[0;32m✔ \x1b[0m\n" + + " \x1b[0;94;40m└\x1b[0m services verbs=[get list watch] \x1b[0;32m✔ \x1b[0m\n", + }, + + { + name: "sa-that-doesnt-exist", + sa: "sa-that-doesnt-exist", + flags: []string{"-n", "test-namespace"}, + + expectedOut: "", + expectedErr: "Error: serviceaccounts \"sa-that-doesnt-exist\" not found", + }, + + { + name: "sa-with-no-bindings", + sa: "sa-with-no-bindings", + flags: []string{"-n", "test-namespace"}, + + expectedOut: "ServiceAccount/sa-with-no-bindings (test-namespace)\n", + }, + } - expected := - "ServiceAccount/sa-under-test (test-namespace)\n" + - "\x1b[0;94;40m├\x1b[0m ClusterRoleBinding/cluster-roles\n" + - "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m└\x1b[0m ClusterRole/cluster-level-role\n" + - "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m apps\n" + - "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m deployments verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n" + - "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m└\x1b[0m replicasets verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n" + - "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m core.k8s.io\n" + - "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m configmaps verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n" + - "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m pods verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n" + - "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m├\x1b[0m pods/log verbs=[get] \x1b[0;32m✔ \x1b[0m\n" + - "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m│\x1b[0m \x1b[0;94;40m└\x1b[0m services verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n" + - "\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m└\x1b[0m networking.k8s.io\n\x1b[0;94;40m│\x1b[0m \x1b[0;94;40m└\x1b[0m ingresses verbs=[get] \x1b[0;32m✔ \x1b[0m\n" + - "\x1b[0;94;40m└\x1b[0m RoleBinding/namespaced-roles (test-namespace)\n" + - " \x1b[0;94;40m└\x1b[0m Role/namespaced-role (test-namespace)\n" + - " \x1b[0;94;40m└\x1b[0m core.k8s.io\n" + - " \x1b[0;94;40m└\x1b[0m secrets verbs=[get watch list] \x1b[0;32m✔ \x1b[0m\n" + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) - Expect(strings.TrimSpace(response)).To(Equal(strings.TrimSpace(expected))) + var args []string + args = append(args, "permissions", tt.sa) + args = append(args, tt.flags...) + + command := exec.Command("kubectl", args...) + session, err := Start(command, GinkgoWriter, GinkgoWriter) + if err != nil { + t.Errorf("unable to exec command %s", err) + } + + session = session.Wait(10 * time.Second) + stdOut := string(session.Out.Contents()) + stdErr := string(session.Err.Contents()) + + assert.Equal(t, strings.TrimSpace(tt.expectedOut), strings.TrimSpace(stdOut)) + assert.Equal(t, strings.TrimSpace(tt.expectedErr), strings.TrimSpace(stdErr)) + }) + } } func TestPluginIntegrationNoColor(t *testing.T) { @@ -84,32 +146,3 @@ func TestPluginIntegrationNoColor(t *testing.T) { ` Expect(strings.TrimSpace(response)).To(Equal(strings.TrimSpace(expected))) } - -func TestAggregatedRolesIntegration(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - RegisterTestingT(t) - - command := exec.Command("kubectl", "permissions", "monitoring", "-n", "test-namespace") - session, err := Start(command, GinkgoWriter, GinkgoWriter) - if err != nil { - t.Errorf("unable to exec command %s", err) - } - - response := string(session.Wait(10 * time.Second).Out.Contents()) - - expected := "ServiceAccount/monitoring (test-namespace)\n" + - "\x1b[0;94;40m└\x1b[0m ClusterRoleBinding/monitoring\n" + - " \x1b[0;94;40m└\x1b[0m ClusterRole/monitoring\n" + - " \x1b[0;94;40m└\x1b[0m core.k8s.io\n" + - " \x1b[0;94;40m├\x1b[0m endpoints verbs=[create] \x1b[0;32m✔ \x1b[0m\n" + - " \x1b[0;94;40m├\x1b[0m endpoints verbs=[get list watch] \x1b[0;32m✔ \x1b[0m\n" + - " \x1b[0;94;40m├\x1b[0m pods verbs=[create] \x1b[0;32m✔ \x1b[0m\n" + - " \x1b[0;94;40m├\x1b[0m pods verbs=[get list watch] \x1b[0;32m✔ \x1b[0m\n" + - " \x1b[0;94;40m├\x1b[0m services verbs=[create] \x1b[0;32m✔ \x1b[0m\n" + - " \x1b[0;94;40m└\x1b[0m services verbs=[get list watch] \x1b[0;32m✔ \x1b[0m\n" - - Expect(strings.TrimSpace(response)).To(Equal(strings.TrimSpace(expected))) -} diff --git a/pkg/cmd/permissions.go b/pkg/cmd/permissions.go index f38fb01..8fd3bc9 100644 --- a/pkg/cmd/permissions.go +++ b/pkg/cmd/permissions.go @@ -34,7 +34,7 @@ var ( # view the permissions for the 'sa' service account in the namespace 'test' %[1]s permissions sa -n tests` - noColor = (os.Getenv("NO_COLOR") == "true") + noColor = os.Getenv("NO_COLOR") == "true" ) // PermissionsOptions provides information to view permissions @@ -69,10 +69,7 @@ func NewCmdPermissions(streams genericclioptions.IOStreams) *cobra.Command { RunE: func(c *cobra.Command, args []string) error { o.Args = args o.Cmd = c - if err := o.Run(); err != nil { - return err - } - return nil + return o.Run() }, } @@ -131,6 +128,10 @@ func (o *PermissionsOptions) Run() error { root := asciitree.Tree{} + root.Add(fmt.Sprintf("ServiceAccount/%s (%s)", + sa.Name, + sa.Namespace)) + clusterRoleBindings, err := client.RbacV1().ClusterRoleBindings().List(ctx, metav1.ListOptions{}) if err != nil { return err @@ -138,6 +139,11 @@ func (o *PermissionsOptions) Run() error { for _, clusterRoleBinding := range clusterRoleBindings.Items { if matches(clusterRoleBinding.Subjects, namespace, name) { + root.Add(fmt.Sprintf("ServiceAccount/%s (%s)#ClusterRoleBinding/%s", + sa.Name, + sa.Namespace, + clusterRoleBinding.Name)) + clusterRole, err := client.RbacV1().ClusterRoles().Get(ctx, clusterRoleBinding.RoleRef.Name, metav1.GetOptions{}) if err != nil { fmt.Println(red(getEmoji(NO_ENTRY)+"WARNING"), err) @@ -198,56 +204,117 @@ func (o *PermissionsOptions) Run() error { for _, roleBinding := range roleBindings.Items { if matches(roleBinding.Subjects, namespace, name) { - role, err := client.RbacV1().Roles(namespace).Get(ctx, roleBinding.RoleRef.Name, metav1.GetOptions{}) - if err != nil { - fmt.Println(red(getEmoji(NO_ENTRY)+"WARNING"), err) - root.Add(fmt.Sprintf("ServiceAccount/%s (%s)#RoleBinding/%s (%s)#Role/%s (%s) %s- %s", - sa.Name, - sa.Namespace, - roleBinding.Name, - roleBinding.Namespace, - roleBinding.RoleRef.Name, - roleBinding.RoleRef.Name, - getEmoji(CROSS_MARK), - red("MISSING!!"))) + root.Add(fmt.Sprintf("ServiceAccount/%s (%s)#RoleBinding/%s (%s)", + sa.Name, + sa.Namespace, + roleBinding.Name, + roleBinding.Namespace)) + + if roleBinding.RoleRef.Kind == "Role" { + role, err := client.RbacV1().Roles(namespace).Get(ctx, roleBinding.RoleRef.Name, metav1.GetOptions{}) + if err != nil { + fmt.Println(red(getEmoji(NO_ENTRY)+"WARNING"), err) + root.Add(fmt.Sprintf("ServiceAccount/%s (%s)#RoleBinding/%s (%s)#Role/%s (%s) %s- %s", + sa.Name, + sa.Namespace, + roleBinding.Name, + roleBinding.Namespace, + roleBinding.RoleRef.Name, + roleBinding.RoleRef.Name, + getEmoji(CROSS_MARK), + red("MISSING!!"))) + } else { + // lets get the permissions + for _, rule := range role.Rules { + for _, resourceName := range rule.Resources { + for _, apiGroup := range rule.APIGroups { + mark := green(getEmoji(CHECK_MARK)) + message := "" + availableApiGroup, ok := r[apiGroup] + if !ok { + fmt.Println(red(getEmoji(NO_ENTRY)+"WARNING"), "API Group", apiGroup, "does not exist") + mark = red(getEmoji(CROSS_MARK)) + message = fmt.Sprintf(" (API Group '%s' does not exist)", apiGroup) + } else { + verbs, ok := availableApiGroup[resourceName] + if !ok { + fmt.Println(red(getEmoji(NO_ENTRY)+"WARNING"), "Resource", resourceName, "does not exist") + mark = red(getEmoji(CROSS_MARK)) + message = fmt.Sprintf(" (Resource '%s' does not exist)", resourceName) + } else { + verbMessage, ok := validateVerbs(rule.Verbs, verbs) + if !ok { + mark = red(getEmoji(CROSS_MARK)) + message = verbMessage + } + } + } + root.Add(fmt.Sprintf("ServiceAccount/%s (%s)#RoleBinding/%s (%s)#Role/%s (%s)#%s#%s verbs=%s %s%s", + sa.Name, + sa.Namespace, + roleBinding.Name, + roleBinding.Namespace, + role.Name, + role.Namespace, + getApiGroup(apiGroup), + resourceName, + rule.Verbs, + mark, + message)) + } + } + } + } } else { - // lets get the permissions - for _, rule := range role.Rules { - for _, resourceName := range rule.Resources { - for _, apiGroup := range rule.APIGroups { - mark := green(getEmoji(CHECK_MARK)) - message := "" - availableApiGroup, ok := r[apiGroup] - if !ok { - fmt.Println(red(getEmoji(NO_ENTRY)+"WARNING"), "API Group", apiGroup, "does not exist") - mark = red(getEmoji(CROSS_MARK)) - message = fmt.Sprintf(" (API Group '%s' does not exist)", apiGroup) - } else { - verbs, ok := availableApiGroup[resourceName] + clusterRole, err := client.RbacV1().ClusterRoles().Get(ctx, roleBinding.RoleRef.Name, metav1.GetOptions{}) + if err != nil { + fmt.Println(red(getEmoji(NO_ENTRY)+"WARNING"), err) + root.Add(fmt.Sprintf("ServiceAccount/%s (%s)#RoleBinding/%s (%s)#ClusterRole/%s %s- %s", + sa.Name, + sa.Namespace, + roleBinding.Name, + roleBinding.Namespace, + roleBinding.RoleRef.Name, + getEmoji(CROSS_MARK), + red("MISSING!!"))) + } else { + // lets get the permissions + for _, rule := range clusterRole.Rules { + for _, resourceName := range rule.Resources { + for _, apiGroup := range rule.APIGroups { + mark := green(getEmoji(CHECK_MARK)) + message := "" + availableApiGroup, ok := r[apiGroup] if !ok { - fmt.Println(red(getEmoji(NO_ENTRY)+"WARNING"), "Resource", resourceName, "does not exist") + fmt.Println(red(getEmoji(NO_ENTRY)+"WARNING"), "API Group", apiGroup, "does not exist") mark = red(getEmoji(CROSS_MARK)) - message = fmt.Sprintf(" (Resource '%s' does not exist)", resourceName) + message = fmt.Sprintf(" (API Group '%s' does not exist)", apiGroup) } else { - verbMessage, ok := validateVerbs(rule.Verbs, verbs) + verbs, ok := availableApiGroup[resourceName] if !ok { + fmt.Println(red(getEmoji(NO_ENTRY)+"WARNING"), "Resource", resourceName, "does not exist") mark = red(getEmoji(CROSS_MARK)) - message = verbMessage + message = fmt.Sprintf(" (Resource '%s' does not exist)", resourceName) + } else { + verbMessage, ok := validateVerbs(rule.Verbs, verbs) + if !ok { + mark = red(getEmoji(CROSS_MARK)) + message = verbMessage + } } } + root.Add(fmt.Sprintf("ServiceAccount/%s (%s)#RoleBinding/%s (%s)#ClusterRole/%s#%s#%s verbs=%s %s%s", + sa.Name, + sa.Namespace, + roleBinding.Name, + roleBinding.Namespace, + clusterRole.Name, + getApiGroup(apiGroup), + resourceName, + rule.Verbs, + mark, + message)) } - root.Add(fmt.Sprintf("ServiceAccount/%s (%s)#RoleBinding/%s (%s)#Role/%s (%s)#%s#%s verbs=%s %s%s", - sa.Name, - sa.Namespace, - roleBinding.Name, - roleBinding.Namespace, - role.Name, - role.Namespace, - getApiGroup(apiGroup), - resourceName, - rule.Verbs, - mark, - message)) } } } @@ -281,9 +348,16 @@ func contains(check string, list []string) bool { func matches(subjects []v1.Subject, namespace string, name string) bool { for _, sub := range subjects { - if sub.Kind == "ServiceAccount" && sub.Name == name && sub.Namespace == namespace { - return true + if sub.Namespace != "" { + if sub.Kind == "ServiceAccount" && sub.Name == name && sub.Namespace == namespace { + return true + } + } else { + if sub.Kind == "ServiceAccount" && sub.Name == name { + return true + } } + } return false } diff --git a/pkg/roles/lister.go b/pkg/roles/lister.go index a0219af..7269b7c 100644 --- a/pkg/roles/lister.go +++ b/pkg/roles/lister.go @@ -31,6 +31,9 @@ func DiscoverRolesAndPermissions(d *discovery.DiscoveryClient) (map[string]map[s verbs = append(verbs, v) } + // always add '*' as a wildcard + verbs = append(verbs, "*") + rolesAndPermissions[groupOnly][resource.Name] = verbs } }