-
Notifications
You must be signed in to change notification settings - Fork 3
feature: use SelfSubjectRulesReview for RBAC, fallback to SelfSubjectAccessRequest #11
Changes from all commits
56ce81b
c49a547
dcb95d4
a98c45b
7e8bfdb
b0f4120
65da237
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,5 +11,5 @@ require ( | |
k8s.io/apimachinery v0.19.14 | ||
k8s.io/client-go v0.19.14 | ||
k8s.io/klog/v2 v2.10.0 // indirect | ||
k8s.io/utils v0.0.0-20210305010621-2afb4311ab10 // indirect | ||
k8s.io/kubectl v0.19.14 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. comment: tough call, seems we're either:
Maybe this one in particular is OK and it's just stuff in k/k that we shouldn't import:
I'm unsure and inclined to just merge what you have 🤷♂️ |
||
) |
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package api | ||
|
||
// WarnResult returns a CheckResult when a warning occurs. | ||
func WarnResult(name string, summary string) *CheckResult { | ||
return &CheckResult{ | ||
Name: name, | ||
State: StateWarning, | ||
Summary: summary, | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
package api_test | ||
|
||
import ( | ||
"testing" | ||
|
||
"cdr.dev/slog/sloggers/slogtest/assert" | ||
"github.com/cdr/coder-doctor/internal/api" | ||
) | ||
|
||
func TestWarnResult(t *testing.T) { | ||
t.Parallel() | ||
res := api.WarnResult("check-name", "something bad happened") | ||
|
||
assert.Equal(t, "name matches", "check-name", res.Name) | ||
assert.Equal(t, "state matches", api.StateWarning, res.State) | ||
assert.Equal(t, "summary matches", "something bad happened", res.Summary) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,27 +3,164 @@ package kube | |
import ( | ||
"context" | ||
"fmt" | ||
"sort" | ||
"strings" | ||
|
||
"golang.org/x/xerrors" | ||
|
||
"cdr.dev/slog" | ||
|
||
"github.com/Masterminds/semver/v3" | ||
|
||
"github.com/cdr/coder-doctor/internal/api" | ||
|
||
authorizationv1 "k8s.io/api/authorization/v1" | ||
rbacv1 "k8s.io/api/rbac/v1" | ||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
authorizationclientv1 "k8s.io/client-go/kubernetes/typed/authorization/v1" | ||
rbacutil "k8s.io/kubectl/pkg/util/rbac" | ||
"k8s.io/kubectl/pkg/util/slice" | ||
) | ||
|
||
// CheckRBAC checks the cluster for the RBAC permissions required by Coder. | ||
// It will attempt to first use a SelfSubjectRulesReview to determine the capabilities | ||
// of the user. If this fails (notably on GKE), fall back to using SelfSubjectAccessRequests | ||
// which is slower but is more likely to work. | ||
func (k *KubernetesChecker) CheckRBAC(ctx context.Context) []*api.CheckResult { | ||
if ssrrResults := k.checkRBACDefault(ctx); ssrrResults != nil { | ||
return ssrrResults | ||
} | ||
|
||
k.log.Warn(ctx, "SelfSubjectRulesReview response incomplete, falling back to SelfSubjectAccessRequests (slow)") | ||
return k.checkRBACFallback(ctx) | ||
} | ||
|
||
func (k *KubernetesChecker) checkRBACDefault(ctx context.Context) []*api.CheckResult { | ||
const checkName = "kubernetes-rbac-ssrr" | ||
authClient := k.client.AuthorizationV1() | ||
results := make([]*api.CheckResult, 0) | ||
|
||
sar := &authorizationv1.SelfSubjectRulesReview{ | ||
Spec: authorizationv1.SelfSubjectRulesReviewSpec{ | ||
Namespace: k.namespace, | ||
}, | ||
} | ||
|
||
response, err := authClient.SelfSubjectRulesReviews().Create(ctx, sar, metav1.CreateOptions{}) | ||
if err != nil { | ||
return []*api.CheckResult{api.SkippedResult(checkName, "unable to create SelfSubjectRulesReview request: "+err.Error())} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: consider adding the error to the Details field, since it's structured data. the longer-term idea is that someone should be able to submit an output log (JSON dump or something) of their CheckResult logs, and we should be able to ingest those into BigQuery or something as a form of cluster telemetry, to help guide our product decisions -- parsing text out is possible but I think it's better to avoid that |
||
} | ||
|
||
if response.Status.Incomplete { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: does this mean that callers need to treat a nil slice return differently from an empty slice? I'd suggest returning a typed error for this condition instead, for example: const errSelfSubjectRulesReviewNotSupported = errors.New("cluster does not support SelfSubjectRulesReview")
if response.Status.Incomplete {
return nil, errSelfSubjectRulesReviewNotSupported
} and then in the caller, you can check if the |
||
return nil // In this case, we should fall back to using SelfSubjectAccessRequests. | ||
} | ||
|
||
// convert the list of rules from the server to PolicyRules and dedupe/compact | ||
breakdownRules := []rbacv1.PolicyRule{} | ||
for _, rule := range convertToPolicyRule(response.Status) { | ||
breakdownRules = append(breakdownRules, rbacutil.BreakdownRule(rule)...) | ||
} | ||
|
||
compactRules, _ := rbacutil.CompactRules(breakdownRules) //nolint:errcheck - err is always nil | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question/suggestion: is err always guaranteed by the API contract to be nil, or is that just the current implementation? if the latter, then I think we should still check, otherwise we can have panics if they change things later it would seem unusual to me for them to return an error that is always guaranteed to be nil, but I'm not too familiar with this API There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Current implementation upstream always returns nil, weirdly enough 🤷 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if it's not guaranteed, then IMO we should check, otherwise it's just a ticking time bomb :) |
||
sort.Stable(rbacutil.SortableRuleSlice(compactRules)) | ||
for _, r := range compactRules { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: I don't know if we can check if Debug logging is enabled; if not, we could avoid the for loop/method call overhead |
||
k.log.Debug(ctx, "Got SSRR PolicyRule", slog.F("rule", r)) | ||
} | ||
|
||
// TODO: optimise this | ||
for req, reqVerbs := range k.reqs.ResourceRequirements { | ||
if err := satisfies(req, reqVerbs, compactRules); err != nil { | ||
summary := fmt.Sprintf("resource %s: %s", req.Resource, err) | ||
results = append(results, api.ErrorResult(checkName, summary, err)) | ||
continue | ||
} | ||
resourceName := req.Resource | ||
if req.Group != "" { | ||
resourceName = req.Group + "/" + req.Resource | ||
} | ||
summary := fmt.Sprintf("resource %s: can %s", resourceName, strings.Join(reqVerbs, ", ")) | ||
results = append(results, api.PassResult(checkName, summary)) | ||
} | ||
|
||
// TODO: remove this when the helm chart is fixed | ||
for req, reqVerbs := range k.reqs.RoleOnlyResourceRequirements { | ||
if err := satisfies(req, reqVerbs, compactRules); err != nil { | ||
summary := fmt.Sprintf("resource %s: %s", req.Resource, err) | ||
results = append(results, api.ErrorResult(checkName, summary, err)) | ||
continue | ||
} | ||
resourceName := req.Resource | ||
if req.Group != "" { | ||
resourceName = req.Group + "/" + req.Resource | ||
} | ||
summary := fmt.Sprintf("resource %s: can %s", resourceName, strings.Join(reqVerbs, ", ")) | ||
results = append(results, api.PassResult(checkName, summary)) | ||
} | ||
|
||
return results | ||
} | ||
|
||
func satisfies(req *ResourceRequirement, verbs ResourceVerbs, rules []rbacv1.PolicyRule) error { | ||
for _, rule := range rules { | ||
if apiGroupsMatch(req.Group, rule.APIGroups) && | ||
apiResourceMatch(req.Resource, rule.Resources) && | ||
verbsMatch(verbs, rule.Verbs) { | ||
return nil | ||
} | ||
} | ||
return xerrors.Errorf("not satisfied: group:%q resource:%q version:%q verbs:%+v", req.Group, req.Resource, req.Version, verbs) | ||
} | ||
|
||
// The below adapted from k8s.io/pkg/apis/rbac/v1/evaluation_helpers.go | ||
func verbsMatch(want, have ResourceVerbs) bool { | ||
if slice.ContainsString(have, "*", nil) { | ||
return true | ||
} | ||
|
||
for _, v := range want { | ||
if !slice.ContainsString(have, v, nil) { | ||
return false | ||
} | ||
} | ||
return true | ||
} | ||
|
||
func apiGroupsMatch(want string, have []string) bool { | ||
for _, g := range have { | ||
if g == "*" { | ||
return true | ||
} | ||
|
||
if g == want { | ||
return true | ||
} | ||
} | ||
return false | ||
} | ||
|
||
func apiResourceMatch(want string, have []string) bool { | ||
for _, r := range have { | ||
if r == "*" { | ||
return true | ||
} | ||
|
||
if r == want { | ||
return true | ||
} | ||
} | ||
return false | ||
} | ||
|
||
// checkRBACFallback uses a SelfSubjectAccessRequest to check the cluster for the required | ||
// accesses. This requires a number of checks and is relatively slow. | ||
func (k *KubernetesChecker) checkRBACFallback(ctx context.Context) []*api.CheckResult { | ||
const checkName = "kubernetes-rbac" | ||
authClient := k.client.AuthorizationV1() | ||
results := make([]*api.CheckResult, 0) | ||
|
||
for req, reqVerbs := range k.reqs.ResourceRequirements { | ||
resName := fmt.Sprintf("%s-%s", checkName, req.Resource) | ||
if err := k.checkOneRBAC(ctx, authClient, req, reqVerbs); err != nil { | ||
if err := k.checkOneRBACSSAR(ctx, authClient, req, reqVerbs); err != nil { | ||
summary := fmt.Sprintf("missing permissions on resource %s: %s", req.Resource, err) | ||
results = append(results, api.ErrorResult(resName, summary, err)) | ||
continue | ||
|
@@ -37,7 +174,7 @@ func (k *KubernetesChecker) CheckRBAC(ctx context.Context) []*api.CheckResult { | |
// that don't exist. | ||
for req, reqVerbs := range k.reqs.RoleOnlyResourceRequirements { | ||
resName := fmt.Sprintf("%s-%s", checkName, req.Resource) | ||
if err := k.checkOneRBAC(ctx, authClient, req, reqVerbs); err != nil { | ||
if err := k.checkOneRBACSSAR(ctx, authClient, req, reqVerbs); err != nil { | ||
summary := fmt.Sprintf("missing permissions on resource %s: %s", req.Resource, err) | ||
results = append(results, api.ErrorResult(resName, summary, err)) | ||
continue | ||
|
@@ -50,7 +187,7 @@ func (k *KubernetesChecker) CheckRBAC(ctx context.Context) []*api.CheckResult { | |
return results | ||
} | ||
|
||
func (k *KubernetesChecker) checkOneRBAC(ctx context.Context, authClient authorizationclientv1.AuthorizationV1Interface, req *ResourceRequirement, reqVerbs ResourceVerbs) error { | ||
func (k *KubernetesChecker) checkOneRBACSSAR(ctx context.Context, authClient authorizationclientv1.AuthorizationV1Interface, req *ResourceRequirement, reqVerbs ResourceVerbs) error { | ||
have := make([]string, 0, len(reqVerbs)) | ||
for _, verb := range reqVerbs { | ||
sar := &authorizationv1.SelfSubjectAccessReview{ | ||
|
@@ -92,3 +229,25 @@ func findClosestVersionRequirements(v *semver.Version) *VersionedResourceRequire | |
} | ||
return nil | ||
} | ||
|
||
// lifted from kubectl/pkg/cmd/auth/cani.go | ||
func convertToPolicyRule(status authorizationv1.SubjectRulesReviewStatus) []rbacv1.PolicyRule { | ||
ret := []rbacv1.PolicyRule{} | ||
for _, resource := range status.ResourceRules { | ||
ret = append(ret, rbacv1.PolicyRule{ | ||
Verbs: resource.Verbs, | ||
APIGroups: resource.APIGroups, | ||
Resources: resource.Resources, | ||
ResourceNames: resource.ResourceNames, | ||
}) | ||
} | ||
|
||
for _, nonResource := range status.NonResourceRules { | ||
ret = append(ret, rbacv1.PolicyRule{ | ||
Verbs: nonResource.Verbs, | ||
NonResourceURLs: nonResource.NonResourceURLs, | ||
}) | ||
} | ||
|
||
return ret | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: use
go test -parallel ./...
I believe
go test -parallel
(no options) should auto-detect available processors, pergo help testflag
:in the future, we might want to consider using gotestsum as the output is nicer