Skip to content

Commit 04c976d

Browse files
authored
Add new operator capability to check if it has access to do an operation (open-telemetry#2467)
* rbac pr testing * makefile convenience * Add test * add chlog * Add a comment * update to allow for policy rule checking * change package * lint fail * better formatting * don't use leading slash for empty group * add more detail for comment
1 parent 1b6d009 commit 04c976d

File tree

12 files changed

+655
-26
lines changed

12 files changed

+655
-26
lines changed

.chloggen/mandate-rbac.yaml

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
2+
change_type: enhancement
3+
4+
# The name of the component, or a single word describing the area of concern, (e.g. operator, target allocator, github action)
5+
component: operator
6+
7+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
8+
note: enables the operator to create subject access reviews for different required permissions.
9+
10+
# One or more tracking issues related to the change
11+
issues: [2426]
12+
13+
# (Optional) One or more lines of additional information to render under the primary note.
14+
# These lines will be padded with 2 spaces and then inserted directly into the document.
15+
# Use pipe (|) for multiline entries.
16+
subtext:

Makefile

+5
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ all: manager
9797
.PHONY: ci
9898
ci: test
9999

100+
# helper to get your gomod up-to-date
101+
.PHONY: gomod
102+
gomod:
103+
go mod tidy && go mod vendor && (cd cmd/otel-allocator && go mod tidy) && (cd cmd/operator-opamp-bridge && go mod tidy)
104+
100105
# Run tests
101106
# setup-envtest uses KUBEBUILDER_ASSETS which points to a directory with binaries (api-server, etcd and kubectl)
102107
.PHONY: test

apis/v1alpha1/collector_webhook.go

+75-11
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ package v1alpha1
1717
import (
1818
"context"
1919
"fmt"
20+
"strings"
2021

2122
"github.com/go-logr/logr"
23+
v1 "k8s.io/api/authorization/v1"
2224
autoscalingv2 "k8s.io/api/autoscaling/v2"
25+
rbacv1 "k8s.io/api/rbac/v1"
2326
"k8s.io/apimachinery/pkg/runtime"
2427
"k8s.io/apimachinery/pkg/util/intstr"
2528
"k8s.io/apimachinery/pkg/util/validation"
@@ -28,12 +31,41 @@ import (
2831

2932
"github.com/open-telemetry/opentelemetry-operator/internal/config"
3033
ta "github.com/open-telemetry/opentelemetry-operator/internal/manifests/targetallocator/adapters"
34+
"github.com/open-telemetry/opentelemetry-operator/internal/rbac"
3135
"github.com/open-telemetry/opentelemetry-operator/pkg/featuregate"
3236
)
3337

3438
var (
3539
_ admission.CustomValidator = &CollectorWebhook{}
3640
_ admission.CustomDefaulter = &CollectorWebhook{}
41+
42+
// targetAllocatorCRPolicyRules are the policy rules required for the CR functionality.
43+
targetAllocatorCRPolicyRules = []*rbacv1.PolicyRule{
44+
{
45+
APIGroups: []string{"monitoring.coreos.com"},
46+
Resources: []string{"servicemonitors", "podmonitors"},
47+
Verbs: []string{"*"},
48+
}, {
49+
APIGroups: []string{""},
50+
Resources: []string{"nodes", "nodes/metrics", "services", "endpoints", "pods"},
51+
Verbs: []string{"get", "list", "watch"},
52+
}, {
53+
APIGroups: []string{""},
54+
Resources: []string{"configmaps"},
55+
Verbs: []string{"get"},
56+
}, {
57+
APIGroups: []string{"discovery.k8s.io"},
58+
Resources: []string{"endpointslices"},
59+
Verbs: []string{"get", "list", "watch"},
60+
}, {
61+
APIGroups: []string{"networking.k8s.io"},
62+
Resources: []string{"ingresses"},
63+
Verbs: []string{"get", "list", "watch"},
64+
}, {
65+
NonResourceURLs: []string{"/metrics"},
66+
Verbs: []string{"get"},
67+
},
68+
}
3769
)
3870

3971
// +kubebuilder:webhook:path=/mutate-opentelemetry-io-v1alpha1-opentelemetrycollector,mutating=true,failurePolicy=fail,groups=opentelemetry.io,resources=opentelemetrycollectors,verbs=create;update,versions=v1alpha1,name=mopentelemetrycollector.kb.io,sideEffects=none,admissionReviewVersions=v1
@@ -42,9 +74,10 @@ var (
4274
// +kubebuilder:object:generate=false
4375

4476
type CollectorWebhook struct {
45-
logger logr.Logger
46-
cfg config.Config
47-
scheme *runtime.Scheme
77+
logger logr.Logger
78+
cfg config.Config
79+
scheme *runtime.Scheme
80+
reviewer *rbac.Reviewer
4881
}
4982

5083
func (c CollectorWebhook) Default(ctx context.Context, obj runtime.Object) error {
@@ -60,23 +93,23 @@ func (c CollectorWebhook) ValidateCreate(ctx context.Context, obj runtime.Object
6093
if !ok {
6194
return nil, fmt.Errorf("expected an OpenTelemetryCollector, received %T", obj)
6295
}
63-
return c.validate(otelcol)
96+
return c.validate(ctx, otelcol)
6497
}
6598

6699
func (c CollectorWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
67100
otelcol, ok := newObj.(*OpenTelemetryCollector)
68101
if !ok {
69102
return nil, fmt.Errorf("expected an OpenTelemetryCollector, received %T", newObj)
70103
}
71-
return c.validate(otelcol)
104+
return c.validate(ctx, otelcol)
72105
}
73106

74107
func (c CollectorWebhook) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
75108
otelcol, ok := obj.(*OpenTelemetryCollector)
76109
if !ok || otelcol == nil {
77110
return nil, fmt.Errorf("expected an OpenTelemetryCollector, received %T", obj)
78111
}
79-
return c.validate(otelcol)
112+
return c.validate(ctx, otelcol)
80113
}
81114

82115
func (c CollectorWebhook) defaulter(r *OpenTelemetryCollector) error {
@@ -169,7 +202,7 @@ func (c CollectorWebhook) defaulter(r *OpenTelemetryCollector) error {
169202
return nil
170203
}
171204

172-
func (c CollectorWebhook) validate(r *OpenTelemetryCollector) (admission.Warnings, error) {
205+
func (c CollectorWebhook) validate(ctx context.Context, r *OpenTelemetryCollector) (admission.Warnings, error) {
173206
warnings := admission.Warnings{}
174207
// validate volumeClaimTemplates
175208
if r.Spec.Mode != ModeStatefulSet && len(r.Spec.VolumeClaimTemplates) > 0 {
@@ -214,6 +247,14 @@ func (c CollectorWebhook) validate(r *OpenTelemetryCollector) (admission.Warning
214247
if err != nil {
215248
return warnings, fmt.Errorf("the OpenTelemetry Spec Prometheus configuration is incorrect, %w", err)
216249
}
250+
// if the prometheusCR is enabled, it needs a suite of permissions to function
251+
if r.Spec.TargetAllocator.PrometheusCR.Enabled {
252+
if subjectAccessReviews, err := c.reviewer.CheckPolicyRules(ctx, r.GetNamespace(), r.Spec.TargetAllocator.ServiceAccount, targetAllocatorCRPolicyRules...); err != nil {
253+
return warnings, fmt.Errorf("unable to check rbac rules %w", err)
254+
} else if allowed, deniedReviews := rbac.AllSubjectAccessReviewsAllowed(subjectAccessReviews); !allowed {
255+
warnings = append(warnings, warningsGroupedByResource(deniedReviews)...)
256+
}
257+
}
217258
}
218259

219260
// validator port config
@@ -360,11 +401,34 @@ func checkAutoscalerSpec(autoscaler *AutoscalerSpec) error {
360401
return nil
361402
}
362403

363-
func SetupCollectorWebhook(mgr ctrl.Manager, cfg config.Config) error {
404+
// warningsGroupedByResource is a helper to take the missing permissions and format them as warnings.
405+
func warningsGroupedByResource(reviews []*v1.SubjectAccessReview) []string {
406+
fullResourceToVerbs := make(map[string][]string)
407+
for _, review := range reviews {
408+
if review.Spec.ResourceAttributes != nil {
409+
key := fmt.Sprintf("%s/%s", review.Spec.ResourceAttributes.Group, review.Spec.ResourceAttributes.Resource)
410+
if len(review.Spec.ResourceAttributes.Group) == 0 {
411+
key = review.Spec.ResourceAttributes.Resource
412+
}
413+
fullResourceToVerbs[key] = append(fullResourceToVerbs[key], review.Spec.ResourceAttributes.Verb)
414+
} else if review.Spec.NonResourceAttributes != nil {
415+
key := fmt.Sprintf("nonResourceURL: %s", review.Spec.NonResourceAttributes.Path)
416+
fullResourceToVerbs[key] = append(fullResourceToVerbs[key], review.Spec.NonResourceAttributes.Verb)
417+
}
418+
}
419+
var warnings []string
420+
for fullResource, verbs := range fullResourceToVerbs {
421+
warnings = append(warnings, fmt.Sprintf("missing the following rules for %s: [%s]", fullResource, strings.Join(verbs, ",")))
422+
}
423+
return warnings
424+
}
425+
426+
func SetupCollectorWebhook(mgr ctrl.Manager, cfg config.Config, reviewer *rbac.Reviewer) error {
364427
cvw := &CollectorWebhook{
365-
logger: mgr.GetLogger().WithValues("handler", "CollectorWebhook"),
366-
scheme: mgr.GetScheme(),
367-
cfg: cfg,
428+
reviewer: reviewer,
429+
logger: mgr.GetLogger().WithValues("handler", "CollectorWebhook"),
430+
scheme: mgr.GetScheme(),
431+
cfg: cfg,
368432
}
369433
return ctrl.NewWebhookManagedBy(mgr).
370434
For(&OpenTelemetryCollector{}).

apis/v1alpha1/collector_webhook_test.go

+154-6
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,19 @@ import (
2323
"github.com/go-logr/logr"
2424
"github.com/stretchr/testify/assert"
2525
appsv1 "k8s.io/api/apps/v1"
26+
authv1 "k8s.io/api/authorization/v1"
2627
autoscalingv2 "k8s.io/api/autoscaling/v2"
2728
v1 "k8s.io/api/core/v1"
2829
"k8s.io/apimachinery/pkg/api/resource"
2930
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3031
"k8s.io/apimachinery/pkg/runtime"
3132
"k8s.io/apimachinery/pkg/util/intstr"
33+
"k8s.io/client-go/kubernetes/fake"
3234
"k8s.io/client-go/kubernetes/scheme"
35+
kubeTesting "k8s.io/client-go/testing"
3336

3437
"github.com/open-telemetry/opentelemetry-operator/internal/config"
38+
"github.com/open-telemetry/opentelemetry-operator/internal/rbac"
3539
)
3640

3741
var (
@@ -438,6 +442,7 @@ func TestOTELColValidatingWebhook(t *testing.T) {
438442
otelcol OpenTelemetryCollector
439443
expectedErr string
440444
expectedWarnings []string
445+
shouldFailSar bool
441446
}{
442447
{
443448
name: "valid empty spec",
@@ -469,6 +474,131 @@ func TestOTELColValidatingWebhook(t *testing.T) {
469474
protocols:
470475
thrift_http:
471476
endpoint: 0.0.0.0:15268
477+
`,
478+
Ports: []v1.ServicePort{
479+
{
480+
Name: "port1",
481+
Port: 5555,
482+
},
483+
{
484+
Name: "port2",
485+
Port: 5554,
486+
Protocol: v1.ProtocolUDP,
487+
},
488+
},
489+
Autoscaler: &AutoscalerSpec{
490+
Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
491+
ScaleDown: &autoscalingv2.HPAScalingRules{
492+
StabilizationWindowSeconds: &three,
493+
},
494+
ScaleUp: &autoscalingv2.HPAScalingRules{
495+
StabilizationWindowSeconds: &five,
496+
},
497+
},
498+
TargetCPUUtilization: &five,
499+
},
500+
},
501+
},
502+
expectedWarnings: []string{
503+
"MaxReplicas is deprecated",
504+
"MinReplicas is deprecated",
505+
},
506+
},
507+
{
508+
name: "prom CR admissions warning",
509+
shouldFailSar: true, // force failure
510+
otelcol: OpenTelemetryCollector{
511+
Spec: OpenTelemetryCollectorSpec{
512+
Mode: ModeStatefulSet,
513+
MinReplicas: &one,
514+
Replicas: &three,
515+
MaxReplicas: &five,
516+
UpgradeStrategy: "adhoc",
517+
TargetAllocator: OpenTelemetryTargetAllocator{
518+
Enabled: true,
519+
PrometheusCR: OpenTelemetryTargetAllocatorPrometheusCR{Enabled: true},
520+
},
521+
Config: `receivers:
522+
examplereceiver:
523+
endpoint: "0.0.0.0:12345"
524+
examplereceiver/settings:
525+
endpoint: "0.0.0.0:12346"
526+
prometheus:
527+
config:
528+
scrape_configs:
529+
- job_name: otel-collector
530+
scrape_interval: 10s
531+
jaeger/custom:
532+
protocols:
533+
thrift_http:
534+
endpoint: 0.0.0.0:15268
535+
`,
536+
Ports: []v1.ServicePort{
537+
{
538+
Name: "port1",
539+
Port: 5555,
540+
},
541+
{
542+
Name: "port2",
543+
Port: 5554,
544+
Protocol: v1.ProtocolUDP,
545+
},
546+
},
547+
Autoscaler: &AutoscalerSpec{
548+
Behavior: &autoscalingv2.HorizontalPodAutoscalerBehavior{
549+
ScaleDown: &autoscalingv2.HPAScalingRules{
550+
StabilizationWindowSeconds: &three,
551+
},
552+
ScaleUp: &autoscalingv2.HPAScalingRules{
553+
StabilizationWindowSeconds: &five,
554+
},
555+
},
556+
TargetCPUUtilization: &five,
557+
},
558+
},
559+
},
560+
expectedWarnings: []string{
561+
"missing the following rules for monitoring.coreos.com/servicemonitors: [*]",
562+
"missing the following rules for monitoring.coreos.com/podmonitors: [*]",
563+
"missing the following rules for nodes/metrics: [get,list,watch]",
564+
"missing the following rules for services: [get,list,watch]",
565+
"missing the following rules for endpoints: [get,list,watch]",
566+
"missing the following rules for networking.k8s.io/ingresses: [get,list,watch]",
567+
"missing the following rules for nodes: [get,list,watch]",
568+
"missing the following rules for pods: [get,list,watch]",
569+
"missing the following rules for configmaps: [get]",
570+
"missing the following rules for discovery.k8s.io/endpointslices: [get,list,watch]",
571+
"missing the following rules for nonResourceURL: /metrics: [get]",
572+
"MaxReplicas is deprecated",
573+
"MinReplicas is deprecated",
574+
},
575+
},
576+
{
577+
name: "prom CR no admissions warning",
578+
shouldFailSar: false, // force SAR okay
579+
otelcol: OpenTelemetryCollector{
580+
Spec: OpenTelemetryCollectorSpec{
581+
Mode: ModeStatefulSet,
582+
Replicas: &three,
583+
UpgradeStrategy: "adhoc",
584+
TargetAllocator: OpenTelemetryTargetAllocator{
585+
Enabled: true,
586+
PrometheusCR: OpenTelemetryTargetAllocatorPrometheusCR{Enabled: true},
587+
},
588+
Config: `receivers:
589+
examplereceiver:
590+
endpoint: "0.0.0.0:12345"
591+
examplereceiver/settings:
592+
endpoint: "0.0.0.0:12346"
593+
prometheus:
594+
config:
595+
scrape_configs:
596+
- job_name: otel-collector
597+
scrape_interval: 10s
598+
jaeger/custom:
599+
protocols:
600+
thrift_http:
601+
endpoint: 0.0.0.0:15268
472602
`,
473603
Ports: []v1.ServicePort{
474604
{
@@ -923,19 +1053,37 @@ func TestOTELColValidatingWebhook(t *testing.T) {
9231053
config.WithCollectorImage("collector:v0.0.0"),
9241054
config.WithTargetAllocatorImage("ta:v0.0.0"),
9251055
),
1056+
reviewer: getReviewer(test.shouldFailSar),
9261057
}
9271058
ctx := context.Background()
9281059
warnings, err := cvw.ValidateCreate(ctx, &test.otelcol)
9291060
if test.expectedErr == "" {
9301061
assert.NoError(t, err)
931-
return
932-
}
933-
if len(test.expectedWarnings) == 0 {
934-
assert.Empty(t, warnings, test.expectedWarnings)
9351062
} else {
936-
assert.ElementsMatch(t, warnings, test.expectedWarnings)
1063+
assert.ErrorContains(t, err, test.expectedErr)
9371064
}
938-
assert.ErrorContains(t, err, test.expectedErr)
1065+
assert.Equal(t, len(test.expectedWarnings), len(warnings))
1066+
assert.ElementsMatch(t, warnings, test.expectedWarnings)
9391067
})
9401068
}
9411069
}
1070+
1071+
func getReviewer(shouldFailSAR bool) *rbac.Reviewer {
1072+
c := fake.NewSimpleClientset()
1073+
c.PrependReactor("create", "subjectaccessreviews", func(action kubeTesting.Action) (handled bool, ret runtime.Object, err error) {
1074+
// check our expectation here
1075+
if !action.Matches("create", "subjectaccessreviews") {
1076+
return false, nil, fmt.Errorf("must be a create for a SAR")
1077+
}
1078+
sar, ok := action.(kubeTesting.CreateAction).GetObject().DeepCopyObject().(*authv1.SubjectAccessReview)
1079+
if !ok || sar == nil {
1080+
return false, nil, fmt.Errorf("bad object")
1081+
}
1082+
sar.Status = authv1.SubjectAccessReviewStatus{
1083+
Allowed: !shouldFailSAR,
1084+
Denied: shouldFailSAR,
1085+
}
1086+
return true, sar, nil
1087+
})
1088+
return rbac.NewReviewer(c)
1089+
}

0 commit comments

Comments
 (0)