Skip to content

Commit 7cdc319

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 7cdc319

6 files changed

Lines changed: 180 additions & 42 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: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"errors"
77
"fmt"
8+
"io"
89
"io/fs"
910
"maps"
1011
"slices"
@@ -26,6 +27,7 @@ import (
2627
helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client"
2728

2829
ocv1 "github.com/operator-framework/operator-controller/api/v1"
30+
"github.com/operator-framework/operator-controller/internal/operator-controller/authorization"
2931
"github.com/operator-framework/operator-controller/internal/operator-controller/labels"
3032
"github.com/operator-framework/operator-controller/internal/shared/util/cache"
3133
)
@@ -274,23 +276,14 @@ type Boxcutter struct {
274276
Scheme *runtime.Scheme
275277
RevisionGenerator ClusterExtensionRevisionGenerator
276278
Preflights []Preflight
279+
PreAuthorizer authorization.PreAuthorizer
277280
FieldOwner string
278281
}
279282

280283
func (bc *Boxcutter) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error) {
281284
return bc.apply(ctx, contentFS, ext, objectLabels, revisionAnnotations)
282285
}
283286

284-
func (bc *Boxcutter) getObjects(rev *ocv1.ClusterExtensionRevision) []client.Object {
285-
var objs []client.Object
286-
for _, phase := range rev.Spec.Phases {
287-
for _, phaseObject := range phase.Objects {
288-
objs = append(objs, &phaseObject.Object)
289-
}
290-
}
291-
return objs
292-
}
293-
294287
func (bc *Boxcutter) createOrUpdate(ctx context.Context, obj client.Object) error {
295288
if obj.GetObjectKind().GroupVersionKind().Empty() {
296289
gvk, err := apiutil.GVKForObject(obj, bc.Scheme)
@@ -309,6 +302,11 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
309302
return false, "", err
310303
}
311304

305+
// Run auth preflight checks
306+
if err := bc.runPreAuthorizationChecks(ctx, ext, desiredRevision); err != nil {
307+
return false, "", err
308+
}
309+
312310
if err := controllerutil.SetControllerReference(ext, desiredRevision, bc.Scheme); err != nil {
313311
return false, "", fmt.Errorf("set ownerref: %w", err)
314312
}
@@ -343,7 +341,7 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
343341
}
344342

345343
// Preflights
346-
plainObjs := bc.getObjects(desiredRevision)
344+
plainObjs := getObjects(desiredRevision)
347345
for _, preflight := range bc.Preflights {
348346
if shouldSkipPreflight(ctx, preflight, ext, state) {
349347
continue
@@ -405,6 +403,18 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
405403
return true, "", nil
406404
}
407405

406+
func (bc *Boxcutter) runPreAuthorizationChecks(ctx context.Context, ext *ocv1.ClusterExtension, rev *ocv1.ClusterExtensionRevision) error {
407+
if bc.PreAuthorizer == nil {
408+
return nil
409+
}
410+
411+
manifestReader, err := revisionManifestReader(rev)
412+
if err != nil {
413+
return err
414+
}
415+
return formatPreAuthorizerOutput(bc.PreAuthorizer.PreAuthorize(ctx, ext, manifestReader))
416+
}
417+
408418
// garbageCollectOldRevisions deletes archived revisions beyond ClusterExtensionRevisionRetentionLimit.
409419
// Active revisions are never deleted. revisionList must be sorted oldest to newest.
410420
func (bc *Boxcutter) garbageCollectOldRevisions(ctx context.Context, revisionList []ocv1.ClusterExtensionRevision) error {
@@ -463,3 +473,27 @@ func splitManifestDocuments(file string) []string {
463473
}
464474
return docs
465475
}
476+
477+
func getObjects(rev *ocv1.ClusterExtensionRevision) []client.Object {
478+
var objs []client.Object
479+
for _, phase := range rev.Spec.Phases {
480+
for _, phaseObject := range phase.Objects {
481+
objs = append(objs, &phaseObject.Object)
482+
}
483+
}
484+
return objs
485+
}
486+
487+
func revisionManifestReader(rev *ocv1.ClusterExtensionRevision) (io.Reader, error) {
488+
var manifestBuilder strings.Builder
489+
for _, obj := range getObjects(rev) {
490+
objBytes, err := yaml.Marshal(obj)
491+
if err != nil {
492+
return nil, fmt.Errorf("error generating revision manifest: %w", err)
493+
}
494+
manifestBuilder.WriteString("---\n")
495+
manifestBuilder.WriteString(string(objBytes))
496+
manifestBuilder.WriteString("\n")
497+
}
498+
return strings.NewReader(manifestBuilder.String()), nil
499+
}

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/applier/helm.go

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -77,29 +77,7 @@ func (h *Helm) runPreAuthorizationChecks(ctx context.Context, ext *ocv1.ClusterE
7777
return fmt.Errorf("error rendering content for pre-authorization checks: %w", err)
7878
}
7979

80-
missingRules, authErr := h.PreAuthorizer.PreAuthorize(ctx, ext, strings.NewReader(tmplRel.Manifest))
81-
82-
var preAuthErrors []error
83-
84-
if len(missingRules) > 0 {
85-
var missingRuleDescriptions []string
86-
for _, policyRules := range missingRules {
87-
for _, rule := range policyRules.MissingRules {
88-
missingRuleDescriptions = append(missingRuleDescriptions, ruleDescription(policyRules.Namespace, rule))
89-
}
90-
}
91-
slices.Sort(missingRuleDescriptions)
92-
// This phrase is explicitly checked by external testing
93-
preAuthErrors = append(preAuthErrors, fmt.Errorf("service account requires the following permissions to manage cluster extension:\n %s", strings.Join(missingRuleDescriptions, "\n ")))
94-
}
95-
if authErr != nil {
96-
preAuthErrors = append(preAuthErrors, fmt.Errorf("authorization evaluation error: %w", authErr))
97-
}
98-
if len(preAuthErrors) > 0 {
99-
// This phrase is explicitly checked by external testing
100-
return fmt.Errorf("pre-authorization failed: %v", errors.Join(preAuthErrors...))
101-
}
102-
return nil
80+
return formatPreAuthorizerOutput(h.PreAuthorizer.PreAuthorize(ctx, ext, strings.NewReader(tmplRel.Manifest)))
10381
}
10482

10583
func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels map[string]string, storageLabels map[string]string) (bool, string, error) {
@@ -328,3 +306,28 @@ func ruleDescription(ns string, rule rbacv1.PolicyRule) string {
328306
}
329307
return sb.String()
330308
}
309+
310+
// formatPreAuthorizerOutput formats the output of PreAuthorizer.PreAuthorize calls into a consistent and deterministic error.
311+
// If the call returns no missing rules, and no error, nil is returned.
312+
func formatPreAuthorizerOutput(missingRules []authorization.ScopedPolicyRules, authErr error) error {
313+
var preAuthErrors []error
314+
if len(missingRules) > 0 {
315+
var missingRuleDescriptions []string
316+
for _, policyRules := range missingRules {
317+
for _, rule := range policyRules.MissingRules {
318+
missingRuleDescriptions = append(missingRuleDescriptions, ruleDescription(policyRules.Namespace, rule))
319+
}
320+
}
321+
slices.Sort(missingRuleDescriptions)
322+
// This phrase is explicitly checked by external testing
323+
preAuthErrors = append(preAuthErrors, fmt.Errorf("service account requires the following permissions to manage cluster extension:\n %s", strings.Join(missingRuleDescriptions, "\n ")))
324+
}
325+
if authErr != nil {
326+
preAuthErrors = append(preAuthErrors, fmt.Errorf("authorization evaluation error: %w", authErr))
327+
}
328+
if len(preAuthErrors) > 0 {
329+
// This phrase is explicitly checked by external testing
330+
return fmt.Errorf("pre-authorization failed: %v", errors.Join(preAuthErrors...))
331+
}
332+
return nil
333+
}

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, includeRevisionPerms 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 includeRevisionPerms {
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)