Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ metadata:
categories: Security
console.openshift.io/disable-operand-delete: "true"
containerImage: openshift.io/external-secrets-operator:latest
createdAt: "2025-08-20T18:17:42Z"
createdAt: "2025-09-08T08:44:22Z"
features.operators.openshift.io/cnf: "false"
features.operators.openshift.io/cni: "false"
features.operators.openshift.io/csi: "false"
Expand Down Expand Up @@ -412,6 +412,16 @@ spec:
- cert-manager.io
resources:
- certificates
verbs:
- create
- delete
- get
- list
- update
- watch
- apiGroups:
- cert-manager.io
resources:
- clusterissuers
- issuers
verbs:
Expand Down
10 changes: 10 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ rules:
- cert-manager.io
resources:
- certificates
verbs:
- create
- delete

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[claude generated] Excellent addition of delete permissions for certificates. This is necessary for the cleanup functionality and properly scoped to cert-manager.io resources.

- get
- list
- update
- watch
- apiGroups:
- cert-manager.io
resources:
- clusterissuers
- issuers
verbs:
Expand Down
4 changes: 4 additions & 0 deletions pkg/controller/common/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,10 @@ func DeleteObject(ctx context.Context, ctrlClient operatorclient.CtrlClient, obj
o = DecodeSecretObjBytes(assets.MustAsset(assetName))
case *corev1.ServiceAccount:
o = DecodeServiceAccountObjBytes(assets.MustAsset(assetName))
case *corev1.Service:
o = DecodeServiceObjBytes(assets.MustAsset(assetName))
case *certmanagerv1.Certificate:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[claude generated] Good addition of Certificate and Service support to DeleteObject function. This properly enables cleanup of these resource types.

o = DecodeCertificateObjBytes(assets.MustAsset(assetName))
default:
panic(fmt.Sprintf("unsupported object type: %T", obj))
}
Expand Down
49 changes: 41 additions & 8 deletions pkg/controller/external_secrets/certificate.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,53 @@ var (
)

func (r *Reconciler) createOrApplyCertificates(es *operatorv1alpha1.ExternalSecrets, resourceLabels map[string]string, recon bool) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this function requires refactoring, probably new functions for handling certificate needs of webhook and bitwarden.

And the comment If cert-manager is not enabled, we don't create or delete any certificates is misleading, adding two different functions will improve the code readability I feel.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

if isCertManagerConfigEnabled(es) {
if err := r.createOrApplyCertificate(es, resourceLabels, webhookCertificateAssetName, recon); err != nil {
return err
}
// Handle webhook certificate
if err := r.handleWebhookCertificate(es, resourceLabels, recon); err != nil {
return err
}

// Handle bitwarden certificate
if err := r.handleBitwardenCertificate(es, resourceLabels, recon); err != nil {
return err
}

return nil
}

// handleWebhookCertificate manages the webhook certificate lifecycle
func (r *Reconciler) handleWebhookCertificate(es *operatorv1alpha1.ExternalSecrets, resourceLabels map[string]string, recon bool) error {
// Only create webhook certificate if cert-manager is enabled
if !isCertManagerConfigEnabled(es) {
return nil
}

return r.createOrApplyCertificate(es, resourceLabels, webhookCertificateAssetName, recon)
}

// handleBitwardenCertificate manages the bitwarden certificate lifecycle
func (r *Reconciler) handleBitwardenCertificate(es *operatorv1alpha1.ExternalSecrets, resourceLabels map[string]string, recon bool) error {
// Bitwarden certificate handling is independent of cert-manager configuration
// Only handle bitwarden certificates when bitwarden is enabled
if isBitwardenConfigEnabled(es) {
bitwardenConfig := es.Spec.ExternalSecretsConfig.BitwardenSecretManagerProvider

// If a secret reference is provided, validate it exists instead of creating a certificate
if bitwardenConfig.SecretRef.Name != "" {
return r.assertSecretRefExists(es, es.Spec.ExternalSecretsConfig.BitwardenSecretManagerProvider)
}
if err := r.createOrApplyCertificate(es, resourceLabels, bitwardenCertificateAssetName, recon); err != nil {
return err
return r.assertSecretRefExists(es, bitwardenConfig)
}

// Create or update bitwarden certificate
return r.createOrApplyCertificate(es, resourceLabels, bitwardenCertificateAssetName, recon)
}
Comment on lines +49 to +61
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bug: Bitwarden certificate is created even when cert-manager is disabled (will fail).

Current flow calls createOrApplyCertificate without guarding on cert-manager. With CertManagerConfig disabled/missing, updateCertificateParams errors on empty IssuerRef (or the CRD may not exist), causing reconcile failures. Guard creation behind isCertManagerConfigEnabled and no-op (or warn) when SecretRef is absent.

Apply:

-// handleBitwardenCertificate manages the bitwarden certificate lifecycle
+// handleBitwardenCertificate manages the Bitwarden certificate lifecycle
 func (r *Reconciler) handleBitwardenCertificate(es *operatorv1alpha1.ExternalSecrets, resourceLabels map[string]string, recon bool) error {
-  // Bitwarden certificate handling is independent of cert-manager configuration
-  // Only handle bitwarden certificates when bitwarden is enabled
+  // Only handle Bitwarden certificates when Bitwarden is enabled
   if isBitwardenConfigEnabled(es) {
     bitwardenConfig := es.Spec.ExternalSecretsConfig.BitwardenSecretManagerProvider
-    
-    // If a secret reference is provided, validate it exists instead of creating a certificate
+
+    // If a secret reference is provided, validate it exists instead of creating a certificate
     if bitwardenConfig.SecretRef.Name != "" {
       return r.assertSecretRefExists(es, bitwardenConfig)
     }
-    
-    // Create or update bitwarden certificate
-    return r.createOrApplyCertificate(es, resourceLabels, bitwardenCertificateAssetName, recon)
+
+    // Create or update Bitwarden certificate only when cert-manager is enabled.
+    if !isCertManagerConfigEnabled(es) {
+      r.log.V(2).Info("Skipping Bitwarden certificate creation: cert-manager disabled and SecretRef not provided")
+      return nil
+    }
+    return r.createOrApplyCertificate(es, resourceLabels, bitwardenCertificateAssetName, recon)
   }
 
   // If bitwarden is not enabled, clean up any existing bitwarden certificate
   return r.cleanupBitwardenCertificate()
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Bitwarden certificate handling is independent of cert-manager configuration
// Only handle bitwarden certificates when bitwarden is enabled
if isBitwardenConfigEnabled(es) {
bitwardenConfig := es.Spec.ExternalSecretsConfig.BitwardenSecretManagerProvider
// If a secret reference is provided, validate it exists instead of creating a certificate
if bitwardenConfig.SecretRef.Name != "" {
return r.assertSecretRefExists(es, es.Spec.ExternalSecretsConfig.BitwardenSecretManagerProvider)
}
if err := r.createOrApplyCertificate(es, resourceLabels, bitwardenCertificateAssetName, recon); err != nil {
return err
return r.assertSecretRefExists(es, bitwardenConfig)
}
// Create or update bitwarden certificate
return r.createOrApplyCertificate(es, resourceLabels, bitwardenCertificateAssetName, recon)
}
// handleBitwardenCertificate manages the Bitwarden certificate lifecycle
func (r *Reconciler) handleBitwardenCertificate(es *operatorv1alpha1.ExternalSecrets, resourceLabels map[string]string, recon bool) error {
// Only handle Bitwarden certificates when Bitwarden is enabled
if isBitwardenConfigEnabled(es) {
bitwardenConfig := es.Spec.ExternalSecretsConfig.BitwardenSecretManagerProvider
// If a secret reference is provided, validate it exists instead of creating a certificate
if bitwardenConfig.SecretRef.Name != "" {
return r.assertSecretRefExists(es, bitwardenConfig)
}
// Create or update Bitwarden certificate only when cert-manager is enabled.
if !isCertManagerConfigEnabled(es) {
r.log.V(2).Info("Skipping Bitwarden certificate creation: cert-manager disabled and SecretRef not provided")
return nil
}
return r.createOrApplyCertificate(es, resourceLabels, bitwardenCertificateAssetName, recon)
}
// If Bitwarden is not enabled, clean up any existing Bitwarden certificate
return r.cleanupBitwardenCertificate()
}
🤖 Prompt for AI Agents
In pkg/controller/external_secrets/certificate.go around lines 49 to 61, the
code creates or updates a Bitwarden certificate even when cert-manager is
disabled, causing reconcile failures; modify the flow so that after checking
isBitwardenConfigEnabled(es) and handling SecretRef.Name, you additionally check
isCertManagerConfigEnabled(es) (or equivalent CertManagerConfig presence/enabled
flag) before calling createOrApplyCertificate; if cert-manager is disabled and
no SecretRef is provided, skip certificate creation and return a no-op (or log a
warning and return nil) so reconcile does not fail due to missing issuer/CRD.


// If bitwarden is not enabled, clean up any existing bitwarden certificate
return r.cleanupBitwardenCertificate()
}
Comment on lines +63 to +65
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify DeleteObject uses the correct namespace for deletion.

We pass only assetName here; if DeleteObject decodes the asset without applying updateNamespace(es, ...), it may target the wrong namespace and fail to delete. If so, consider decoding the object, calling updateNamespace, and deleting via the client.

Also applies to: 69-71


🏁 Script executed:

#!/bin/bash
# Inspect DeleteObject and decode helpers for namespace handling.
rg -n 'func\s+DeleteObject\(' -n -C5 pkg/controller/common | cat
rg -n 'Decode(Service|Certificate)ObjBytes' -C3 pkg/controller/common | cat
rg -n 'updateNamespace\(' -n pkg/controller/common | cat

Length of output: 2503


🏁 Script executed:

#!/bin/bash
# Inspect cleanupBitwardenCertificate implementation for DeleteObject usage.
rg -n 'cleanupBitwardenCertificate' -C5 pkg/controller/external_secrets/certificate.go

Length of output: 672


🏁 Script executed:

#!/bin/bash
# Search for updateNamespace definition and usage across repository
rg -n 'updateNamespace' -C3 .

Length of output: 6973


Ensure delete uses ExternalSecrets namespace in cleanupBitwardenCertificate
common.DeleteObject decodes the Certificate asset without applying updateNamespace, so it will delete in the asset’s own namespace (from the YAML) rather than the ExternalSecrets CR’s namespace—making cleanup silently fail. In pkg/controller/external_secrets/certificate.go (cleanupBitwardenCertificate), decode the asset, call updateNamespace(cert, es), then delete via r.CtrlClient.Delete.

🤖 Prompt for AI Agents
In pkg/controller/external_secrets/certificate.go around lines 63 to 65, the
cleanupBitwardenCertificate call deletes the Certificate using
common.DeleteObject which decodes the asset without applying updateNamespace,
causing a delete in the asset's YAML namespace; decode the Certificate asset
into a cert object, call updateNamespace(&cert, es) to set the ExternalSecrets
CR namespace, then perform the delete using r.CtrlClient.Delete(ctx, &cert)
(handle NotFound gracefully and return any other errors).


// cleanupBitwardenCertificate removes the bitwarden certificate when bitwarden is disabled
func (r *Reconciler) cleanupBitwardenCertificate() error {
if err := common.DeleteObject(r.ctx, r.CtrlClient, &certmanagerv1.Certificate{}, bitwardenCertificateAssetName); err != nil {
return fmt.Errorf("failed to delete bitwarden certificate: %w", err)
}
return nil
}
Expand Down
85 changes: 85 additions & 0 deletions pkg/controller/external_secrets/certificate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,91 @@ func TestCreateOrApplyCertificates(t *testing.T) {
},
recon: false,
},
{
name: "bitwarden certificate deleted when bitwarden disabled",
preReq: func(r *Reconciler, m *fakes.FakeCtrlClient) {
m.ExistsCalls(func(ctx context.Context, ns types.NamespacedName, obj client.Object) (bool, error) {
if ns.Name == serviceExternalSecretWebhookName {
return false, nil // webhook certificate doesn't exist, will be created
}
if ns.Name == "bitwarden-tls-certs" {
// Bitwarden certificate exists and needs to be deleted
cert := testCertificate(bitwardenCertificateAssetName)
cert.DeepCopyInto(obj.(*certmanagerv1.Certificate))
return true, nil
}
if ns.Name == "test-issuer" {
return true, nil // issuer exists
}
return false, nil
})
m.DeleteCalls(func(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
return commontest.TestClientError // Simulates deletion failure
})
m.CreateCalls(func(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
cert, ok := obj.(*certmanagerv1.Certificate)
if !ok {
return fmt.Errorf("expected *certmanagerv1.Certificate, got %T", obj)
}
if cert.Name == serviceExternalSecretWebhookName {
return nil // webhook certificate creation succeeds
}
t.Errorf("Unexpected create call for %s", cert.Name)
return nil
})
m.GetCalls(func(ctx context.Context, ns types.NamespacedName, obj client.Object) error {
if ns.Name == "test-issuer" && ns.Namespace == commontest.TestExternalSecretsNamespace {
testIssuer().DeepCopyInto(obj.(*certmanagerv1.Issuer))
return nil
}
return fmt.Errorf("object not found")
})
},
es: func(es *v1alpha1.ExternalSecrets) {
es.Spec.ExternalSecretsConfig.CertManagerConfig.Enabled = "true"
es.Spec.ExternalSecretsConfig.CertManagerConfig.IssuerRef.Name = "test-issuer"
// Bitwarden is disabled (explicitly set to false), so deletion should be triggered
es.Spec.ExternalSecretsConfig.BitwardenSecretManagerProvider = &v1alpha1.BitwardenSecretManagerProvider{
Enabled: "false",
}
},
recon: false,
wantErr: "failed to delete bitwarden certificate: test client error",
},
{
name: "no certificate operations when cert-manager disabled",
preReq: func(r *Reconciler, m *fakes.FakeCtrlClient) {
// Mock functions that should NOT be called
m.ExistsCalls(func(ctx context.Context, ns types.NamespacedName, obj client.Object) (bool, error) {
t.Errorf("Exists() should not be called when cert-manager is disabled, but was called for %s", ns.Name)
return false, nil
})
m.CreateCalls(func(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
t.Errorf("Create() should not be called when cert-manager is disabled")
return nil
})
m.DeleteCalls(func(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
t.Errorf("Delete() should not be called when cert-manager is disabled")
return nil
})
m.GetCalls(func(ctx context.Context, ns types.NamespacedName, obj client.Object) error {
t.Errorf("Get() should not be called when cert-manager is disabled")
return nil
})
},
es: func(es *v1alpha1.ExternalSecrets) {
// cert-manager is explicitly disabled (or not configured)
// This should trigger early return in createOrApplyCertificates
es.Spec.ExternalSecretsConfig = &v1alpha1.ExternalSecretsConfig{
// No CertManagerConfig means cert-manager is disabled
BitwardenSecretManagerProvider: &v1alpha1.BitwardenSecretManagerProvider{
Enabled: "true", // Even with Bitwarden enabled, no cert operations should happen
},
}
},
recon: false,
// No error expected - should return successfully without any operations
},
}

for _, tt := range tests {
Expand Down
3 changes: 2 additions & 1 deletion pkg/controller/external_secrets/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ type Reconciler struct {
// +kubebuilder:rbac:groups=admissionregistration.k8s.io,resources=validatingwebhookconfigurations,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups="",resources=events;secrets;services;serviceaccounts,verbs=get;list;watch;create;update;delete;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;delete
// +kubebuilder:rbac:groups=cert-manager.io,resources=certificates;clusterissuers;issuers,verbs=get;list;watch;create;update
// +kubebuilder:rbac:groups=cert-manager.io,resources=clusterissuers;issuers,verbs=get;list;watch;create;update
// +kubebuilder:rbac:groups=cert-manager.io,resources=certificates,verbs=get;list;watch;create;update;delete
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create

// +kubebuilder:rbac:groups="",resources=endpoints,verbs=get;list;watch;create
Expand Down
32 changes: 32 additions & 0 deletions pkg/controller/external_secrets/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,38 @@ func TestCreateOrApplyServices(t *testing.T) {
},
wantErr: `failed to create service external-secrets/external-secrets-webhook: test client error`,
},
{
name: "bitwarden service deleted when bitwarden disabled",
preReq: func(r *Reconciler, m *fakes.FakeCtrlClient) {
m.ExistsCalls(func(ctx context.Context, ns types.NamespacedName, obj client.Object) (bool, error) {
switch o := obj.(type) {
case *corev1.Service:
if ns.Name == "bitwarden-sdk-server" {
svc := testService(bitwardenServiceAssetName)
svc.DeepCopyInto(o)
return true, nil // Service exists and needs to be deleted
}
// For webhook service
svc := testService("external-secrets/resources/service_external-secrets-webhook.yml")
svc.DeepCopyInto(o)
return true, nil
}
return false, nil
})
m.DeleteCalls(func(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
return commontest.TestClientError // Simulates deletion failure
})
},
updateExternalSecretsObj: func(es *operatorv1alpha1.ExternalSecrets) {
// Bitwarden is disabled (default), so deletion should be triggered
es.Spec.ExternalSecretsConfig = &operatorv1alpha1.ExternalSecretsConfig{
BitwardenSecretManagerProvider: &operatorv1alpha1.BitwardenSecretManagerProvider{
Enabled: "false", // This triggers deletion logic
},
}
},
wantErr: `failed to delete service object: test client error`,
},
}

for _, tt := range tests {
Expand Down
3 changes: 3 additions & 0 deletions pkg/controller/external_secrets/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ func (r *Reconciler) createOrApplyServices(externalsecrets *operatorv1alpha1.Ext

for _, service := range servicesToCreate {
if !service.condition {
if err := common.DeleteObject(r.ctx, r.CtrlClient, &corev1.Service{}, service.assetName); err != nil {
return fmt.Errorf("failed to delete service object: %w", err)
}
continue
}
if err := r.createOrApplyServiceFromAsset(externalsecrets, service.assetName, resourceLabels, externalsecretsCreateRecon); err != nil {
Expand Down