Skip to content

Commit d542d16

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 049f813 commit d542d16

6 files changed

Lines changed: 193 additions & 44 deletions

File tree

cmd/operator-controller/main.go

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

601+
// determine if PreAuthorizer should be enabled based on feature gate
602+
var preAuth authorization.PreAuthorizer
603+
if features.OperatorControllerFeatureGate.Enabled(features.PreflightPermissions) {
604+
preAuth = authorization.NewRBACPreAuthorizer(c.mgr.GetClient(), authorization.WithClusterExtensionRevisionPerms())
605+
}
606+
601607
// TODO: add support for preflight checks
602608
// TODO: better scheme handling - which types do we want to support?
603609
_ = apiextensionsv1.AddToScheme(c.mgr.GetScheme())
@@ -610,6 +616,7 @@ func (c *boxcutterReconcilerConfigurator) Configure(ceReconciler *controllers.Cl
610616
Scheme: c.mgr.GetScheme(),
611617
RevisionGenerator: rg,
612618
Preflights: c.preflights,
619+
PreAuthorizer: preAuth,
613620
FieldOwner: fmt.Sprintf("%s/clusterextension-controller", fieldOwnerPrefix),
614621
}
615622
revisionStatesGetter := &controllers.BoxcutterRevisionStatesGetter{Reader: c.mgr.GetClient()}

internal/operator-controller/applier/boxcutter.go

Lines changed: 49 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
)
@@ -280,23 +282,14 @@ type Boxcutter struct {
280282
Scheme *runtime.Scheme
281283
RevisionGenerator ClusterExtensionRevisionGenerator
282284
Preflights []Preflight
285+
PreAuthorizer authorization.PreAuthorizer
283286
FieldOwner string
284287
}
285288

286289
func (bc *Boxcutter) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (bool, string, error) {
287290
return bc.apply(ctx, contentFS, ext, objectLabels, revisionAnnotations)
288291
}
289292

290-
func (bc *Boxcutter) getObjects(rev *ocv1.ClusterExtensionRevision) []client.Object {
291-
var objs []client.Object
292-
for _, phase := range rev.Spec.Phases {
293-
for _, phaseObject := range phase.Objects {
294-
objs = append(objs, &phaseObject.Object)
295-
}
296-
}
297-
return objs
298-
}
299-
300293
func (bc *Boxcutter) createOrUpdate(ctx context.Context, obj client.Object) error {
301294
if obj.GetObjectKind().GroupVersionKind().Empty() {
302295
gvk, err := apiutil.GVKForObject(obj, bc.Scheme)
@@ -315,6 +308,11 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
315308
return false, "", err
316309
}
317310

311+
// Run auth preflight checks
312+
if err := bc.runPreAuthorizationChecks(ctx, ext, desiredRevision); err != nil {
313+
return false, "", err
314+
}
315+
318316
if err := controllerutil.SetControllerReference(ext, desiredRevision, bc.Scheme); err != nil {
319317
return false, "", fmt.Errorf("set ownerref: %w", err)
320318
}
@@ -349,7 +347,7 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
349347
}
350348

351349
// Preflights
352-
plainObjs := bc.getObjects(desiredRevision)
350+
plainObjs := getObjects(desiredRevision)
353351
for _, preflight := range bc.Preflights {
354352
if shouldSkipPreflight(ctx, preflight, ext, state) {
355353
continue
@@ -411,6 +409,20 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
411409
return true, "", nil
412410
}
413411

412+
// runPreAuthorizationChecks runs PreAuthorization checks if the PreAuthorizer is set. An error will be returned if
413+
// the ClusterExtension service account does not have the necessary permissions to manage to the revision's resources
414+
func (bc *Boxcutter) runPreAuthorizationChecks(ctx context.Context, ext *ocv1.ClusterExtension, rev *ocv1.ClusterExtensionRevision) error {
415+
if bc.PreAuthorizer == nil {
416+
return nil
417+
}
418+
419+
manifestReader, err := revisionManifestReader(rev)
420+
if err != nil {
421+
return err
422+
}
423+
return formatPreAuthorizerOutput(bc.PreAuthorizer.PreAuthorize(ctx, ext, manifestReader))
424+
}
425+
414426
// garbageCollectOldRevisions deletes archived revisions beyond ClusterExtensionRevisionRetentionLimit.
415427
// Active revisions are never deleted. revisionList must be sorted oldest to newest.
416428
func (bc *Boxcutter) garbageCollectOldRevisions(ctx context.Context, revisionList []ocv1.ClusterExtensionRevision) error {
@@ -469,3 +481,29 @@ func splitManifestDocuments(file string) []string {
469481
}
470482
return docs
471483
}
484+
485+
// getObjects returns a slice of all objects in the revision
486+
func getObjects(rev *ocv1.ClusterExtensionRevision) []client.Object {
487+
var objs []client.Object
488+
for _, phase := range rev.Spec.Phases {
489+
for _, phaseObject := range phase.Objects {
490+
objs = append(objs, &phaseObject.Object)
491+
}
492+
}
493+
return objs
494+
}
495+
496+
// revisionMenifestReader returns an io.Reader containing all manifests in the revision
497+
func revisionManifestReader(rev *ocv1.ClusterExtensionRevision) (io.Reader, error) {
498+
var manifestBuilder strings.Builder
499+
for _, obj := range getObjects(rev) {
500+
objBytes, err := yaml.Marshal(obj)
501+
if err != nil {
502+
return nil, fmt.Errorf("error generating revision manifest: %w", err)
503+
}
504+
manifestBuilder.WriteString("---\n")
505+
manifestBuilder.WriteString(string(objBytes))
506+
manifestBuilder.WriteString("\n")
507+
}
508+
return strings.NewReader(manifestBuilder.String()), nil
509+
}

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

@@ -952,6 +954,44 @@ func TestBoxcutter_Apply(t *testing.T) {
952954
}
953955
}
954956

957+
func Test_PreAuthorizer_Integration(t *testing.T) {
958+
testScheme := runtime.NewScheme()
959+
require.NoError(t, ocv1.AddToScheme(testScheme))
960+
961+
// This is the revision that the mock builder will produce by default.
962+
// We calculate its hash to use in the tests.
963+
ext := &ocv1.ClusterExtension{
964+
ObjectMeta: metav1.ObjectMeta{
965+
Name: "test-ext",
966+
UID: "test-uid",
967+
},
968+
}
969+
fakeClient := fake.NewClientBuilder().WithScheme(testScheme).Build()
970+
971+
boxcutter := &applier.Boxcutter{
972+
Client: fakeClient,
973+
Scheme: testScheme,
974+
FieldOwner: "test-owner",
975+
RevisionGenerator: &mockBundleRevisionBuilder{
976+
makeRevisionFunc: func(ctx context.Context, bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotation map[string]string) (*ocv1.ClusterExtensionRevision, error) {
977+
return &ocv1.ClusterExtensionRevision{}, nil
978+
},
979+
},
980+
PreAuthorizer: &fakePreAuthorizer{
981+
err: errors.New("test error"),
982+
},
983+
}
984+
985+
dummyBundleFs := fstest.MapFS{}
986+
revisionAnnotations := map[string]string{}
987+
installSucceeded, installStatus, err := boxcutter.Apply(t.Context(), dummyBundleFs, ext, nil, revisionAnnotations)
988+
989+
require.False(t, installSucceeded)
990+
require.Empty(t, installStatus)
991+
require.Error(t, err)
992+
require.Equal(t, "pre-authorization failed: authorization evaluation error: test error", err.Error())
993+
}
994+
955995
func TestBoxcutterStorageMigrator(t *testing.T) {
956996
t.Run("creates revision", func(t *testing.T) {
957997
testScheme := runtime.NewScheme()
@@ -1130,3 +1170,13 @@ func (s *statusWriterMock) Patch(ctx context.Context, obj client.Object, patch c
11301170
func (s *statusWriterMock) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error {
11311171
return nil
11321172
}
1173+
1174+
var _ authorization.PreAuthorizer = &fakePreAuthorizer{}
1175+
1176+
type fakePreAuthorizer struct {
1177+
err error
1178+
}
1179+
1180+
func (f fakePreAuthorizer) PreAuthorize(_ context.Context, _ *ocv1.ClusterExtension, _ io.Reader) ([]authorization.ScopedPolicyRules, error) {
1181+
return nil, f.err
1182+
}

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: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ type ScopedPolicyRules struct {
4444
MissingRules []rbacv1.PolicyRule
4545
}
4646

47+
// Option configures the behavior of the RBACPreAuthorizer
48+
type Option func(preAuthorizer *rbacPreAuthorizer)
49+
50+
// WithClusterExtensionRevisionPerms configures the RBACPreAuthorizer to also require
51+
// permissions required against ClusterExtensionRevisions
52+
func WithClusterExtensionRevisionPerms() Option {
53+
return func(preAuthorizer *rbacPreAuthorizer) {
54+
preAuthorizer.includeClusterExtensionRevisionPerms = true
55+
}
56+
}
57+
4758
var objectVerbs = []string{"get", "patch", "update", "delete"}
4859

4960
// Here we are splitting collection verbs based on required scope
@@ -55,17 +66,25 @@ var namespacedCollectionVerbs = []string{"create"}
5566
var clusterCollectionVerbs = []string{"list", "watch"}
5667

5768
type rbacPreAuthorizer struct {
58-
authorizer authorizer.Authorizer
59-
ruleResolver validation.AuthorizationRuleResolver
60-
restMapper meta.RESTMapper
69+
authorizer authorizer.Authorizer
70+
ruleResolver validation.AuthorizationRuleResolver
71+
restMapper meta.RESTMapper
72+
includeClusterExtensionRevisionPerms bool
73+
}
74+
75+
func (a *rbacPreAuthorizer) applyOpts(opts []Option) *rbacPreAuthorizer {
76+
for _, applyOption := range opts {
77+
applyOption(a)
78+
}
79+
return a
6180
}
6281

63-
func NewRBACPreAuthorizer(cl client.Client) PreAuthorizer {
64-
return &rbacPreAuthorizer{
82+
func NewRBACPreAuthorizer(cl client.Client, opts ...Option) PreAuthorizer {
83+
return (&rbacPreAuthorizer{
6584
authorizer: newRBACAuthorizer(cl),
6685
ruleResolver: newRBACRulesResolver(cl),
6786
restMapper: cl.RESTMapper(),
68-
}
87+
}).applyOpts(opts)
6988
}
7089

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

90109
var preAuthEvaluationErrors []error
91110
missingRules, err := a.authorizeAttributesRecords(ctx, attributesRecords)
@@ -316,7 +335,7 @@ func (dm *decodedManifest) rbacObjects() []client.Object {
316335
return objects
317336
}
318337

319-
func (dm *decodedManifest) asAuthorizationAttributesRecordsForUser(manifestManager user.Info, ext *ocv1.ClusterExtension) []authorizer.AttributesRecord {
338+
func (dm *decodedManifest) asAuthorizationAttributesRecordsForUser(manifestManager user.Info, ext *ocv1.ClusterExtension, includeClusterExtensionRevisionPerms bool) []authorizer.AttributesRecord {
320339
var attributeRecords []authorizer.AttributesRecord
321340

322341
for gvr, keys := range dm.gvrs {
@@ -374,6 +393,16 @@ func (dm *decodedManifest) asAuthorizationAttributesRecordsForUser(manifestManag
374393
ResourceRequest: true,
375394
Verb: verb,
376395
})
396+
if includeClusterExtensionRevisionPerms {
397+
attributeRecords = append(attributeRecords, authorizer.AttributesRecord{
398+
User: manifestManager,
399+
APIGroup: ext.GroupVersionKind().Group,
400+
APIVersion: ext.GroupVersionKind().Version,
401+
Resource: "clusterextensionrevisions/finalizers",
402+
ResourceRequest: true,
403+
Verb: verb,
404+
})
405+
}
377406
}
378407
}
379408
return attributeRecords

0 commit comments

Comments
 (0)