├── NOTICE ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── support_question.md └── workflows │ ├── build.yaml │ └── release.yaml ├── Makefile ├── .goreleaser.yml ├── cmd └── kubectl-who-can │ └── main.go ├── pkg └── cmd │ ├── access_checker.go │ ├── namespace_validator.go │ ├── access_checker_test.go │ ├── namespace_validator_test.go │ ├── policy_rule_matcher.go │ ├── resource_resolver.go │ ├── printer.go │ ├── resource_resolver_test.go │ ├── policy_rule_matcher_test.go │ ├── printer_test.go │ ├── list.go │ └── list_test.go ├── CONTRIBUTING.md ├── .krew.yaml ├── go.mod ├── README.md ├── LICENSE └── test └── integration_test.go /NOTICE: -------------------------------------------------------------------------------- 1 | kubectl-who-can 2 | Copyright 2019-2022 Aqua Security Software Ltd. 3 | 4 | This product includes software developed by Aqua Security (https://aquasec.com). -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | release 3 | kubectl-who-can 4 | 5 | coverage.txt 6 | 7 | # GoLand / IntelliJ IDEA project config files 8 | .idea/ 9 | 10 | # Kubernetes integration test binary 11 | test/bin/integration_test 12 | 13 | # Directory Cache Files 14 | .DS_Store 15 | thumbs.db 16 | 17 | *.json 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: I would like to report a bug within the project 4 | labels: bug 5 | --- 6 | 7 | ### What happened 8 | 9 | 12 | 13 | ### Expected behavior 14 | 15 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: I have a suggestion (and might want to implement myself) 4 | labels: enhancement 5 | --- 6 | 7 | ## What would you like to be added 8 | 9 | 12 | 13 | ## Why is this needed 14 | 15 | 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCES := $(shell find . -name '*.go') 2 | BINARY := kubectl-who-can 3 | 4 | build: kubectl-who-can 5 | 6 | $(BINARY): $(SOURCES) 7 | GO111MODULE=on CGO_ENABLED=0 go build -o $(BINARY) ./cmd/kubectl-who-can/main.go 8 | 9 | unit-tests: $(SOURCES) 10 | GO111MODULE=on go test -v -short -race -timeout 30s -coverprofile=coverage.txt -covermode=atomic ./... 11 | 12 | integration-tests: $(SOURCES) 13 | GO111MODULE=on go test -v test/integration_test.go 14 | 15 | .PHONY: clean 16 | clean: 17 | rm $(BINARY) 18 | rm coverage.txt 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support_question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support Question 3 | about: I have a question and require assistance 4 | labels: question 5 | --- 6 | 7 | 11 | 12 | ## What are you trying to achieve 13 | 14 | 17 | 18 | ## Minimal example (if applicable) 19 | 20 | 24 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | builds: 5 | - # Path to main.go file or main package. 6 | main: ./cmd/kubectl-who-can/main.go 7 | # Custom environment variables to be set during the builds. 8 | env: 9 | - CGO_ENABLED=0 10 | - GO111MODULE=on 11 | # GOOS list to build for. 12 | goos: 13 | - darwin 14 | - linux 15 | - windows 16 | # GOARCH to build for. 17 | goarch: 18 | - amd64 19 | - arm64 20 | archives: 21 | - # Archive name template 22 | name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" 23 | replacements: 24 | amd64: x86_64 25 | format_overrides: 26 | - goos: windows 27 | format: zip 28 | checksum: 29 | name_template: 'checksums.txt' 30 | snapshot: 31 | name_template: "{{ .Tag }}-next" 32 | changelog: 33 | sort: asc 34 | filters: 35 | exclude: 36 | - '^docs' 37 | - '^test' 38 | - '^release' 39 | -------------------------------------------------------------------------------- /cmd/kubectl-who-can/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/aquasecurity/kubectl-who-can/pkg/cmd" 7 | clioptions "k8s.io/cli-runtime/pkg/genericclioptions" 8 | 9 | "flag" 10 | "os" 11 | 12 | "github.com/spf13/pflag" 13 | // Load all known auth plugins 14 | _ "k8s.io/client-go/plugin/pkg/client/auth" 15 | "k8s.io/klog/v2" 16 | ) 17 | 18 | func initFlags() { 19 | klog.InitFlags(nil) 20 | pflag.CommandLine.AddGoFlagSet(flag.CommandLine) 21 | 22 | // Hide all klog flags except for -v 23 | flag.CommandLine.VisitAll(func(f *flag.Flag) { 24 | if f.Name != "v" { 25 | pflag.Lookup(f.Name).Hidden = true 26 | } 27 | }) 28 | } 29 | 30 | func main() { 31 | defer klog.Flush() 32 | 33 | initFlags() 34 | root, err := cmd.NewWhoCanCommand(clioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}) 35 | if err != nil { 36 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 37 | os.Exit(1) 38 | } 39 | if err := root.Execute(); err != nil { 40 | os.Exit(1) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-20.04 11 | steps: 12 | - name: Setup Go 13 | uses: actions/setup-go@v1 14 | with: 15 | go-version: 1.17 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | - name: Run unit tests 19 | run: make unit-tests 20 | - name: Upload code coverage 21 | uses: codecov/codecov-action@v2 22 | with: 23 | file: ./coverage.txt 24 | - name: Setup Kubernetes cluster (KIND) 25 | uses: engineerd/setup-kind@v0.5.0 26 | - name: Test connection to Kubernetes cluster 27 | run: | 28 | kubectl cluster-info 29 | - name: Run integration tests 30 | run: make integration-tests 31 | env: 32 | KUBECONFIG: /home/runner/.kube/config 33 | - name: Release snapshot 34 | uses: goreleaser/goreleaser-action@v2 35 | with: 36 | version: v1.5.0 37 | args: release --snapshot --skip-publish --rm-dist 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - name: Setup Go 12 | uses: actions/setup-go@v1 13 | with: 14 | go-version: 1.17 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | - name: Run unit tests 20 | run: make unit-tests 21 | - name: Setup Kubernetes cluster (KIND) 22 | uses: engineerd/setup-kind@v0.5.0 23 | - name: Test connection to Kubernetes cluster 24 | run: | 25 | kubectl cluster-info 26 | - name: Run integration tests 27 | run: make integration-tests 28 | env: 29 | KUBECONFIG: /home/runner/.kube/config 30 | - name: Release 31 | uses: goreleaser/goreleaser-action@v2 32 | with: 33 | version: v1.5.0 34 | args: release --rm-dist 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | - name: Update new version for plugin 'kubectl-who-can' in krew-index 38 | uses: rajatjindal/krew-release-bot@v0.0.40 39 | -------------------------------------------------------------------------------- /pkg/cmd/access_checker.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | authz "k8s.io/api/authorization/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | clientauthz "k8s.io/client-go/kubernetes/typed/authorization/v1" 9 | ) 10 | 11 | // AccessChecker wraps the IsAllowedTo method. 12 | // 13 | // IsAllowedTo checks whether the current user is allowed to perform the given action in the specified namespace. 14 | // Specifying "" as namespace performs check in all namespaces. 15 | type AccessChecker interface { 16 | IsAllowedTo(verb, resource, namespace string) (bool, error) 17 | } 18 | 19 | type accessChecker struct { 20 | client clientauthz.SelfSubjectAccessReviewInterface 21 | } 22 | 23 | // NewAccessChecker constructs the default AccessChecker. 24 | func NewAccessChecker(client clientauthz.SelfSubjectAccessReviewInterface) AccessChecker { 25 | return &accessChecker{ 26 | client: client, 27 | } 28 | } 29 | 30 | func (ac *accessChecker) IsAllowedTo(verb, resource, namespace string) (bool, error) { 31 | sar := &authz.SelfSubjectAccessReview{ 32 | Spec: authz.SelfSubjectAccessReviewSpec{ 33 | ResourceAttributes: &authz.ResourceAttributes{ 34 | Verb: verb, 35 | Resource: resource, 36 | Namespace: namespace, 37 | }, 38 | }, 39 | } 40 | 41 | sar, err := ac.client.Create(context.Background(), sar, metav1.CreateOptions{}) 42 | if err != nil { 43 | return false, err 44 | } 45 | 46 | return sar.Status.Allowed, nil 47 | } 48 | -------------------------------------------------------------------------------- /pkg/cmd/namespace_validator.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | core "k8s.io/api/core/v1" 8 | "k8s.io/apimachinery/pkg/api/errors" 9 | meta "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | clientcore "k8s.io/client-go/kubernetes/typed/core/v1" 11 | ) 12 | 13 | // NamespaceValidator wraps the Validate method. 14 | // 15 | // Validate checks whether the given namespace exists or not. 16 | // Returns nil if it exists, an error otherwise. 17 | type NamespaceValidator interface { 18 | Validate(name string) error 19 | } 20 | 21 | type namespaceValidator struct { 22 | client clientcore.NamespaceInterface 23 | } 24 | 25 | // NewNamespaceValidator constructs the default NamespaceValidator. 26 | func NewNamespaceValidator(client clientcore.NamespaceInterface) NamespaceValidator { 27 | return &namespaceValidator{ 28 | client: client, 29 | } 30 | } 31 | 32 | func (w *namespaceValidator) Validate(name string) error { 33 | if name != core.NamespaceAll { 34 | ctx := context.Background() 35 | ns, err := w.client.Get(ctx, name, meta.GetOptions{}) 36 | if err != nil { 37 | if statusErr, ok := err.(*errors.StatusError); ok && 38 | statusErr.Status().Reason == meta.StatusReasonNotFound { 39 | return fmt.Errorf("\"%s\" not found", name) 40 | } 41 | return fmt.Errorf("getting namespace: %v", err) 42 | } 43 | if ns.Status.Phase != core.NamespaceActive { 44 | return fmt.Errorf("invalid status: %v", ns.Status.Phase) 45 | } 46 | } 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Thank you for taking interest in contributing to kubectl-who-can ! 2 | 3 | ## Issues 4 | 5 | - Feel free to open issues for any reason as long as you make it clear if this issue is about a bug/feature/question/comment. 6 | - Please spend a small amount of time giving due diligence to the issue tracker. Your issue might be a duplicate. If it is, please add your comment to the existing issue. 7 | - Remember users might be searching for your issue in the future, so please give it a meaningful title to help others. 8 | - The issue should clearly explain the reason for opening, the proposal if you have any, and any technical information that's relevant. 9 | 10 | ## Pull Requests 11 | 12 | 1. Every Pull Request should have an associated Issue unless you are fixing a trivial documentation issue. 13 | 1. Your PR is more likely to be accepted if it focuses on just one change. 14 | 1. Describe what the PR does. There's no convention enforced, but please try to be concise and descriptive. Treat the PR description as a commit message. Titles that starts with "fix"/"add"/"improve"/"remove" are good examples. 15 | 1. Please add the associated Issue in the PR description. 16 | 1. There's no need to add or tag reviewers. 17 | 1. If a reviewer commented on your code, or asked for changes, please remember to mark the discussion as resolved after you address it. PRs with unresolved issues should not be merged (even if the comment is unclear or requires no action from your side). 18 | 1. Please include a comment with the results before and after your change. 19 | 1. Please include proper tests with your code 20 | -------------------------------------------------------------------------------- /pkg/cmd/access_checker_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | authz "k8s.io/api/authorization/v1" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | "k8s.io/client-go/kubernetes/fake" 11 | clientauthz "k8s.io/client-go/kubernetes/typed/authorization/v1" 12 | clienttesting "k8s.io/client-go/testing" 13 | ) 14 | 15 | func TestIsAllowed(t *testing.T) { 16 | 17 | data := []struct { 18 | scenario string 19 | reactionFunc clienttesting.ReactionFunc 20 | 21 | allowed bool 22 | err error 23 | }{ 24 | { 25 | scenario: "Should return true when SSAR's allowed property is true", 26 | reactionFunc: newSelfSubjectAccessReviewsReactionFunc(true, nil), 27 | allowed: true, 28 | }, 29 | { 30 | scenario: "Should return false when SSAR's allowed property is false", 31 | reactionFunc: newSelfSubjectAccessReviewsReactionFunc(false, nil), 32 | allowed: false, 33 | }, 34 | { 35 | scenario: "Should return error when API request fails", 36 | reactionFunc: newSelfSubjectAccessReviewsReactionFunc(false, errors.New("api is down")), 37 | err: errors.New("api is down"), 38 | }, 39 | } 40 | 41 | for _, tt := range data { 42 | t.Run(tt.scenario, func(t *testing.T) { 43 | // given 44 | client := newClient(tt.reactionFunc) 45 | 46 | // when 47 | allowed, err := NewAccessChecker(client).IsAllowedTo("list", "roles", "") 48 | 49 | // then 50 | assert.Equal(t, tt.allowed, allowed) 51 | assert.Equal(t, tt.err, err) 52 | }) 53 | } 54 | 55 | } 56 | 57 | func newClient(reaction clienttesting.ReactionFunc) clientauthz.SelfSubjectAccessReviewInterface { 58 | client := fake.NewSimpleClientset() 59 | client.Fake.PrependReactor("create", "selfsubjectaccessreviews", reaction) 60 | return client.AuthorizationV1().SelfSubjectAccessReviews() 61 | } 62 | 63 | func newSelfSubjectAccessReviewsReactionFunc(allowed bool, err error) clienttesting.ReactionFunc { 64 | return func(action clienttesting.Action) (bool, runtime.Object, error) { 65 | sar := &authz.SelfSubjectAccessReview{ 66 | Status: authz.SubjectAccessReviewStatus{ 67 | Allowed: allowed, 68 | }, 69 | } 70 | return true, sar, err 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pkg/cmd/namespace_validator_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | v1 "k8s.io/api/core/v1" 9 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/runtime" 12 | "k8s.io/client-go/kubernetes/fake" 13 | v12 "k8s.io/client-go/kubernetes/typed/core/v1" 14 | k8stesting "k8s.io/client-go/testing" 15 | ) 16 | 17 | func TestNamespaceValidator_Validate(t *testing.T) { 18 | 19 | data := []struct { 20 | TestName string 21 | 22 | APIReturnedNamespace *v1.Namespace 23 | APIReturnedErr error 24 | 25 | ExpectedErr error 26 | }{ 27 | { 28 | TestName: "Should return error when getting namespace fails", 29 | 30 | APIReturnedNamespace: nil, 31 | APIReturnedErr: errors.New("server is down"), 32 | 33 | ExpectedErr: errors.New("getting namespace: server is down"), 34 | }, { 35 | TestName: "Should return error when namespace does not exist", 36 | 37 | APIReturnedNamespace: nil, 38 | APIReturnedErr: &k8serrors.StatusError{ 39 | ErrStatus: metav1.Status{ 40 | Reason: metav1.StatusReasonNotFound, 41 | }, 42 | }, 43 | 44 | ExpectedErr: errors.New("\"my.namespace\" not found"), 45 | }, { 46 | TestName: "Should return error when namespace is not active", 47 | 48 | APIReturnedNamespace: &v1.Namespace{ 49 | Status: v1.NamespaceStatus{ 50 | Phase: v1.NamespaceTerminating, 51 | }, 52 | }, 53 | APIReturnedErr: nil, 54 | 55 | ExpectedErr: errors.New("invalid status: Terminating"), 56 | }, { 57 | TestName: "Should return nil when namespace is active", 58 | 59 | APIReturnedNamespace: &v1.Namespace{ 60 | Status: v1.NamespaceStatus{ 61 | Phase: v1.NamespaceActive, 62 | }, 63 | }, 64 | APIReturnedErr: nil, 65 | 66 | ExpectedErr: nil, 67 | }, 68 | } 69 | 70 | for _, tt := range data { 71 | t.Run(tt.TestName, func(t *testing.T) { 72 | // given 73 | namespace := newNamespaces(newGetNamespacesReactionFunc(tt.APIReturnedNamespace, tt.APIReturnedErr)) 74 | validator := NewNamespaceValidator(namespace) 75 | 76 | // when 77 | err := validator.Validate("my.namespace") 78 | 79 | // then 80 | assert.Equal(t, tt.ExpectedErr, err) 81 | }) 82 | } 83 | 84 | } 85 | 86 | func newNamespaces(reaction k8stesting.ReactionFunc) v12.NamespaceInterface { 87 | client := fake.NewSimpleClientset() 88 | client.Fake.PrependReactor("get", "namespaces", reaction) 89 | return client.CoreV1().Namespaces() 90 | } 91 | 92 | func newGetNamespacesReactionFunc(ns *v1.Namespace, err error) k8stesting.ReactionFunc { 93 | return func(action k8stesting.Action) (bool, runtime.Object, error) { 94 | return true, ns, err 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.krew.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: krew.googlecontainertools.github.com/v1alpha2 2 | kind: Plugin 3 | metadata: 4 | name: who-can 5 | spec: 6 | version: "{{ .TagName }}" 7 | homepage: https://github.com/aquasecurity/kubectl-who-can 8 | shortDescription: >- 9 | Shows who has RBAC permissions to access Kubernetes resources 10 | description: |+2 11 | Shows which subjects have RBAC permissions to VERB [TYPE | TYPE/NAME | NONRESOURCEURL] 12 | 13 | VERB is a logical Kubernetes API verb like 'get', 'list', 'watch', 'delete', etc. 14 | TYPE is a Kubernetes resource. Shortcuts and API groups will be resolved, e.g. 'po' or 'pod.metrics.k8s.io'. 15 | NAME is the name of a particular Kubernetes resource. 16 | NONRESOURCEURL is a partial URL that starts with "/". 17 | 18 | For example, if you want to find all subjects who have permission to 19 | delete pods in a particular namespace, or to delete nodes in the cluster 20 | (dangerous!) you could run the following commands: 21 | 22 | $ kubectl who-can delete pods --namespace foo 23 | $ kubectl who-can delete nodes 24 | 25 | For usage or examples, run: 26 | 27 | $ kubectl who-can -h 28 | caveats: | 29 | The plugin requires the rights to list (Cluster)Role and (Cluster)RoleBindings. 30 | platforms: 31 | - selector: 32 | matchLabels: 33 | os: darwin 34 | arch: amd64 35 | {{addURIAndSha "https://github.com/aquasecurity/kubectl-who-can/releases/download/{{ .TagName }}/kubectl-who-can_darwin_x86_64.tar.gz" .TagName | indent 6}} 36 | files: 37 | - from: kubectl-who-can 38 | to: . 39 | - from: LICENSE 40 | to: . 41 | bin: kubectl-who-can 42 | - selector: 43 | matchLabels: 44 | os: darwin 45 | arch: arm64 46 | {{addURIAndSha "https://github.com/aquasecurity/kubectl-who-can/releases/download/{{ .TagName }}/kubectl-who-can_darwin_arm64.tar.gz" .TagName | indent 6}} 47 | files: 48 | - from: kubectl-who-can 49 | to: . 50 | - from: LICENSE 51 | to: . 52 | bin: kubectl-who-can 53 | - selector: 54 | matchLabels: 55 | os: linux 56 | arch: amd64 57 | {{addURIAndSha "https://github.com/aquasecurity/kubectl-who-can/releases/download/{{ .TagName }}/kubectl-who-can_linux_x86_64.tar.gz" .TagName | indent 6}} 58 | files: 59 | - from: kubectl-who-can 60 | to: . 61 | - from: LICENSE 62 | to: . 63 | bin: kubectl-who-can 64 | - selector: 65 | matchLabels: 66 | os: linux 67 | arch: arm64 68 | {{addURIAndSha "https://github.com/aquasecurity/kubectl-who-can/releases/download/{{ .TagName }}/kubectl-who-can_linux_arm64.tar.gz" .TagName | indent 6}} 69 | files: 70 | - from: kubectl-who-can 71 | to: . 72 | - from: LICENSE 73 | to: . 74 | bin: kubectl-who-can 75 | - selector: 76 | matchLabels: 77 | os: windows 78 | arch: amd64 79 | {{addURIAndSha "https://github.com/aquasecurity/kubectl-who-can/releases/download/{{ .TagName }}/kubectl-who-can_windows_x86_64.zip" .TagName | indent 6}} 80 | files: 81 | - from: kubectl-who-can.exe 82 | to: . 83 | - from: LICENSE 84 | to: . 85 | bin: kubectl-who-can.exe 86 | - selector: 87 | matchLabels: 88 | os: windows 89 | arch: arm64 90 | {{addURIAndSha "https://github.com/aquasecurity/kubectl-who-can/releases/download/{{ .TagName }}/kubectl-who-can_windows_arm64.zip" .TagName | indent 6}} 91 | files: 92 | - from: kubectl-who-can.exe 93 | to: . 94 | - from: LICENSE 95 | to: . 96 | bin: kubectl-who-can.exe 97 | 98 | -------------------------------------------------------------------------------- /pkg/cmd/policy_rule_matcher.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | rbac "k8s.io/api/rbac/v1" 5 | "k8s.io/klog/v2" 6 | ) 7 | 8 | // PolicyRuleMatcher wraps the Matches* methods. 9 | // 10 | // MatchesRole returns `true` if any PolicyRule defined by the given Role matches the specified Action, `false` otherwise. 11 | // 12 | // MatchesClusterRole returns `true` if any PolicyRule defined by the given ClusterRole matches the specified Action, `false` otherwise. 13 | type PolicyRuleMatcher interface { 14 | MatchesRole(role rbac.Role, action resolvedAction) bool 15 | MatchesClusterRole(role rbac.ClusterRole, action resolvedAction) bool 16 | } 17 | 18 | type matcher struct { 19 | } 20 | 21 | // NewPolicyRuleMatcher constructs the default PolicyRuleMatcher. 22 | func NewPolicyRuleMatcher() PolicyRuleMatcher { 23 | return &matcher{} 24 | } 25 | 26 | func (m *matcher) MatchesRole(role rbac.Role, action resolvedAction) bool { 27 | for _, rule := range role.Rules { 28 | if !m.matches(rule, action) { 29 | continue 30 | } 31 | klog.V(4).Infof("Role [%s] matches action filter? YES", role.Name) 32 | return true 33 | } 34 | klog.V(4).Infof("Role [%s] matches action filter? NO", role.Name) 35 | return false 36 | } 37 | 38 | func (m *matcher) MatchesClusterRole(role rbac.ClusterRole, action resolvedAction) bool { 39 | for _, rule := range role.Rules { 40 | if !m.matches(rule, action) { 41 | continue 42 | } 43 | 44 | klog.V(4).Infof("ClusterRole [%s] matches action filter? YES", role.Name) 45 | return true 46 | } 47 | klog.V(4).Infof("ClusterRole [%s] matches action filter? NO", role.Name) 48 | return false 49 | } 50 | 51 | // matches returns `true` if the given PolicyRule matches the specified Action, `false` otherwise. 52 | func (m *matcher) matches(rule rbac.PolicyRule, action resolvedAction) bool { 53 | if action.NonResourceURL != "" { 54 | return m.matchesVerb(rule, action.Verb) && 55 | m.matchesNonResourceURL(rule, action.NonResourceURL) 56 | } 57 | 58 | resource := action.gr.Resource 59 | if action.SubResource != "" { 60 | resource += "/" + action.SubResource 61 | } 62 | 63 | return m.matchesVerb(rule, action.Verb) && 64 | m.matchesResource(rule, resource) && 65 | m.matchesAPIGroup(rule, action.gr.Group) && 66 | m.matchesResourceName(rule, action.ResourceName) 67 | } 68 | 69 | func (m *matcher) matchesAPIGroup(rule rbac.PolicyRule, actionGroup string) bool { 70 | for _, group := range rule.APIGroups { 71 | if group == rbac.APIGroupAll || group == actionGroup { 72 | return true 73 | } 74 | } 75 | return false 76 | } 77 | 78 | func (m *matcher) matchesVerb(rule rbac.PolicyRule, actionVerb string) bool { 79 | for _, verb := range rule.Verbs { 80 | if verb == rbac.VerbAll || verb == actionVerb { 81 | return true 82 | } 83 | } 84 | return false 85 | } 86 | 87 | func (m *matcher) matchesResource(rule rbac.PolicyRule, actionResource string) bool { 88 | for _, resource := range rule.Resources { 89 | if resource == rbac.ResourceAll || resource == actionResource { 90 | return true 91 | } 92 | } 93 | return false 94 | } 95 | 96 | func (m *matcher) matchesResourceName(rule rbac.PolicyRule, actionResourceName string) bool { 97 | if actionResourceName == "" && len(rule.ResourceNames) == 0 { 98 | return true 99 | } 100 | if len(rule.ResourceNames) == 0 { 101 | return true 102 | } 103 | for _, name := range rule.ResourceNames { 104 | if name == actionResourceName { 105 | return true 106 | } 107 | } 108 | return false 109 | } 110 | 111 | func (m *matcher) matchesNonResourceURL(rule rbac.PolicyRule, actionNonResourceURL string) bool { 112 | for _, URL := range rule.NonResourceURLs { 113 | if URL == actionNonResourceURL { 114 | return true 115 | } 116 | } 117 | return false 118 | } 119 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aquasecurity/kubectl-who-can 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/spf13/cobra v1.3.0 7 | github.com/spf13/pflag v1.0.5 8 | github.com/stretchr/testify v1.7.0 9 | k8s.io/api v0.23.3 10 | k8s.io/apiextensions-apiserver v0.23.3 11 | k8s.io/apimachinery v0.23.3 12 | k8s.io/cli-runtime v0.23.3 13 | k8s.io/client-go v0.23.3 14 | k8s.io/klog/v2 v2.30.0 15 | ) 16 | 17 | require ( 18 | cloud.google.com/go v0.99.0 // indirect 19 | github.com/Azure/go-autorest v14.2.0+incompatible // indirect 20 | github.com/Azure/go-autorest/autorest v0.11.18 // indirect 21 | github.com/Azure/go-autorest/autorest/adal v0.9.13 // indirect 22 | github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect 23 | github.com/Azure/go-autorest/logger v0.2.1 // indirect 24 | github.com/Azure/go-autorest/tracing v0.6.0 // indirect 25 | github.com/PuerkitoBio/purell v1.1.1 // indirect 26 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/evanphx/json-patch v4.12.0+incompatible // indirect 29 | github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect 30 | github.com/go-errors/errors v1.0.1 // indirect 31 | github.com/go-logr/logr v1.2.0 // indirect 32 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 33 | github.com/go-openapi/jsonreference v0.19.5 // indirect 34 | github.com/go-openapi/swag v0.19.14 // indirect 35 | github.com/gogo/protobuf v1.3.2 // indirect 36 | github.com/golang/protobuf v1.5.2 // indirect 37 | github.com/google/btree v1.0.1 // indirect 38 | github.com/google/go-cmp v0.5.6 // indirect 39 | github.com/google/gofuzz v1.1.0 // indirect 40 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 41 | github.com/google/uuid v1.1.2 // indirect 42 | github.com/googleapis/gnostic v0.5.5 // indirect 43 | github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect 44 | github.com/imdario/mergo v0.3.5 // indirect 45 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 46 | github.com/josharian/intern v1.0.0 // indirect 47 | github.com/json-iterator/go v1.1.12 // indirect 48 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 49 | github.com/mailru/easyjson v0.7.6 // indirect 50 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 51 | github.com/modern-go/reflect2 v1.0.2 // indirect 52 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 53 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 54 | github.com/pkg/errors v0.9.1 // indirect 55 | github.com/pmezard/go-difflib v1.0.0 // indirect 56 | github.com/stretchr/objx v0.2.0 // indirect 57 | github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca // indirect 58 | go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect 59 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect 60 | golang.org/x/net v0.0.0-20211209124913-491a49abca63 // indirect 61 | golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect 62 | golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect 63 | golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b // indirect 64 | golang.org/x/text v0.3.7 // indirect 65 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect 66 | google.golang.org/appengine v1.6.7 // indirect 67 | google.golang.org/protobuf v1.27.1 // indirect 68 | gopkg.in/inf.v0 v0.9.1 // indirect 69 | gopkg.in/yaml.v2 v2.4.0 // indirect 70 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 71 | k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect 72 | k8s.io/utils v0.0.0-20211116205334-6203023598ed // indirect 73 | sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 // indirect 74 | sigs.k8s.io/kustomize/api v0.10.1 // indirect 75 | sigs.k8s.io/kustomize/kyaml v0.13.0 // indirect 76 | sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect 77 | sigs.k8s.io/yaml v1.2.0 // indirect 78 | ) 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub Release][release-img]][release] 2 | [![GitHub Action][build-action-img]][build-action] 3 | [![Coverage Status][cov-img]][cov] 4 | [![Go Report Card][report-card-img]][report-card] 5 | [![License][license-img]][license] 6 | [![GitHub All Releases][github-all-releases-img]][release] 7 | 8 | # kubectl-who-can 9 | 10 | Shows which subjects have RBAC permissions to VERB [TYPE | TYPE/NAME | NONRESOURCEURL] in Kubernetes. 11 | 12 | [![asciicast][asciicast-img]][asciicast] 13 | 14 | ## Installation 15 | 16 | There are several ways to install `kubectl-who-can`. The recommended installation is via the `kubectl` plugin manager 17 | called [`krew`](https://github.com/kubernetes-sigs/krew). 18 | 19 | ### krew 20 | 21 | I assume that you've already [installed](https://github.com/kubernetes-sigs/krew#installation) `krew`. Then run the following command: 22 | 23 | ``` 24 | kubectl krew install who-can 25 | ``` 26 | 27 | The plugin will be available as `kubectl who-can`. 28 | 29 | ### Manual 30 | 31 | Download a [release distribution archive][release] for your operating system, extract it, and add the `kubectl-who-can` 32 | executable to your `$PATH`. For example, to manually install `kubectl-who-can` on macOS run the following command: 33 | 34 | ``` 35 | VERSION=`git describe --abbrev=0` 36 | 37 | mkdir -p /tmp/who-can/$VERSION && \ 38 | curl -L https://github.com/aquasecurity/kubectl-who-can/releases/download/$VERSION/kubectl-who-can_darwin_x86_64.tar.gz \ 39 | | tar xz -C /tmp/who-can/$VERSION && \ 40 | sudo mv -i /tmp/who-can/$VERSION/kubectl-who-can /usr/local/bin 41 | ``` 42 | 43 | ## Build from Source 44 | 45 | This is a standard Go program. If you already know how to build 46 | and install Go code, you probably won't need these instructions. 47 | 48 | Note that while the code is small, it has some rather big 49 | dependencies, and fetching + building these dependencies can 50 | take a few minutes. 51 | 52 | Option 1 (if you have a Go compiler and want to tweak the code): 53 | ```bash 54 | # Clone this repository (or your fork) 55 | git clone https://github.com/aquasecurity/kubectl-who-can 56 | cd kubectl-who-can 57 | make 58 | ``` 59 | The `kubectl-who-can` binary will be in the current directory. 60 | 61 | Option 2 (if you have a Go compiler and just want the binary): 62 | ``` 63 | go install github.com/aquasecurity/kubectl-who-can/cmd/kubectl-who-can@latest 64 | ``` 65 | The `kubectl-who-can` binary will be in `$GOPATH/bin`. 66 | 67 | Option 3 (if you don't have a Go compiler, but have Docker installed): 68 | ``` 69 | docker run --rm -v /usr/local/bin:/go/bin golang:1.17 go install github.com/aquasecurity/kubectl-who-can/cmd/kubectl-who-can@latest 70 | ``` 71 | The `kubectl-who-can` binary will be in `/usr/local/bin`. 72 | 73 | ## Usage 74 | 75 | `$ kubectl who-can VERB (TYPE | TYPE/NAME | NONRESOURCEURL) [flags]` 76 | 77 | ### Flags 78 | 79 | Name | Shorthand | Default | Usage 80 | -----------------|-----------|---------|---------------------------- 81 | namespace | n | | If present, the namespace scope for this CLI request 82 | all-namespaces | A | false | If true, check for users that can do the specified action in any of the available namespaces 83 | subresource | | | Specify a sub-resource such as pod/log or deployment/scale 84 | 85 | For additional details on flags and usage, run `kubectl who-can --help`. 86 | 87 | [release-img]: https://img.shields.io/github/release/aquasecurity/kubectl-who-can.svg?logo=github 88 | [release]: https://github.com/aquasecurity/kubectl-who-can/releases 89 | 90 | [build-action-img]: https://github.com/aquasecurity/kubectl-who-can/workflows/build/badge.svg 91 | [build-action]: https://github.com/aquasecurity/kubectl-who-can/actions 92 | 93 | [cov-img]: https://codecov.io/github/aquasecurity/kubectl-who-can/branch/main/graph/badge.svg 94 | [cov]: https://codecov.io/github/aquasecurity/kubectl-who-can 95 | 96 | [report-card-img]: https://goreportcard.com/badge/github.com/aquasecurity/kubectl-who-can 97 | [report-card]: https://goreportcard.com/report/github.com/aquasecurity/kubectl-who-can 98 | 99 | [license-img]: https://img.shields.io/github/license/aquasecurity/kubectl-who-can.svg 100 | [license]: https://github.com/aquasecurity/kubectl-who-can/blob/main/LICENSE 101 | [github-all-releases-img]: https://img.shields.io/github/downloads/aquasecurity/kubectl-who-can/total?logo=github 102 | 103 | [asciicast-img]: https://asciinema.org/a/ccqqYwA5L5rMV9kd1tgzyZJ2j.svg 104 | [asciicast]: https://asciinema.org/a/ccqqYwA5L5rMV9kd1tgzyZJ2j 105 | -------------------------------------------------------------------------------- /pkg/cmd/resource_resolver.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | rbac "k8s.io/api/rbac/v1" 6 | "k8s.io/apimachinery/pkg/api/meta" 7 | apismeta "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/apimachinery/pkg/runtime/schema" 9 | "k8s.io/client-go/discovery" 10 | "k8s.io/klog/v2" 11 | "strings" 12 | ) 13 | 14 | // ResourceResolver wraps the Resolve method. 15 | // 16 | // Resolve attempts to resolve a GroupResource by `resource` and `subResource`. 17 | // It also validates that the specified `verb` is supported by the resolved resource. 18 | type ResourceResolver interface { 19 | Resolve(verb, resource, subResource string) (schema.GroupResource, error) 20 | } 21 | 22 | type resourceResolver struct { 23 | client discovery.DiscoveryInterface 24 | mapper meta.RESTMapper 25 | } 26 | 27 | // NewResourceResolver constructs the default ResourceResolver. 28 | func NewResourceResolver(client discovery.DiscoveryInterface, mapper meta.RESTMapper) ResourceResolver { 29 | return &resourceResolver{ 30 | client: client, 31 | mapper: mapper, 32 | } 33 | } 34 | 35 | func (rv *resourceResolver) Resolve(verb, resource, subResource string) (schema.GroupResource, error) { 36 | if resource == rbac.ResourceAll { 37 | return schema.GroupResource{Resource: resource}, nil 38 | } 39 | 40 | name := resource 41 | if subResource != "" { 42 | name = name + "/" + subResource 43 | } 44 | 45 | gvr, err := rv.resolveGVR(resource) 46 | if err != nil { 47 | klog.V(3).Infof("Error while resolving GVR for resource %s: %v", resource, err) 48 | return schema.GroupResource{}, fmt.Errorf("the server doesn't have a resource type \"%s\"", name) 49 | } 50 | 51 | apiResource, err := rv.resolveAPIResource(gvr, subResource) 52 | if err != nil { 53 | klog.V(3).Infof("Error while resolving APIResource for GVR %v and subResource %s: %v", gvr, subResource, err) 54 | return schema.GroupResource{}, fmt.Errorf("the server doesn't have a resource type \"%s\"", name) 55 | } 56 | 57 | if !rv.isVerbSupportedBy(verb, apiResource) { 58 | return schema.GroupResource{}, fmt.Errorf("the \"%s\" resource does not support the \"%s\" verb, only %v", apiResource.Name, verb, apiResource.Verbs) 59 | } 60 | 61 | return gvr.GroupResource(), nil 62 | } 63 | 64 | func (rv *resourceResolver) resolveGVR(resource string) (schema.GroupVersionResource, error) { 65 | if resource == rbac.ResourceAll { 66 | return schema.GroupVersionResource{Resource: resource}, nil 67 | } 68 | 69 | fullySpecifiedGVR, groupResource := schema.ParseResourceArg(strings.ToLower(resource)) 70 | gvr := schema.GroupVersionResource{} 71 | if fullySpecifiedGVR != nil { 72 | gvr, _ = rv.mapper.ResourceFor(*fullySpecifiedGVR) 73 | } 74 | 75 | if gvr.Empty() { 76 | var err error 77 | gvr, err = rv.mapper.ResourceFor(groupResource.WithVersion("")) 78 | if err != nil { 79 | return schema.GroupVersionResource{}, err 80 | } 81 | } 82 | 83 | return gvr, nil 84 | } 85 | 86 | func (rv *resourceResolver) resolveAPIResource(gvr schema.GroupVersionResource, subResource string) (apismeta.APIResource, error) { 87 | index, err := rv.indexResources(gvr) 88 | if err != nil { 89 | return apismeta.APIResource{}, err 90 | } 91 | 92 | apiResource, err := rv.lookupResource(index, gvr.Resource) 93 | if err != nil { 94 | return apismeta.APIResource{}, err 95 | } 96 | 97 | if subResource != "" { 98 | apiResource, err = rv.lookupSubResource(index, apiResource.Name+"/"+subResource) 99 | if err != nil { 100 | return apismeta.APIResource{}, err 101 | } 102 | } 103 | return apiResource, nil 104 | } 105 | 106 | func (rv *resourceResolver) lookupResource(index map[string]apismeta.APIResource, resourceArg string) (apismeta.APIResource, error) { 107 | apiResource, ok := index[resourceArg] 108 | if ok { 109 | return apiResource, nil 110 | } 111 | 112 | return apismeta.APIResource{}, fmt.Errorf("not found \"%s\"", resourceArg) 113 | } 114 | 115 | func (rv *resourceResolver) lookupSubResource(index map[string]apismeta.APIResource, subResource string) (apismeta.APIResource, error) { 116 | apiResource, ok := index[subResource] 117 | if !ok { 118 | return apismeta.APIResource{}, fmt.Errorf("not found \"%s\"", subResource) 119 | } 120 | return apiResource, nil 121 | } 122 | 123 | // indexResources builds a lookup index for APIResources where the keys are plural resources names. 124 | // NB A subresource is also represented by APIResource and the corresponding key is /, 125 | // for example, `pods/log` or `deployments/scale`. 126 | func (rv *resourceResolver) indexResources(gvr schema.GroupVersionResource) (map[string]apismeta.APIResource, error) { 127 | index := make(map[string]apismeta.APIResource) 128 | 129 | resourceList, err := rv.client.ServerResourcesForGroupVersion(gvr.GroupVersion().String()) 130 | if err != nil { 131 | return nil, fmt.Errorf("getting API groups: %v", err) 132 | } 133 | for _, res := range resourceList.APIResources { 134 | index[res.Name] = res 135 | } 136 | 137 | return index, nil 138 | } 139 | 140 | // isVerbSupportedBy returns `true` if the given verb is supported by the given resource, `false` otherwise. 141 | // Returns `true` if the given verb equals VerbAll. 142 | func (rv *resourceResolver) isVerbSupportedBy(verb string, resource apismeta.APIResource) bool { 143 | if verb == rbac.VerbAll { 144 | return true 145 | } 146 | if resource.Name == "podsecuritypolicies" && verb == "use" { 147 | return true 148 | } 149 | supported := false 150 | for _, v := range resource.Verbs { 151 | if v == verb { 152 | supported = true 153 | } 154 | } 155 | return supported 156 | } 157 | -------------------------------------------------------------------------------- /pkg/cmd/printer.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "strings" 8 | "text/tabwriter" 9 | 10 | rbac "k8s.io/api/rbac/v1" 11 | ) 12 | 13 | // Printer formats and prints check results and warnings. 14 | type Printer struct { 15 | out io.Writer 16 | wide bool 17 | } 18 | 19 | // NewPrinter constructs a new Printer with the specified output io.Writer 20 | // and output format. 21 | func NewPrinter(out io.Writer, wide bool) *Printer { 22 | return &Printer{ 23 | out: out, 24 | wide: wide, 25 | } 26 | } 27 | 28 | // Struct to hold either rb or crb objects 29 | type rowData struct { 30 | Name string `json:"name"` 31 | RoleRef rbac.RoleRef `json:"roleRef" protobuf:"bytes,3,opt,name=roleRef"` 32 | Subjects []rbac.Subject `json:"subjects,omitempty" protobuf:"bytes,2,rep,name=subjects"` 33 | } 34 | 35 | // ExportData exports data to a file. 36 | func (p *Printer) ExportData(action Action, roleBindings []rbac.RoleBinding, clusterRoleBindings []rbac.ClusterRoleBinding) { 37 | // Final data to be exported as JSON 38 | data := make(map[string]interface{}) 39 | 40 | if action.Resource != "" { 41 | // NonResourceURL permissions can only be granted through ClusterRoles. Hence no point in printing RoleBindings section. 42 | if len(roleBindings) != 0 { 43 | rbData := []rowData{} 44 | // Get required data from each roleBinding 45 | for _, rb := range roleBindings { 46 | if len(rb.Subjects) != 0 { 47 | rbData = append(rbData, rowData{rb.Name, rb.RoleRef, rb.Subjects}) 48 | } 49 | } 50 | data["roleBindings"] = rbData 51 | } 52 | } 53 | 54 | if len(clusterRoleBindings) != 0 { 55 | crbData := []rowData{} 56 | // Get required data from each roleBinding 57 | for _, crb := range clusterRoleBindings { 58 | if len(crb.Subjects) != 0 { 59 | crbData = append(crbData, rowData{crb.Name, crb.RoleRef, crb.Subjects}) 60 | } 61 | } 62 | data["clusterRoleBindings"] = crbData 63 | } 64 | 65 | // get encoder to write data into output stream 66 | encoder := json.NewEncoder(p.out) 67 | 68 | // Set json indentation to 4 spaces 69 | encoder.SetIndent("", " ") 70 | 71 | // Write data 72 | _ = encoder.Encode(data) 73 | } 74 | 75 | func (p *Printer) PrintChecks(action Action, roleBindings []rbac.RoleBinding, clusterRoleBindings []rbac.ClusterRoleBinding) { 76 | wr := new(tabwriter.Writer) 77 | wr.Init(p.out, 0, 8, 2, ' ', 0) 78 | 79 | if action.Resource != "" { 80 | // NonResourceURL permissions can only be granted through ClusterRoles. Hence no point in printing RoleBindings section. 81 | if len(roleBindings) == 0 { 82 | _, _ = fmt.Fprintf(p.out, "No subjects found with permissions to %s assigned through RoleBindings\n", action) 83 | } else { 84 | p.printBindingsHeader(wr) 85 | for _, rb := range roleBindings { 86 | for _, s := range rb.Subjects { 87 | p.printBindingRow(wr, rb, s) 88 | } 89 | } 90 | } 91 | 92 | _, _ = fmt.Fprintln(wr) 93 | } 94 | 95 | if len(clusterRoleBindings) == 0 { 96 | _, _ = fmt.Fprintf(p.out, "No subjects found with permissions to %s assigned through ClusterRoleBindings\n", action) 97 | } else { 98 | p.printClusterBindingsHeader(wr) 99 | for _, rb := range clusterRoleBindings { 100 | for _, s := range rb.Subjects { 101 | p.printClusterBindingRow(wr, rb, s) 102 | } 103 | } 104 | } 105 | _ = wr.Flush() 106 | } 107 | 108 | func (p *Printer) printBindingsHeader(wr *tabwriter.Writer) { 109 | var columns []string 110 | if p.wide { 111 | columns = []string{"ROLEBINDING", "ROLE", "NAMESPACE", "SUBJECT", "TYPE", "SA-NAMESPACE"} 112 | } else { 113 | columns = []string{"ROLEBINDING", "NAMESPACE", "SUBJECT", "TYPE", "SA-NAMESPACE"} 114 | } 115 | _, _ = fmt.Fprintln(wr, strings.Join(columns, "\t")) 116 | } 117 | 118 | func (p *Printer) printBindingRow(wr *tabwriter.Writer, rb rbac.RoleBinding, s rbac.Subject) { 119 | var format string 120 | var args []interface{} 121 | 122 | if p.wide { 123 | format = "%s\t%s/%s\t%s\t%s\t%s\t%s\n" 124 | args = []interface{}{rb.Name, rb.RoleRef.Kind, rb.RoleRef.Name, rb.Namespace, s.Name, s.Kind, s.Namespace} 125 | } else { 126 | format = "%s\t%s\t%s\t%s\t%s\n" 127 | args = []interface{}{rb.Name, rb.Namespace, s.Name, s.Kind, s.Namespace} 128 | } 129 | _, _ = fmt.Fprintf(wr, format, args...) 130 | } 131 | 132 | func (p *Printer) printClusterBindingsHeader(wr *tabwriter.Writer) { 133 | var columns []string 134 | if p.wide { 135 | columns = []string{"CLUSTERROLEBINDING", "ROLE", "SUBJECT", "TYPE", "SA-NAMESPACE"} 136 | } else { 137 | columns = []string{"CLUSTERROLEBINDING", "SUBJECT", "TYPE", "SA-NAMESPACE"} 138 | } 139 | _, _ = fmt.Fprintln(wr, strings.Join(columns, "\t")) 140 | } 141 | 142 | func (p *Printer) printClusterBindingRow(wr *tabwriter.Writer, crb rbac.ClusterRoleBinding, s rbac.Subject) { 143 | var format string 144 | var args []interface{} 145 | if p.wide { 146 | format = "%s\t%s/%s\t%s\t%s\t%s\n" 147 | args = []interface{}{crb.Name, crb.RoleRef.Kind, crb.RoleRef.Name, s.Name, s.Kind, s.Namespace} 148 | } else { 149 | format = "%s\t%s\t%s\t%s\n" 150 | args = []interface{}{crb.Name, s.Name, s.Kind, s.Namespace} 151 | } 152 | _, _ = fmt.Fprintf(wr, format, args...) 153 | } 154 | 155 | // PrintWarnings prints warnings, if any, returned by CheckAPIAccess. 156 | func (p *Printer) PrintWarnings(warnings []string) { 157 | if len(warnings) > 0 { 158 | _, _ = fmt.Fprintln(p.out, "Warning: The list might not be complete due to missing permission(s):") 159 | for _, warning := range warnings { 160 | _, _ = fmt.Fprintf(p.out, "\t%s\n", warning) 161 | } 162 | _, _ = fmt.Fprintln(p.out) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /pkg/cmd/resource_resolver_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/mock" 7 | rbac "k8s.io/api/rbac/v1" 8 | "k8s.io/apimachinery/pkg/api/meta" 9 | apismeta "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/runtime/schema" 11 | "k8s.io/client-go/kubernetes/fake" 12 | "testing" 13 | ) 14 | 15 | type mapperMock struct { 16 | meta.DefaultRESTMapper 17 | mock.Mock 18 | } 19 | 20 | func (mm *mapperMock) ResourceFor(resource schema.GroupVersionResource) (schema.GroupVersionResource, error) { 21 | args := mm.Called(resource) 22 | return args.Get(0).(schema.GroupVersionResource), args.Error(1) 23 | } 24 | 25 | func TestResourceResolver_Resolve(t *testing.T) { 26 | 27 | podsGVR := schema.GroupVersionResource{Version: "v1", Resource: "pods"} 28 | podsGR := schema.GroupResource{Resource: "pods"} 29 | deploymentsGVR := schema.GroupVersionResource{Group: "extensions", Version: "v1beta1", Resource: "deployments"} 30 | deploymentsGR := schema.GroupResource{Group: "extensions", Resource: "deployments"} 31 | pspGVR := schema.GroupVersionResource{Group: "policy", Version: "v1beta1", Resource: "podsecuritypolicies"} 32 | pspGV := schema.GroupResource{Group: "policy", Resource: "podsecuritypolicies"} 33 | 34 | client := fake.NewSimpleClientset() 35 | 36 | client.Resources = []*apismeta.APIResourceList{ 37 | { 38 | GroupVersion: "v1", 39 | APIResources: []apismeta.APIResource{ 40 | {Group: "", Version: "v1", Name: "pods", ShortNames: []string{"po"}, Verbs: []string{"list", "create", "delete"}}, 41 | {Group: "", Version: "v1", Name: "pods/log", ShortNames: []string{}, Verbs: []string{"get"}}, 42 | {Group: "", Version: "v1", Name: "services", ShortNames: []string{"svc"}, Verbs: []string{"list", "delete"}}, 43 | }, 44 | }, 45 | { 46 | GroupVersion: "extensions/v1beta1", 47 | APIResources: []apismeta.APIResource{ 48 | {Group: "extensions", Version: "v1beta1", Name: "deployments", Verbs: []string{"list", "get"}}, 49 | {Group: "extensions", Version: "v1beta1", Name: "deployments/scale", Verbs: []string{"update", "patch"}}, 50 | }, 51 | }, 52 | { 53 | GroupVersion: "policy/v1beta1", 54 | APIResources: []apismeta.APIResource{ 55 | {Group: "policy", Version: "v1beta1", Name: "podsecuritypolicies", Verbs: []string{"list", "get"}}, 56 | }, 57 | }, 58 | } 59 | 60 | type mappingResult struct { 61 | argGVR schema.GroupVersionResource 62 | 63 | returnGVR schema.GroupVersionResource 64 | returnError error 65 | } 66 | 67 | testCases := []struct { 68 | name string 69 | action Action 70 | mappingResult *mappingResult 71 | expectedGR schema.GroupResource 72 | expectedError error 73 | }{ 74 | { 75 | name: "A", 76 | action: Action{Verb: "list", Resource: "pods"}, 77 | mappingResult: &mappingResult{ 78 | argGVR: schema.GroupVersionResource{Resource: "pods"}, 79 | returnGVR: podsGVR, 80 | }, 81 | expectedGR: podsGR, 82 | }, 83 | { 84 | name: "B", 85 | action: Action{Verb: "list", Resource: "po"}, 86 | mappingResult: &mappingResult{ 87 | argGVR: schema.GroupVersionResource{Resource: "po"}, 88 | returnGVR: podsGVR, 89 | }, 90 | expectedGR: podsGR, 91 | }, 92 | { 93 | name: "C", 94 | action: Action{Verb: "eat", Resource: "pods"}, 95 | mappingResult: &mappingResult{ 96 | argGVR: schema.GroupVersionResource{Resource: "pods"}, 97 | returnGVR: podsGVR, 98 | }, 99 | expectedError: errors.New("the \"pods\" resource does not support the \"eat\" verb, only [list create delete]"), 100 | }, 101 | { 102 | name: "D", 103 | action: Action{Verb: "list", Resource: "deployments.extensions"}, 104 | mappingResult: &mappingResult{ 105 | argGVR: schema.GroupVersionResource{Group: "extensions", Version: "", Resource: "deployments"}, 106 | returnGVR: deploymentsGVR, 107 | }, 108 | expectedGR: deploymentsGR, 109 | }, 110 | { 111 | name: "E", 112 | action: Action{Verb: "get", Resource: "pods", SubResource: "log"}, 113 | mappingResult: &mappingResult{ 114 | argGVR: schema.GroupVersionResource{Resource: "pods"}, 115 | returnGVR: podsGVR, 116 | }, 117 | expectedGR: podsGR, 118 | }, 119 | { 120 | name: "F", 121 | action: Action{Verb: "get", Resource: "pods", SubResource: "logz"}, 122 | mappingResult: &mappingResult{ 123 | argGVR: schema.GroupVersionResource{Resource: "pods"}, 124 | returnGVR: podsGVR, 125 | }, 126 | expectedError: errors.New("the server doesn't have a resource type \"pods/logz\""), 127 | }, 128 | { 129 | name: "G", 130 | action: Action{Verb: "list", Resource: "bees"}, 131 | mappingResult: &mappingResult{ 132 | argGVR: schema.GroupVersionResource{Resource: "bees"}, 133 | returnError: errors.New("mapping failed"), 134 | }, 135 | expectedError: errors.New("the server doesn't have a resource type \"bees\""), 136 | }, 137 | { 138 | name: "H", 139 | action: Action{Verb: rbac.VerbAll, Resource: "pods"}, 140 | mappingResult: &mappingResult{ 141 | argGVR: schema.GroupVersionResource{Resource: "pods"}, 142 | returnGVR: podsGVR, 143 | }, 144 | expectedGR: podsGR, 145 | }, 146 | { 147 | name: "I", 148 | action: Action{Verb: "list", Resource: rbac.ResourceAll}, 149 | expectedGR: schema.GroupResource{Resource: rbac.ResourceAll}, 150 | }, 151 | { 152 | name: "Should resolve psp", 153 | action: Action{Verb: "use", Resource: "psp"}, 154 | mappingResult: &mappingResult{ 155 | argGVR: schema.GroupVersionResource{Resource: "psp"}, 156 | returnGVR: pspGVR, 157 | }, 158 | expectedGR: pspGV, 159 | }, 160 | { 161 | name: "Should return error when psp verb is not supported", 162 | action: Action{Verb: "cook", Resource: "psp"}, 163 | mappingResult: &mappingResult{ 164 | argGVR: schema.GroupVersionResource{Resource: "psp"}, 165 | returnGVR: pspGVR, 166 | }, 167 | expectedError: errors.New("the \"podsecuritypolicies\" resource does not support the \"cook\" verb, only [list get]"), 168 | }, 169 | } 170 | 171 | for _, tc := range testCases { 172 | t.Run(tc.name, func(t *testing.T) { 173 | mapper := new(mapperMock) 174 | 175 | if tc.mappingResult != nil { 176 | mapper.On("ResourceFor", tc.mappingResult.argGVR). 177 | Return(tc.mappingResult.returnGVR, tc.mappingResult.returnError) 178 | } 179 | 180 | resolver := NewResourceResolver(client.Discovery(), mapper) 181 | 182 | resource, err := resolver.Resolve(tc.action.Verb, tc.action.Resource, tc.action.SubResource) 183 | 184 | assert.Equal(t, tc.expectedError, err) 185 | assert.Equal(t, tc.expectedGR, resource) 186 | 187 | mapper.AssertExpectations(t) 188 | }) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /pkg/cmd/policy_rule_matcher_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | rbac "k8s.io/api/rbac/v1" 6 | meta "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | "testing" 9 | ) 10 | 11 | func TestMatcher_MatchesRole(t *testing.T) { 12 | // given 13 | matcher := NewPolicyRuleMatcher() 14 | role := rbac.Role{ 15 | ObjectMeta: meta.ObjectMeta{Name: "view-services"}, 16 | Rules: []rbac.PolicyRule{ 17 | { 18 | Verbs: []string{"get", "list"}, 19 | APIGroups: []string{""}, 20 | Resources: []string{"services"}, 21 | }, 22 | { 23 | Verbs: []string{"get", "list"}, 24 | APIGroups: []string{"extensions"}, 25 | Resources: []string{"deployments"}, 26 | }, 27 | }, 28 | } 29 | action := resolvedAction{ 30 | Action: Action{ 31 | Verb: "list", 32 | }, 33 | gr: schema.GroupResource{ 34 | Group: "extensions", 35 | Resource: "deployments", 36 | }, 37 | } 38 | 39 | // then 40 | assert.True(t, matcher.MatchesRole(role, action)) 41 | } 42 | 43 | func TestMatcher_MatchesClusterRole(t *testing.T) { 44 | // given 45 | matcher := NewPolicyRuleMatcher() 46 | role := rbac.ClusterRole{ 47 | ObjectMeta: meta.ObjectMeta{Name: "edit-deployments"}, 48 | Rules: []rbac.PolicyRule{ 49 | { 50 | Verbs: []string{"update", "patch", "delete"}, 51 | APIGroups: []string{""}, 52 | Resources: []string{"deployments"}, 53 | }, 54 | { 55 | Verbs: []string{"update"}, 56 | APIGroups: []string{"extensions"}, 57 | Resources: []string{"deployments/scale"}, 58 | }, 59 | }, 60 | } 61 | action := resolvedAction{ 62 | Action: Action{ 63 | Verb: "update", 64 | SubResource: "scale", 65 | }, 66 | gr: schema.GroupResource{ 67 | Group: "extensions", 68 | Resource: "deployments", 69 | }, 70 | } 71 | 72 | // then 73 | assert.True(t, matcher.MatchesClusterRole(role, action)) 74 | } 75 | 76 | func TestMatcher_matches(t *testing.T) { 77 | servicesGR := schema.GroupResource{Resource: "services"} 78 | 79 | data := []struct { 80 | scenario string 81 | 82 | rule rbac.PolicyRule 83 | action resolvedAction 84 | 85 | matches bool 86 | }{ 87 | { 88 | scenario: "A", 89 | action: resolvedAction{ 90 | Action: Action{Verb: "get"}, 91 | gr: servicesGR, 92 | }, 93 | rule: rbac.PolicyRule{ 94 | Verbs: []string{"get", "list"}, 95 | APIGroups: []string{""}, 96 | Resources: []string{"services"}, 97 | }, 98 | matches: true, 99 | }, 100 | { 101 | scenario: "B", 102 | action: resolvedAction{ 103 | Action: Action{Verb: "get"}, 104 | gr: servicesGR, 105 | }, 106 | rule: rbac.PolicyRule{ 107 | Verbs: []string{"get", "list"}, 108 | APIGroups: []string{""}, 109 | Resources: []string{"*"}, 110 | }, 111 | matches: true, 112 | }, 113 | { 114 | scenario: "C", 115 | action: resolvedAction{ 116 | Action: Action{Verb: "get"}, 117 | gr: servicesGR, 118 | }, 119 | rule: rbac.PolicyRule{ 120 | Verbs: []string{rbac.VerbAll}, 121 | APIGroups: []string{""}, 122 | Resources: []string{"services"}, 123 | }, 124 | matches: true, 125 | }, 126 | { 127 | scenario: "D", 128 | action: resolvedAction{ 129 | Action: Action{Verb: "get", ResourceName: "mongodb"}, 130 | gr: servicesGR, 131 | }, 132 | rule: rbac.PolicyRule{ 133 | Verbs: []string{"get", "list"}, 134 | APIGroups: []string{""}, 135 | Resources: []string{"services"}, 136 | }, 137 | matches: true, 138 | }, 139 | { 140 | scenario: "E", 141 | action: resolvedAction{ 142 | Action: Action{Verb: "get", ResourceName: "mongodb"}, 143 | gr: servicesGR, 144 | }, 145 | rule: rbac.PolicyRule{ 146 | Verbs: []string{"get", "list"}, 147 | APIGroups: []string{""}, 148 | Resources: []string{"services"}, 149 | ResourceNames: []string{"mongodb", "nginx"}, 150 | }, 151 | matches: true, 152 | }, 153 | { 154 | scenario: "F", 155 | action: resolvedAction{ 156 | Action: Action{Verb: "get", ResourceName: "mongodb"}, 157 | gr: servicesGR, 158 | }, 159 | rule: rbac.PolicyRule{ 160 | Verbs: []string{"get", "list"}, 161 | APIGroups: []string{""}, 162 | Resources: []string{"services"}, 163 | ResourceNames: []string{"nginx"}, 164 | }, 165 | matches: false, 166 | }, 167 | { 168 | scenario: "G", 169 | action: resolvedAction{ 170 | Action: Action{Verb: "get"}, 171 | gr: servicesGR, 172 | }, 173 | rule: rbac.PolicyRule{ 174 | Verbs: []string{"get", "list"}, 175 | APIGroups: []string{""}, 176 | Resources: []string{"services"}, 177 | ResourceNames: []string{"nginx"}, 178 | }, 179 | matches: false, 180 | }, 181 | { 182 | scenario: "H", 183 | action: resolvedAction{ 184 | Action: Action{Verb: "get"}, 185 | gr: schema.GroupResource{Resource: "pods"}, 186 | }, 187 | rule: rbac.PolicyRule{ 188 | Verbs: []string{"create"}, 189 | APIGroups: []string{""}, 190 | Resources: []string{"pods"}, 191 | }, 192 | matches: false, 193 | }, 194 | { 195 | scenario: "I", 196 | action: resolvedAction{ 197 | Action: Action{Verb: "get"}, 198 | gr: schema.GroupResource{Resource: "persistentvolumes"}, 199 | }, 200 | rule: rbac.PolicyRule{ 201 | Verbs: []string{"get"}, 202 | APIGroups: []string{""}, 203 | Resources: []string{"pods"}, 204 | }, 205 | matches: false, 206 | }, 207 | { 208 | scenario: "J", 209 | action: resolvedAction{Action: Action{Verb: "get", NonResourceURL: "/logs"}}, 210 | rule: rbac.PolicyRule{ 211 | Verbs: []string{"get"}, 212 | NonResourceURLs: []string{"/logs"}, 213 | }, 214 | matches: true, 215 | }, 216 | { 217 | scenario: "K", 218 | action: resolvedAction{Action: Action{Verb: "get", NonResourceURL: "/logs"}}, 219 | rule: rbac.PolicyRule{ 220 | Verbs: []string{"post"}, 221 | NonResourceURLs: []string{"/logs"}, 222 | }, 223 | matches: false, 224 | }, 225 | { 226 | scenario: "L", 227 | action: resolvedAction{Action: Action{Verb: "get", NonResourceURL: "/logs"}}, 228 | rule: rbac.PolicyRule{ 229 | Verbs: []string{"get"}, 230 | NonResourceURLs: []string{"/api"}, 231 | }, 232 | matches: false, 233 | }, 234 | { 235 | scenario: "Should return true when PolicyRule's APIGroup matches resolved resource's group", 236 | action: resolvedAction{ 237 | Action: Action{Verb: "get"}, 238 | gr: schema.GroupResource{Resource: "deployments", Group: "extensions"}, 239 | }, 240 | rule: rbac.PolicyRule{ 241 | Verbs: []string{"get"}, 242 | APIGroups: []string{"extensions"}, 243 | Resources: []string{"deployments"}, 244 | }, 245 | matches: true, 246 | }, 247 | { 248 | scenario: "Should return true when PolicyRule's APIGroup matches all ('*') resource groups", 249 | action: resolvedAction{ 250 | Action: Action{Verb: "get"}, 251 | gr: schema.GroupResource{Resource: "pods", Group: "metrics.k8s.io"}, 252 | }, 253 | rule: rbac.PolicyRule{ 254 | Verbs: []string{"get"}, 255 | APIGroups: []string{"*"}, 256 | Resources: []string{"pods"}, 257 | }, 258 | matches: true, 259 | }, 260 | { 261 | scenario: "Should return false when PolicyRule's APIGroup doesn't match resolved resource's Group", 262 | action: resolvedAction{ 263 | Action: Action{Verb: "get"}, 264 | gr: schema.GroupResource{Resource: "pods", Group: "metrics.k8s.io"}, 265 | }, 266 | rule: rbac.PolicyRule{ 267 | Verbs: []string{"get"}, 268 | APIGroups: []string{""}, 269 | Resources: []string{"pods"}, 270 | }, 271 | matches: false, 272 | }, 273 | } 274 | 275 | // given 276 | policyRuleMatcher := matcher{} 277 | 278 | for _, tt := range data { 279 | t.Run(tt.scenario, func(t *testing.T) { 280 | // when 281 | matches := policyRuleMatcher.matches(tt.rule, tt.action) 282 | 283 | // then 284 | assert.Equal(t, tt.matches, matches) 285 | }) 286 | } 287 | 288 | } 289 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /pkg/cmd/printer_test.go: -------------------------------------------------------------------------------- 1 | package cmd_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/aquasecurity/kubectl-who-can/pkg/cmd" 8 | "github.com/stretchr/testify/assert" 9 | rbac "k8s.io/api/rbac/v1" 10 | meta "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | func TestPrinter_PrintWarnings(t *testing.T) { 14 | 15 | data := []struct { 16 | scenario string 17 | warnings []string 18 | expectedOutput string 19 | }{ 20 | { 21 | scenario: "A", 22 | warnings: []string{"w1", "w2"}, 23 | expectedOutput: "Warning: The list might not be complete due to missing permission(s):\n\tw1\n\tw2\n\n", 24 | }, 25 | { 26 | scenario: "B", 27 | warnings: []string{}, 28 | expectedOutput: "", 29 | }, 30 | { 31 | scenario: "C", 32 | warnings: nil, 33 | expectedOutput: "", 34 | }, 35 | } 36 | 37 | for _, tt := range data { 38 | t.Run(tt.scenario, func(t *testing.T) { 39 | var buf bytes.Buffer 40 | cmd.NewPrinter(&buf, false).PrintWarnings(tt.warnings) 41 | assert.Equal(t, tt.expectedOutput, buf.String()) 42 | }) 43 | } 44 | } 45 | 46 | // TODO Use more descriptive names for test cases rather than A, B, C, ... 47 | func TestPrinter_PrintChecks(t *testing.T) { 48 | testCases := []struct { 49 | scenario string 50 | 51 | verb string 52 | resource string 53 | nonResourceURL string 54 | resourceName string 55 | 56 | roleBindings []rbac.RoleBinding 57 | clusterRoleBindings []rbac.ClusterRoleBinding 58 | 59 | wide bool 60 | output string 61 | }{ 62 | { 63 | scenario: "A", 64 | verb: "get", resource: "pods", resourceName: "", 65 | output: `No subjects found with permissions to get pods assigned through RoleBindings 66 | 67 | No subjects found with permissions to get pods assigned through ClusterRoleBindings 68 | `, 69 | }, 70 | { 71 | scenario: "B", 72 | verb: "get", resource: "pods", resourceName: "my-pod", 73 | output: `No subjects found with permissions to get pods/my-pod assigned through RoleBindings 74 | 75 | No subjects found with permissions to get pods/my-pod assigned through ClusterRoleBindings 76 | `, 77 | }, 78 | { 79 | scenario: "C", 80 | verb: "get", nonResourceURL: "/healthz", 81 | output: "No subjects found with permissions to get /healthz assigned through ClusterRoleBindings\n", 82 | }, 83 | { 84 | scenario: "D", 85 | verb: "get", resource: "pods", 86 | roleBindings: []rbac.RoleBinding{ 87 | { 88 | ObjectMeta: meta.ObjectMeta{Name: "Alice-can-view-pods", Namespace: "default"}, 89 | Subjects: []rbac.Subject{ 90 | {Name: "Alice", Kind: "User"}, 91 | }}, 92 | { 93 | ObjectMeta: meta.ObjectMeta{Name: "Admins-can-view-pods", Namespace: "bar"}, 94 | Subjects: []rbac.Subject{ 95 | {Name: "Admins", Kind: "Group"}, 96 | }}, 97 | }, 98 | clusterRoleBindings: []rbac.ClusterRoleBinding{ 99 | { 100 | ObjectMeta: meta.ObjectMeta{Name: "Bob-and-Eve-can-view-pods", Namespace: "default"}, 101 | Subjects: []rbac.Subject{ 102 | {Name: "Bob", Kind: "ServiceAccount", Namespace: "foo"}, 103 | {Name: "Eve", Kind: "User"}, 104 | }, 105 | }, 106 | }, 107 | output: `ROLEBINDING NAMESPACE SUBJECT TYPE SA-NAMESPACE 108 | Alice-can-view-pods default Alice User 109 | Admins-can-view-pods bar Admins Group 110 | 111 | CLUSTERROLEBINDING SUBJECT TYPE SA-NAMESPACE 112 | Bob-and-Eve-can-view-pods Bob ServiceAccount foo 113 | Bob-and-Eve-can-view-pods Eve User 114 | `, 115 | }, 116 | { 117 | scenario: "E", 118 | verb: "get", resource: "pods", 119 | roleBindings: []rbac.RoleBinding{ 120 | { 121 | ObjectMeta: meta.ObjectMeta{Name: "Alice-can-view-pods", Namespace: "default"}, 122 | RoleRef: rbac.RoleRef{ 123 | Kind: cmd.RoleKind, 124 | Name: "view-pods", 125 | }, 126 | Subjects: []rbac.Subject{ 127 | {Name: "Alice", Kind: "User"}, 128 | }}, 129 | { 130 | ObjectMeta: meta.ObjectMeta{Name: "Admins-can-view-pods", Namespace: "bar"}, 131 | RoleRef: rbac.RoleRef{ 132 | Kind: cmd.ClusterRoleKind, 133 | Name: "view", 134 | }, 135 | Subjects: []rbac.Subject{ 136 | {Name: "Admins", Kind: "Group"}, 137 | }}, 138 | }, 139 | clusterRoleBindings: []rbac.ClusterRoleBinding{ 140 | { 141 | ObjectMeta: meta.ObjectMeta{Name: "Bob-and-Eve-can-view-pods", Namespace: "default"}, 142 | RoleRef: rbac.RoleRef{ 143 | Kind: cmd.ClusterRoleKind, 144 | Name: "view", 145 | }, 146 | Subjects: []rbac.Subject{ 147 | {Name: "Bob", Kind: "ServiceAccount", Namespace: "foo"}, 148 | {Name: "Eve", Kind: "User"}, 149 | }, 150 | }, 151 | }, 152 | wide: true, 153 | output: `ROLEBINDING ROLE NAMESPACE SUBJECT TYPE SA-NAMESPACE 154 | Alice-can-view-pods Role/view-pods default Alice User 155 | Admins-can-view-pods ClusterRole/view bar Admins Group 156 | 157 | CLUSTERROLEBINDING ROLE SUBJECT TYPE SA-NAMESPACE 158 | Bob-and-Eve-can-view-pods ClusterRole/view Bob ServiceAccount foo 159 | Bob-and-Eve-can-view-pods ClusterRole/view Eve User 160 | `, 161 | }, 162 | } 163 | 164 | for _, tt := range testCases { 165 | t.Run(tt.scenario, func(t *testing.T) { 166 | // given 167 | var buf bytes.Buffer 168 | action := cmd.Action{ 169 | Verb: tt.verb, 170 | Resource: tt.resource, 171 | NonResourceURL: tt.nonResourceURL, 172 | ResourceName: tt.resourceName, 173 | } 174 | 175 | // when 176 | cmd.NewPrinter(&buf, tt.wide). 177 | PrintChecks(action, tt.roleBindings, tt.clusterRoleBindings) 178 | 179 | // then 180 | assert.Equal(t, tt.output, buf.String()) 181 | }) 182 | 183 | } 184 | 185 | } 186 | 187 | func TestPrinter_ExportData(t *testing.T) { 188 | testCases := []struct { 189 | scenario string 190 | 191 | verb string 192 | resource string 193 | nonResourceURL string 194 | resourceName string 195 | 196 | roleBindings []rbac.RoleBinding 197 | clusterRoleBindings []rbac.ClusterRoleBinding 198 | 199 | wide bool 200 | output string 201 | }{ 202 | { 203 | scenario: "A", 204 | verb: "get", resource: "pods", resourceName: "", 205 | output: "{}\n", 206 | }, 207 | { 208 | scenario: "B", 209 | verb: "get", resource: "pods", resourceName: "my-pod", 210 | output: "{}\n", 211 | }, 212 | { 213 | scenario: "C", 214 | verb: "get", nonResourceURL: "/healthz", 215 | output: "{}\n", 216 | }, 217 | { 218 | scenario: "D", 219 | verb: "get", resource: "pods", 220 | roleBindings: []rbac.RoleBinding{ 221 | { 222 | ObjectMeta: meta.ObjectMeta{Name: "Alice-can-view-pods", Namespace: "default"}, 223 | Subjects: []rbac.Subject{ 224 | {Name: "Alice", Kind: "User"}, 225 | }}, 226 | { 227 | ObjectMeta: meta.ObjectMeta{Name: "Admins-can-view-pods", Namespace: "bar"}, 228 | Subjects: []rbac.Subject{ 229 | {Name: "Admins", Kind: "Group"}, 230 | }}, 231 | }, 232 | clusterRoleBindings: []rbac.ClusterRoleBinding{ 233 | { 234 | ObjectMeta: meta.ObjectMeta{Name: "Bob-and-Eve-can-view-pods", Namespace: "default"}, 235 | Subjects: []rbac.Subject{ 236 | {Name: "Bob", Kind: "ServiceAccount", Namespace: "foo"}, 237 | {Name: "Eve", Kind: "User"}, 238 | }, 239 | }, 240 | }, 241 | output: "{\n \"clusterRoleBindings\": [\n {\n \"name\": \"Bob-and-Eve-can-view-pods\",\n \"roleRef\": {\n \"apiGroup\": \"\",\n \"kind\": \"\",\n \"name\": \"\"\n },\n \"subjects\": [\n {\n \"kind\": \"ServiceAccount\",\n \"name\": \"Bob\",\n \"namespace\": \"foo\"\n },\n {\n \"kind\": \"User\",\n \"name\": \"Eve\"\n }\n ]\n }\n ],\n \"roleBindings\": [\n {\n \"name\": \"Alice-can-view-pods\",\n \"roleRef\": {\n \"apiGroup\": \"\",\n \"kind\": \"\",\n \"name\": \"\"\n },\n \"subjects\": [\n {\n \"kind\": \"User\",\n \"name\": \"Alice\"\n }\n ]\n },\n {\n \"name\": \"Admins-can-view-pods\",\n \"roleRef\": {\n \"apiGroup\": \"\",\n \"kind\": \"\",\n \"name\": \"\"\n },\n \"subjects\": [\n {\n \"kind\": \"Group\",\n \"name\": \"Admins\"\n }\n ]\n }\n ]\n}\n", 242 | }, 243 | { 244 | scenario: "E", 245 | verb: "get", resource: "pods", 246 | roleBindings: []rbac.RoleBinding{ 247 | { 248 | ObjectMeta: meta.ObjectMeta{Name: "Alice-can-view-pods", Namespace: "default"}, 249 | RoleRef: rbac.RoleRef{ 250 | Kind: cmd.RoleKind, 251 | Name: "view-pods", 252 | }, 253 | Subjects: []rbac.Subject{ 254 | {Name: "Alice", Kind: "User"}, 255 | }}, 256 | { 257 | ObjectMeta: meta.ObjectMeta{Name: "Admins-can-view-pods", Namespace: "bar"}, 258 | RoleRef: rbac.RoleRef{ 259 | Kind: cmd.ClusterRoleKind, 260 | Name: "view", 261 | }, 262 | Subjects: []rbac.Subject{ 263 | {Name: "Admins", Kind: "Group"}, 264 | }}, 265 | }, 266 | clusterRoleBindings: []rbac.ClusterRoleBinding{ 267 | { 268 | ObjectMeta: meta.ObjectMeta{Name: "Bob-and-Eve-can-view-pods", Namespace: "default"}, 269 | RoleRef: rbac.RoleRef{ 270 | Kind: cmd.ClusterRoleKind, 271 | Name: "view", 272 | }, 273 | Subjects: []rbac.Subject{ 274 | {Name: "Bob", Kind: "ServiceAccount", Namespace: "foo"}, 275 | {Name: "Eve", Kind: "User"}, 276 | }, 277 | }, 278 | }, 279 | wide: true, 280 | output: "{\n \"clusterRoleBindings\": [\n {\n \"name\": \"Bob-and-Eve-can-view-pods\",\n \"roleRef\": {\n \"apiGroup\": \"\",\n \"kind\": \"ClusterRole\",\n \"name\": \"view\"\n },\n \"subjects\": [\n {\n \"kind\": \"ServiceAccount\",\n \"name\": \"Bob\",\n \"namespace\": \"foo\"\n },\n {\n \"kind\": \"User\",\n \"name\": \"Eve\"\n }\n ]\n }\n ],\n \"roleBindings\": [\n {\n \"name\": \"Alice-can-view-pods\",\n \"roleRef\": {\n \"apiGroup\": \"\",\n \"kind\": \"Role\",\n \"name\": \"view-pods\"\n },\n \"subjects\": [\n {\n \"kind\": \"User\",\n \"name\": \"Alice\"\n }\n ]\n },\n {\n \"name\": \"Admins-can-view-pods\",\n \"roleRef\": {\n \"apiGroup\": \"\",\n \"kind\": \"ClusterRole\",\n \"name\": \"view\"\n },\n \"subjects\": [\n {\n \"kind\": \"Group\",\n \"name\": \"Admins\"\n }\n ]\n }\n ]\n}\n", 281 | }, 282 | { 283 | scenario: "F", 284 | verb: "get", resource: "pods", 285 | roleBindings: []rbac.RoleBinding{ 286 | { 287 | ObjectMeta: meta.ObjectMeta{Name: "Alice-can-view-pods", Namespace: "default"}, 288 | Subjects: []rbac.Subject{}, 289 | }, 290 | }, 291 | clusterRoleBindings: []rbac.ClusterRoleBinding{ 292 | { 293 | ObjectMeta: meta.ObjectMeta{Name: "Bob-and-Eve-can-view-pods", Namespace: "default"}, 294 | Subjects: []rbac.Subject{}, 295 | }, 296 | }, 297 | output: "{\n \"clusterRoleBindings\": [],\n \"roleBindings\": []\n}\n", 298 | }, 299 | } 300 | 301 | for _, tt := range testCases { 302 | t.Run(tt.scenario, func(t *testing.T) { 303 | // given 304 | var buf bytes.Buffer 305 | action := cmd.Action{ 306 | Verb: tt.verb, 307 | Resource: tt.resource, 308 | NonResourceURL: tt.nonResourceURL, 309 | ResourceName: tt.resourceName, 310 | } 311 | 312 | // when 313 | cmd.NewPrinter(&buf, tt.wide). 314 | ExportData(action, tt.roleBindings, tt.clusterRoleBindings) 315 | 316 | // then 317 | assert.Equal(t, tt.output, buf.String()) 318 | }) 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /test/integration_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/aquasecurity/kubectl-who-can/pkg/cmd" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | core "k8s.io/api/core/v1" 14 | rbac "k8s.io/api/rbac/v1" 15 | apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" 16 | clientext "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1beta1" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | "k8s.io/apimachinery/pkg/labels" 19 | clioptions "k8s.io/cli-runtime/pkg/genericclioptions" 20 | client "k8s.io/client-go/kubernetes" 21 | "k8s.io/client-go/tools/clientcmd" 22 | ) 23 | 24 | var ( 25 | // commonLabels is a set of common labels added to each object created by this integration test, which allows us 26 | // to do a proper cleanup and distinguish them from the default object created on cluster init. 27 | // 28 | // kubectl delete ns,crd,role,rolebinding,clusterrole,clusterrolebindings -l app.kubernetes.io/name=who-can 29 | commonLabels = labels.Set{ 30 | "app.kubernetes.io/name": "who-can", 31 | } 32 | ) 33 | 34 | func TestIntegration(t *testing.T) { 35 | if testing.Short() { 36 | t.Skip("Integration test") 37 | } 38 | 39 | config, err := clientcmd.BuildConfigFromFlags("", os.Getenv("KUBECONFIG")) 40 | require.NoError(t, err) 41 | 42 | coreClient, err := client.NewForConfig(config) 43 | require.NoError(t, err) 44 | 45 | extClient, err := clientext.NewForConfig(config) 46 | require.NoError(t, err) 47 | 48 | createCRDs(t, extClient.CustomResourceDefinitions()) 49 | configureRBAC(t, coreClient) 50 | 51 | testCases := []struct { 52 | name string 53 | args []string 54 | output []string 55 | }{ 56 | { 57 | name: "Should print who can create configmaps", 58 | args: []string{"create", "cm"}, 59 | output: []string{ 60 | "ROLEBINDING NAMESPACE SUBJECT TYPE SA-NAMESPACE", 61 | "alice-can-create-configmaps default Alice User", 62 | "rory-can-create-configmaps default Rory User", 63 | }, 64 | }, 65 | { 66 | name: "Should print who can get /healthz", 67 | args: []string{"get", "/logs"}, 68 | output: []string{ 69 | "CLUSTERROLEBINDING SUBJECT TYPE SA-NAMESPACE", 70 | "bob-can-get-logs Bob User"}, 71 | }, 72 | { 73 | name: "Should print who can list services in the namespace `foo`", 74 | args: []string{"list", "services", "-n", "foo"}, 75 | output: []string{ 76 | "operator-can-view-services foo operator ServiceAccount bar", 77 | }, 78 | }, 79 | { 80 | name: "Should print who can scale deployments", 81 | args: []string{"update", "deployment", "--subresource", "scale"}, 82 | output: []string{ 83 | "devops-can-scale-workloads default devops Group", 84 | }, 85 | }, 86 | { 87 | name: "Should print who can get pod named `pod-xyz` in the namespace `foo`", 88 | args: []string{"get", "pods/pod-xyz", "--namespace=foo"}, 89 | output: []string{ 90 | "batman-can-view-pod-xyz foo Batman User", 91 | }, 92 | }, 93 | { 94 | name: "Should print who can list pods in group `metrics.k8s.io`", 95 | args: []string{"list", "pods.metrics.k8s.io"}, 96 | output: []string{ 97 | "spiderman-can-view-pod-metrics Spiderman User", 98 | }, 99 | }, 100 | } 101 | for _, tt := range testCases { 102 | t.Run(tt.name, func(t *testing.T) { 103 | 104 | streams, _, out, _ := clioptions.NewTestIOStreams() 105 | root, err := cmd.NewWhoCanCommand(streams) 106 | require.NoError(t, err) 107 | 108 | root.SetArgs(tt.args) 109 | 110 | err = root.Execute() 111 | require.NoError(t, err) 112 | 113 | prettyPrintWhoCanOutput(t, tt.args, out) 114 | 115 | for _, line := range tt.output { 116 | // TODO Improve asserts on the output 117 | // I believe we can do better with such asserts by leveraging label selectors. 118 | // By adding the "app.kubernetes.io/name=who-can" label to [Cluster]Roles and 119 | // [Cluster]RoleBindings created by this integration test, we can distinguish 120 | // them from the default RBAC settings which are labeled with 121 | // "kubernetes.io/bootstrapping=rbac-defaults" or do not have labels at all. 122 | assert.Contains(t, out.String(), line) 123 | } 124 | }) 125 | } 126 | 127 | } 128 | 129 | func prettyPrintWhoCanOutput(t *testing.T, args []string, out *bytes.Buffer) { 130 | t.Helper() 131 | 132 | if testing.Verbose() { 133 | t.Logf("\n%s\n%s\n%s%s\n", strings.Repeat("~", 117), 134 | "$ kubectl who-can "+strings.Join(args, " "), 135 | out.String(), 136 | strings.Repeat("~", 117)) 137 | } 138 | } 139 | 140 | func createCRDs(t *testing.T, client clientext.CustomResourceDefinitionInterface) { 141 | t.Helper() 142 | ctx := context.Background() 143 | _, err := client.Create(ctx, &apiext.CustomResourceDefinition{ 144 | ObjectMeta: metav1.ObjectMeta{ 145 | Name: "pods.metrics.k8s.io", 146 | Labels: commonLabels, 147 | }, 148 | Spec: apiext.CustomResourceDefinitionSpec{ 149 | Scope: apiext.NamespaceScoped, 150 | Group: "metrics.k8s.io", 151 | Versions: []apiext.CustomResourceDefinitionVersion{ 152 | { 153 | Name: "v1beta1", 154 | Served: true, 155 | Storage: true, 156 | }, 157 | }, 158 | Names: apiext.CustomResourceDefinitionNames{ 159 | Kind: "PodMetrics", 160 | Singular: "pod", 161 | Plural: "pods", 162 | ShortNames: []string{"po"}, 163 | }, 164 | }, 165 | }, metav1.CreateOptions{}) 166 | require.NoError(t, err) 167 | } 168 | 169 | func configureRBAC(t *testing.T, coreClient client.Interface) { 170 | t.Helper() 171 | ctx := context.Background() 172 | clientRBAC := coreClient.RbacV1() 173 | 174 | const namespaceFoo = "foo" 175 | 176 | // Define ClusterRoles and ClusterRoleBindings 177 | _, err := clientRBAC.ClusterRoles().Create(ctx, &rbac.ClusterRole{ 178 | ObjectMeta: metav1.ObjectMeta{ 179 | Name: "create-configmaps", 180 | Labels: commonLabels, 181 | }, 182 | Rules: []rbac.PolicyRule{ 183 | { 184 | APIGroups: []string{""}, 185 | Verbs: []string{"create"}, 186 | Resources: []string{"configmaps"}, 187 | }, 188 | }, 189 | }, metav1.CreateOptions{}) 190 | require.NoError(t, err) 191 | 192 | _, err = clientRBAC.ClusterRoles().Create(ctx, &rbac.ClusterRole{ 193 | ObjectMeta: metav1.ObjectMeta{ 194 | Name: "get-logs", 195 | Labels: commonLabels, 196 | }, 197 | Rules: []rbac.PolicyRule{ 198 | { 199 | Verbs: []string{"get"}, 200 | NonResourceURLs: []string{"/logs"}, 201 | }, 202 | }, 203 | }, metav1.CreateOptions{}) 204 | require.NoError(t, err) 205 | 206 | _, err = clientRBAC.ClusterRoles().Create(ctx, &rbac.ClusterRole{ 207 | ObjectMeta: metav1.ObjectMeta{ 208 | Name: "view-pod-metrics", 209 | Labels: commonLabels, 210 | }, 211 | Rules: []rbac.PolicyRule{ 212 | { 213 | Verbs: []string{"get", "list"}, 214 | APIGroups: []string{"metrics.k8s.io"}, 215 | Resources: []string{"pods"}, 216 | }, 217 | }, 218 | }, metav1.CreateOptions{}) 219 | 220 | _, err = clientRBAC.ClusterRoleBindings().Create(ctx, &rbac.ClusterRoleBinding{ 221 | ObjectMeta: metav1.ObjectMeta{ 222 | Name: "bob-can-get-logs", 223 | Labels: commonLabels, 224 | }, 225 | RoleRef: rbac.RoleRef{ 226 | Name: "get-logs", 227 | Kind: cmd.ClusterRoleKind, 228 | }, 229 | Subjects: []rbac.Subject{ 230 | { 231 | Kind: rbac.UserKind, 232 | Name: "Bob", 233 | }, 234 | }, 235 | }, metav1.CreateOptions{}) 236 | require.NoError(t, err) 237 | 238 | _, err = clientRBAC.ClusterRoleBindings().Create(ctx, &rbac.ClusterRoleBinding{ 239 | ObjectMeta: metav1.ObjectMeta{ 240 | Name: "spiderman-can-view-pod-metrics", 241 | Labels: commonLabels, 242 | }, 243 | RoleRef: rbac.RoleRef{ 244 | Name: "view-pod-metrics", 245 | Kind: cmd.ClusterRoleKind, 246 | }, 247 | Subjects: []rbac.Subject{ 248 | { 249 | Kind: rbac.UserKind, 250 | Name: "Spiderman", 251 | }, 252 | }, 253 | }, metav1.CreateOptions{}) 254 | 255 | // Define Roles and RoleBindings 256 | _, err = clientRBAC.Roles(core.NamespaceDefault).Create(ctx, &rbac.Role{ 257 | ObjectMeta: metav1.ObjectMeta{ 258 | Name: "create-configmaps", 259 | Labels: commonLabels, 260 | }, 261 | Rules: []rbac.PolicyRule{ 262 | { 263 | APIGroups: []string{""}, 264 | Verbs: []string{"create"}, 265 | Resources: []string{"configmaps"}, 266 | }, 267 | }, 268 | }, metav1.CreateOptions{}) 269 | require.NoError(t, err) 270 | 271 | _, err = clientRBAC.RoleBindings(core.NamespaceDefault).Create(ctx, &rbac.RoleBinding{ 272 | ObjectMeta: metav1.ObjectMeta{ 273 | Name: "alice-can-create-configmaps", 274 | Labels: commonLabels, 275 | }, 276 | RoleRef: rbac.RoleRef{ 277 | Name: "create-configmaps", 278 | Kind: cmd.RoleKind, 279 | }, 280 | Subjects: []rbac.Subject{ 281 | { 282 | Kind: rbac.UserKind, 283 | Name: "Alice", 284 | }, 285 | }, 286 | }, metav1.CreateOptions{}) 287 | require.NoError(t, err) 288 | 289 | _, err = clientRBAC.RoleBindings(core.NamespaceDefault).Create(ctx, &rbac.RoleBinding{ 290 | ObjectMeta: metav1.ObjectMeta{ 291 | Name: "rory-can-create-configmaps", 292 | Labels: commonLabels, 293 | }, 294 | RoleRef: rbac.RoleRef{ 295 | Name: "create-configmaps", 296 | Kind: cmd.ClusterRoleKind, 297 | }, 298 | Subjects: []rbac.Subject{ 299 | { 300 | Kind: rbac.UserKind, 301 | Name: "Rory", 302 | }, 303 | }, 304 | }, metav1.CreateOptions{}) 305 | require.NoError(t, err) 306 | 307 | _, err = clientRBAC.Roles(core.NamespaceDefault).Create(ctx, &rbac.Role{ 308 | ObjectMeta: metav1.ObjectMeta{ 309 | Name: "scale-workloads", 310 | Labels: commonLabels, 311 | }, 312 | Rules: []rbac.PolicyRule{ 313 | { 314 | APIGroups: []string{"apps"}, // TODO For old clusters it's extensions, newer are apps 315 | Verbs: []string{"update"}, 316 | Resources: []string{"deployments/scale"}, 317 | }, 318 | }, 319 | }, metav1.CreateOptions{}) 320 | 321 | _, err = clientRBAC.RoleBindings(core.NamespaceDefault).Create(ctx, &rbac.RoleBinding{ 322 | ObjectMeta: metav1.ObjectMeta{ 323 | Name: "devops-can-scale-workloads", 324 | Labels: commonLabels, 325 | }, 326 | RoleRef: rbac.RoleRef{ 327 | Name: "scale-workloads", 328 | Kind: cmd.RoleKind, 329 | }, 330 | Subjects: []rbac.Subject{ 331 | { 332 | Kind: rbac.GroupKind, 333 | Name: "devops", 334 | }, 335 | }, 336 | }, metav1.CreateOptions{}) 337 | 338 | // Configure foo namespace 339 | _, err = coreClient.CoreV1().Namespaces().Create(ctx, &core.Namespace{ 340 | ObjectMeta: metav1.ObjectMeta{ 341 | Name: namespaceFoo, 342 | Labels: commonLabels, 343 | }, 344 | }, metav1.CreateOptions{}) 345 | require.NoError(t, err) 346 | 347 | _, err = clientRBAC.Roles(namespaceFoo).Create(ctx, &rbac.Role{ 348 | ObjectMeta: metav1.ObjectMeta{ 349 | Name: "view-services", 350 | Labels: commonLabels, 351 | }, 352 | Rules: []rbac.PolicyRule{ 353 | { 354 | APIGroups: []string{""}, 355 | Verbs: []string{"get", "list"}, 356 | Resources: []string{"services"}, 357 | }, 358 | { 359 | APIGroups: []string{""}, 360 | Verbs: []string{"get", "list"}, 361 | Resources: []string{"endpoints"}, 362 | }, 363 | }, 364 | }, metav1.CreateOptions{}) 365 | require.NoError(t, err) 366 | 367 | _, err = clientRBAC.Roles(namespaceFoo).Create(ctx, &rbac.Role{ 368 | ObjectMeta: metav1.ObjectMeta{ 369 | Name: "view-pod-xyz", 370 | Labels: commonLabels, 371 | }, 372 | Rules: []rbac.PolicyRule{ 373 | { 374 | APIGroups: []string{""}, 375 | Verbs: []string{"get"}, 376 | Resources: []string{"pods"}, 377 | ResourceNames: []string{"pod-xyz"}, 378 | }, 379 | }, 380 | }, metav1.CreateOptions{}) 381 | 382 | _, err = clientRBAC.RoleBindings(namespaceFoo).Create(ctx, &rbac.RoleBinding{ 383 | ObjectMeta: metav1.ObjectMeta{ 384 | Name: "operator-can-view-services", 385 | Labels: commonLabels, 386 | }, 387 | RoleRef: rbac.RoleRef{ 388 | Name: "view-services", 389 | Kind: cmd.RoleKind, 390 | }, 391 | Subjects: []rbac.Subject{ 392 | { 393 | Kind: rbac.ServiceAccountKind, 394 | Name: "operator", 395 | Namespace: "bar", 396 | }, 397 | }, 398 | }, metav1.CreateOptions{}) 399 | 400 | _, err = clientRBAC.RoleBindings(namespaceFoo).Create(ctx, &rbac.RoleBinding{ 401 | ObjectMeta: metav1.ObjectMeta{ 402 | Name: "batman-can-view-pod-xyz", 403 | Labels: commonLabels, 404 | }, 405 | RoleRef: rbac.RoleRef{ 406 | Name: "view-pod-xyz", 407 | Kind: cmd.RoleKind, 408 | }, 409 | Subjects: []rbac.Subject{ 410 | { 411 | Kind: rbac.UserKind, 412 | Name: "Batman", 413 | }, 414 | }, 415 | }, metav1.CreateOptions{}) 416 | 417 | } 418 | -------------------------------------------------------------------------------- /pkg/cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/pflag" 12 | core "k8s.io/api/core/v1" 13 | rbac "k8s.io/api/rbac/v1" 14 | apimeta "k8s.io/apimachinery/pkg/api/meta" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | "k8s.io/apimachinery/pkg/runtime/schema" 17 | clioptions "k8s.io/cli-runtime/pkg/genericclioptions" 18 | "k8s.io/client-go/kubernetes" 19 | clientcore "k8s.io/client-go/kubernetes/typed/core/v1" 20 | clientrbac "k8s.io/client-go/kubernetes/typed/rbac/v1" 21 | "k8s.io/client-go/rest" 22 | "k8s.io/client-go/tools/clientcmd" 23 | "k8s.io/klog/v2" 24 | ) 25 | 26 | const ( 27 | whoCanUsage = `kubectl who-can VERB (TYPE | TYPE/NAME | NONRESOURCEURL)` 28 | whoCanLong = `Shows which users, groups and service accounts can perform a given verb on a given resource type. 29 | 30 | VERB is a logical Kubernetes API verb like 'get', 'list', 'watch', 'delete', etc. 31 | TYPE is a Kubernetes resource. Shortcuts and API groups will be resolved, e.g. 'po' or 'pods.metrics.k8s.io'. 32 | NAME is the name of a particular Kubernetes resource. 33 | NONRESOURCEURL is a partial URL that starts with "/".` 34 | whoCanExample = ` # List who can get pods from any of the available namespaces 35 | kubectl who-can get pods --all-namespaces 36 | 37 | # List who can create pods in the current namespace 38 | kubectl who-can create pods 39 | 40 | # List who can get pods specifying the API group 41 | kubectl who-can get pods.metrics.k8s.io 42 | 43 | # List who can create services in namespace "foo" 44 | kubectl who-can create services -n foo 45 | 46 | # List who can get the service named "mongodb" in namespace "bar" 47 | kubectl who-can get svc/mongodb --namespace bar 48 | 49 | # List who can do everything with pods in the current namespace 50 | kubectl who-can '*' pods 51 | 52 | # List who can list every resource in the namespace "baz" 53 | kubectl who-can list '*' -n baz 54 | 55 | # List who can read pod logs 56 | kubectl who-can get pods --subresource=log 57 | 58 | # List who can access the URL /logs/ 59 | kubectl who-can get /logs` 60 | ) 61 | 62 | const ( 63 | // RoleKind is the RoleRef's Kind referencing a Role. 64 | RoleKind = "Role" 65 | // ClusterRoleKind is the RoleRef's Kind referencing a ClusterRole. 66 | ClusterRoleKind = "ClusterRole" 67 | ) 68 | 69 | const ( 70 | subResourceFlag = "subresource" 71 | allNamespacesFlag = "all-namespaces" 72 | namespaceFlag = "namespace" 73 | outputFlag = "output" 74 | outputWide = "wide" 75 | outputJson = "json" 76 | ) 77 | 78 | // Action represents an action a subject can be given permission to. 79 | type Action struct { 80 | Verb string 81 | Resource string 82 | ResourceName string 83 | SubResource string 84 | 85 | NonResourceURL string 86 | 87 | Namespace string 88 | AllNamespaces bool 89 | } 90 | 91 | type resolvedAction struct { 92 | Action 93 | 94 | gr schema.GroupResource 95 | } 96 | 97 | // roles is a set of Role names matching the specified Action. 98 | type roles map[string]struct{} 99 | 100 | // clusterRoles is a set of ClusterRole names matching the specified Action. 101 | type clusterRoles map[string]struct{} 102 | 103 | type WhoCan struct { 104 | clientNamespace clientcore.NamespaceInterface 105 | clientRBAC clientrbac.RbacV1Interface 106 | 107 | namespaceValidator NamespaceValidator 108 | resourceResolver ResourceResolver 109 | accessChecker AccessChecker 110 | policyRuleMatcher PolicyRuleMatcher 111 | } 112 | 113 | // NewWhoCan constructs a new WhoCan checker with the specified rest.Config and RESTMapper. 114 | func NewWhoCan(restConfig *rest.Config, mapper apimeta.RESTMapper) (*WhoCan, error) { 115 | client, err := kubernetes.NewForConfig(restConfig) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | clientNamespace := client.CoreV1().Namespaces() 121 | 122 | return &WhoCan{ 123 | clientNamespace: clientNamespace, 124 | clientRBAC: client.RbacV1(), 125 | namespaceValidator: NewNamespaceValidator(clientNamespace), 126 | resourceResolver: NewResourceResolver(client.Discovery(), mapper), 127 | accessChecker: NewAccessChecker(client.AuthorizationV1().SelfSubjectAccessReviews()), 128 | policyRuleMatcher: NewPolicyRuleMatcher(), 129 | }, nil 130 | } 131 | 132 | // NewWhoCanCommand constructs the WhoCan command with the specified IOStreams. 133 | func NewWhoCanCommand(streams clioptions.IOStreams) (*cobra.Command, error) { 134 | var configFlags *clioptions.ConfigFlags 135 | 136 | cmd := &cobra.Command{ 137 | Use: whoCanUsage, 138 | Long: whoCanLong, 139 | Example: whoCanExample, 140 | SilenceUsage: true, 141 | RunE: func(cmd *cobra.Command, args []string) error { 142 | clientConfig := configFlags.ToRawKubeConfigLoader() 143 | restConfig, err := clientConfig.ClientConfig() 144 | if err != nil { 145 | return fmt.Errorf("getting rest config: %v", err) 146 | } 147 | 148 | mapper, err := configFlags.ToRESTMapper() 149 | if err != nil { 150 | return fmt.Errorf("getting mapper: %v", err) 151 | } 152 | 153 | action, err := ActionFrom(clientConfig, cmd.Flags(), args) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | o, err := NewWhoCan(restConfig, mapper) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | warnings, err := o.CheckAPIAccess(action) 164 | if err != nil { 165 | return err 166 | } 167 | 168 | output, err := cmd.Flags().GetString(outputFlag) 169 | if err != nil { 170 | return err 171 | } 172 | 173 | printer := NewPrinter(streams.Out, output == outputWide) 174 | 175 | // Output warnings 176 | printer.PrintWarnings(warnings) 177 | 178 | roleBindings, clusterRoleBindings, err := o.Check(action) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | // Output check results 184 | output = strings.ToLower(output) 185 | if output == outputJson { 186 | printer.ExportData(action, roleBindings, clusterRoleBindings) 187 | } else if output == outputWide || output == "" { 188 | printer.PrintChecks(action, roleBindings, clusterRoleBindings) 189 | } else { 190 | return fmt.Errorf("invalid output format: %v", output) 191 | } 192 | 193 | return nil 194 | }, 195 | } 196 | 197 | cmd.Flags().String(subResourceFlag, "", "SubResource such as pod/log or deployment/scale") 198 | cmd.Flags().BoolP(allNamespacesFlag, "A", false, "If true, check for users that can do the specified action in any of the available namespaces") 199 | cmd.Flags().StringP(outputFlag, "o", "", "Output format. Currently the only supported output format is wide or JSON.") 200 | 201 | flag.CommandLine.VisitAll(func(gf *flag.Flag) { 202 | cmd.Flags().AddGoFlag(gf) 203 | }) 204 | configFlags = clioptions.NewConfigFlags(true) 205 | configFlags.AddFlags(cmd.Flags()) 206 | 207 | return cmd, nil 208 | } 209 | 210 | // ActionFrom sets all information required to check who can perform the specified action. 211 | func ActionFrom(clientConfig clientcmd.ClientConfig, flags *pflag.FlagSet, args []string) (action Action, err error) { 212 | if len(args) < 2 { 213 | err = errors.New("you must specify two or three arguments: verb, resource, and optional resourceName") 214 | return 215 | } 216 | 217 | action.Verb = args[0] 218 | if strings.HasPrefix(args[1], "/") { 219 | action.NonResourceURL = args[1] 220 | klog.V(3).Infof("Resolved nonResourceURL `%s`", action.NonResourceURL) 221 | } else { 222 | resourceTokens := strings.SplitN(args[1], "/", 2) 223 | action.Resource = resourceTokens[0] 224 | if len(resourceTokens) > 1 { 225 | action.ResourceName = resourceTokens[1] 226 | klog.V(3).Infof("Resolved resourceName `%s`", action.ResourceName) 227 | } 228 | } 229 | 230 | action.SubResource, err = flags.GetString(subResourceFlag) 231 | if err != nil { 232 | return 233 | } 234 | 235 | action.AllNamespaces, err = flags.GetBool(allNamespacesFlag) 236 | if err != nil { 237 | return 238 | } 239 | 240 | if action.AllNamespaces { 241 | action.Namespace = core.NamespaceAll 242 | klog.V(3).Infof("Resolved namespace `%s` from --all-namespaces flag", action.Namespace) 243 | return 244 | } 245 | 246 | action.Namespace, err = flags.GetString(namespaceFlag) 247 | if err != nil { 248 | return 249 | } 250 | 251 | if action.Namespace != "" { 252 | klog.V(3).Infof("Resolved namespace `%s` from --namespace flag", action.Namespace) 253 | return 254 | } 255 | 256 | // Neither --all-namespaces nor --namespace flag was specified 257 | action.Namespace, _, err = clientConfig.Namespace() 258 | if err != nil { 259 | err = fmt.Errorf("getting namespace from current context: %v", err) 260 | } 261 | klog.V(3).Infof("Resolved namespace `%s` from current context", action.Namespace) 262 | return 263 | } 264 | 265 | // Validate makes sure that the specified action is valid. 266 | func (w *WhoCan) validate(action Action) error { 267 | if action.NonResourceURL != "" && action.SubResource != "" { 268 | return fmt.Errorf("--subresource cannot be used with NONRESOURCEURL") 269 | } 270 | 271 | err := w.namespaceValidator.Validate(action.Namespace) 272 | if err != nil { 273 | return fmt.Errorf("validating namespace: %v", err) 274 | } 275 | 276 | return nil 277 | } 278 | 279 | // Check checks who can perform the action specified by WhoCanOptions and returns the role bindings that allows the 280 | // action to be performed. 281 | func (w *WhoCan) Check(action Action) (roleBindings []rbac.RoleBinding, clusterRoleBindings []rbac.ClusterRoleBinding, err error) { 282 | err = w.validate(action) 283 | if err != nil { 284 | err = fmt.Errorf("validation: %v", err) 285 | return 286 | } 287 | 288 | resolvedAction := resolvedAction{Action: action} 289 | 290 | if action.Resource != "" { 291 | resolvedAction.gr, err = w.resourceResolver.Resolve(action.Verb, action.Resource, action.SubResource) 292 | if err != nil { 293 | err = fmt.Errorf("resolving resource: %v", err) 294 | return 295 | } 296 | klog.V(3).Infof("Resolved resource `%s`", resolvedAction.gr.String()) 297 | } 298 | 299 | // Get the Roles that relate to the Verbs and Resources we are interested in 300 | roleNames, err := w.getRolesFor(resolvedAction) 301 | if err != nil { 302 | return []rbac.RoleBinding{}, []rbac.ClusterRoleBinding{}, fmt.Errorf("getting Roles: %v", err) 303 | } 304 | 305 | // Get the ClusterRoles that relate to the verbs and resources we are interested in 306 | clusterRoleNames, err := w.getClusterRolesFor(resolvedAction) 307 | if err != nil { 308 | return []rbac.RoleBinding{}, []rbac.ClusterRoleBinding{}, fmt.Errorf("getting ClusterRoles: %v", err) 309 | } 310 | 311 | // Get the RoleBindings that relate to this set of Roles or ClusterRoles 312 | roleBindings, err = w.getRoleBindings(resolvedAction, roleNames, clusterRoleNames) 313 | if err != nil { 314 | err = fmt.Errorf("getting RoleBindings: %v", err) 315 | return 316 | } 317 | 318 | // Get the ClusterRoleBindings that relate to this set of ClusterRoles 319 | clusterRoleBindings, err = w.getClusterRoleBindings(clusterRoleNames) 320 | if err != nil { 321 | err = fmt.Errorf("getting ClusterRoleBindings: %v", err) 322 | return 323 | } 324 | 325 | return 326 | } 327 | 328 | // CheckAPIAccess checks whether the subject in the current context has enough privileges to query Kubernetes API 329 | // server to perform Check. 330 | func (w *WhoCan) CheckAPIAccess(action Action) ([]string, error) { 331 | type check struct { 332 | verb string 333 | resource string 334 | namespace string 335 | } 336 | 337 | var checks []check 338 | var warnings []string 339 | ctx := context.Background() 340 | 341 | // Determine which checks need to be executed. 342 | if action.Namespace == "" { 343 | checks = append(checks, check{"list", "namespaces", ""}) 344 | 345 | nsList, err := w.clientNamespace.List(ctx, metav1.ListOptions{}) 346 | if err != nil { 347 | return nil, fmt.Errorf("listing namespaces: %v", err) 348 | } 349 | for _, ns := range nsList.Items { 350 | checks = append(checks, check{"list", "roles", ns.Name}) 351 | checks = append(checks, check{"list", "rolebindings", ns.Name}) 352 | } 353 | } else { 354 | checks = append(checks, check{"list", "roles", action.Namespace}) 355 | checks = append(checks, check{"list", "rolebindings", action.Namespace}) 356 | } 357 | 358 | // Actually run the checks and collect warnings. 359 | for _, check := range checks { 360 | allowed, err := w.accessChecker.IsAllowedTo(check.verb, check.resource, check.namespace) 361 | if err != nil { 362 | return nil, err 363 | } 364 | if !allowed { 365 | var msg string 366 | 367 | if check.namespace == "" { 368 | msg = fmt.Sprintf("The user is not allowed to %s %s", check.verb, check.resource) 369 | } else { 370 | msg = fmt.Sprintf("The user is not allowed to %s %s in the %s namespace", check.verb, check.resource, check.namespace) 371 | } 372 | 373 | warnings = append(warnings, msg) 374 | } 375 | } 376 | 377 | return warnings, nil 378 | } 379 | 380 | // GetRolesFor returns a set of names of Roles matching the specified Action. 381 | func (w *WhoCan) getRolesFor(action resolvedAction) (roles, error) { 382 | ctx := context.Background() 383 | rl, err := w.clientRBAC.Roles(action.Namespace).List(ctx, metav1.ListOptions{}) 384 | if err != nil { 385 | return nil, err 386 | } 387 | 388 | roleNames := make(map[string]struct{}, 10) 389 | 390 | for _, item := range rl.Items { 391 | if w.policyRuleMatcher.MatchesRole(item, action) { 392 | if _, ok := roleNames[item.Name]; !ok { 393 | roleNames[item.Name] = struct{}{} 394 | } 395 | } 396 | } 397 | 398 | return roleNames, nil 399 | } 400 | 401 | // GetClusterRolesFor returns a set of names of ClusterRoles matching the specified Action. 402 | func (w *WhoCan) getClusterRolesFor(action resolvedAction) (clusterRoles, error) { 403 | ctx := context.Background() 404 | crl, err := w.clientRBAC.ClusterRoles().List(ctx, metav1.ListOptions{}) 405 | if err != nil { 406 | return nil, err 407 | } 408 | 409 | cr := make(map[string]struct{}, 10) 410 | 411 | for _, item := range crl.Items { 412 | if w.policyRuleMatcher.MatchesClusterRole(item, action) { 413 | if _, ok := cr[item.Name]; !ok { 414 | cr[item.Name] = struct{}{} 415 | } 416 | } 417 | } 418 | return cr, nil 419 | } 420 | 421 | // GetRoleBindings returns the RoleBindings that refer to the given set of Role names or ClusterRole names. 422 | func (w *WhoCan) getRoleBindings(action resolvedAction, roleNames roles, clusterRoleNames clusterRoles) (roleBindings []rbac.RoleBinding, err error) { 423 | // TODO I'm wondering if GetRoleBindings should be invoked at all when the --all-namespaces flag is specified? 424 | if action.Namespace == core.NamespaceAll { 425 | return 426 | } 427 | ctx := context.Background() 428 | list, err := w.clientRBAC.RoleBindings(action.Namespace).List(ctx, metav1.ListOptions{}) 429 | if err != nil { 430 | return 431 | } 432 | 433 | for _, roleBinding := range list.Items { 434 | if roleBinding.RoleRef.Kind == RoleKind { 435 | if _, ok := roleNames[roleBinding.RoleRef.Name]; ok { 436 | roleBindings = append(roleBindings, roleBinding) 437 | } 438 | } else if roleBinding.RoleRef.Kind == ClusterRoleKind { 439 | if _, ok := clusterRoleNames[roleBinding.RoleRef.Name]; ok { 440 | roleBindings = append(roleBindings, roleBinding) 441 | } 442 | } 443 | } 444 | 445 | return 446 | } 447 | 448 | // GetClusterRoleBindings returns the ClusterRoleBindings that refer to the given sef of ClusterRole names. 449 | func (w *WhoCan) getClusterRoleBindings(clusterRoleNames clusterRoles) (clusterRoleBindings []rbac.ClusterRoleBinding, err error) { 450 | ctx := context.Background() 451 | list, err := w.clientRBAC.ClusterRoleBindings().List(ctx, metav1.ListOptions{}) 452 | if err != nil { 453 | return 454 | } 455 | 456 | for _, roleBinding := range list.Items { 457 | if _, ok := clusterRoleNames[roleBinding.RoleRef.Name]; ok { 458 | clusterRoleBindings = append(clusterRoleBindings, roleBinding) 459 | } 460 | } 461 | 462 | return 463 | } 464 | 465 | func (w Action) String() string { 466 | if w.NonResourceURL != "" { 467 | return fmt.Sprintf("%s %s", w.Verb, w.NonResourceURL) 468 | } 469 | name := w.ResourceName 470 | if name != "" { 471 | name = "/" + name 472 | } 473 | return fmt.Sprintf("%s %s%s", w.Verb, w.Resource, name) 474 | } 475 | -------------------------------------------------------------------------------- /pkg/cmd/list_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/spf13/pflag" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/mock" 10 | "github.com/stretchr/testify/require" 11 | core "k8s.io/api/core/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/runtime" 14 | "k8s.io/apimachinery/pkg/runtime/schema" 15 | "k8s.io/client-go/kubernetes/fake" 16 | clientTesting "k8s.io/client-go/testing" 17 | "k8s.io/client-go/tools/clientcmd" 18 | 19 | rbac "k8s.io/api/rbac/v1" 20 | ) 21 | 22 | type accessCheckerMock struct { 23 | mock.Mock 24 | } 25 | 26 | func (m *accessCheckerMock) IsAllowedTo(verb, resource, namespace string) (bool, error) { 27 | args := m.Called(verb, resource, namespace) 28 | return args.Bool(0), args.Error(1) 29 | } 30 | 31 | type namespaceValidatorMock struct { 32 | mock.Mock 33 | } 34 | 35 | func (w *namespaceValidatorMock) Validate(name string) error { 36 | args := w.Called(name) 37 | return args.Error(0) 38 | } 39 | 40 | type resourceResolverMock struct { 41 | mock.Mock 42 | } 43 | 44 | func (r *resourceResolverMock) Resolve(verb, resource, subResource string) (schema.GroupResource, error) { 45 | args := r.Called(verb, resource, subResource) 46 | return args.Get(0).(schema.GroupResource), args.Error(1) 47 | } 48 | 49 | type clientConfigMock struct { 50 | mock.Mock 51 | clientcmd.DirectClientConfig 52 | } 53 | 54 | func (cc *clientConfigMock) Namespace() (string, bool, error) { 55 | args := cc.Called() 56 | return args.String(0), args.Bool(1), args.Error(2) 57 | } 58 | 59 | type policyRuleMatcherMock struct { 60 | mock.Mock 61 | } 62 | 63 | func (prm *policyRuleMatcherMock) MatchesRole(role rbac.Role, action resolvedAction) bool { 64 | args := prm.Called(role, action) 65 | return args.Bool(0) 66 | } 67 | 68 | func (prm *policyRuleMatcherMock) MatchesClusterRole(role rbac.ClusterRole, action resolvedAction) bool { 69 | args := prm.Called(role, action) 70 | return args.Bool(0) 71 | } 72 | 73 | func TestActionFrom(t *testing.T) { 74 | 75 | type currentContext struct { 76 | namespace string 77 | err error 78 | } 79 | 80 | type flags struct { 81 | subResource string 82 | namespace string 83 | allNamespaces bool 84 | } 85 | 86 | testCases := []struct { 87 | name string 88 | 89 | currentContext *currentContext 90 | flags flags 91 | args []string 92 | 93 | expectedAction Action 94 | expectedError error 95 | }{ 96 | { 97 | name: "A", 98 | currentContext: ¤tContext{namespace: "foo"}, 99 | args: []string{"list", "pods"}, 100 | flags: flags{namespace: "", allNamespaces: false}, 101 | expectedAction: Action{ 102 | Namespace: "foo", 103 | Verb: "list", 104 | Resource: "pods", 105 | ResourceName: "", 106 | }, 107 | }, 108 | { 109 | name: "B", 110 | currentContext: ¤tContext{err: errors.New("cannot open context")}, 111 | flags: flags{namespace: "", allNamespaces: false}, 112 | args: []string{"list", "pods"}, 113 | expectedAction: Action{ 114 | Namespace: "", 115 | Verb: "list", 116 | Resource: "pods", 117 | ResourceName: "", 118 | }, 119 | expectedError: errors.New("getting namespace from current context: cannot open context"), 120 | }, 121 | { 122 | name: "C", 123 | flags: flags{namespace: "", allNamespaces: true}, 124 | args: []string{"get", "service/mongodb"}, 125 | expectedAction: Action{ 126 | AllNamespaces: true, 127 | Namespace: core.NamespaceAll, 128 | Verb: "get", 129 | Resource: "service", 130 | ResourceName: "mongodb", 131 | }, 132 | }, 133 | { 134 | name: "D", 135 | flags: flags{namespace: "bar", allNamespaces: false}, 136 | args: []string{"delete", "pv"}, 137 | expectedAction: Action{ 138 | Namespace: "bar", 139 | Verb: "delete", 140 | Resource: "pv", 141 | }, 142 | }, 143 | { 144 | name: "F", 145 | flags: flags{namespace: "foo"}, 146 | args: []string{"get", "/logs"}, 147 | expectedAction: Action{ 148 | Namespace: "foo", 149 | Verb: "get", 150 | NonResourceURL: "/logs", 151 | }, 152 | }, 153 | { 154 | name: "G", 155 | args: []string{}, 156 | expectedError: errors.New("you must specify two or three arguments: verb, resource, and optional resourceName"), 157 | }, 158 | } 159 | 160 | for _, tt := range testCases { 161 | t.Run(tt.name, func(t *testing.T) { 162 | //setup 163 | 164 | clientConfig := new(clientConfigMock) 165 | 166 | if tt.currentContext != nil { 167 | clientConfig.On("Namespace").Return(tt.currentContext.namespace, false, tt.currentContext.err) 168 | } 169 | 170 | flags := pflag.NewFlagSet("test", pflag.ContinueOnError) 171 | flags.String(namespaceFlag, tt.flags.namespace, "") 172 | flags.Bool(allNamespacesFlag, tt.flags.allNamespaces, "") 173 | flags.String(subResourceFlag, "", "") 174 | 175 | // when 176 | o, err := ActionFrom(clientConfig, flags, tt.args) 177 | 178 | // then 179 | assert.Equal(t, tt.expectedError, err) 180 | assert.Equal(t, tt.expectedAction, o) 181 | 182 | clientConfig.AssertExpectations(t) 183 | }) 184 | 185 | } 186 | 187 | } 188 | 189 | func TestValidate(t *testing.T) { 190 | type namespaceValidation struct { 191 | returnedError error 192 | } 193 | 194 | data := []struct { 195 | scenario string 196 | 197 | nonResourceURL string 198 | subResource string 199 | namespace string 200 | 201 | *namespaceValidation 202 | 203 | expectedErr error 204 | }{ 205 | { 206 | scenario: "Should return nil when namespace is valid", 207 | namespace: "foo", 208 | namespaceValidation: &namespaceValidation{returnedError: nil}, 209 | }, 210 | { 211 | scenario: "Should return error when namespace does not exist", 212 | namespace: "bar", 213 | namespaceValidation: &namespaceValidation{returnedError: errors.New("\"bar\" not found")}, 214 | expectedErr: errors.New("validating namespace: \"bar\" not found"), 215 | }, 216 | { 217 | scenario: "Should return error when --subresource flag is used with non-resource URL", 218 | nonResourceURL: "/api", 219 | subResource: "logs", 220 | expectedErr: errors.New("--subresource cannot be used with NONRESOURCEURL"), 221 | }, 222 | } 223 | 224 | for _, tt := range data { 225 | t.Run(tt.scenario, func(t *testing.T) { 226 | // given 227 | namespaceValidator := new(namespaceValidatorMock) 228 | if tt.namespaceValidation != nil { 229 | namespaceValidator.On("Validate", tt.namespace). 230 | Return(tt.namespaceValidation.returnedError) 231 | } 232 | 233 | o := &WhoCan{ 234 | namespaceValidator: namespaceValidator, 235 | } 236 | 237 | action := Action{ 238 | NonResourceURL: tt.nonResourceURL, 239 | SubResource: tt.subResource, 240 | Namespace: tt.namespace, 241 | } 242 | 243 | // when 244 | err := o.validate(action) 245 | 246 | // then 247 | assert.Equal(t, tt.expectedErr, err) 248 | namespaceValidator.AssertExpectations(t) 249 | }) 250 | } 251 | } 252 | 253 | func TestWhoCan_CheckAPIAccess(t *testing.T) { 254 | const ( 255 | FooNs = "foo" 256 | BarNs = "bar" 257 | ) 258 | 259 | type permission struct { 260 | verb string 261 | resource string 262 | namespace string 263 | allowed bool 264 | } 265 | 266 | client := fake.NewSimpleClientset() 267 | client.Fake.PrependReactor("list", "namespaces", func(action clientTesting.Action) (handled bool, ret runtime.Object, err error) { 268 | list := &core.NamespaceList{ 269 | Items: []core.Namespace{ 270 | { 271 | ObjectMeta: metav1.ObjectMeta{Name: FooNs}, 272 | }, 273 | { 274 | ObjectMeta: metav1.ObjectMeta{Name: BarNs}, 275 | }, 276 | }, 277 | } 278 | 279 | return true, list, nil 280 | }) 281 | 282 | data := []struct { 283 | scenario string 284 | namespace string 285 | permissions []permission 286 | 287 | expectedWarnings []string 288 | expectedError error 289 | }{ 290 | { 291 | scenario: "A", 292 | namespace: core.NamespaceAll, 293 | permissions: []permission{ 294 | // Permissions to list all namespaces 295 | {verb: "list", resource: "namespaces", namespace: core.NamespaceAll, allowed: false}, 296 | // Permissions in the foo namespace 297 | {verb: "list", resource: "roles", namespace: FooNs, allowed: true}, 298 | {verb: "list", resource: "rolebindings", namespace: FooNs, allowed: true}, 299 | // Permissions in the bar namespace 300 | {verb: "list", resource: "roles", namespace: BarNs, allowed: false}, 301 | {verb: "list", resource: "rolebindings", namespace: BarNs, allowed: false}, 302 | }, 303 | expectedWarnings: []string{ 304 | "The user is not allowed to list namespaces", 305 | "The user is not allowed to list roles in the bar namespace", 306 | "The user is not allowed to list rolebindings in the bar namespace", 307 | }, 308 | }, 309 | { 310 | scenario: "B", 311 | namespace: FooNs, 312 | permissions: []permission{ 313 | // Permissions in the foo namespace 314 | {verb: "list", resource: "roles", namespace: FooNs, allowed: true}, 315 | {verb: "list", resource: "rolebindings", namespace: FooNs, allowed: false}, 316 | }, 317 | expectedWarnings: []string{ 318 | "The user is not allowed to list rolebindings in the foo namespace", 319 | }, 320 | }, 321 | } 322 | 323 | for _, tt := range data { 324 | t.Run(tt.scenario, func(t *testing.T) { 325 | // setup 326 | namespaceValidator := new(namespaceValidatorMock) 327 | resourceResolver := new(resourceResolverMock) 328 | accessChecker := new(accessCheckerMock) 329 | policyRuleMatcher := new(policyRuleMatcherMock) 330 | for _, prm := range tt.permissions { 331 | accessChecker.On("IsAllowedTo", prm.verb, prm.resource, prm.namespace). 332 | Return(prm.allowed, nil) 333 | } 334 | 335 | // given 336 | wc := WhoCan{ 337 | clientNamespace: client.CoreV1().Namespaces(), 338 | clientRBAC: client.RbacV1(), 339 | namespaceValidator: namespaceValidator, 340 | resourceResolver: resourceResolver, 341 | accessChecker: accessChecker, 342 | policyRuleMatcher: policyRuleMatcher, 343 | } 344 | action := Action{ 345 | Namespace: tt.namespace, 346 | } 347 | 348 | // when 349 | warnings, err := wc.CheckAPIAccess(action) 350 | 351 | // then 352 | assert.Equal(t, tt.expectedError, err) 353 | assert.Equal(t, tt.expectedWarnings, warnings) 354 | 355 | accessChecker.AssertExpectations(t) 356 | }) 357 | } 358 | 359 | } 360 | 361 | func TestWhoCan_GetRolesFor(t *testing.T) { 362 | // given 363 | policyRuleMatcher := new(policyRuleMatcherMock) 364 | client := fake.NewSimpleClientset() 365 | 366 | action := resolvedAction{Action: Action{Verb: "list", Resource: "services"}} 367 | 368 | viewServicesRole := rbac.Role{ 369 | ObjectMeta: metav1.ObjectMeta{ 370 | Name: "view-services", 371 | }, 372 | Rules: []rbac.PolicyRule{ 373 | { 374 | Verbs: []string{"get", "list"}, 375 | Resources: []string{"services"}, 376 | }, 377 | }, 378 | } 379 | 380 | viewPodsRole := rbac.Role{ 381 | ObjectMeta: metav1.ObjectMeta{ 382 | Name: "view-pods", 383 | }, 384 | Rules: []rbac.PolicyRule{ 385 | { 386 | Verbs: []string{"get", "list"}, 387 | Resources: []string{"services"}, 388 | }, 389 | }, 390 | } 391 | 392 | client.Fake.PrependReactor("list", "roles", func(action clientTesting.Action) (handled bool, ret runtime.Object, err error) { 393 | list := &rbac.RoleList{ 394 | Items: []rbac.Role{ 395 | viewServicesRole, 396 | viewPodsRole, 397 | }, 398 | } 399 | 400 | return true, list, nil 401 | }) 402 | 403 | policyRuleMatcher.On("MatchesRole", viewServicesRole, action).Return(true) 404 | policyRuleMatcher.On("MatchesRole", viewPodsRole, action).Return(false) 405 | 406 | wc := WhoCan{ 407 | clientRBAC: client.RbacV1(), 408 | policyRuleMatcher: policyRuleMatcher, 409 | } 410 | 411 | // when 412 | names, err := wc.getRolesFor(action) 413 | 414 | // then 415 | require.NoError(t, err) 416 | assert.EqualValues(t, map[string]struct{}{"view-services": {}}, names) 417 | policyRuleMatcher.AssertExpectations(t) 418 | } 419 | 420 | func TestWhoCan_GetClusterRolesFor(t *testing.T) { 421 | // given 422 | policyRuleMatcher := new(policyRuleMatcherMock) 423 | client := fake.NewSimpleClientset() 424 | 425 | action := resolvedAction{Action: Action{Verb: "get", Resource: "/logs"}} 426 | 427 | getLogsRole := rbac.ClusterRole{ 428 | ObjectMeta: metav1.ObjectMeta{ 429 | Name: "get-logs", 430 | }, 431 | Rules: []rbac.PolicyRule{ 432 | { 433 | Verbs: []string{"get"}, 434 | NonResourceURLs: []string{"/logs"}, 435 | }, 436 | }, 437 | } 438 | 439 | getApiRole := rbac.ClusterRole{ 440 | ObjectMeta: metav1.ObjectMeta{ 441 | Name: "get-api", 442 | }, 443 | Rules: []rbac.PolicyRule{ 444 | { 445 | Verbs: []string{"get"}, 446 | NonResourceURLs: []string{"/api"}, 447 | }, 448 | }, 449 | } 450 | 451 | client.Fake.PrependReactor("list", "clusterroles", func(action clientTesting.Action) (handled bool, ret runtime.Object, err error) { 452 | list := &rbac.ClusterRoleList{ 453 | Items: []rbac.ClusterRole{ 454 | getLogsRole, 455 | getApiRole, 456 | }, 457 | } 458 | 459 | return true, list, nil 460 | }) 461 | 462 | policyRuleMatcher.On("MatchesClusterRole", getLogsRole, action).Return(false) 463 | policyRuleMatcher.On("MatchesClusterRole", getApiRole, action).Return(true) 464 | 465 | wc := WhoCan{ 466 | clientRBAC: client.RbacV1(), 467 | policyRuleMatcher: policyRuleMatcher, 468 | } 469 | 470 | // when 471 | names, err := wc.getClusterRolesFor(action) 472 | 473 | // then 474 | require.NoError(t, err) 475 | assert.EqualValues(t, map[string]struct{}{"get-api": {}}, names) 476 | policyRuleMatcher.AssertExpectations(t) 477 | } 478 | 479 | func TestWhoCan_GetRoleBindings(t *testing.T) { 480 | client := fake.NewSimpleClientset() 481 | 482 | namespace := "foo" 483 | roleNames := map[string]struct{}{"view-pods": {}} 484 | clusterRoleNames := map[string]struct{}{"view-configmaps": {}} 485 | 486 | viewPodsBnd := rbac.RoleBinding{ 487 | ObjectMeta: metav1.ObjectMeta{ 488 | Name: "view-pods-bnd", 489 | Namespace: namespace, 490 | }, 491 | RoleRef: rbac.RoleRef{ 492 | Kind: "Role", 493 | Name: "view-pods", 494 | }, 495 | } 496 | 497 | viewConfigMapsBnd := rbac.RoleBinding{ 498 | ObjectMeta: metav1.ObjectMeta{ 499 | Name: "view-configmaps-bnd", 500 | }, 501 | RoleRef: rbac.RoleRef{ 502 | Kind: "ClusterRole", 503 | Name: "view-configmaps", 504 | }, 505 | } 506 | 507 | client.Fake.PrependReactor("list", "rolebindings", func(action clientTesting.Action) (handled bool, ret runtime.Object, err error) { 508 | list := &rbac.RoleBindingList{ 509 | Items: []rbac.RoleBinding{ 510 | viewPodsBnd, 511 | viewConfigMapsBnd, 512 | }, 513 | } 514 | 515 | return true, list, nil 516 | }) 517 | 518 | wc := WhoCan{ 519 | clientRBAC: client.RbacV1(), 520 | } 521 | action := resolvedAction{Action: Action{Namespace: namespace}} 522 | 523 | // when 524 | bindings, err := wc.getRoleBindings(action, roleNames, clusterRoleNames) 525 | 526 | // then 527 | require.NoError(t, err) 528 | assert.Equal(t, 2, len(bindings)) 529 | assert.Contains(t, bindings, viewPodsBnd) 530 | assert.Contains(t, bindings, viewConfigMapsBnd) 531 | } 532 | 533 | func TestWhoCan_GetClusterRoleBindings(t *testing.T) { 534 | client := fake.NewSimpleClientset() 535 | clusterRoleNames := map[string]struct{}{"get-healthz": {}} 536 | 537 | getLogsBnd := rbac.ClusterRoleBinding{ 538 | ObjectMeta: metav1.ObjectMeta{ 539 | Name: "get-logs-bnd", 540 | }, 541 | RoleRef: rbac.RoleRef{ 542 | Kind: "ClusterRoleBinding", 543 | Name: "get-logs", 544 | }, 545 | } 546 | 547 | getHealthzBnd := rbac.ClusterRoleBinding{ 548 | ObjectMeta: metav1.ObjectMeta{ 549 | Name: "get-healthz-bnd", 550 | }, 551 | RoleRef: rbac.RoleRef{ 552 | Kind: "ClusterRoleBinding", 553 | Name: "get-healthz", 554 | }, 555 | } 556 | 557 | client.Fake.PrependReactor("list", "clusterrolebindings", func(action clientTesting.Action) (handled bool, ret runtime.Object, err error) { 558 | list := &rbac.ClusterRoleBindingList{ 559 | Items: []rbac.ClusterRoleBinding{ 560 | getLogsBnd, 561 | getHealthzBnd, 562 | }, 563 | } 564 | 565 | return true, list, nil 566 | }) 567 | 568 | wc := WhoCan{ 569 | clientRBAC: client.RbacV1(), 570 | } 571 | 572 | // when 573 | bindings, err := wc.getClusterRoleBindings(clusterRoleNames) 574 | 575 | // then 576 | require.NoError(t, err) 577 | assert.Equal(t, 1, len(bindings)) 578 | assert.Contains(t, bindings, getHealthzBnd) 579 | } 580 | --------------------------------------------------------------------------------