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

Commit 831e4d5

Browse files
authored
feat: kubernetes: check RBAC (#6)
* check RBAC requirements * add api.PassResult convenience function
1 parent b33297a commit 831e4d5

File tree

8 files changed

+311
-7
lines changed

8 files changed

+311
-7
lines changed

.golangci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ linters-settings:
5454
alias:
5555
- pkg: k8s.io/api/core/(v[\w\d]+)
5656
alias: core$1
57+
- pkg: k8s.io/api/authorization/(v[\w\d]+)
58+
alias: authorization$1
5759

5860
- pkg: k8s.io/apimachinery/pkg/apis/meta/(v[\w\d]+)
5961
alias: meta$1
@@ -65,7 +67,7 @@ linters-settings:
6567
- pkg: k8s.io/client-go/kubernetes/typed/authentication/(v[\w\d]+)
6668
alias: authentication$1
6769
- pkg: k8s.io/client-go/kubernetes/typed/authorization/(v[\w\d]+)
68-
alias: authorization$1
70+
alias: authorizationclient$1
6971
- pkg: k8s.io/client-go/kubernetes/typed/autoscaling/(v[\w\d]+)
7072
alias: autoscaling$1
7173
- pkg: k8s.io/client-go/kubernetes/typed/batch/(v[\w\d]+)

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
github.com/Masterminds/semver/v3 v3.1.1
88
github.com/spf13/cobra v1.2.1
99
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1
10+
k8s.io/api v0.19.14
1011
k8s.io/apimachinery v0.19.14
1112
k8s.io/client-go v0.19.14
1213
k8s.io/klog/v2 v2.10.0 // indirect

internal/api/pass.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package api
2+
3+
// PassResults returns a CheckResult when everything is OK.
4+
func PassResult(name string, summary string) *CheckResult {
5+
return &CheckResult{
6+
Name: name,
7+
State: StatePassed,
8+
Summary: summary,
9+
Details: map[string]interface{}{},
10+
}
11+
}

internal/api/pass_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package api_test
2+
3+
import (
4+
"testing"
5+
6+
"cdr.dev/slog/sloggers/slogtest/assert"
7+
"github.com/cdr/coder-doctor/internal/api"
8+
)
9+
10+
func TestPassResult(t *testing.T) {
11+
t.Parallel()
12+
13+
res := api.PassResult("check-name", "check succeeded")
14+
15+
assert.Equal(t, "name matches", "check-name", res.Name)
16+
assert.Equal(t, "state matches", api.StatePassed, res.State)
17+
assert.Equal(t, "summary matches", "check succeeded", res.Summary)
18+
assert.Equal(t, "error matches", nil, res.Details["error"])
19+
}

internal/checks/kube/kubernetes.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
var _ = api.Checker(&KubernetesChecker{})
1818

1919
type KubernetesChecker struct {
20+
namespace string
2021
client kubernetes.Interface
2122
writer api.ResultWriter
2223
coderVersion *semver.Version
@@ -27,9 +28,10 @@ type Option func(k *KubernetesChecker)
2728

2829
func NewKubernetesChecker(client kubernetes.Interface, opts ...Option) *KubernetesChecker {
2930
checker := &KubernetesChecker{
30-
client: client,
31-
log: slog.Make(sloghuman.Sink(io.Discard)),
32-
writer: &api.DiscardWriter{},
31+
namespace: "default",
32+
client: client,
33+
log: slog.Make(sloghuman.Sink(io.Discard)),
34+
writer: &api.DiscardWriter{},
3335
// Select the newest version by default
3436
coderVersion: semver.MustParse("100.0.0"),
3537
}
@@ -59,6 +61,12 @@ func WithLogger(log slog.Logger) Option {
5961
}
6062
}
6163

64+
func WithNamespace(ns string) Option {
65+
return func(k *KubernetesChecker) {
66+
k.namespace = ns
67+
}
68+
}
69+
6270
func (*KubernetesChecker) Validate() error {
6371
return nil
6472
}
@@ -68,5 +76,11 @@ func (k *KubernetesChecker) Run(ctx context.Context) error {
6876
if err != nil {
6977
return xerrors.Errorf("check version: %w", err)
7078
}
79+
80+
for _, res := range k.CheckRBAC(ctx) {
81+
if err := k.writer.WriteResult(res); err != nil {
82+
return xerrors.Errorf("check RBAC: %w", err)
83+
}
84+
}
7185
return nil
7286
}

internal/checks/kube/rbac.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,130 @@
11
package kube
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"golang.org/x/xerrors"
9+
10+
"github.com/Masterminds/semver/v3"
11+
12+
"github.com/cdr/coder-doctor/internal/api"
13+
14+
authorizationv1 "k8s.io/api/authorization/v1"
15+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16+
authorizationclientv1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
17+
)
18+
19+
type RBACRequirement struct {
20+
APIGroup string
21+
Resource string
22+
Verbs []string
23+
}
24+
25+
type VersionedRBACRequirements struct {
26+
VersionConstraints *semver.Constraints
27+
RBACRequirements []*RBACRequirement
28+
}
29+
30+
var verbsCreateDeleteList = []string{"create", "delete", "list"}
31+
32+
func NewRBACRequirement(apiGroup, resource string, verbs ...string) *RBACRequirement {
33+
return &RBACRequirement{
34+
APIGroup: apiGroup,
35+
Resource: resource,
36+
Verbs: verbs,
37+
}
38+
}
39+
40+
var allVersionedRBACRequirements = []VersionedRBACRequirements{
41+
{
42+
VersionConstraints: api.MustConstraint(">= 1.20"),
43+
RBACRequirements: []*RBACRequirement{
44+
NewRBACRequirement("", "pods", verbsCreateDeleteList...),
45+
NewRBACRequirement("", "roles", verbsCreateDeleteList...),
46+
NewRBACRequirement("", "rolebindings", verbsCreateDeleteList...),
47+
NewRBACRequirement("", "secrets", verbsCreateDeleteList...),
48+
NewRBACRequirement("", "serviceaccounts", verbsCreateDeleteList...),
49+
NewRBACRequirement("", "services", verbsCreateDeleteList...),
50+
NewRBACRequirement("apps", "deployments", verbsCreateDeleteList...),
51+
NewRBACRequirement("apps", "replicasets", verbsCreateDeleteList...),
52+
NewRBACRequirement("apps", "statefulsets", verbsCreateDeleteList...),
53+
NewRBACRequirement("extensions", "ingresses", verbsCreateDeleteList...),
54+
},
55+
},
56+
}
57+
58+
func (k *KubernetesChecker) CheckRBAC(ctx context.Context) []*api.CheckResult {
59+
const checkName = "kubernetes-rbac"
60+
authClient := k.client.AuthorizationV1()
61+
rbacReqs := findClosestVersionRequirements(k.coderVersion)
62+
results := make([]*api.CheckResult, 0)
63+
if rbacReqs == nil {
64+
results = append(results,
65+
api.ErrorResult(
66+
checkName,
67+
"unable to check RBAC requirements",
68+
xerrors.Errorf("unhandled coder version: %s", k.coderVersion.String()),
69+
),
70+
)
71+
return results
72+
}
73+
74+
for _, req := range rbacReqs.RBACRequirements {
75+
resName := fmt.Sprintf("%s-%s", checkName, req.Resource)
76+
if err := k.checkOneRBAC(ctx, authClient, req); err != nil {
77+
summary := fmt.Sprintf("missing permissions on resource %s: %s", req.Resource, err)
78+
results = append(results, api.ErrorResult(resName, summary, err))
79+
continue
80+
}
81+
82+
summary := fmt.Sprintf("%s: can %s", req.Resource, strings.Join(req.Verbs, ", "))
83+
results = append(results, api.PassResult(resName, summary))
84+
}
85+
86+
return results
87+
}
88+
89+
func (k *KubernetesChecker) checkOneRBAC(ctx context.Context, authClient authorizationclientv1.AuthorizationV1Interface, req *RBACRequirement) error {
90+
have := make([]string, 0, len(req.Verbs))
91+
for _, verb := range req.Verbs {
92+
sar := &authorizationv1.SelfSubjectAccessReview{
93+
Spec: authorizationv1.SelfSubjectAccessReviewSpec{
94+
ResourceAttributes: &authorizationv1.ResourceAttributes{
95+
Namespace: k.namespace,
96+
Group: req.APIGroup,
97+
Resource: req.Resource,
98+
Verb: verb,
99+
},
100+
},
101+
}
102+
103+
response, err := authClient.SelfSubjectAccessReviews().Create(ctx, sar, metav1.CreateOptions{})
104+
105+
if err != nil {
106+
// should not fail - short-circuit
107+
return xerrors.Errorf("failed to create SelfSubjectAccessReview request: %w", err)
108+
}
109+
110+
if response.Status.Allowed {
111+
have = append(have, verb)
112+
continue
113+
}
114+
}
115+
116+
if len(have) != len(req.Verbs) {
117+
return xerrors.Errorf(fmt.Sprintf("need: %+v have: %+v", req.Verbs, have))
118+
}
119+
120+
return nil
121+
}
122+
123+
func findClosestVersionRequirements(v *semver.Version) *VersionedRBACRequirements {
124+
for _, vreqs := range allVersionedRBACRequirements {
125+
if vreqs.VersionConstraints.Check(v) {
126+
return &vreqs
127+
}
128+
}
129+
return nil
130+
}

internal/checks/kube/rbac_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package kube
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
authorizationv1 "k8s.io/api/authorization/v1"
11+
"k8s.io/client-go/kubernetes"
12+
"k8s.io/client-go/rest"
13+
14+
"cdr.dev/slog/sloggers/slogtest/assert"
15+
16+
"github.com/Masterminds/semver/v3"
17+
18+
"github.com/cdr/coder-doctor/internal/api"
19+
)
20+
21+
func Test_CheckRBAC(t *testing.T) {
22+
t.Parallel()
23+
24+
tests := []struct {
25+
Name string
26+
Response *authorizationv1.SelfSubjectAccessReview
27+
F func(*testing.T, []*api.CheckResult)
28+
}{
29+
{
30+
Name: "all allowed",
31+
Response: &selfSubjectAccessReviewAllowed,
32+
F: func(t *testing.T, results []*api.CheckResult) {
33+
for _, result := range results {
34+
assert.True(t, result.Name+" should not error", result.Details["error"] == nil)
35+
assert.True(t, result.Name+" should pass", result.State == api.StatePassed)
36+
}
37+
},
38+
},
39+
{
40+
Name: "all denied",
41+
Response: &selfSubjectAccessReviewDenied,
42+
F: func(t *testing.T, results []*api.CheckResult) {
43+
for _, result := range results {
44+
assert.True(t, result.Name+" should have an error", result.Details["error"] != nil)
45+
assert.True(t, result.Name+" should fail", result.State == api.StateFailed)
46+
}
47+
},
48+
},
49+
}
50+
51+
for _, test := range tests {
52+
test := test
53+
t.Run(test.Name, func(t *testing.T) {
54+
t.Parallel()
55+
56+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
57+
w.Header().Set("Content-Type", "application/json")
58+
w.WriteHeader(http.StatusOK)
59+
err := json.NewEncoder(w).Encode(test.Response)
60+
assert.Success(t, "failed to encode response", err)
61+
}))
62+
defer server.Close()
63+
64+
client, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
65+
assert.Success(t, "failed to create client", err)
66+
67+
checker := NewKubernetesChecker(client)
68+
results := checker.CheckRBAC(context.Background())
69+
test.F(t, results)
70+
})
71+
}
72+
}
73+
74+
func Test_CheckRBAC_ClientError(t *testing.T) {
75+
t.Parallel()
76+
77+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
78+
w.Header().Set("Content-Type", "application/json")
79+
w.WriteHeader(http.StatusInternalServerError)
80+
}))
81+
defer server.Close()
82+
83+
client, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
84+
assert.Success(t, "failed to create client", err)
85+
86+
checker := NewKubernetesChecker(client)
87+
results := checker.CheckRBAC(context.Background())
88+
for _, result := range results {
89+
assert.ErrorContains(t, result.Name+" should show correct error", result.Details["error"].(error), "failed to create SelfSubjectAccessReview request")
90+
assert.True(t, result.Name+" should fail", result.State == api.StateFailed)
91+
}
92+
}
93+
94+
func Test_CheckRBAC_UnknownCoderVerseion(t *testing.T) {
95+
t.Parallel()
96+
97+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
98+
w.Header().Set("Content-Type", "application/json")
99+
w.WriteHeader(http.StatusInternalServerError)
100+
}))
101+
defer server.Close()
102+
103+
client, err := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
104+
assert.Success(t, "failed to create client", err)
105+
106+
checker := NewKubernetesChecker(client, WithCoderVersion(semver.MustParse("0.0.1")))
107+
108+
results := checker.CheckRBAC(context.Background())
109+
for _, result := range results {
110+
assert.ErrorContains(t, result.Name+" should show correct error", result.Details["error"].(error), "unhandled coder version")
111+
assert.True(t, result.Name+" should fail", result.State == api.StateFailed)
112+
}
113+
}
114+
115+
var selfSubjectAccessReviewAllowed authorizationv1.SelfSubjectAccessReview = authorizationv1.SelfSubjectAccessReview{
116+
Status: authorizationv1.SubjectAccessReviewStatus{
117+
Allowed: true,
118+
},
119+
}
120+
121+
var selfSubjectAccessReviewDenied authorizationv1.SelfSubjectAccessReview = authorizationv1.SelfSubjectAccessReview{
122+
Status: authorizationv1.SubjectAccessReviewStatus{
123+
Allowed: false,
124+
},
125+
}

internal/cmd/check/kubernetes/kubernetes.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,13 @@ func run(cmd *cobra.Command, _ []string) error {
115115
log = log.Leveled(slog.LevelDebug)
116116
}
117117

118+
currentContext := rawConfig.Contexts[rawConfig.CurrentContext]
119+
118120
log.Info(cmd.Context(), "kubernetes config:",
119121
slog.F("context", rawConfig.CurrentContext),
120-
slog.F("cluster", rawConfig.Contexts[rawConfig.CurrentContext].Cluster),
121-
slog.F("namespace", rawConfig.Contexts[rawConfig.CurrentContext].Namespace),
122-
slog.F("authinfo", rawConfig.Contexts[rawConfig.CurrentContext].AuthInfo),
122+
slog.F("cluster", currentContext.Cluster),
123+
slog.F("namespace", currentContext.Namespace),
124+
slog.F("authinfo", currentContext.AuthInfo),
123125
)
124126

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

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

0 commit comments

Comments
 (0)