Skip to content
This repository was archived by the owner on Nov 14, 2024. It is now read-only.

feat: kubernetes: check RBAC #6

Merged
merged 16 commits into from
Aug 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ linters-settings:
alias:
- pkg: k8s.io/api/core/(v[\w\d]+)
alias: core$1
- pkg: k8s.io/api/authorization/(v[\w\d]+)
alias: authorization$1

- pkg: k8s.io/apimachinery/pkg/apis/meta/(v[\w\d]+)
alias: meta$1
Expand All @@ -65,7 +67,7 @@ linters-settings:
- pkg: k8s.io/client-go/kubernetes/typed/authentication/(v[\w\d]+)
alias: authentication$1
- pkg: k8s.io/client-go/kubernetes/typed/authorization/(v[\w\d]+)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, it's kinda unfortunate that we have to import a bunch of these this way, but makes sense to me!

alias: authorization$1
alias: authorizationclient$1
- pkg: k8s.io/client-go/kubernetes/typed/autoscaling/(v[\w\d]+)
alias: autoscaling$1
- pkg: k8s.io/client-go/kubernetes/typed/batch/(v[\w\d]+)
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/Masterminds/semver/v3 v3.1.1
github.com/spf13/cobra v1.2.1
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1
k8s.io/api v0.19.14
k8s.io/apimachinery v0.19.14
k8s.io/client-go v0.19.14
k8s.io/klog/v2 v2.10.0 // indirect
Expand Down
11 changes: 11 additions & 0 deletions internal/api/pass.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package api

// PassResults returns a CheckResult when everything is OK.
func PassResult(name string, summary string) *CheckResult {
return &CheckResult{
Name: name,
State: StatePassed,
Summary: summary,
Details: map[string]interface{}{},
}
}
19 changes: 19 additions & 0 deletions internal/api/pass_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package api_test

import (
"testing"

"cdr.dev/slog/sloggers/slogtest/assert"
"github.com/cdr/coder-doctor/internal/api"
)

func TestPassResult(t *testing.T) {
t.Parallel()

res := api.PassResult("check-name", "check succeeded")

assert.Equal(t, "name matches", "check-name", res.Name)
assert.Equal(t, "state matches", api.StatePassed, res.State)
assert.Equal(t, "summary matches", "check succeeded", res.Summary)
assert.Equal(t, "error matches", nil, res.Details["error"])
}
20 changes: 17 additions & 3 deletions internal/checks/kube/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
var _ = api.Checker(&KubernetesChecker{})

type KubernetesChecker struct {
namespace string
client kubernetes.Interface
writer api.ResultWriter
coderVersion *semver.Version
Expand All @@ -27,9 +28,10 @@ type Option func(k *KubernetesChecker)

func NewKubernetesChecker(client kubernetes.Interface, opts ...Option) *KubernetesChecker {
checker := &KubernetesChecker{
client: client,
log: slog.Make(sloghuman.Sink(io.Discard)),
writer: &api.DiscardWriter{},
namespace: "default",
client: client,
log: slog.Make(sloghuman.Sink(io.Discard)),
writer: &api.DiscardWriter{},
// Select the newest version by default
coderVersion: semver.MustParse("100.0.0"),
}
Expand Down Expand Up @@ -59,6 +61,12 @@ func WithLogger(log slog.Logger) Option {
}
}

func WithNamespace(ns string) Option {
return func(k *KubernetesChecker) {
k.namespace = ns
}
}

func (*KubernetesChecker) Validate() error {
return nil
}
Expand All @@ -68,5 +76,11 @@ func (k *KubernetesChecker) Run(ctx context.Context) error {
if err != nil {
return xerrors.Errorf("check version: %w", err)
}

for _, res := range k.CheckRBAC(ctx) {
if err := k.writer.WriteResult(res); err != nil {
return xerrors.Errorf("check RBAC: %w", err)
}
}
return nil
}
129 changes: 129 additions & 0 deletions internal/checks/kube/rbac.go
Original file line number Diff line number Diff line change
@@ -1 +1,130 @@
package kube

import (
"context"
"fmt"
"strings"

"golang.org/x/xerrors"

"github.com/Masterminds/semver/v3"

"github.com/cdr/coder-doctor/internal/api"

authorizationv1 "k8s.io/api/authorization/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
authorizationclientv1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
)

type RBACRequirement struct {
APIGroup string
Resource string
Verbs []string
}

type VersionedRBACRequirements struct {
VersionConstraints *semver.Constraints
RBACRequirements []*RBACRequirement
}

var verbsCreateDeleteList = []string{"create", "delete", "list"}

func NewRBACRequirement(apiGroup, resource string, verbs ...string) *RBACRequirement {
return &RBACRequirement{
APIGroup: apiGroup,
Resource: resource,
Verbs: verbs,
}
}

var allVersionedRBACRequirements = []VersionedRBACRequirements{
{
VersionConstraints: api.MustConstraint(">= 1.20"),
RBACRequirements: []*RBACRequirement{
NewRBACRequirement("", "pods", verbsCreateDeleteList...),
NewRBACRequirement("", "roles", verbsCreateDeleteList...),
NewRBACRequirement("", "rolebindings", verbsCreateDeleteList...),
NewRBACRequirement("", "secrets", verbsCreateDeleteList...),
NewRBACRequirement("", "serviceaccounts", verbsCreateDeleteList...),
NewRBACRequirement("", "services", verbsCreateDeleteList...),
NewRBACRequirement("apps", "deployments", verbsCreateDeleteList...),
NewRBACRequirement("apps", "replicasets", verbsCreateDeleteList...),
NewRBACRequirement("apps", "statefulsets", verbsCreateDeleteList...),
NewRBACRequirement("extensions", "ingresses", verbsCreateDeleteList...),
},
},
}

func (k *KubernetesChecker) CheckRBAC(ctx context.Context) []*api.CheckResult {
const checkName = "kubernetes-rbac"
authClient := k.client.AuthorizationV1()
rbacReqs := findClosestVersionRequirements(k.coderVersion)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not important to do now, can do this as another PR -- can we check this in the Validate step, so that these errors are impossible? The idea is that only server errors or genuine failures should cause FAIL results, bad input should be caught before anything runs (calling code should call Validate, check for errors, and then Run)

results := make([]*api.CheckResult, 0)
if rbacReqs == nil {
results = append(results,
api.ErrorResult(
checkName,
"unable to check RBAC requirements",
xerrors.Errorf("unhandled coder version: %s", k.coderVersion.String()),
),
)
return results
}

for _, req := range rbacReqs.RBACRequirements {
resName := fmt.Sprintf("%s-%s", checkName, req.Resource)
if err := k.checkOneRBAC(ctx, authClient, req); err != nil {
summary := fmt.Sprintf("missing permissions on resource %s: %s", req.Resource, err)
results = append(results, api.ErrorResult(resName, summary, err))
continue
}

summary := fmt.Sprintf("%s: can %s", req.Resource, strings.Join(req.Verbs, ", "))
results = append(results, api.PassResult(resName, summary))
}

return results
}

func (k *KubernetesChecker) checkOneRBAC(ctx context.Context, authClient authorizationclientv1.AuthorizationV1Interface, req *RBACRequirement) error {
have := make([]string, 0, len(req.Verbs))
for _, verb := range req.Verbs {
sar := &authorizationv1.SelfSubjectAccessReview{
Spec: authorizationv1.SelfSubjectAccessReviewSpec{
ResourceAttributes: &authorizationv1.ResourceAttributes{
Namespace: k.namespace,
Group: req.APIGroup,
Resource: req.Resource,
Verb: verb,
},
},
}

response, err := authClient.SelfSubjectAccessReviews().Create(ctx, sar, metav1.CreateOptions{})

if err != nil {
// should not fail - short-circuit
return xerrors.Errorf("failed to create SelfSubjectAccessReview request: %w", err)
}

if response.Status.Allowed {
have = append(have, verb)
continue
}
}

if len(have) != len(req.Verbs) {
return xerrors.Errorf(fmt.Sprintf("need: %+v have: %+v", req.Verbs, have))
}

return nil
}

func findClosestVersionRequirements(v *semver.Version) *VersionedRBACRequirements {
for _, vreqs := range allVersionedRBACRequirements {
if vreqs.VersionConstraints.Check(v) {
return &vreqs
}
}
return nil
}
125 changes: 125 additions & 0 deletions internal/checks/kube/rbac_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package kube

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

authorizationv1 "k8s.io/api/authorization/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"

"cdr.dev/slog/sloggers/slogtest/assert"

"github.com/Masterminds/semver/v3"

"github.com/cdr/coder-doctor/internal/api"
)

func Test_CheckRBAC(t *testing.T) {
t.Parallel()

tests := []struct {
Name string
Response *authorizationv1.SelfSubjectAccessReview
F func(*testing.T, []*api.CheckResult)
}{
{
Name: "all allowed",
Response: &selfSubjectAccessReviewAllowed,
F: func(t *testing.T, results []*api.CheckResult) {
for _, result := range results {
assert.True(t, result.Name+" should not error", result.Details["error"] == nil)
assert.True(t, result.Name+" should pass", result.State == api.StatePassed)
}
},
},
{
Name: "all denied",
Response: &selfSubjectAccessReviewDenied,
F: func(t *testing.T, results []*api.CheckResult) {
for _, result := range results {
assert.True(t, result.Name+" should have an error", result.Details["error"] != nil)
assert.True(t, result.Name+" should fail", result.State == api.StateFailed)
}
},
},
}

for _, test := range tests {
test := test
t.Run(test.Name, func(t *testing.T) {
t.Parallel()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(test.Response)
assert.Success(t, "failed to encode response", err)
}))
defer server.Close()

client, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
assert.Success(t, "failed to create client", err)

checker := NewKubernetesChecker(client)
results := checker.CheckRBAC(context.Background())
test.F(t, results)
})
}
}

func Test_CheckRBAC_ClientError(t *testing.T) {
t.Parallel()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()

client, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
assert.Success(t, "failed to create client", err)

checker := NewKubernetesChecker(client)
results := checker.CheckRBAC(context.Background())
for _, result := range results {
assert.ErrorContains(t, result.Name+" should show correct error", result.Details["error"].(error), "failed to create SelfSubjectAccessReview request")
assert.True(t, result.Name+" should fail", result.State == api.StateFailed)
}
}

func Test_CheckRBAC_UnknownCoderVerseion(t *testing.T) {
t.Parallel()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()

client, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
assert.Success(t, "failed to create client", err)

checker := NewKubernetesChecker(client, WithCoderVersion(semver.MustParse("0.0.1")))

results := checker.CheckRBAC(context.Background())
for _, result := range results {
assert.ErrorContains(t, result.Name+" should show correct error", result.Details["error"].(error), "unhandled coder version")
assert.True(t, result.Name+" should fail", result.State == api.StateFailed)
}
}

var selfSubjectAccessReviewAllowed authorizationv1.SelfSubjectAccessReview = authorizationv1.SelfSubjectAccessReview{
Status: authorizationv1.SubjectAccessReviewStatus{
Allowed: true,
},
}

var selfSubjectAccessReviewDenied authorizationv1.SelfSubjectAccessReview = authorizationv1.SelfSubjectAccessReview{
Status: authorizationv1.SubjectAccessReviewStatus{
Allowed: false,
},
}
9 changes: 6 additions & 3 deletions internal/cmd/check/kubernetes/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,13 @@ func run(cmd *cobra.Command, _ []string) error {
log = log.Leveled(slog.LevelDebug)
}

currentContext := rawConfig.Contexts[rawConfig.CurrentContext]

log.Info(cmd.Context(), "kubernetes config:",
slog.F("context", rawConfig.CurrentContext),
slog.F("cluster", rawConfig.Contexts[rawConfig.CurrentContext].Cluster),
slog.F("namespace", rawConfig.Contexts[rawConfig.CurrentContext].Namespace),
slog.F("authinfo", rawConfig.Contexts[rawConfig.CurrentContext].AuthInfo),
slog.F("cluster", currentContext.Cluster),
slog.F("namespace", currentContext.Namespace),
slog.F("authinfo", currentContext.AuthInfo),
)

hw := humanwriter.New(os.Stdout)
Expand All @@ -136,6 +138,7 @@ func run(cmd *cobra.Command, _ []string) error {
kube.WithLogger(log),
kube.WithCoderVersion(cv),
kube.WithWriter(hw),
kube.WithNamespace(currentContext.Namespace),
)

if err := localChecker.Run(cmd.Context()); err != nil {
Expand Down