Skip to content

Commit 6f94c27

Browse files
author
Per Goncalves da Silva
committed
Add preflight checks to Boxcutter applier
Signed-off-by: Per Goncalves da Silva <pegoncal@redhat.com>
1 parent 1fa4169 commit 6f94c27

5 files changed

Lines changed: 158 additions & 8 deletions

File tree

cmd/operator-controller/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,12 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
601601
return err
602602
}
603603

604+
// determine if PreAuthorizer should be enabled based on feature gate
605+
var preAuth authorization.PreAuthorizer
606+
if features.OperatorControllerFeatureGate.Enabled(features.PreflightPermissions) {
607+
preAuth = authorization.NewRBACPreAuthorizer(c.mgr.GetClient(), authorization.WithClusterExtensionRevisionPerms())
608+
}
609+
604610
// TODO: add support for preflight checks
605611
// TODO: better scheme handling - which types do we want to support?
606612
_ = apiextensionsv1.AddToScheme(c.mgr.GetScheme())
@@ -613,6 +619,7 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
613619
Scheme: c.mgr.GetScheme(),
614620
RevisionGenerator: rg,
615621
Preflights: c.preflights,
622+
PreAuthorizer: preAuth,
616623
FieldOwner: fmt.Sprintf("%s/clusterextension-controller", fieldOwnerPrefix),
617624
}
618625
revisionStatesGetter := &controllers.BoxcutterRevisionStatesGetter{Reader: c.mgr.GetClient()}

internal/operator-controller/applier/boxcutter.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client"
2727

2828
ocv1 "github.com/operator-framework/operator-controller/api/v1"
29+
"github.com/operator-framework/operator-controller/internal/operator-controller/authorization"
2930
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
3031
"github.com/operator-framework/operator-controller/internal/shared/util/cache"
3132
)
@@ -274,6 +275,7 @@ type Boxcutter struct {
274275
Scheme *runtime.Scheme
275276
RevisionGenerator ClusterExtensionRevisionGenerator
276277
Preflights []Preflight
278+
PreAuthorizer authorization.PreAuthorizer
277279
FieldOwner string
278280
}
279281

@@ -309,6 +311,11 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
309311
return false, "", err
310312
}
311313

314+
// Run auth preflight checks
315+
if err := bc.runPreAuthorizationChecks(ctx, ext, desiredRevision); err != nil {
316+
return false, "", err
317+
}
318+
312319
if err := controllerutil.SetControllerReference(ext, desiredRevision, bc.Scheme); err != nil {
313320
return false, "", fmt.Errorf("set ownerref: %w", err)
314321
}
@@ -405,6 +412,48 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
405412
return true, "", nil
406413
}
407414

415+
func (bc *Boxcutter) runPreAuthorizationChecks(ctx context.Context, ext *ocv1.ClusterExtension, rev *ocv1.ClusterExtensionRevision) error {
416+
if bc.PreAuthorizer == nil {
417+
return nil
418+
}
419+
420+
var manifestBuilder strings.Builder
421+
for _, phase := range rev.Spec.Phases {
422+
for _, phaseObject := range phase.Objects {
423+
objBytes, err := yaml.Marshal(phaseObject.Object.Object)
424+
if err != nil {
425+
return fmt.Errorf("error generating revision manifest: %w", err)
426+
}
427+
manifestBuilder.WriteString("---\n")
428+
manifestBuilder.WriteString(string(objBytes))
429+
manifestBuilder.WriteString("\n")
430+
}
431+
}
432+
missingRules, authErr := bc.PreAuthorizer.PreAuthorize(ctx, ext, strings.NewReader(manifestBuilder.String()))
433+
434+
var preAuthErrors []error
435+
436+
if len(missingRules) > 0 {
437+
var missingRuleDescriptions []string
438+
for _, policyRules := range missingRules {
439+
for _, rule := range policyRules.MissingRules {
440+
missingRuleDescriptions = append(missingRuleDescriptions, ruleDescription(policyRules.Namespace, rule))
441+
}
442+
}
443+
slices.Sort(missingRuleDescriptions)
444+
// This phrase is explicitly checked by external testing
445+
preAuthErrors = append(preAuthErrors, fmt.Errorf("service account requires the following permissions to manage cluster extension:\n %s", strings.Join(missingRuleDescriptions, "\n ")))
446+
}
447+
if authErr != nil {
448+
preAuthErrors = append(preAuthErrors, fmt.Errorf("authorization evaluation error: %w", authErr))
449+
}
450+
if len(preAuthErrors) > 0 {
451+
// This phrase is explicitly checked by external testing
452+
return fmt.Errorf("pre-authorization failed: %v", errors.Join(preAuthErrors...))
453+
}
454+
return nil
455+
}
456+
408457
// garbageCollectOldRevisions deletes archived revisions beyond ClusterExtensionRevisionRetentionLimit.
409458
// Active revisions are never deleted. revisionList must be sorted oldest to newest.
410459
func (bc *Boxcutter) garbageCollectOldRevisions(ctx context.Context, revisionList []ocv1.ClusterExtensionRevision) error {

internal/operator-controller/applier/boxcutter_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
78
"io/fs"
89
"strings"
910
"testing"
@@ -28,6 +29,7 @@ import (
2829

2930
ocv1 "github.com/operator-framework/operator-controller/api/v1"
3031
"github.com/operator-framework/operator-controller/internal/operator-controller/applier"
32+
"github.com/operator-framework/operator-controller/internal/operator-controller/authorization"
3133
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
3234
)
3335

@@ -928,6 +930,44 @@ func TestBoxcutter_Apply(t *testing.T) {
928930
}
929931
}
930932

933+
func Test_PreAuthorizer_Integration(t *testing.T) {
934+
testScheme := runtime.NewScheme()
935+
require.NoError(t, ocv1.AddToScheme(testScheme))
936+
937+
// This is the revision that the mock builder will produce by default.
938+
// We calculate its hash to use in the tests.
939+
ext := &ocv1.ClusterExtension{
940+
ObjectMeta: metav1.ObjectMeta{
941+
Name: "test-ext",
942+
UID: "test-uid",
943+
},
944+
}
945+
fakeClient := fake.NewClientBuilder().WithScheme(testScheme).Build()
946+
947+
boxcutter := &applier.Boxcutter{
948+
Client: fakeClient,
949+
Scheme: testScheme,
950+
FieldOwner: "test-owner",
951+
RevisionGenerator: &mockBundleRevisionBuilder{
952+
makeRevisionFunc: func(ctx context.Context, bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotation map[string]string) (*ocv1.ClusterExtensionRevision, error) {
953+
return &ocv1.ClusterExtensionRevision{}, nil
954+
},
955+
},
956+
PreAuthorizer: &fakePreAuthorizer{
957+
err: errors.New("test error"),
958+
},
959+
}
960+
961+
dummyBundleFs := fstest.MapFS{}
962+
revisionAnnotations := map[string]string{}
963+
installSucceeded, installStatus, err := boxcutter.Apply(t.Context(), dummyBundleFs, ext, nil, revisionAnnotations)
964+
965+
require.False(t, installSucceeded)
966+
require.Empty(t, installStatus)
967+
require.Error(t, err)
968+
require.Equal(t, "pre-authorization failed: authorization evaluation error: test error", err.Error())
969+
}
970+
931971
func TestBoxcutterStorageMigrator(t *testing.T) {
932972
t.Run("creates revision", func(t *testing.T) {
933973
testScheme := runtime.NewScheme()
@@ -1106,3 +1146,13 @@ func (s *statusWriterMock) Patch(ctx context.Context, obj client.Object, patch c
11061146
func (s *statusWriterMock) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error {
11071147
return nil
11081148
}
1149+
1150+
var _ authorization.PreAuthorizer = &fakePreAuthorizer{}
1151+
1152+
type fakePreAuthorizer struct {
1153+
err error
1154+
}
1155+
1156+
func (f fakePreAuthorizer) PreAuthorize(_ context.Context, _ *ocv1.ClusterExtension, _ io.Reader) ([]authorization.ScopedPolicyRules, error) {
1157+
return nil, f.err
1158+
}

internal/operator-controller/authorization/rbac.go

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ type ScopedPolicyRules struct {
4444
MissingRules []rbacv1.PolicyRule
4545
}
4646

47+
type Option func(preAuthorizer *rbacPreAuthorizer)
48+
49+
func WithClusterExtensionRevisionPerms() Option {
50+
return func(preAuthorizer *rbacPreAuthorizer) {
51+
preAuthorizer.includeClusterExtensionRevisionPerms = true
52+
}
53+
}
54+
4755
var objectVerbs = []string{"get", "patch", "update", "delete"}
4856

4957
// Here we are splitting collection verbs based on required scope
@@ -55,17 +63,25 @@ var namespacedCollectionVerbs = []string{"create"}
5563
var clusterCollectionVerbs = []string{"list", "watch"}
5664

5765
type rbacPreAuthorizer struct {
58-
authorizer authorizer.Authorizer
59-
ruleResolver validation.AuthorizationRuleResolver
60-
restMapper meta.RESTMapper
66+
authorizer authorizer.Authorizer
67+
ruleResolver validation.AuthorizationRuleResolver
68+
restMapper meta.RESTMapper
69+
includeClusterExtensionRevisionPerms bool
70+
}
71+
72+
func (a *rbacPreAuthorizer) applyOpts(opts []Option) *rbacPreAuthorizer {
73+
for _, applyOption := range opts {
74+
applyOption(a)
75+
}
76+
return a
6177
}
6278

63-
func NewRBACPreAuthorizer(cl client.Client) PreAuthorizer {
64-
return &rbacPreAuthorizer{
79+
func NewRBACPreAuthorizer(cl client.Client, opts ...Option) PreAuthorizer {
80+
return (&rbacPreAuthorizer{
6581
authorizer: newRBACAuthorizer(cl),
6682
ruleResolver: newRBACRulesResolver(cl),
6783
restMapper: cl.RESTMapper(),
68-
}
84+
}).applyOpts(opts)
6985
}
7086

7187
// PreAuthorize validates whether the current user/request satisfies the necessary permissions
@@ -85,7 +101,7 @@ func (a *rbacPreAuthorizer) PreAuthorize(ctx context.Context, ext *ocv1.ClusterE
85101
return nil, err
86102
}
87103
manifestManager := &user.DefaultInfo{Name: fmt.Sprintf("system:serviceaccount:%s:%s", ext.Spec.Namespace, ext.Spec.ServiceAccount.Name)}
88-
attributesRecords := dm.asAuthorizationAttributesRecordsForUser(manifestManager, ext)
104+
attributesRecords := dm.asAuthorizationAttributesRecordsForUser(manifestManager, ext, a.includeClusterExtensionRevisionPerms)
89105

90106
var preAuthEvaluationErrors []error
91107
missingRules, err := a.authorizeAttributesRecords(ctx, attributesRecords)
@@ -316,7 +332,7 @@ func (dm *decodedManifest) rbacObjects() []client.Object {
316332
return objects
317333
}
318334

319-
func (dm *decodedManifest) asAuthorizationAttributesRecordsForUser(manifestManager user.Info, ext *ocv1.ClusterExtension) []authorizer.AttributesRecord {
335+
func (dm *decodedManifest) asAuthorizationAttributesRecordsForUser(manifestManager user.Info, ext *ocv1.ClusterExtension, boxcutterMode bool) []authorizer.AttributesRecord {
320336
var attributeRecords []authorizer.AttributesRecord
321337

322338
for gvr, keys := range dm.gvrs {
@@ -374,6 +390,16 @@ func (dm *decodedManifest) asAuthorizationAttributesRecordsForUser(manifestManag
374390
ResourceRequest: true,
375391
Verb: verb,
376392
})
393+
if boxcutterMode {
394+
attributeRecords = append(attributeRecords, authorizer.AttributesRecord{
395+
User: manifestManager,
396+
APIGroup: ext.GroupVersionKind().Group,
397+
APIVersion: ext.GroupVersionKind().Version,
398+
Resource: "clusterextensionrevisions/finalizers",
399+
ResourceRequest: true,
400+
Verb: verb,
401+
})
402+
}
377403
}
378404
}
379405
return attributeRecords

internal/operator-controller/authorization/rbac_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,24 @@ func TestPreAuthorize_MissingRBAC(t *testing.T) {
434434
})
435435
}
436436

437+
func TestPreAuthorize_MissingRBAC_WithClusterExtensionRevisionPerms(t *testing.T) {
438+
t.Run("preauthorize fails and finds missing rbac rules", func(t *testing.T) {
439+
// Note: the escalating cluster role does not have update permissions clusterextensionrevisions/finalizers
440+
fakeClient := setupFakeClient(escalatingClusterRole)
441+
preAuth := NewRBACPreAuthorizer(fakeClient, WithClusterExtensionRevisionPerms())
442+
missingRules, err := preAuth.PreAuthorize(context.TODO(), &exampleClusterExtension, strings.NewReader(testManifest))
443+
require.NoError(t, err)
444+
require.Equal(t, []ScopedPolicyRules{
445+
{
446+
Namespace: "",
447+
MissingRules: []rbacv1.PolicyRule{
448+
{Verbs: []string{"update"}, APIGroups: []string{""}, Resources: []string{"clusterextensionrevisions/finalizers"}, ResourceNames: []string(nil), NonResourceURLs: []string(nil)},
449+
},
450+
},
451+
}, missingRules)
452+
})
453+
}
454+
437455
func TestPreAuthorizeMultiNamespace_MissingRBAC(t *testing.T) {
438456
t.Run("preauthorize fails and finds missing rbac rules in multiple namespaces", func(t *testing.T) {
439457
fakeClient := setupFakeClient(limitedClusterRole)

0 commit comments

Comments
 (0)