Skip to content

Commit bca7a49

Browse files
perdasilvaPer G. da Silvaclaude
authored
✨ Make RBACPreAuthorizer collection verbs configurable (operator-framework#2539)
Make both clusterCollectionVerbs and namespacedCollectionVerbs on RBACPreAuthorizer configurable via functional options, decoupling them from the hardcoded verbs that were tightly coupled to the contentmanager's requirements. Both the helm and boxcutter appliers explicitly configure namespacedCollectionVerbs with "create". The helm applier additionally configures clusterCollectionVerbs with "list" and "watch" (needed by contentmanager), while the boxcutter applier uses no cluster collection verbs. Also updates e2e tests to select the appropriate RBAC template based on the BoxcutterRuntime feature gate, using a narrower template without list/watch when BoxcutterRuntime is enabled. Closes: operator-framework#1911 Signed-off-by: Per G. da Silva <pegoncal@redhat.com> Co-authored-by: Per G. da Silva <pegoncal@redhat.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 95afce0 commit bca7a49

7 files changed

Lines changed: 363 additions & 27 deletions

File tree

cmd/operator-controller/main.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -724,7 +724,12 @@ func (c *helmReconcilerConfigurator) Configure(ceReconciler *controllers.Cluster
724724
// determine if PreAuthorizer should be enabled based on feature gate
725725
var preAuth authorization.PreAuthorizer
726726
if features.OperatorControllerFeatureGate.Enabled(features.PreflightPermissions) {
727-
preAuth = authorization.NewRBACPreAuthorizer(c.mgr.GetClient())
727+
preAuth = authorization.NewRBACPreAuthorizer(
728+
c.mgr.GetClient(),
729+
// Additional verbs / bundle manifest that are expected by the content manager to watch those resources
730+
authorization.WithClusterCollectionVerbs("list", "watch"),
731+
authorization.WithNamespacedCollectionVerbs("create"),
732+
)
728733
}
729734

730735
cm := contentmanager.NewManager(clientRestConfigMapper, c.mgr.GetConfig(), c.mgr.GetRESTMapper())

internal/operator-controller/authorization/rbac.go

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import (
2929
rbacv1helpers "k8s.io/kubernetes/pkg/apis/rbac/v1"
3030
rbacregistry "k8s.io/kubernetes/pkg/registry/rbac"
3131
"k8s.io/kubernetes/pkg/registry/rbac/validation"
32-
rbac "k8s.io/kubernetes/plugin/pkg/auth/authorizer/rbac"
32+
"k8s.io/kubernetes/plugin/pkg/auth/authorizer/rbac"
3333
"sigs.k8s.io/controller-runtime/pkg/client"
3434
)
3535

@@ -59,26 +59,42 @@ type ScopedPolicyRules struct {
5959

6060
var objectVerbs = []string{"get", "patch", "update", "delete"}
6161

62-
// Here we are splitting collection verbs based on required scope
63-
// NB: this split is tightly coupled to the requirements of the contentmanager, specifically
64-
// its need for cluster-scoped list/watch permissions.
65-
// TODO: We are accepting this coupling for now, but plan to decouple
66-
// TODO: link for above https://github.com/operator-framework/operator-controller/issues/1911
67-
var namespacedCollectionVerbs = []string{"create"}
68-
var clusterCollectionVerbs = []string{"list", "watch"}
62+
type RBACPreAuthorizerOption func(*rbacPreAuthorizer)
63+
64+
// WithClusterCollectionVerbs configures cluster-scoped collection verbs (e.g. list, watch)
65+
// that are checked in addition to object and namespaced collection verbs.
66+
func WithClusterCollectionVerbs(verbs ...string) RBACPreAuthorizerOption {
67+
return func(a *rbacPreAuthorizer) {
68+
a.clusterCollectionVerbs = slices.Clone(verbs)
69+
}
70+
}
71+
72+
// WithNamespacedCollectionVerbs configures namespaced collection verbs (e.g. create)
73+
// that are checked for each unique namespace across all objects in a GVR.
74+
func WithNamespacedCollectionVerbs(verbs ...string) RBACPreAuthorizerOption {
75+
return func(a *rbacPreAuthorizer) {
76+
a.namespacedCollectionVerbs = slices.Clone(verbs)
77+
}
78+
}
6979

7080
type rbacPreAuthorizer struct {
71-
authorizer authorizer.Authorizer
72-
ruleResolver validation.AuthorizationRuleResolver
73-
restMapper meta.RESTMapper
81+
authorizer authorizer.Authorizer
82+
ruleResolver validation.AuthorizationRuleResolver
83+
restMapper meta.RESTMapper
84+
clusterCollectionVerbs []string
85+
namespacedCollectionVerbs []string
7486
}
7587

76-
func NewRBACPreAuthorizer(cl client.Client) PreAuthorizer {
77-
return &rbacPreAuthorizer{
88+
func NewRBACPreAuthorizer(cl client.Client, opts ...RBACPreAuthorizerOption) PreAuthorizer {
89+
a := &rbacPreAuthorizer{
7890
authorizer: newRBACAuthorizer(cl),
7991
ruleResolver: newRBACRulesResolver(cl),
8092
restMapper: cl.RESTMapper(),
8193
}
94+
for _, opt := range opts {
95+
opt(a)
96+
}
97+
return a
8298
}
8399

84100
func (a *rbacPreAuthorizer) PreAuthorize(ctx context.Context, user user.Info, manifestReader io.Reader, additionalRequiredPerms ...UserAuthorizerAttributesFactory) ([]ScopedPolicyRules, error) {
@@ -88,7 +104,7 @@ func (a *rbacPreAuthorizer) PreAuthorize(ctx context.Context, user user.Info, ma
88104
}
89105

90106
// derive manifest related attributes records
91-
attributesRecords := dm.asAuthorizationAttributesRecordsForUser(user)
107+
attributesRecords := dm.asAuthorizationAttributesRecordsForUser(user, a.clusterCollectionVerbs, a.namespacedCollectionVerbs)
92108

93109
// append additional required perms
94110
for _, fn := range additionalRequiredPerms {
@@ -324,7 +340,7 @@ func (dm *decodedManifest) rbacObjects() []client.Object {
324340
return objects
325341
}
326342

327-
func (dm *decodedManifest) asAuthorizationAttributesRecordsForUser(manifestManager user.Info) []authorizer.AttributesRecord {
343+
func (dm *decodedManifest) asAuthorizationAttributesRecordsForUser(manifestManager user.Info, clusterCollectionVerbs, namespacedCollectionVerbs []string) []authorizer.AttributesRecord {
328344
// Calculate initial capacity as an upper-bound estimate:
329345
// - For each key: len(objectVerbs) records (4)
330346
// - For unique namespaces: len(namespacedCollectionVerbs) records (1 per unique namespace across all keys in a GVR)
@@ -369,7 +385,7 @@ func (dm *decodedManifest) asAuthorizationAttributesRecordsForUser(manifestManag
369385
})
370386
}
371387
}
372-
// generate records for cluster-scoped collection verbs (list, watch) required by contentmanager
388+
// generate records for cluster-scoped collection verbs (e.g. list, watch)
373389
for _, v := range clusterCollectionVerbs {
374390
attributeRecords = append(attributeRecords, authorizer.AttributesRecord{
375391
User: manifestManager,

internal/operator-controller/authorization/rbac_test.go

Lines changed: 236 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ func setupFakeClient(role client.Object) client.Client {
396396
func TestPreAuthorize_Success(t *testing.T) {
397397
t.Run("preauthorize succeeds with no missing rbac rules", func(t *testing.T) {
398398
fakeClient := setupFakeClient(privilegedClusterRole)
399-
preAuth := NewRBACPreAuthorizer(fakeClient)
399+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"), WithNamespacedCollectionVerbs("create"))
400400
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
401401
require.NoError(t, err)
402402
require.Equal(t, []ScopedPolicyRules{}, missingRules)
@@ -406,7 +406,7 @@ func TestPreAuthorize_Success(t *testing.T) {
406406
func TestPreAuthorize_MissingRBAC(t *testing.T) {
407407
t.Run("preauthorize fails and finds missing rbac rules", func(t *testing.T) {
408408
fakeClient := setupFakeClient(limitedClusterRole)
409-
preAuth := NewRBACPreAuthorizer(fakeClient)
409+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"), WithNamespacedCollectionVerbs("create"))
410410
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
411411
require.NoError(t, err)
412412
require.Equal(t, expectedSingleNamespaceMissingRules, missingRules)
@@ -416,7 +416,7 @@ func TestPreAuthorize_MissingRBAC(t *testing.T) {
416416
func TestPreAuthorizeMultiNamespace_MissingRBAC(t *testing.T) {
417417
t.Run("preauthorize fails and finds missing rbac rules in multiple namespaces", func(t *testing.T) {
418418
fakeClient := setupFakeClient(limitedClusterRole)
419-
preAuth := NewRBACPreAuthorizer(fakeClient)
419+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"), WithNamespacedCollectionVerbs("create"))
420420
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifestMultiNamespace))
421421
require.NoError(t, err)
422422
require.Equal(t, expectedMultiNamespaceMissingRules, missingRules)
@@ -426,7 +426,7 @@ func TestPreAuthorizeMultiNamespace_MissingRBAC(t *testing.T) {
426426
func TestPreAuthorize_CheckEscalation(t *testing.T) {
427427
t.Run("preauthorize succeeds with no missing rbac rules", func(t *testing.T) {
428428
fakeClient := setupFakeClient(escalatingClusterRole)
429-
preAuth := NewRBACPreAuthorizer(fakeClient)
429+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"), WithNamespacedCollectionVerbs("create"))
430430
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
431431
require.NoError(t, err)
432432
require.Equal(t, []ScopedPolicyRules{}, missingRules)
@@ -436,7 +436,7 @@ func TestPreAuthorize_CheckEscalation(t *testing.T) {
436436
func TestPreAuthorize_AdditionalRequiredPerms_MissingRBAC(t *testing.T) {
437437
t.Run("preauthorize fails and finds missing rbac rules coming from the additional required permissions", func(t *testing.T) {
438438
fakeClient := setupFakeClient(escalatingClusterRole)
439-
preAuth := NewRBACPreAuthorizer(fakeClient)
439+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"), WithNamespacedCollectionVerbs("create"))
440440
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest), func(user user.Info) []authorizer.AttributesRecord {
441441
return []authorizer.AttributesRecord{
442442
{
@@ -465,6 +465,237 @@ func TestPreAuthorize_AdditionalRequiredPerms_MissingRBAC(t *testing.T) {
465465
})
466466
}
467467

468+
func TestPreAuthorize_WithClusterCollectionVerbs(t *testing.T) {
469+
// expectedNamespacedMissingRules are the missing rules expected in the "test-namespace"
470+
// namespace regardless of cluster collection verb configuration. These come from object
471+
// verbs (get, patch, update, delete), namespaced collection verbs (create), and the
472+
// escalation check for the role/rolebinding in the manifest.
473+
expectedNamespacedMissingRules := ScopedPolicyRules{
474+
Namespace: "test-namespace",
475+
MissingRules: []rbacv1.PolicyRule{
476+
{
477+
Verbs: []string{"create"},
478+
APIGroups: []string{"*"},
479+
Resources: []string{"certificates"}},
480+
{
481+
Verbs: []string{"create"},
482+
APIGroups: []string{""},
483+
Resources: []string{"services"}},
484+
{
485+
Verbs: []string{"create"},
486+
APIGroups: []string{"rbac.authorization.k8s.io"},
487+
Resources: []string{"rolebindings"}},
488+
{
489+
Verbs: []string{"create"},
490+
APIGroups: []string{"rbac.authorization.k8s.io"},
491+
Resources: []string{"roles"}},
492+
{
493+
Verbs: []string{"delete", "get", "patch", "update"},
494+
APIGroups: []string{""},
495+
Resources: []string{"services"},
496+
ResourceNames: []string{"test-service"}},
497+
{
498+
Verbs: []string{"delete", "get", "patch", "update"},
499+
APIGroups: []string{"rbac.authorization.k8s.io"},
500+
Resources: []string{"rolebindings"},
501+
ResourceNames: []string{"test-extension-binding"}},
502+
{
503+
Verbs: []string{"delete", "get", "patch", "update"},
504+
APIGroups: []string{"rbac.authorization.k8s.io"},
505+
Resources: []string{"roles"},
506+
ResourceNames: []string{"test-extension-role"}},
507+
{
508+
Verbs: []string{"watch"},
509+
APIGroups: []string{"*"},
510+
Resources: []string{"serviceaccounts"},
511+
},
512+
},
513+
}
514+
515+
t.Run("no cluster collection verbs option omits cluster-scoped collection rules", func(t *testing.T) {
516+
fakeClient := setupFakeClient(limitedClusterRole)
517+
preAuth := NewRBACPreAuthorizer(fakeClient, WithNamespacedCollectionVerbs("create"))
518+
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
519+
require.NoError(t, err)
520+
// With no cluster collection verbs, there should be no cluster-scoped (namespace="") missing rules
521+
require.Equal(t, []ScopedPolicyRules{expectedNamespacedMissingRules}, missingRules)
522+
})
523+
524+
t.Run("cluster verbs option only checks those verbs at cluster scope", func(t *testing.T) {
525+
fakeClient := setupFakeClient(limitedClusterRole)
526+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("get", "patch", "update"), WithNamespacedCollectionVerbs("create"))
527+
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
528+
require.NoError(t, err)
529+
require.Equal(t, []ScopedPolicyRules{
530+
{
531+
Namespace: "",
532+
MissingRules: []rbacv1.PolicyRule{
533+
{
534+
Verbs: []string{"get", "patch", "update"},
535+
APIGroups: []string{""},
536+
Resources: []string{"services"},
537+
ResourceNames: []string(nil),
538+
NonResourceURLs: []string(nil)},
539+
{
540+
Verbs: []string{"get", "patch", "update"},
541+
APIGroups: []string{"rbac.authorization.k8s.io"},
542+
Resources: []string{"rolebindings"},
543+
ResourceNames: []string(nil),
544+
NonResourceURLs: []string(nil)},
545+
{
546+
Verbs: []string{"get", "patch", "update"},
547+
APIGroups: []string{"rbac.authorization.k8s.io"},
548+
Resources: []string{"roles"},
549+
ResourceNames: []string(nil),
550+
NonResourceURLs: []string(nil),
551+
},
552+
},
553+
},
554+
expectedNamespacedMissingRules,
555+
}, missingRules)
556+
})
557+
558+
t.Run("privileged user with no cluster collection verbs succeeds", func(t *testing.T) {
559+
fakeClient := setupFakeClient(privilegedClusterRole)
560+
preAuth := NewRBACPreAuthorizer(fakeClient, WithNamespacedCollectionVerbs("create"))
561+
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
562+
require.NoError(t, err)
563+
require.Equal(t, []ScopedPolicyRules{}, missingRules)
564+
})
565+
}
566+
567+
func TestPreAuthorize_WithNamespacedCollectionVerbs(t *testing.T) {
568+
// expectedClusterMissingRules are the missing rules expected at cluster scope
569+
// when cluster collection verbs are configured as "list", "watch".
570+
expectedClusterMissingRules := ScopedPolicyRules{
571+
Namespace: "",
572+
MissingRules: []rbacv1.PolicyRule{
573+
{
574+
Verbs: []string{"list", "watch"},
575+
APIGroups: []string{""},
576+
Resources: []string{"services"},
577+
ResourceNames: []string(nil),
578+
NonResourceURLs: []string(nil)},
579+
{
580+
Verbs: []string{"list", "watch"},
581+
APIGroups: []string{"rbac.authorization.k8s.io"},
582+
Resources: []string{"rolebindings"},
583+
ResourceNames: []string(nil),
584+
NonResourceURLs: []string(nil)},
585+
{
586+
Verbs: []string{"list", "watch"},
587+
APIGroups: []string{"rbac.authorization.k8s.io"},
588+
Resources: []string{"roles"},
589+
ResourceNames: []string(nil),
590+
NonResourceURLs: []string(nil),
591+
},
592+
},
593+
}
594+
595+
t.Run("no namespaced collection verbs option omits namespaced collection rules", func(t *testing.T) {
596+
fakeClient := setupFakeClient(limitedClusterRole)
597+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"))
598+
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
599+
require.NoError(t, err)
600+
// Without namespaced collection verbs, no "create" rules from collection verbs should appear,
601+
// but object verbs (get, patch, update, delete) and escalation checks still apply
602+
require.Equal(t, []ScopedPolicyRules{
603+
expectedClusterMissingRules,
604+
{
605+
Namespace: "test-namespace",
606+
MissingRules: []rbacv1.PolicyRule{
607+
{
608+
Verbs: []string{"create"},
609+
APIGroups: []string{"*"},
610+
Resources: []string{"certificates"}},
611+
{
612+
Verbs: []string{"delete", "get", "patch", "update"},
613+
APIGroups: []string{""},
614+
Resources: []string{"services"},
615+
ResourceNames: []string{"test-service"}},
616+
{
617+
Verbs: []string{"delete", "get", "patch", "update"},
618+
APIGroups: []string{"rbac.authorization.k8s.io"},
619+
Resources: []string{"rolebindings"},
620+
ResourceNames: []string{"test-extension-binding"}},
621+
{
622+
Verbs: []string{"delete", "get", "patch", "update"},
623+
APIGroups: []string{"rbac.authorization.k8s.io"},
624+
Resources: []string{"roles"},
625+
ResourceNames: []string{"test-extension-role"}},
626+
{
627+
Verbs: []string{"watch"},
628+
APIGroups: []string{"*"},
629+
Resources: []string{"serviceaccounts"},
630+
},
631+
},
632+
},
633+
}, missingRules)
634+
})
635+
636+
t.Run("namespaced collection verbs option checks those verbs per namespace", func(t *testing.T) {
637+
fakeClient := setupFakeClient(limitedClusterRole)
638+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"), WithNamespacedCollectionVerbs("create", "deletecollection"))
639+
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
640+
require.NoError(t, err)
641+
// Should have cluster-scoped missing rules plus namespaced rules with both create and deletecollection.
642+
// Note: "certificates" with apiGroup "*" comes from the escalation check on the Role, not
643+
// from namespaced collection verbs, so it only has "create".
644+
require.Equal(t, []ScopedPolicyRules{
645+
expectedClusterMissingRules,
646+
{
647+
Namespace: "test-namespace",
648+
MissingRules: []rbacv1.PolicyRule{
649+
{
650+
Verbs: []string{"create", "deletecollection"},
651+
APIGroups: []string{""},
652+
Resources: []string{"services"}},
653+
{
654+
Verbs: []string{"create", "deletecollection"},
655+
APIGroups: []string{"rbac.authorization.k8s.io"},
656+
Resources: []string{"rolebindings"}},
657+
{
658+
Verbs: []string{"create", "deletecollection"},
659+
APIGroups: []string{"rbac.authorization.k8s.io"},
660+
Resources: []string{"roles"}},
661+
{
662+
Verbs: []string{"create"},
663+
APIGroups: []string{"*"},
664+
Resources: []string{"certificates"}},
665+
{
666+
Verbs: []string{"delete", "get", "patch", "update"},
667+
APIGroups: []string{""},
668+
Resources: []string{"services"},
669+
ResourceNames: []string{"test-service"}},
670+
{
671+
Verbs: []string{"delete", "get", "patch", "update"},
672+
APIGroups: []string{"rbac.authorization.k8s.io"},
673+
Resources: []string{"rolebindings"},
674+
ResourceNames: []string{"test-extension-binding"}},
675+
{
676+
Verbs: []string{"delete", "get", "patch", "update"},
677+
APIGroups: []string{"rbac.authorization.k8s.io"},
678+
Resources: []string{"roles"},
679+
ResourceNames: []string{"test-extension-role"}},
680+
{
681+
Verbs: []string{"watch"},
682+
APIGroups: []string{"*"},
683+
Resources: []string{"serviceaccounts"},
684+
},
685+
},
686+
},
687+
}, missingRules)
688+
})
689+
690+
t.Run("privileged user with custom namespaced collection verbs succeeds", func(t *testing.T) {
691+
fakeClient := setupFakeClient(privilegedClusterRole)
692+
preAuth := NewRBACPreAuthorizer(fakeClient, WithNamespacedCollectionVerbs("create", "deletecollection"))
693+
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
694+
require.NoError(t, err)
695+
require.Equal(t, []ScopedPolicyRules{}, missingRules)
696+
})
697+
}
698+
468699
// TestParseEscalationErrorForMissingRules Are tests with respect to https://github.com/kubernetes/api/blob/e8d4d542f6a9a16a694bfc8e3b8cd1557eecfc9d/rbac/v1/types.go#L49-L74
469700
// Goal is: prove the regex works as planned AND that if the error messages ever change we'll learn about it with these tests
470701
func TestParseEscalationErrorForMissingRules_ParsingLogic(t *testing.T) {

0 commit comments

Comments
 (0)