Skip to content

Commit 678687c

Browse files
committed
Add client-only helm dry-run to helm applier
Adds a check that makes sure the installer service account has the necessary get permissions to get all the resources it will need to inspect for a server-connected dry-run and returns errors detailing all the missing get permissions. This prevents a hung server-connected dry-run getting caught on individual missing get permissions. Signed-off-by: Tayler Geiger <[email protected]>
1 parent becde51 commit 678687c

File tree

5 files changed

+188
-0
lines changed

5 files changed

+188
-0
lines changed

Diff for: cmd/operator-controller/main.go

+3
Original file line numberDiff line numberDiff line change
@@ -380,9 +380,12 @@ func main() {
380380
crdupgradesafety.NewPreflight(aeClient.CustomResourceDefinitions()),
381381
}
382382

383+
acm := applier.NewAuthClientMapper(clientRestConfigMapper, mgr.GetConfig())
384+
383385
helmApplier := &applier.Helm{
384386
ActionClientGetter: acg,
385387
Preflights: preflights,
388+
AuthClientMapper: acm,
386389
}
387390

388391
cm := contentmanager.NewManager(clientRestConfigMapper, mgr.GetConfig(), mgr.GetRESTMapper())

Diff for: go.mod

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ require (
3838
k8s.io/client-go v0.32.0
3939
k8s.io/component-base v0.32.0
4040
k8s.io/klog/v2 v2.130.1
41+
k8s.io/kubernetes v1.31.2
4142
k8s.io/utils v0.0.0-20241210054802-24370beab758
4243
sigs.k8s.io/controller-runtime v0.19.4
4344
sigs.k8s.io/yaml v1.4.0
@@ -244,6 +245,7 @@ require (
244245
gopkg.in/inf.v0 v0.9.1 // indirect
245246
gopkg.in/warnings.v0 v0.1.2 // indirect
246247
gopkg.in/yaml.v3 v3.0.1 // indirect
248+
k8s.io/component-helpers v0.32.0 // indirect
247249
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
248250
k8s.io/kubectl v0.32.0 // indirect
249251
oras.land/oras-go v1.2.5 // indirect

Diff for: go.sum

+4
Original file line numberDiff line numberDiff line change
@@ -1007,12 +1007,16 @@ k8s.io/client-go v0.32.0 h1:DimtMcnN/JIKZcrSrstiwvvZvLjG0aSxy8PxN8IChp8=
10071007
k8s.io/client-go v0.32.0/go.mod h1:boDWvdM1Drk4NJj/VddSLnx59X3OPgwrOo0vGbtq9+8=
10081008
k8s.io/component-base v0.32.0 h1:d6cWHZkCiiep41ObYQS6IcgzOUQUNpywm39KVYaUqzU=
10091009
k8s.io/component-base v0.32.0/go.mod h1:JLG2W5TUxUu5uDyKiH2R/7NnxJo1HlPoRIIbVLkK5eM=
1010+
k8s.io/component-helpers v0.32.0 h1:pQEEBmRt3pDJJX98cQvZshDgJFeKRM4YtYkMmfOlczw=
1011+
k8s.io/component-helpers v0.32.0/go.mod h1:9RuClQatbClcokXOcDWSzFKQm1huIf0FzQlPRpizlMc=
10101012
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
10111013
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
10121014
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y=
10131015
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4=
10141016
k8s.io/kubectl v0.32.0 h1:rpxl+ng9qeG79YA4Em9tLSfX0G8W0vfaiPVrc/WR7Xw=
10151017
k8s.io/kubectl v0.32.0/go.mod h1:qIjSX+QgPQUgdy8ps6eKsYNF+YmFOAO3WygfucIqFiE=
1018+
k8s.io/kubernetes v1.31.2 h1:VNSu4O7Xn5FFRsh9ePXyEPg6ucR21fOftarSdi053Gs=
1019+
k8s.io/kubernetes v1.31.2/go.mod h1:9xmT2buyTYj8TRKwRae7FcuY8k5+xlxv7VivvO0KKfs=
10161020
k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0=
10171021
k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
10181022
oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo=

Diff for: internal/operator-controller/applier/helm.go

+68
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ import (
1818
corev1 "k8s.io/api/core/v1"
1919
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2020
apimachyaml "k8s.io/apimachinery/pkg/util/yaml"
21+
"k8s.io/client-go/rest"
2122
"sigs.k8s.io/controller-runtime/pkg/client"
2223
"sigs.k8s.io/controller-runtime/pkg/log"
2324

2425
helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client"
26+
authorizationv1client "k8s.io/client-go/kubernetes/typed/authorization/v1"
2527

2628
ocv1 "github.com/operator-framework/operator-controller/api/v1"
29+
"github.com/operator-framework/operator-controller/internal/operator-controller/authorization"
2730
"github.com/operator-framework/operator-controller/internal/operator-controller/features"
2831
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/convert"
2932
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/preflights/crdupgradesafety"
@@ -53,9 +56,38 @@ type Preflight interface {
5356
Upgrade(context.Context, *release.Release) error
5457
}
5558

59+
type RestConfigMapper func(context.Context, client.Object, *rest.Config) (*rest.Config, error)
60+
61+
type AuthClientMapper struct {
62+
rcm RestConfigMapper
63+
baseCfg *rest.Config
64+
}
65+
66+
func (acm *AuthClientMapper) GetAuthenticationClient(ctx context.Context, ext *ocv1.ClusterExtension) (*authorizationv1client.AuthorizationV1Client, error) {
67+
authcfg, err := acm.rcm(ctx, ext, acm.baseCfg)
68+
if err != nil {
69+
return nil, err
70+
}
71+
72+
authclient, err := authorizationv1client.NewForConfig(authcfg)
73+
if err != nil {
74+
return nil, err
75+
}
76+
77+
return authclient, nil
78+
}
79+
5680
type Helm struct {
5781
ActionClientGetter helmclient.ActionClientGetter
5882
Preflights []Preflight
83+
AuthClientMapper AuthClientMapper
84+
}
85+
86+
func NewAuthClientMapper(rcm RestConfigMapper, baseCfg *rest.Config) AuthClientMapper {
87+
return AuthClientMapper{
88+
rcm: rcm,
89+
baseCfg: baseCfg,
90+
}
5991
}
6092

6193
// shouldSkipPreflight is a helper to determine if the preflight check is CRDUpgradeSafety AND
@@ -79,7 +111,21 @@ func shouldSkipPreflight(ctx context.Context, preflight Preflight, ext *ocv1.Clu
79111
}
80112

81113
func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels map[string]string, storageLabels map[string]string) ([]client.Object, string, error) {
114+
115+
if features.OperatorControllerFeatureGate.Enabled(features.PreflightPermissions) {
116+
authclient, err := h.AuthClientMapper.GetAuthenticationClient(ctx, ext)
117+
if err != nil {
118+
return nil, "", err
119+
}
120+
121+
err = h.checkContentPermissions(ctx, contentFS, authclient, ext)
122+
if err != nil {
123+
return nil, "", err
124+
}
125+
}
126+
82127
chrt, err := convert.RegistryV1ToHelmChart(ctx, contentFS, ext.Spec.Namespace, []string{corev1.NamespaceAll})
128+
83129
if err != nil {
84130
return nil, "", err
85131
}
@@ -152,8 +198,25 @@ func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExte
152198
return relObjects, state, nil
153199
}
154200

201+
func (h *Helm) checkContentPermissions(ctx context.Context, contentFS fs.FS, authcl *authorizationv1client.AuthorizationV1Client, ext *ocv1.ClusterExtension) error {
202+
reg, err := convert.ParseFS(ctx, contentFS)
203+
if err != nil {
204+
return err
205+
}
206+
207+
plain, err := convert.Convert(reg, ext.Spec.Namespace, []string{corev1.NamespaceAll})
208+
if err != nil {
209+
return err
210+
}
211+
212+
err = authorization.CheckObjectPermissions(ctx, authcl, plain.Objects, ext)
213+
214+
return err
215+
}
216+
155217
func (h *Helm) getReleaseState(cl helmclient.ActionInterface, ext *ocv1.ClusterExtension, chrt *chart.Chart, values chartutil.Values, post postrender.PostRenderer) (*release.Release, *release.Release, string, error) {
156218
currentRelease, err := cl.Get(ext.GetName())
219+
157220
if errors.Is(err, driver.ErrReleaseNotFound) {
158221
desiredRelease, err := cl.Install(ext.GetName(), ext.Spec.Namespace, chrt, values, func(i *action.Install) error {
159222
i.DryRun = true
@@ -191,6 +254,11 @@ func (h *Helm) getReleaseState(cl helmclient.ActionInterface, ext *ocv1.ClusterE
191254
return currentRelease, desiredRelease, relState, nil
192255
}
193256

257+
// RulesAllow() checks expects resource names to be lowercase and plural, there's probably a better way to do this
258+
func sanitizeResourceName(resourceName string) string {
259+
return strings.ToLower(resourceName) + "s"
260+
}
261+
194262
type postrenderer struct {
195263
labels map[string]string
196264
cascade postrender.PostRenderer
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package authorization
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"slices"
8+
"strings"
9+
10+
ocv1 "github.com/operator-framework/operator-controller/api/v1"
11+
authv1 "k8s.io/api/authorization/v1"
12+
rbacv1 "k8s.io/api/rbac/v1"
13+
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
authorizationv1client "k8s.io/client-go/kubernetes/typed/authorization/v1"
15+
"sigs.k8s.io/controller-runtime/pkg/client"
16+
)
17+
18+
const (
19+
SelfSubjectRulesReview string = "SelfSubjectRulesReview"
20+
SelfSubjectAccessReview string = "SelfSubjectAccessReview"
21+
)
22+
23+
func CheckObjectPermissions(ctx context.Context, authcl *authorizationv1client.AuthorizationV1Client, objects []client.Object, ext *ocv1.ClusterExtension) error {
24+
ssrr := &authv1.SelfSubjectRulesReview{
25+
Spec: authv1.SelfSubjectRulesReviewSpec{
26+
Namespace: ext.Spec.Namespace,
27+
},
28+
}
29+
30+
ssrr, err := authcl.SelfSubjectRulesReviews().Create(ctx, ssrr, v1.CreateOptions{})
31+
if err != nil {
32+
return err
33+
}
34+
35+
rules := []rbacv1.PolicyRule{}
36+
for _, rule := range ssrr.Status.ResourceRules {
37+
rules = append(rules, rbacv1.PolicyRule{
38+
Verbs: rule.Verbs,
39+
APIGroups: rule.APIGroups,
40+
Resources: rule.Resources,
41+
ResourceNames: rule.ResourceNames,
42+
})
43+
}
44+
45+
for _, rule := range ssrr.Status.NonResourceRules {
46+
rules = append(rules, rbacv1.PolicyRule{
47+
Verbs: rule.Verbs,
48+
NonResourceURLs: rule.NonResourceURLs,
49+
})
50+
}
51+
52+
resAttrs := []authv1.ResourceAttributes{}
53+
namespacedErrs := []error{}
54+
clusterScopedErrs := []error{}
55+
requiredVerbs := []string{"get", "create", "update", "list", "watch", "delete", "patch"}
56+
57+
for _, o := range objects {
58+
for _, verb := range requiredVerbs {
59+
resAttrs = append(resAttrs, authv1.ResourceAttributes{
60+
Namespace: o.GetNamespace(),
61+
Verb: verb,
62+
Resource: sanitizeResourceName(o.GetObjectKind().GroupVersionKind().Kind),
63+
Group: o.GetObjectKind().GroupVersionKind().Group,
64+
Name: o.GetName(),
65+
})
66+
}
67+
}
68+
69+
for _, resAttr := range resAttrs {
70+
if !canI(resAttr, rules) {
71+
if resAttr.Namespace != "" {
72+
namespacedErrs = append(namespacedErrs, fmt.Errorf("cannot %s %s %s in namespace %s",
73+
resAttr.Verb,
74+
strings.TrimSuffix(resAttr.Resource, "s"),
75+
resAttr.Name,
76+
resAttr.Namespace))
77+
continue
78+
}
79+
clusterScopedErrs = append(clusterScopedErrs, fmt.Errorf("cannot %s %s %s",
80+
resAttr.Verb,
81+
strings.TrimSuffix(resAttr.Resource, "s"),
82+
resAttr.Name))
83+
}
84+
}
85+
errs := append(namespacedErrs, clusterScopedErrs...)
86+
if len(errs) > 0 {
87+
errs = append([]error{fmt.Errorf("installer service account %s is missing required permissions", ext.Spec.ServiceAccount.Name)}, errs...)
88+
}
89+
90+
return errors.Join(errs...)
91+
92+
}
93+
94+
func canI(resAttr authv1.ResourceAttributes, rules []rbacv1.PolicyRule) bool {
95+
var canI bool
96+
for _, rule := range rules {
97+
if (slices.Contains(rule.APIGroups, resAttr.Group) || slices.Contains(rule.APIGroups, "*")) &&
98+
(slices.Contains(rule.Resources, resAttr.Resource) || slices.Contains(rule.Resources, "*")) &&
99+
(slices.Contains(rule.Verbs, resAttr.Verb) || slices.Contains(rule.Verbs, "*")) &&
100+
(slices.Contains(rule.ResourceNames, resAttr.Name) || len(rule.ResourceNames) == 0) {
101+
canI = true
102+
break
103+
}
104+
}
105+
return canI
106+
}
107+
108+
// RulesAllow() checks expects resource names to be lowercase and plural, there's probably a better way to do this
109+
func sanitizeResourceName(resourceName string) string {
110+
return strings.ToLower(resourceName) + "s"
111+
}

0 commit comments

Comments
 (0)