Skip to content

Commit 876225e

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 876225e

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
@@ -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 & 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,20 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust
405403
return true, "", nil
406404
}
407405

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

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: 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)