Skip to content

Commit 6b2498a

Browse files
committed
v2: correct and test controller RBAC
Prior to this commit the declared permissions for the RedpandaReconciler had become out of date. This went unnoticed due to tests utilizing admin permissions or the inflated permissions required for executing `rpk debug bundle`. This commit corrects the permission declaration of the RedpandaReconciler, updates its tests to use the ClusterRole and Role generated by controller-gen, and adds a test to statically assert the correctness of the permissions.
1 parent 3a34a83 commit 6b2498a

File tree

6 files changed

+529
-41
lines changed

6 files changed

+529
-41
lines changed

operator/config/rbac/bases/operator/role.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ rules:
155155
- clusterroles
156156
verbs:
157157
- create
158+
- delete
158159
- get
159160
- list
160161
- patch
@@ -215,6 +216,8 @@ rules:
215216
resources:
216217
- configmaps
217218
- pods
219+
- rolebindings
220+
- roles
218221
- secrets
219222
- serviceaccounts
220223
- services
@@ -394,6 +397,7 @@ rules:
394397
- apiGroups:
395398
- monitoring.coreos.com
396399
resources:
400+
- podmonitors
397401
- servicemonitors
398402
verbs:
399403
- create

operator/internal/controller/redpanda/redpanda_controller.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,22 +104,20 @@ type RedpandaReconciler struct {
104104

105105
// any resource that Redpanda helm creates and flux controller needs to reconcile them
106106
// +kubebuilder:rbac:groups="",namespace=default,resources=pods,verbs=get;list;watch;create;update;patch;delete
107-
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,namespace=default,resources=rolebindings,verbs=get;list;watch;create;update;patch;delete
108-
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,namespace=default,resources=roles,verbs=get;list;watch;create;update;patch;delete
107+
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles;clusterrolebindings,verbs=get;list;watch;create;update;patch;delete
108+
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,namespace=default,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch;delete
109109
// +kubebuilder:rbac:groups=batch,namespace=default,resources=jobs,verbs=get;list;watch;create;update;patch;delete
110-
// +kubebuilder:rbac:groups=core,namespace=default,resources=secrets,verbs=get;list;watch;create;update;patch;delete
111-
// +kubebuilder:rbac:groups=core,namespace=default,resources=services,verbs=get;list;watch;create;update;patch;delete
112-
// +kubebuilder:rbac:groups=core,namespace=default,resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete
110+
// +kubebuilder:rbac:groups=core,namespace=default,resources=configmaps;roles;rolebindings;secrets;services;serviceaccounts,verbs=get;list;watch;create;update;patch;delete
113111
// +kubebuilder:rbac:groups=apps,namespace=default,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete;
114112
// +kubebuilder:rbac:groups=policy,namespace=default,resources=poddisruptionbudgets,verbs=get;list;watch;create;update;patch;delete
115113
// +kubebuilder:rbac:groups=apps,namespace=default,resources=deployments,verbs=get;list;watch;create;update;patch;delete
116114
// +kubebuilder:rbac:groups=cert-manager.io,namespace=default,resources=certificates,verbs=get;create;update;patch;delete;list;watch
117115
// +kubebuilder:rbac:groups=cert-manager.io,namespace=default,resources=issuers,verbs=get;create;update;patch;delete;list;watch
118-
// +kubebuilder:rbac:groups="monitoring.coreos.com",namespace=default,resources=servicemonitors,verbs=get;list;watch;create;update;patch;delete
119-
// +kubebuilder:rbac:groups=networking.k8s.io,namespace=default,resources=ingresses,verbs=get;list;watch;create;update;patch;delete
116+
// +kubebuilder:rbac:groups="monitoring.coreos.com",namespace=default,resources=podmonitors;servicemonitors,verbs=get;list;watch;create;update;patch;delete
120117

121118
// Console chart
122119
// +kubebuilder:rbac:groups=autoscaling,namespace=default,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;patch;delete
120+
// +kubebuilder:rbac:groups=networking.k8s.io,namespace=default,resources=ingresses,verbs=get;list;watch;create;update;patch;delete
123121

124122
// redpanda resources
125123
// +kubebuilder:rbac:groups=cluster.redpanda.com,namespace=default,resources=redpandas,verbs=get;list;watch;create;update;patch;delete

operator/internal/controller/redpanda/redpanda_controller_test.go

Lines changed: 162 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ package redpanda_test
1111

1212
import (
1313
"context"
14+
_ "embed"
1415
"encoding/json"
1516
"fmt"
1617
"math/rand"
1718
"sort"
1819
"strings"
20+
"slices"
1921
"testing"
2022
"time"
2123

@@ -36,6 +38,7 @@ import (
3638
"github.com/stretchr/testify/suite"
3739
appsv1 "k8s.io/api/apps/v1"
3840
corev1 "k8s.io/api/core/v1"
41+
rbacv1 "k8s.io/api/rbac/v1"
3942
apierrors "k8s.io/apimachinery/pkg/api/errors"
4043
apimeta "k8s.io/apimachinery/pkg/api/meta"
4144
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -47,6 +50,12 @@ import (
4750
"sigs.k8s.io/controller-runtime/pkg/client"
4851
)
4952

53+
// operatorRBAC is the ClusterRole and Role generated via controller-gen and
54+
// goembeded so it can be used for tests.
55+
//
56+
//go:embed role.yaml
57+
var operatorRBAC []byte
58+
5059
// NB: This test setup is largely incompatible with webhooks. Though we might
5160
// be able to figure something freaky out.
5261
func TestRedpandaController(t *testing.T) {
@@ -254,7 +263,7 @@ func (s *RedpandaControllerSuite) TestManagedDecommission() {
254263
"operator.redpanda.com/managed-decommission": "2999-12-31T00:00:00Z",
255264
}
256265

257-
s.applyAndWaitFor(rp, func(o client.Object) bool {
266+
s.applyAndWaitFor(func(o client.Object) bool {
258267
rp := o.(*redpandav1alpha2.Redpanda)
259268

260269
for _, cond := range rp.Status.Conditions {
@@ -263,7 +272,7 @@ func (s *RedpandaControllerSuite) TestManagedDecommission() {
263272
}
264273
}
265274
return false
266-
})
275+
}, rp)
267276

268277
s.waitFor(rp, func(o client.Object) bool {
269278
rp := o.(*redpandav1alpha2.Redpanda)
@@ -318,15 +327,15 @@ func (s *RedpandaControllerSuite) TestClusterSettings() {
318327

319328
rp.Spec.ClusterSpec.Config.Cluster = &runtime.RawExtension{Raw: asJson}
320329
s.applyAndWait(rp)
321-
s.applyAndWaitFor(rp, func(o client.Object) bool {
330+
s.applyAndWaitFor(func(o client.Object) bool {
322331
rp := o.(*redpandav1alpha2.Redpanda)
323332
for _, cond := range rp.Status.Conditions {
324333
if cond.Type == redpandav1alpha2.ClusterConfigSynced {
325334
return cond.ObservedGeneration == rp.Generation && cond.Status == metav1.ConditionTrue
326335
}
327336
}
328337
return false
329-
})
338+
}, rp)
330339
}
331340
s.applyAndWait(&corev1.Secret{
332341
ObjectMeta: metav1.ObjectMeta{
@@ -426,14 +435,14 @@ func (s *RedpandaControllerSuite) SetupSuite() {
426435
// rest config given to the manager.
427436
s.ctx = context.Background()
428437
s.env = testenv.New(t, testenv.Options{
429-
Scheme: controller.UnifiedScheme,
438+
Scheme: controller.V2Scheme,
430439
CRDs: crds.All(),
431440
Logger: testr.New(t),
432441
})
433442

434443
s.client = s.env.Client()
435444

436-
s.env.SetupManager(func(mgr ctrl.Manager) error {
445+
s.env.SetupManager(s.setupRBAC(), func(mgr ctrl.Manager) error {
437446
controllers := flux.NewFluxControllers(mgr, fluxclient.Options{}, fluxclient.KubeConfigOptions{})
438447
for _, controller := range controllers {
439448
if err := controller.SetupWithManager(s.ctx, mgr); err != nil {
@@ -462,18 +471,81 @@ func (s *RedpandaControllerSuite) SetupSuite() {
462471
})
463472
}
464473

465-
func (s *RedpandaControllerSuite) minimalRP(useFlux bool) *redpandav1alpha2.Redpanda {
474+
func (s *RedpandaControllerSuite) setupRBAC() string {
475+
roles, err := kube.DecodeYAML(operatorRBAC, s.client.Scheme())
476+
s.Require().NoError(err)
477+
478+
role := roles[1].(*rbacv1.Role)
479+
clusterRole := roles[0].(*rbacv1.ClusterRole)
480+
481+
// Inject additional permissions required for running in testenv.
482+
role.Rules = append(role.Rules, rbacv1.PolicyRule{
483+
APIGroups: []string{""},
484+
Resources: []string{"pods/portforward"},
485+
Verbs: []string{"*"},
486+
})
487+
488+
name := "testenv-" + s.randString(6)
489+
490+
role.Name = name
491+
role.Namespace = s.env.Namespace()
492+
clusterRole.Name = name
493+
clusterRole.Namespace = s.env.Namespace()
494+
495+
s.applyAndWait(roles...)
496+
s.applyAndWait(
497+
&corev1.ServiceAccount{
498+
ObjectMeta: metav1.ObjectMeta{
499+
Name: name,
500+
},
501+
},
502+
&rbacv1.RoleBinding{
503+
ObjectMeta: metav1.ObjectMeta{
504+
Name: name,
505+
},
506+
Subjects: []rbacv1.Subject{
507+
{Kind: "ServiceAccount", Namespace: s.env.Namespace(), Name: name},
508+
},
509+
RoleRef: rbacv1.RoleRef{
510+
APIGroup: "rbac.authorization.k8s.io",
511+
Kind: "Role",
512+
Name: role.Name,
513+
},
514+
},
515+
&rbacv1.ClusterRoleBinding{
516+
ObjectMeta: metav1.ObjectMeta{
517+
Name: name,
518+
},
519+
Subjects: []rbacv1.Subject{
520+
{Kind: "ServiceAccount", Namespace: s.env.Namespace(), Name: name},
521+
},
522+
RoleRef: rbacv1.RoleRef{
523+
APIGroup: "rbac.authorization.k8s.io",
524+
Kind: "ClusterRole",
525+
Name: clusterRole.Name,
526+
},
527+
},
528+
)
529+
530+
return name
531+
}
532+
533+
func (s *RedpandaControllerSuite) randString(length int) string {
466534
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
467535

468-
name := "rp-"
536+
name := ""
469537
for i := 0; i < 6; i++ {
470538
//nolint:gosec // not meant to be a secure random string.
471539
name += string(alphabet[rand.Intn(len(alphabet))])
472540
}
473541

542+
return name
543+
}
544+
545+
func (s *RedpandaControllerSuite) minimalRP(useFlux bool) *redpandav1alpha2.Redpanda {
474546
return &redpandav1alpha2.Redpanda{
475547
ObjectMeta: metav1.ObjectMeta{
476-
Name: name, // GenerateName doesn't play nice with SSA.
548+
Name: "rp-" + s.randString(6), // GenerateName doesn't play nice with SSA.
477549
},
478550
Spec: redpandav1alpha2.RedpandaSpec{
479551
ChartRef: redpandav1alpha2.ChartRef{
@@ -541,45 +613,50 @@ func (s *RedpandaControllerSuite) deleteAndWait(obj client.Object) {
541613
}))
542614
}
543615

544-
func (s *RedpandaControllerSuite) applyAndWait(obj client.Object) {
545-
s.applyAndWaitFor(obj, func(o client.Object) bool {
616+
func (s *RedpandaControllerSuite) applyAndWait(objs ...client.Object) {
617+
s.applyAndWaitFor(func(obj client.Object) bool {
546618
switch obj := obj.(type) {
547619
case *redpandav1alpha2.Redpanda:
548620
ready := apimeta.IsStatusConditionTrue(obj.Status.Conditions, "Ready")
549621
upToDate := obj.Generation != 0 && obj.Generation == obj.Status.ObservedGeneration
550622
return upToDate && ready
551623

552-
case *corev1.Secret, *corev1.ConfigMap:
624+
case *corev1.Secret, *corev1.ConfigMap, *corev1.ServiceAccount,
625+
*rbacv1.ClusterRole, *rbacv1.Role, *rbacv1.RoleBinding, *rbacv1.ClusterRoleBinding:
553626
return true
554627

555628
default:
556629
s.T().Fatalf("unhandled object %T in applyAndWait", obj)
557630
panic("unreachable")
558631
}
559-
})
632+
}, objs...)
560633
}
561634

562-
func (s *RedpandaControllerSuite) applyAndWaitFor(obj client.Object, cond func(client.Object) bool) {
563-
gvk, err := s.client.GroupVersionKindFor(obj)
564-
s.NoError(err)
635+
func (s *RedpandaControllerSuite) applyAndWaitFor(cond func(client.Object) bool, objs ...client.Object) {
636+
for _, obj := range objs {
637+
gvk, err := s.client.GroupVersionKindFor(obj)
638+
s.NoError(err)
565639

566-
obj.SetManagedFields(nil)
567-
obj.GetObjectKind().SetGroupVersionKind(gvk)
640+
obj.SetManagedFields(nil)
641+
obj.GetObjectKind().SetGroupVersionKind(gvk)
568642

569-
s.Require().NoError(s.client.Patch(s.ctx, obj, client.Apply, client.ForceOwnership, client.FieldOwner("tests")))
643+
s.Require().NoError(s.client.Patch(s.ctx, obj, client.Apply, client.ForceOwnership, client.FieldOwner("tests")))
644+
}
570645

571-
s.NoError(wait.PollUntilContextTimeout(s.ctx, 5*time.Second, 5*time.Minute, false, func(ctx context.Context) (done bool, err error) {
572-
if err := s.client.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil {
573-
return false, err
574-
}
646+
for _, obj := range objs {
647+
s.NoError(wait.PollUntilContextTimeout(s.ctx, 5*time.Second, 5*time.Minute, false, func(ctx context.Context) (done bool, err error) {
648+
if err := s.client.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil {
649+
return false, err
650+
}
575651

576-
if cond(obj) {
577-
return true, nil
578-
}
652+
if cond(obj) {
653+
return true, nil
654+
}
579655

580-
s.T().Logf("waiting for %T %q to be ready", obj, obj.GetName())
581-
return false, nil
582-
}))
656+
s.T().Logf("waiting for %T %q to be ready", obj, obj.GetName())
657+
return false, nil
658+
}))
659+
}
583660
}
584661

585662
func (s *RedpandaControllerSuite) waitFor(obj client.Object, cond func(client.Object) bool) {
@@ -686,3 +763,59 @@ func TestPostInstallUpgradeJobIndex(t *testing.T) {
686763
// `clusterConfigfor` utilizes.
687764
require.Equal(t, "bootstrap-yaml-envsubst", job.Spec.Template.Spec.InitContainers[0].Name)
688765
}
766+
767+
// TestControllerRBAC asserts that the declared Roles and ClusterRoles of the
768+
// RedpandaReconciler line up with all the resource types it needs to manage.
769+
func TestControllerRBAC(t *testing.T) {
770+
scheme := controller.V2Scheme
771+
772+
expectedVerbs := []string{"create", "delete", "get", "list", "patch", "update", "watch"}
773+
774+
roles, err := kube.DecodeYAML(operatorRBAC, scheme)
775+
require.NoError(t, err)
776+
777+
role := roles[1].(*rbacv1.Role)
778+
clusterRole := roles[0].(*rbacv1.ClusterRole)
779+
780+
for _, typ := range redpandachart.Types() {
781+
gkvs, _, err := scheme.ObjectKinds(typ)
782+
require.NoError(t, err)
783+
784+
require.Len(t, gkvs, 1)
785+
gvk := gkvs[0]
786+
787+
rules := role.Rules
788+
if !isNamespaced(typ) {
789+
rules = clusterRole.Rules
790+
}
791+
792+
group := gvk.Group
793+
kind := pluralize(gvk.Kind)
794+
795+
idx := slices.IndexFunc(rules, func(rule rbacv1.PolicyRule) bool {
796+
return slices.Contains(rule.APIGroups, group) && slices.Contains(rule.Resources, kind)
797+
})
798+
799+
require.NotEqual(t, -1, idx, "missing rules for %s %s", gvk.Group, kind)
800+
require.EqualValues(t, expectedVerbs, rules[idx].Verbs, "incorrect verbs for %s %s", gvk.Group, kind)
801+
}
802+
}
803+
804+
func isNamespaced(obj client.Object) bool {
805+
switch obj.(type) {
806+
case *corev1.Namespace, *rbacv1.ClusterRole, *rbacv1.ClusterRoleBinding:
807+
return false
808+
default:
809+
return true
810+
}
811+
}
812+
813+
func pluralize(kind string) string {
814+
switch kind[len(kind)-1] {
815+
case 's':
816+
return strings.ToLower(kind) + "es"
817+
818+
default:
819+
return strings.ToLower(kind) + "s"
820+
}
821+
}

0 commit comments

Comments
 (0)