Skip to content

Commit 7145047

Browse files
authored
add create verb to boxcutter preflight (#2587)
1 parent da4f73c commit 7145047

File tree

6 files changed

+162
-141
lines changed

6 files changed

+162
-141
lines changed

cmd/operator-controller/main.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,6 @@ func (c *helmReconcilerConfigurator) Configure(ceReconciler *controllers.Cluster
744744
c.mgr.GetClient(),
745745
// Additional verbs / bundle manifest that are expected by the content manager to watch those resources
746746
authorization.WithClusterCollectionVerbs("list", "watch"),
747-
authorization.WithNamespacedCollectionVerbs("create"),
748747
)
749748
}
750749

internal/operator-controller/authorization/rbac.go

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,16 @@ type ScopedPolicyRules struct {
5757
MissingRules []rbacv1.PolicyRule
5858
}
5959

60+
// objectVerbs are the verbs checked for each specific resource (by name) in the manifest.
61+
// These verbs operate on existing resource instances and can be restricted by resourceNames in RBAC rules.
6062
var objectVerbs = []string{"get", "patch", "update", "delete"}
6163

64+
// namespacedCollectionVerbs are the verbs checked once per unique namespace across all resources in a GVR.
65+
// These verbs operate at the resource type level (collection) and cannot be restricted by resourceNames.
66+
// The "create" verb is included here because it operates on the collection level - you can't specify
67+
// resourceNames for create operations since the resource doesn't exist yet.
68+
var namespacedCollectionVerbs = []string{"create"}
69+
6270
type RBACPreAuthorizerOption func(*rbacPreAuthorizer)
6371

6472
// WithClusterCollectionVerbs configures cluster-scoped collection verbs (e.g. list, watch)
@@ -69,20 +77,11 @@ func WithClusterCollectionVerbs(verbs ...string) RBACPreAuthorizerOption {
6977
}
7078
}
7179

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-
}
79-
8080
type rbacPreAuthorizer struct {
81-
authorizer authorizer.Authorizer
82-
ruleResolver validation.AuthorizationRuleResolver
83-
restMapper meta.RESTMapper
84-
clusterCollectionVerbs []string
85-
namespacedCollectionVerbs []string
81+
authorizer authorizer.Authorizer
82+
ruleResolver validation.AuthorizationRuleResolver
83+
restMapper meta.RESTMapper
84+
clusterCollectionVerbs []string
8685
}
8786

8887
func NewRBACPreAuthorizer(cl client.Client, opts ...RBACPreAuthorizerOption) PreAuthorizer {
@@ -104,7 +103,7 @@ func (a *rbacPreAuthorizer) PreAuthorize(ctx context.Context, user user.Info, ma
104103
}
105104

106105
// derive manifest related attributes records
107-
attributesRecords := dm.asAuthorizationAttributesRecordsForUser(user, a.clusterCollectionVerbs, a.namespacedCollectionVerbs)
106+
attributesRecords := dm.asAuthorizationAttributesRecordsForUser(user, a.clusterCollectionVerbs, namespacedCollectionVerbs)
108107

109108
// append additional required perms
110109
for _, fn := range additionalRequiredPerms {

internal/operator-controller/authorization/rbac_test.go

Lines changed: 18 additions & 126 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, WithClusterCollectionVerbs("list", "watch"), WithNamespacedCollectionVerbs("create"))
399+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"))
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, WithClusterCollectionVerbs("list", "watch"), WithNamespacedCollectionVerbs("create"))
409+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"))
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, WithClusterCollectionVerbs("list", "watch"), WithNamespacedCollectionVerbs("create"))
419+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"))
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, WithClusterCollectionVerbs("list", "watch"), WithNamespacedCollectionVerbs("create"))
429+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"))
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, WithClusterCollectionVerbs("list", "watch"), WithNamespacedCollectionVerbs("create"))
439+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"))
440440
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest), func(user user.Info) []authorizer.AttributesRecord {
441441
return []authorizer.AttributesRecord{
442442
{
@@ -514,7 +514,7 @@ func TestPreAuthorize_WithClusterCollectionVerbs(t *testing.T) {
514514

515515
t.Run("no cluster collection verbs option omits cluster-scoped collection rules", func(t *testing.T) {
516516
fakeClient := setupFakeClient(limitedClusterRole)
517-
preAuth := NewRBACPreAuthorizer(fakeClient, WithNamespacedCollectionVerbs("create"))
517+
preAuth := NewRBACPreAuthorizer(fakeClient)
518518
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
519519
require.NoError(t, err)
520520
// With no cluster collection verbs, there should be no cluster-scoped (namespace="") missing rules
@@ -523,7 +523,7 @@ func TestPreAuthorize_WithClusterCollectionVerbs(t *testing.T) {
523523

524524
t.Run("cluster verbs option only checks those verbs at cluster scope", func(t *testing.T) {
525525
fakeClient := setupFakeClient(limitedClusterRole)
526-
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("get", "patch", "update"), WithNamespacedCollectionVerbs("create"))
526+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("get", "patch", "update"))
527527
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
528528
require.NoError(t, err)
529529
require.Equal(t, []ScopedPolicyRules{
@@ -557,139 +557,31 @@ func TestPreAuthorize_WithClusterCollectionVerbs(t *testing.T) {
557557

558558
t.Run("privileged user with no cluster collection verbs succeeds", func(t *testing.T) {
559559
fakeClient := setupFakeClient(privilegedClusterRole)
560-
preAuth := NewRBACPreAuthorizer(fakeClient, WithNamespacedCollectionVerbs("create"))
560+
preAuth := NewRBACPreAuthorizer(fakeClient)
561561
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
562562
require.NoError(t, err)
563563
require.Equal(t, []ScopedPolicyRules{}, missingRules)
564564
})
565565
}
566566

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-
}
567+
func TestPreAuthorize_NamespacedCollectionVerbs(t *testing.T) {
568+
// With namespacedCollectionVerbs now being a fixed variable (containing "create"),
569+
// this test verifies that "create" permissions are always checked at the namespace level.
594570

595-
t.Run("no namespaced collection verbs option omits namespaced collection rules", func(t *testing.T) {
571+
t.Run("create verb is always checked as namespaced collection verb", func(t *testing.T) {
596572
fakeClient := setupFakeClient(limitedClusterRole)
597573
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterCollectionVerbs("list", "watch"))
598574
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
599575
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)
576+
// The "create" verb should always appear in missing rules because it's part of the
577+
// namespacedCollectionVerbs. This test verifies expectedSingleNamespaceMissingRules
578+
// which includes "create" verbs for namespace-scoped resources.
579+
require.Equal(t, expectedSingleNamespaceMissingRules, missingRules)
688580
})
689581

690-
t.Run("privileged user with custom namespaced collection verbs succeeds", func(t *testing.T) {
582+
t.Run("privileged user with namespaced collection verbs succeeds", func(t *testing.T) {
691583
fakeClient := setupFakeClient(privilegedClusterRole)
692-
preAuth := NewRBACPreAuthorizer(fakeClient, WithNamespacedCollectionVerbs("create", "deletecollection"))
584+
preAuth := NewRBACPreAuthorizer(fakeClient)
693585
missingRules, err := preAuth.PreAuthorize(context.TODO(), testUser, strings.NewReader(testManifest))
694586
require.NoError(t, err)
695587
require.Equal(t, []ScopedPolicyRules{}, missingRules)

test/e2e/features/install.feature

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,3 +564,38 @@ Feature: Install ClusterExtension
564564
nodeSelector:
565565
kubernetes.io/os: linux
566566
"""
567+
568+
@BoxcutterRuntime
569+
@PreflightPermissions
570+
Scenario: Boxcutter preflight check detects missing CREATE permissions
571+
Given ServiceAccount "olm-sa" without create permissions is available in ${TEST_NAMESPACE}
572+
And ClusterExtension is applied
573+
"""
574+
apiVersion: olm.operatorframework.io/v1
575+
kind: ClusterExtension
576+
metadata:
577+
name: ${NAME}
578+
spec:
579+
namespace: ${TEST_NAMESPACE}
580+
serviceAccount:
581+
name: olm-sa
582+
source:
583+
sourceType: Catalog
584+
catalog:
585+
packageName: test
586+
selector:
587+
matchLabels:
588+
"olm.operatorframework.io/metadata.name": test-catalog
589+
"""
590+
And ClusterExtension reports Progressing as True with Reason Retrying and Message includes:
591+
"""
592+
pre-authorization failed: service account requires the following permissions to manage cluster extension
593+
"""
594+
And ClusterExtension reports Progressing as True with Reason Retrying and Message includes:
595+
"""
596+
Verbs:[create]
597+
"""
598+
When ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE}
599+
Then ClusterExtension is available
600+
And ClusterExtension reports Progressing as True with Reason Succeeded
601+
And ClusterExtension reports Installed as True

test/e2e/steps/steps.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3535
"k8s.io/apimachinery/pkg/util/sets"
3636
k8sresource "k8s.io/cli-runtime/pkg/resource"
37+
"k8s.io/component-base/featuregate"
3738
"k8s.io/utils/ptr"
3839
"sigs.k8s.io/controller-runtime/pkg/client"
3940
"sigs.k8s.io/yaml"
@@ -125,6 +126,7 @@ func RegisterSteps(sc *godog.ScenarioContext) {
125126
sc.Step(`^(?i)ServiceAccount "([^"]*)" with permissions to install extensions is available in "([^"]*)" namespace$`, ServiceAccountWithNeededPermissionsIsAvailableInGivenNamespace)
126127
sc.Step(`^(?i)ServiceAccount "([^"]*)" with needed permissions is available in test namespace$`, ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace)
127128
sc.Step(`^(?i)ServiceAccount "([^"]*)" with needed permissions is available in \${TEST_NAMESPACE}$`, ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace)
129+
sc.Step(`^(?i)ServiceAccount "([^"]*)" without create permissions is available in \${TEST_NAMESPACE}$`, ServiceAccountWithoutCreatePermissionsIsAvailableInTestNamespace)
128130
sc.Step(`^(?i)ServiceAccount "([^"]*)" is available in \${TEST_NAMESPACE}$`, ServiceAccountIsAvailableInNamespace)
129131
sc.Step(`^(?i)ServiceAccount "([^"]*)" in test namespace is cluster admin$`, ServiceAccountWithClusterAdminPermissionsIsAvailableInNamespace)
130132
sc.Step(`^(?i)ServiceAccount "([^"]+)" in test namespace has permissions to fetch "([^"]+)" metrics$`, ServiceAccountWithFetchMetricsPermissions)
@@ -436,6 +438,11 @@ type msgMatchFn func(string) bool
436438

437439
func alwaysMatch(_ string) bool { return true }
438440

441+
func isFeatureGateEnabled(feature featuregate.Feature) bool {
442+
enabled, found := featureGates[feature]
443+
return enabled && found
444+
}
445+
439446
func messageComparison(ctx context.Context, msg *godog.DocString) msgMatchFn {
440447
msgCmp := alwaysMatch
441448
if msg != nil {
@@ -1119,6 +1126,23 @@ func ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace(ctx context.C
11191126
return applyPermissionsToServiceAccount(ctx, serviceAccount, rbacTemplate)
11201127
}
11211128

1129+
// ServiceAccountWithoutCreatePermissionsIsAvailableInTestNamespace creates a ServiceAccount with permissions that
1130+
// intentionally exclude the "create" verb to test preflight permission validation for Boxcutter applier.
1131+
// This is used to verify that the preflight check properly detects missing CREATE permissions.
1132+
// Note: This function requires both @BoxcutterRuntime and @PreflightPermissions tags.
1133+
func ServiceAccountWithoutCreatePermissionsIsAvailableInTestNamespace(ctx context.Context, serviceAccount string) error {
1134+
// This test is only valid with Boxcutter runtime enabled
1135+
if !isFeatureGateEnabled(features.BoxcutterRuntime) {
1136+
return fmt.Errorf("this step requires BoxcutterRuntime feature gate to be enabled")
1137+
}
1138+
// It also requires preflight permissions checks to be enabled
1139+
if !isFeatureGateEnabled(features.PreflightPermissions) {
1140+
return fmt.Errorf("this step requires PreflightPermissions feature gate to be enabled")
1141+
}
1142+
rbacTemplate := fmt.Sprintf("%s-boxcutter-no-create-rbac-template.yaml", serviceAccount)
1143+
return applyPermissionsToServiceAccount(ctx, serviceAccount, rbacTemplate)
1144+
}
1145+
11221146
// ServiceAccountWithNeededPermissionsIsAvailableInGivenNamespace creates a ServiceAccount and enables creation of any cluster extension on behalf of this account.
11231147
func ServiceAccountWithNeededPermissionsIsAvailableInGivenNamespace(ctx context.Context, serviceAccount string, ns string) error {
11241148
sc := scenarioCtx(ctx)

0 commit comments

Comments
 (0)