diff --git a/.golangci.yml b/.golangci.yml index 3237faa..4c30f74 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 @@ -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]+) - 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]+) diff --git a/go.mod b/go.mod index bdf1f6a..3ba1fcd 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/api/pass.go b/internal/api/pass.go new file mode 100644 index 0000000..fb859eb --- /dev/null +++ b/internal/api/pass.go @@ -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{}{}, + } +} diff --git a/internal/api/pass_test.go b/internal/api/pass_test.go new file mode 100644 index 0000000..a4f06c6 --- /dev/null +++ b/internal/api/pass_test.go @@ -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"]) +} diff --git a/internal/checks/kube/kubernetes.go b/internal/checks/kube/kubernetes.go index 486d817..32c3a73 100644 --- a/internal/checks/kube/kubernetes.go +++ b/internal/checks/kube/kubernetes.go @@ -17,6 +17,7 @@ import ( var _ = api.Checker(&KubernetesChecker{}) type KubernetesChecker struct { + namespace string client kubernetes.Interface writer api.ResultWriter coderVersion *semver.Version @@ -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"), } @@ -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 } @@ -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 } diff --git a/internal/checks/kube/rbac.go b/internal/checks/kube/rbac.go index f84e1e4..96f8e8a 100644 --- a/internal/checks/kube/rbac.go +++ b/internal/checks/kube/rbac.go @@ -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) + 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 +} diff --git a/internal/checks/kube/rbac_test.go b/internal/checks/kube/rbac_test.go new file mode 100644 index 0000000..d28c778 --- /dev/null +++ b/internal/checks/kube/rbac_test.go @@ -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, + }, +} diff --git a/internal/cmd/check/kubernetes/kubernetes.go b/internal/cmd/check/kubernetes/kubernetes.go index 533fa95..7f9323a 100644 --- a/internal/cmd/check/kubernetes/kubernetes.go +++ b/internal/cmd/check/kubernetes/kubernetes.go @@ -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) @@ -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 {