Skip to content

Commit e9001d6

Browse files
committed
Introduce global decryption for SOPS age keys
Signed-off-by: Matheus Pimenta <[email protected]>
1 parent a342d00 commit e9001d6

File tree

5 files changed

+125
-29
lines changed

5 files changed

+125
-29
lines changed

internal/controller/kustomization_controller.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ type KustomizationReconciler struct {
104104
NoRemoteBases bool
105105
FailFast bool
106106
DefaultServiceAccount string
107+
SOPSAgeSecret string
108+
RuntimeNamespace string
107109
KubeConfigOpts runtimeClient.KubeConfigOptions
108110
ConcurrentSSA int
109111
DisallowedFieldManagers []string
@@ -642,7 +644,19 @@ func (r *KustomizationReconciler) generate(obj unstructured.Unstructured,
642644
func (r *KustomizationReconciler) build(ctx context.Context,
643645
obj *kustomizev1.Kustomization, u unstructured.Unstructured,
644646
workDir, dirPath string) ([]byte, error) {
645-
dec, cleanup, err := decryptor.NewTempDecryptor(workDir, r.Client, obj, r.TokenCache)
647+
648+
// Build decryptor.
649+
decryptorOpts := []decryptor.Option{
650+
decryptor.WithRoot(workDir),
651+
}
652+
if r.TokenCache != nil {
653+
decryptorOpts = append(decryptorOpts, decryptor.WithTokenCache(*r.TokenCache))
654+
}
655+
if r.SOPSAgeSecret != "" {
656+
decryptorOpts = append(decryptorOpts,
657+
decryptor.WithSOPSAgeSecret(r.SOPSAgeSecret, r.RuntimeNamespace))
658+
}
659+
dec, cleanup, err := decryptor.New(r.Client, obj, decryptorOpts...)
646660
if err != nil {
647661
return nil, err
648662
}

internal/decryptor/decryptor.go

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -162,32 +162,30 @@ type Decryptor struct {
162162
// decryptor.
163163
keyServices []keyservice.KeyServiceClient
164164
localServiceOnce sync.Once
165-
}
166165

167-
// NewDecryptor creates a new Decryptor for the given kustomization.
168-
// gnuPGHome can be empty, in which case the systems' keyring is used.
169-
func NewDecryptor(root string, client client.Client, kustomization *kustomizev1.Kustomization,
170-
maxFileSize int64, gnuPGHome string, tokenCache *cache.TokenCache) *Decryptor {
171-
return &Decryptor{
172-
root: root,
173-
client: client,
174-
kustomization: kustomization,
175-
maxFileSize: maxFileSize,
176-
gnuPGHome: pgp.GnuPGHome(gnuPGHome),
177-
tokenCache: tokenCache,
178-
}
166+
// sopsAgeSecret is the NamespacedName of the Secret containing
167+
// a fallback SOPS age decryption key.
168+
sopsAgeSecret *types.NamespacedName
179169
}
180170

181-
// NewTempDecryptor creates a new Decryptor, with a temporary GnuPG
171+
// New creates a new Decryptor, with a temporary GnuPG
182172
// home directory to Decryptor.ImportKeys() into.
183-
func NewTempDecryptor(root string, client client.Client, kustomization *kustomizev1.Kustomization,
184-
tokenCache *cache.TokenCache) (*Decryptor, func(), error) {
173+
func New(client client.Client, kustomization *kustomizev1.Kustomization, opts ...Option) (*Decryptor, func(), error) {
185174
gnuPGHome, err := pgp.NewGnuPGHome()
186175
if err != nil {
187176
return nil, nil, fmt.Errorf("cannot create decryptor: %w", err)
188177
}
189178
cleanup := func() { _ = os.RemoveAll(gnuPGHome.String()) }
190-
return NewDecryptor(root, client, kustomization, maxEncryptedFileSize, gnuPGHome.String(), tokenCache), cleanup, nil
179+
d := &Decryptor{
180+
client: client,
181+
kustomization: kustomization,
182+
maxFileSize: maxEncryptedFileSize,
183+
gnuPGHome: gnuPGHome,
184+
}
185+
for _, opt := range opts {
186+
opt(d)
187+
}
188+
return d, cleanup, nil
191189
}
192190

193191
// IsEncryptedSecret checks if the given object is a Kubernetes Secret encrypted
@@ -210,16 +208,43 @@ func IsEncryptedSecret(object *unstructured.Unstructured) bool {
210208
// For the import of PGP keys, the Decryptor must be configured with
211209
// an absolute GnuPG home directory path.
212210
func (d *Decryptor) ImportKeys(ctx context.Context) error {
213-
if d.kustomization.Spec.Decryption == nil || d.kustomization.Spec.Decryption.SecretRef == nil {
211+
if d.kustomization.Spec.Decryption == nil ||
212+
(d.kustomization.Spec.Decryption.SecretRef == nil && d.sopsAgeSecret == nil) {
214213
return nil
215214
}
216215

217216
provider := d.kustomization.Spec.Decryption.Provider
218217
switch provider {
219218
case DecryptionProviderSOPS:
219+
secretRef := d.kustomization.Spec.Decryption.SecretRef
220+
221+
// We handle the SOPS age global decryption separately, as most of the other
222+
// decryption providers already support global decryption in other ways, and
223+
// we don't want to introduce duplicate methods of achieving the same.
224+
// Furthermore, allowing e.g. cloud provider credentials to be fetched
225+
// from this global secret would prevent workload identity from working.
226+
if secretRef == nil && d.sopsAgeSecret != nil {
227+
var secret corev1.Secret
228+
if err := d.client.Get(ctx, *d.sopsAgeSecret, &secret); err != nil {
229+
if apierrors.IsNotFound(err) {
230+
return err
231+
}
232+
return fmt.Errorf("cannot get %s SOPS age decryption Secret '%s': %w", provider, *d.sopsAgeSecret, err)
233+
}
234+
for name, value := range secret.Data {
235+
if filepath.Ext(name) == DecryptionAgeExt {
236+
if err := d.ageIdentities.Import(string(value)); err != nil {
237+
return fmt.Errorf("failed to import '%s' data from %s SOPS age decryption Secret '%s': %w",
238+
name, provider, *d.sopsAgeSecret, err)
239+
}
240+
}
241+
}
242+
return nil
243+
}
244+
220245
secretName := types.NamespacedName{
221246
Namespace: d.kustomization.GetNamespace(),
222-
Name: d.kustomization.Spec.Decryption.SecretRef.Name,
247+
Name: secretRef.Name,
223248
}
224249

225250
var secret corev1.Secret

internal/decryptor/decryptor_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ clientSecret: some-client-secret`),
376376
},
377377
}
378378

379-
d, cleanup, err := NewTempDecryptor("", cb.Build(), &kustomization, nil)
379+
d, cleanup, err := New(cb.Build(), &kustomization)
380380
g.Expect(err).ToNot(HaveOccurred())
381381
t.Cleanup(cleanup)
382382

@@ -605,7 +605,7 @@ func TestDecryptor_DecryptResource(t *testing.T) {
605605
Provider: DecryptionProviderSOPS,
606606
}
607607

608-
d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), kus, nil)
608+
d, cleanup, err := New(fake.NewClientBuilder().Build(), kus)
609609
g.Expect(err).ToNot(HaveOccurred())
610610
t.Cleanup(cleanup)
611611

@@ -646,7 +646,7 @@ func TestDecryptor_DecryptResource(t *testing.T) {
646646
Provider: DecryptionProviderSOPS,
647647
}
648648

649-
d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), kus, nil)
649+
d, cleanup, err := New(fake.NewClientBuilder().Build(), kus)
650650
g.Expect(err).ToNot(HaveOccurred())
651651
t.Cleanup(cleanup)
652652

@@ -681,7 +681,7 @@ func TestDecryptor_DecryptResource(t *testing.T) {
681681
Provider: DecryptionProviderSOPS,
682682
}
683683

684-
d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), kus, nil)
684+
d, cleanup, err := New(fake.NewClientBuilder().Build(), kus)
685685
g.Expect(err).ToNot(HaveOccurred())
686686
t.Cleanup(cleanup)
687687

@@ -716,7 +716,7 @@ func TestDecryptor_DecryptResource(t *testing.T) {
716716
Provider: DecryptionProviderSOPS,
717717
}
718718

719-
d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), kus, nil)
719+
d, cleanup, err := New(fake.NewClientBuilder().Build(), kus)
720720
g.Expect(err).ToNot(HaveOccurred())
721721
t.Cleanup(cleanup)
722722

@@ -765,7 +765,7 @@ func TestDecryptor_DecryptResource(t *testing.T) {
765765
t.Run("nil resource", func(t *testing.T) {
766766
g := NewWithT(t)
767767

768-
d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), kustomization.DeepCopy(), nil)
768+
d, cleanup, err := New(fake.NewClientBuilder().Build(), kustomization.DeepCopy())
769769
g.Expect(err).ToNot(HaveOccurred())
770770
t.Cleanup(cleanup)
771771

@@ -777,7 +777,7 @@ func TestDecryptor_DecryptResource(t *testing.T) {
777777
t.Run("no decryption spec", func(t *testing.T) {
778778
g := NewWithT(t)
779779

780-
d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), kustomization.DeepCopy(), nil)
780+
d, cleanup, err := New(fake.NewClientBuilder().Build(), kustomization.DeepCopy())
781781
g.Expect(err).ToNot(HaveOccurred())
782782
t.Cleanup(cleanup)
783783

@@ -793,7 +793,7 @@ func TestDecryptor_DecryptResource(t *testing.T) {
793793
kus.Spec.Decryption = &kustomizev1.Decryption{
794794
Provider: "not-supported",
795795
}
796-
d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), kus, nil)
796+
d, cleanup, err := New(fake.NewClientBuilder().Build(), kus)
797797
g.Expect(err).ToNot(HaveOccurred())
798798
t.Cleanup(cleanup)
799799

internal/decryptor/options.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package decryptor
18+
19+
import (
20+
"k8s.io/apimachinery/pkg/types"
21+
22+
"github.com/fluxcd/pkg/cache"
23+
)
24+
25+
// Option is a functional option for configuring the Decryptor.
26+
type Option func(o *Decryptor)
27+
28+
// WithRoot sets the root directory for the Decryptor.
29+
func WithRoot(root string) Option {
30+
return func(o *Decryptor) {
31+
o.root = root
32+
}
33+
}
34+
35+
// WithTokenCache sets the token cache for the Decryptor.
36+
func WithTokenCache(tokenCache cache.TokenCache) Option {
37+
return func(o *Decryptor) {
38+
o.tokenCache = &tokenCache
39+
}
40+
}
41+
42+
// WithSOPSAgeSecret sets the SOPSAgeSecret for the Decryptor.
43+
func WithSOPSAgeSecret(name, namespace string) Option {
44+
return func(o *Decryptor) {
45+
o.sopsAgeSecret = &types.NamespacedName{
46+
Name: name,
47+
Namespace: namespace,
48+
}
49+
}
50+
}

main.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ func main() {
9696
noRemoteBases bool
9797
httpRetry int
9898
defaultServiceAccount string
99+
sopsAgeSecret string
100+
runtimeNamespace string
99101
featureGates feathelper.FeatureGates
100102
disallowedFieldManagers []string
101103
tokenCacheOptions pkgcache.TokenFlags
@@ -111,6 +113,7 @@ func main() {
111113
"Disallow remote bases usage in Kustomize overlays. When this flag is enabled, all resources must refer to local files included in the source artifact.")
112114
flag.IntVar(&httpRetry, "http-retry", 9, "The maximum number of retries when failing to fetch artifacts over HTTP.")
113115
flag.StringVar(&defaultServiceAccount, "default-service-account", "", "Default service account used for impersonation.")
116+
flag.StringVar(&sopsAgeSecret, "sops-age-secret", "", "The name of a Kubernetes secret in the RUNTIME_NAMESPACE containing a SOPS age decryption key for fallback usage.")
114117
flag.StringArrayVar(&disallowedFieldManagers, "override-manager", []string{}, "Field manager disallowed to perform changes on managed resources.")
115118

116119
clientOptions.BindFlags(flag.CommandLine)
@@ -148,9 +151,11 @@ func main() {
148151
os.Exit(1)
149152
}
150153

154+
runtimeNamespace = os.Getenv("RUNTIME_NAMESPACE")
155+
151156
watchNamespace := ""
152157
if !watchOptions.AllNamespaces {
153-
watchNamespace = os.Getenv("RUNTIME_NAMESPACE")
158+
watchNamespace = runtimeNamespace
154159
}
155160

156161
watchSelector, err := runtimeCtrl.GetWatchSelector(watchOptions)
@@ -271,6 +276,8 @@ func main() {
271276
if err = (&controller.KustomizationReconciler{
272277
ControllerName: controllerName,
273278
DefaultServiceAccount: defaultServiceAccount,
279+
SOPSAgeSecret: sopsAgeSecret,
280+
RuntimeNamespace: runtimeNamespace,
274281
Client: mgr.GetClient(),
275282
Mapper: restMapper,
276283
APIReader: mgr.GetAPIReader(),

0 commit comments

Comments
 (0)