Skip to content

Commit

Permalink
feat: Resolving API groups (#40)
Browse files Browse the repository at this point in the history
* test: Add integration test scenario for resolving API group

* feat: Resolve resource API groups

Kubernetes assigns resources to API grups. So you can have pods
in the core group as well as pods in the metrics.k8s.io groups.
With the changes introduced the plugin allows you to specify the
fully qualified resoruce name at the command line, for example:

$ kubect who-can get pods
$ kubect who-can get pods.metrics.k8s.io

Resolves: #35, #38
  • Loading branch information
danielpacak authored and lizrice committed Jul 8, 2019
1 parent 01427bc commit 5ab9e06
Show file tree
Hide file tree
Showing 13 changed files with 590 additions and 180 deletions.
18 changes: 0 additions & 18 deletions .github/main.workflow

This file was deleted.

9 changes: 6 additions & 3 deletions README.md
Expand Up @@ -6,9 +6,9 @@

# kubectl-who-can

Shows who has permissions to VERB [TYPE | TYPE/NAME | NONRESOURCEURL] in Kubernetes.
Shows which subjects have RBAC permissions to VERB [TYPE | TYPE/NAME | NONRESOURCEURL] in Kubernetes.

[![asciicast](https://asciinema.org/a/ccqqYwA5L5rMV9kd1tgzyZJ2j.svg)](https://asciinema.org/a/ccqqYwA5L5rMV9kd1tgzyZJ2j)
[![asciicast][asciicast-img]][asciicast]

## Installation

Expand All @@ -31,7 +31,7 @@ Download a release distribution archive for your operating system, extract it, a
executable to your `$PATH`. For example, to manually install `kubectl-who-can` on macOS run the following command:

```
VERSION="v0.1.0-alpha.1"
VERSION=`git describe --abbrev=0`
mkdir -p /tmp/who-can/$VERSION && \
curl -L https://github.com/aquasecurity/kubectl-who-can/releases/download/$VERSION/kubectl-who-can_darwin_x86_64.tar.gz \
Expand Down Expand Up @@ -83,3 +83,6 @@ The `kubectl-who-can` binary will be in `/usr/local/bin`.

[license-img]: https://img.shields.io/github/license/aquasecurity/kubectl-who-can.svg
[license]: https://github.com/aquasecurity/kubectl-who-can/blob/master/LICENSE

[asciicast-img]: https://asciinema.org/a/ccqqYwA5L5rMV9kd1tgzyZJ2j.svg
[asciicast]: https://asciinema.org/a/ccqqYwA5L5rMV9kd1tgzyZJ2j
10 changes: 5 additions & 5 deletions go.mod
Expand Up @@ -4,11 +4,11 @@ go 1.12

require (
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b
github.com/spf13/cobra v0.0.0-20180319062004-c439c4fa0937
github.com/stretchr/objx v0.2.0 // indirect
github.com/spf13/cobra v0.0.4
github.com/stretchr/testify v1.3.0
k8s.io/api v0.0.0-20190612125737-db0771252981
k8s.io/apimachinery v0.0.0-20190612125636-6a5db36e93ad
k8s.io/api v0.0.0-20190703205437-39734b2a72fe
k8s.io/apiextensions-apiserver v0.0.0-20190704050600-357b4270afe4
k8s.io/apimachinery v0.0.0-20190703205208-4cfb76a8bf76
k8s.io/cli-runtime v0.0.0-20190612131021-ced92c4c4749
k8s.io/client-go v0.0.0-20190612125919-5c45477a8ae7
k8s.io/client-go v0.0.0-20190704045512-07281898b0f0
)
181 changes: 181 additions & 0 deletions go.sum

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pkg/cmd/access_checker.go
Expand Up @@ -17,6 +17,7 @@ type accessChecker struct {
client clientauthz.SelfSubjectAccessReviewInterface
}

// NewAccessChecker constructs the default AccessChecker.
func NewAccessChecker(client clientauthz.SelfSubjectAccessReviewInterface) AccessChecker {
return &accessChecker{
client: client,
Expand Down
10 changes: 8 additions & 2 deletions pkg/cmd/list.go
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"github.com/spf13/cobra"
core "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
clioptions "k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/kubernetes"
clientcore "k8s.io/client-go/kubernetes/typed/core/v1"
Expand All @@ -24,14 +25,18 @@ const (
whoCanLong = `Shows which users, groups and service accounts can perform a given verb on a given resource type.
VERB is a logical Kubernetes API verb like 'get', 'list', 'watch', 'delete', etc.
TYPE is a Kubernetes resource. Shortcuts, such as 'pod' or 'po' will be resolved. NAME is the name of a particular Kubernetes resource.
TYPE is a Kubernetes resource. Shortcuts and API groups will be resolved, e.g. 'po' or 'pods.metrics.k8s.io'.
NAME is the name of a particular Kubernetes resource.
NONRESOURCEURL is a partial URL that starts with "/".`
whoCanExample = ` # List who can get pods in any namespace
kubectl who-can get pods --all-namespaces
# List who can create pods in the current namespace
kubectl who-can create pods
# List who can get pods specifying the API group
kubectl who-can get pods.metrics.k8s.io
# List who can create services in namespace "foo"
kubectl who-can create services -n foo
Expand Down Expand Up @@ -63,6 +68,7 @@ type Action struct {
nonResourceURL string
subResource string
resourceName string
gr schema.GroupResource

namespace string
allNamespaces bool
Expand Down Expand Up @@ -186,7 +192,7 @@ func (w *whoCan) Complete(args []string) error {
}

if w.resource != "" {
w.resource, err = w.resourceResolver.Resolve(w.verb, w.resource, w.subResource)
w.gr, err = w.resourceResolver.Resolve(w.verb, w.resource, w.subResource)
if err != nil {
return fmt.Errorf("resolving resource: %v", err)
}
Expand Down
31 changes: 16 additions & 15 deletions pkg/cmd/list_test.go
Expand Up @@ -9,6 +9,7 @@ import (
core "k8s.io/api/core/v1"
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
clioptions "k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/kubernetes/fake"
clientTesting "k8s.io/client-go/testing"
Expand Down Expand Up @@ -40,9 +41,9 @@ type resourceResolverMock struct {
mock.Mock
}

func (r *resourceResolverMock) Resolve(verb, resource, subResource string) (string, error) {
func (r *resourceResolverMock) Resolve(verb, resource, subResource string) (schema.GroupResource, error) {
args := r.Called(verb, resource, subResource)
return args.String(0), args.Error(1)
return args.Get(0).(schema.GroupResource), args.Error(1)
}

type clientConfigMock struct {
Expand Down Expand Up @@ -91,8 +92,8 @@ func TestComplete(t *testing.T) {
resource string
subResource string

result string
err error
gr schema.GroupResource
err error
}

type expected struct {
Expand All @@ -106,20 +107,20 @@ func TestComplete(t *testing.T) {
data := []struct {
scenario string

*currentContext
currentContext *currentContext

flags flags
args []string
*resolution
flags flags
args []string
resolution *resolution

expected
expected expected
}{
{
scenario: "A",
currentContext: &currentContext{namespace: "foo"},
flags: flags{namespace: "", allNamespaces: false},
args: []string{"list", "pods"},
resolution: &resolution{verb: "list", resource: "pods", result: "pods"},
resolution: &resolution{verb: "list", resource: "pods", gr: schema.GroupResource{Resource: "pods"}},
expected: expected{
namespace: "foo",
verb: "list",
Expand All @@ -132,7 +133,7 @@ func TestComplete(t *testing.T) {
currentContext: &currentContext{err: errors.New("cannot open context")},
flags: flags{namespace: "", allNamespaces: false},
args: []string{"list", "pods"},
resolution: &resolution{verb: "list", resource: "pods", result: "pods"},
resolution: &resolution{verb: "list", resource: "pods", gr: schema.GroupResource{Resource: "pods"}},
expected: expected{
namespace: "",
verb: "list",
Expand All @@ -145,7 +146,7 @@ func TestComplete(t *testing.T) {
scenario: "C",
flags: flags{namespace: "", allNamespaces: true},
args: []string{"get", "service/mongodb"},
resolution: &resolution{verb: "get", resource: "service", result: "services"},
resolution: &resolution{verb: "get", resource: "service", gr: schema.GroupResource{Resource: "services"}},
expected: expected{
namespace: core.NamespaceAll,
verb: "get",
Expand All @@ -157,7 +158,7 @@ func TestComplete(t *testing.T) {
scenario: "D",
flags: flags{namespace: "bar", allNamespaces: false},
args: []string{"delete", "pv"},
resolution: &resolution{verb: "delete", resource: "pv", result: "persistentvolumes"},
resolution: &resolution{verb: "delete", resource: "pv", gr: schema.GroupResource{Resource: "persistentvolumes"}},
expected: expected{
namespace: "bar",
verb: "delete",
Expand Down Expand Up @@ -211,7 +212,7 @@ func TestComplete(t *testing.T) {

if tt.resolution != nil {
resourceResolver.On("Resolve", tt.resolution.verb, tt.resolution.resource, tt.resolution.subResource).
Return(tt.resolution.result, tt.resolution.err)
Return(tt.resolution.gr, tt.resolution.err)
}
if tt.currentContext != nil {
clientConfig.On("Namespace").Return(tt.currentContext.namespace, false, tt.currentContext.err)
Expand Down Expand Up @@ -239,7 +240,7 @@ func TestComplete(t *testing.T) {
assert.Equal(t, tt.expected.err, err)
assert.Equal(t, tt.expected.namespace, o.namespace)
assert.Equal(t, tt.expected.verb, o.verb)
assert.Equal(t, tt.expected.resource, o.resource)
assert.Equal(t, tt.expected.resource, o.gr.Resource)
assert.Equal(t, tt.expected.resourceName, o.resourceName)

clientConfig.AssertExpectations(t)
Expand Down
5 changes: 5 additions & 0 deletions pkg/cmd/namespace_validator.go
Expand Up @@ -8,6 +8,10 @@ import (
clientcore "k8s.io/client-go/kubernetes/typed/core/v1"
)

// NamespaceValidator wraps the Validate method.
//
// Validate checks whether the given namespace exists or not.
// Returns nil if it exists, an error otherwise.
type NamespaceValidator interface {
Validate(name string) error
}
Expand All @@ -16,6 +20,7 @@ type namespaceValidator struct {
client clientcore.NamespaceInterface
}

// NewNamespaceValidator constructs the default NamespaceValidator.
func NewNamespaceValidator(client clientcore.NamespaceInterface) NamespaceValidator {
return &namespaceValidator{
client: client,
Expand Down
22 changes: 19 additions & 3 deletions pkg/cmd/policy_rule_matcher.go
Expand Up @@ -8,7 +8,8 @@ import (
// PolicyRuleMatcher wraps the Matches* methods.
//
// MatchesRole returns `true` if any PolicyRule defined by the given Role matches the specified Action, `false` otherwise.
// MatchesClusterRole returns `true` if any PolicyRule defined by the given ClusterRole matches the specified Action, `false` otherwise.
//
// MatchesClusterRole returns `true` if any PolicyRule defined by the given ClusterRole matches the specified Action, `false` otherwise.
type PolicyRuleMatcher interface {
MatchesRole(role rbac.Role, action Action) bool
MatchesClusterRole(role rbac.ClusterRole, action Action) bool
Expand All @@ -17,7 +18,7 @@ type PolicyRuleMatcher interface {
type matcher struct {
}

// NewPolicyRuleMatcher constructs a PolicyRuleMatcher.
// NewPolicyRuleMatcher constructs the default PolicyRuleMatcher.
func NewPolicyRuleMatcher() PolicyRuleMatcher {
return &matcher{}
}
Expand Down Expand Up @@ -54,11 +55,26 @@ func (m *matcher) matches(rule rbac.PolicyRule, action Action) bool {
m.matchesNonResourceURL(rule, action.nonResourceURL)
}

resource := action.gr.Resource
if action.subResource != "" {
resource += "/" + action.subResource
}

return m.matchesVerb(rule, action.verb) &&
m.matchesResource(rule, action.resource) &&
m.matchesResource(rule, resource) &&
m.matchesAPIGroup(rule, action.gr.Group) &&
m.matchesResourceName(rule, action.resourceName)
}

func (m *matcher) matchesAPIGroup(rule rbac.PolicyRule, actionGroup string) bool {
for _, group := range rule.APIGroups {
if group == rbac.APIGroupAll || group == actionGroup {
return true
}
}
return false
}

func (m *matcher) matchesVerb(rule rbac.PolicyRule, actionVerb string) bool {
for _, verb := range rule.Verbs {
if verb == rbac.VerbAll || verb == actionVerb {
Expand Down

0 comments on commit 5ab9e06

Please sign in to comment.