Browse Source

feat(provider): implement AWS Certificate Manager provider (#6117)

* feat(apis): adds CertificateManager AWSServiceType

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* feat(aws): scaffold certificate manager provider (WiP) - GetSecret + tests

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* chore: resolve conflicts

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* chore(aws): Add license to files

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* feat(aws): implement tests for certificatemanager

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* fix(apis): add CertificateManager type to AWSServiceType enum

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* chore: run make reviewable

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* fix: sonarqube warning

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* chore: run make reviewable

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* chore: consistent CertificateManager enum addition

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* chore: add required Passphrase parameter

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* chore: Add region validation for CertificateManager to match SecretsManager and ParameterStore

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>

* feat(provider): implement write-only ACM provider

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* fix: expect std k8s tls secret and split leaf+chain

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* fix: race condition in certificate import

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* chore: generate updated CRDs

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* docs: add AWS certificate manager provider

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* fix: race condition due to aws list api eventual consistency

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* feat: add content hash tag to skip redundant re-imports

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* chore: remove skip of self-signed certificates in the chain

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* chore: cleanup

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* docs: clenaup acm intro paragraph

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* chore: implement custom resolver and move mock client

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* feat: finilize acm external secret implementation

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* docs: fix formatting

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* fix: lint issues

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* fix: static analysis findings

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* refactor: add certBundle struct

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* chore: add ACM e2e tests

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* docs: update ACM and AWS Access pages

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* chore: convert EC PK to PKCS#8 in test cert

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* fix: minor issues (coderabbit review)

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* fix: acm e2e test (coderabbit review)

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* fix: acm e2e test cleanup (coderabbit review)

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* fix: variable shadowing in acm e2e test provider

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* chore: add constants for repeated strings

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* chore: address review comments

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* chore: address sonarqube issues

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* chore: use cert fingerprint for data staleness check

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* docs: update acm caching refs

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* fix: acm e2e managed test

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

* chore: fix indentation after conflict resolution

Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>

---------

Signed-off-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>
Signed-off-by: dsp0x4 <36164827+dsp0x4@users.noreply.github.com>
Co-authored-by: aleksandarss <aleksandar-sasa.calic@hotmail.com>
Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Co-authored-by: Jean-Philippe Evrard <jean-philippe.evrard+rochepub@external.roche.com>
Paolo De Santis 6 days ago
parent
commit
4c5cb4a44e
38 changed files with 3111 additions and 21 deletions
  1. 4 1
      apis/externalsecrets/v1/secretstore_aws_types.go
  2. 1 0
      config/crds/bases/external-secrets.io_clustersecretstores.yaml
  3. 1 0
      config/crds/bases/external-secrets.io_secretstores.yaml
  4. 2 0
      deploy/crds/bundle.yaml
  5. 5 1
      docs/api/spec.md
  6. 53 0
      docs/guides/pushsecret-datato.md
  7. 2 0
      docs/introduction/stability-support.md
  8. 5 3
      docs/provider/aws-access.md
  9. 172 0
      docs/provider/aws-certificate-manager.md
  10. 11 0
      docs/snippets/aws-acm-cert-manager-certificate.yaml
  11. 17 0
      docs/snippets/aws-acm-cert-manager-push-secret.yaml
  12. 23 0
      docs/snippets/aws-acm-external-secret-chain.yaml
  13. 22 0
      docs/snippets/aws-acm-external-secret.yaml
  14. 24 0
      docs/snippets/aws-acm-push-secret-with-metadata.yaml
  15. 17 0
      docs/snippets/aws-acm-push-secret.yaml
  16. 17 0
      docs/snippets/aws-acm-store.yaml
  17. 4 2
      e2e/go.mod
  18. 8 4
      e2e/go.sum
  19. 53 0
      e2e/suites/provider/cases/aws/certificatemanager/certificatemanager.go
  20. 95 0
      e2e/suites/provider/cases/aws/certificatemanager/certificatemanager_managed.go
  21. 285 0
      e2e/suites/provider/cases/aws/certificatemanager/provider.go
  22. 281 0
      e2e/suites/provider/cases/aws/certificatemanager/push_secret.go
  23. 1 0
      e2e/suites/provider/cases/import.go
  24. 2 0
      go.mod
  25. 4 0
      go.sum
  26. 1 0
      hack/api-docs/mkdocs.yml
  27. 668 0
      providers/v1/aws/certificatemanager/certificatemanager.go
  28. 1030 0
      providers/v1/aws/certificatemanager/certificatemanager_test.go
  29. 109 0
      providers/v1/aws/certificatemanager/fake/fake.go
  30. 72 0
      providers/v1/aws/certificatemanager/resolver.go
  31. 5 0
      providers/v1/aws/go.mod
  32. 8 0
      providers/v1/aws/go.sum
  33. 51 5
      providers/v1/aws/provider.go
  34. 18 2
      providers/v1/aws/provider_test.go
  35. 13 1
      runtime/constants/constants.go
  36. 25 0
      terraform/aws/infrastructure/modules/cluster/irsa.tf
  37. 1 1
      tests/__snapshot__/clustersecretstore-v1.yaml
  38. 1 1
      tests/__snapshot__/secretstore-v1.yaml

+ 4 - 1
apis/externalsecrets/v1/secretstore_aws_types.go

@@ -52,7 +52,7 @@ type AWSJWTAuth struct {
 }
 
 // AWSServiceType is a enum that defines the service/API that is used to fetch the secrets.
-// +kubebuilder:validation:Enum=SecretsManager;ParameterStore
+// +kubebuilder:validation:Enum=SecretsManager;ParameterStore;CertificateManager
 type AWSServiceType string
 
 const (
@@ -62,6 +62,9 @@ const (
 	// AWSServiceParameterStore is the AWS SystemsManager ParameterStore service.
 	// see: https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html
 	AWSServiceParameterStore AWSServiceType = "ParameterStore"
+	// AWSServiceCertificateManager is the AWS Certificate Manager service.
+	// see: https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html
+	AWSServiceCertificateManager AWSServiceType = "CertificateManager"
 )
 
 // SecretsManager defines how the provider behaves when interacting with AWS

+ 1 - 0
config/crds/bases/external-secrets.io_clustersecretstores.yaml

@@ -603,6 +603,7 @@ spec:
                         enum:
                         - SecretsManager
                         - ParameterStore
+                        - CertificateManager
                         type: string
                       sessionTags:
                         description: AWS STS assume role session tags

+ 1 - 0
config/crds/bases/external-secrets.io_secretstores.yaml

@@ -603,6 +603,7 @@ spec:
                         enum:
                         - SecretsManager
                         - ParameterStore
+                        - CertificateManager
                         type: string
                       sessionTags:
                         description: AWS STS assume role session tags

+ 2 - 0
deploy/crds/bundle.yaml

@@ -2900,6 +2900,7 @@ spec:
                           enum:
                             - SecretsManager
                             - ParameterStore
+                            - CertificateManager
                           type: string
                         sessionTags:
                           description: AWS STS assume role session tags
@@ -15676,6 +15677,7 @@ spec:
                           enum:
                             - SecretsManager
                             - ParameterStore
+                            - CertificateManager
                           type: string
                         sessionTags:
                           description: AWS STS assume role session tags

+ 5 - 1
docs/api/spec.md

@@ -351,7 +351,11 @@ string
 <th>Description</th>
 </tr>
 </thead>
-<tbody><tr><td><p>&#34;ParameterStore&#34;</p></td>
+<tbody><tr><td><p>&#34;CertificateManager&#34;</p></td>
+<td><p>AWSServiceCertificateManager is the AWS Certificate Manager service.
+see: <a href="https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html">https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html</a></p>
+</td>
+</tr><tr><td><p>&#34;ParameterStore&#34;</p></td>
 <td><p>AWSServiceParameterStore is the AWS SystemsManager ParameterStore service.
 see: <a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html">https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-parameter-store.html</a></p>
 </td>

+ 53 - 0
docs/guides/pushsecret-datato.md

@@ -72,6 +72,7 @@ my-app/config → {"DB_HOST":"localhost","DB_USER":"admin","DB_PASS":"s3cr3t"}
 |---|---|---|---|
 | AWS Secrets Manager | Named secret (JSON) | **Yes** | `remoteKey` = secret name; store `prefix` is prepended |
 | AWS Parameter Store | Named parameter | **Yes** | `remoteKey` = parameter path |
+| AWS Certificate Manager | Named cert | **Yes** | `remoteKey` = `external-secrets-remote-key` tag value |
 | Azure Key Vault | Named secret/key/cert | **Yes** | `remoteKey` = object name |
 | GCP Secret Manager | Named secret | **Yes** | `remoteKey` = secret ID |
 | HashiCorp Vault | Named path (JSON) | **Yes** | `remoteKey` = Vault path |
@@ -156,6 +157,58 @@ my-app/db-config → {"DB_HOST":"localhost","DB_USER":"admin","DB_PASS":"s3cr3t"
 # → AWS secret name: myapp/db-config
 ```
 
+### AWS Certificate Manager
+
+ACM imports a TLS certificate as a single resource. The source Kubernetes Secret must be of type `kubernetes.io/tls` and contain both `tls.crt` (PEM-encoded leaf, optionally followed by intermediates) and `tls.key` (PEM-encoded private key). `remoteKey` becomes the value of the `external-secrets-remote-key` tag that ESO uses to locate the certificate on subsequent reconciles.
+
+!!! warning "Do not filter out `tls.crt` or `tls.key`"
+    The ACM provider always reads `tls.crt` and `tls.key` from the source secret. If `match.regexp` excludes either, the push fails with `key "tls.crt" not found or empty`. Either omit `match` entirely, or write a pattern that includes both keys (e.g. `^tls\.`).
+
+```yaml
+apiVersion: external-secrets.io/v1
+kind: SecretStore
+metadata:
+  name: aws-acm-store
+spec:
+  provider:
+    aws:
+      service: CertificateManager
+      region: us-east-1
+---
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: push-tls-to-acm
+spec:
+  secretStoreRefs:
+    - name: aws-acm-store
+      kind: SecretStore
+  selector:
+    secret:
+      name: my-tls-cert    # kubernetes.io/tls Secret with tls.crt and tls.key
+  dataTo:
+    - storeRef:
+        name: aws-acm-store
+      remoteKey: my-app-cert    # → external-secrets-remote-key tag
+      metadata:
+        apiVersion: kubernetes.external-secrets.io/v1alpha1
+        kind: PushSecretMetadata
+        spec:
+          tags:                 # optional: extra AWS resource tags on the imported cert
+            environment: prod
+            team: platform
+```
+
+Result in ACM: a certificate tagged with `managed-by=external-secrets`, `external-secrets-remote-key=my-app-cert`, plus any custom tags from `metadata.spec.tags`. The reserved tags `managed-by` and `external-secrets-remote-key` cannot be overridden via `metadata`.
+
+**With a store prefix:**
+
+```yaml
+# SecretStore has prefix: certs/
+# dataTo remoteKey: my-app-cert
+# → external-secrets-remote-key tag value: certs/my-app-cert
+```
+
 ### Azure Key Vault
 
 ```yaml

+ 2 - 0
docs/introduction/stability-support.md

@@ -67,6 +67,7 @@ The following table describes the stability level of each provider and who's res
 |----------------------------------------------------------------------------------------------------------------|----------:| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
 | [AWS Secrets Manager](https://external-secrets.io/latest/provider/aws-secrets-manager/)                        |    stable | [external-secrets](https://github.com/external-secrets)                                             |
 | [AWS Parameter Store](https://external-secrets.io/latest/provider/aws-parameter-store/)                        |    stable | [external-secrets](https://github.com/external-secrets)                                             |
+| [AWS Certificate Manager](https://external-secrets.io/latest/provider/aws-certificate-manager/)                |     alpha | [external-secrets](https://github.com/external-secrets)                                             |
 | [Akeyless](https://external-secrets.io/latest/provider/akeyless)                                               |    stable | [external-secrets](https://github.com/external-secrets)                                             |
 | [Azure Keyvault](https://external-secrets.io/latest/provider/azure-key-vault/)                                 |    stable | [external-secrets](https://github.com/external-secrets)                                             |
 | [Barbican](https://external-secrets.io/latest/provider/barbican)                                               |     alpha | [@rkferreira](https://github.com/rkferreira)                                                        |
@@ -117,6 +118,7 @@ The following table show the support for features across different providers.
 | Akeyless                         |      x       |      x       |                      |            x            |        x         |      x      |              x              |
 | AWS Secrets Manager              |      x       |      x       |          x           |            x            |        x         |      x      |              x              |
 | AWS Parameter Store              |      x       |      x       |          x           |            x            |        x         |      x      |              x              |
+| AWS Certificate Manager          |              |              |                      |            x            |        x         |      x      |              x              |
 | Azure Keyvault                   |      x       |      x       |          x           |            x            |        x         |      x      |              x              |
 | Barbican                         |      x       |              |                      |                         |        x         |             |                             |
 | Beyondtrust                      |              |              |                      |                         |        x         |      x      |                             |

+ 5 - 3
docs/provider/aws-access.md

@@ -4,11 +4,11 @@
 
 ![Pod Identity Authentication](../pictures/diagrams-provider-aws-auth-pod-identity.png)
 
-Note: If you are using Parameter Store replace `service: SecretsManager` with `service: ParameterStore` in all examples below.
+Note: If you are using Parameter Store replace `service: SecretsManager` with `service: ParameterStore` in all examples below. For Certificate Manager use `service: CertificateManager`.
 
 This is basically a zero-configuration authentication method that inherits the credentials from the runtime environment using the [aws sdk default credential chain](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html#credentials-default).
 
-You can attach a role to the pod using [IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html), [kiam](https://github.com/uswitch/kiam) or [kube2iam](https://github.com/jtblin/kube2iam). When no other authentication method is configured in the `Kind=Secretstore` this role is used to make all API calls against AWS Secrets Manager or SSM Parameter Store.
+You can attach a role to the pod using [IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html), [kiam](https://github.com/uswitch/kiam) or [kube2iam](https://github.com/jtblin/kube2iam). When no other authentication method is configured in the `Kind=SecretStore` this role is used to make all API calls against AWS Secrets Manager, SSM Parameter Store or Certificate Manager.
 
 Based on the Pod's identity you can do a `sts:assumeRole` before fetching the secrets to limit access to certain keys in your provider. This is optional.
 
@@ -182,7 +182,7 @@ _Note:_ For even more details you can follow this post for more setup and inform
 
 ## Custom Endpoints
 
-You can define custom AWS endpoints if you want to use regional, vpc or custom endpoints. See List of endpoints for [Secrets Manager](https://docs.aws.amazon.com/general/latest/gr/asm.html), [Secure Systems Manager](https://docs.aws.amazon.com/general/latest/gr/ssm.html) and [Security Token Service](https://docs.aws.amazon.com/general/latest/gr/sts.html).
+You can define custom AWS endpoints if you want to use regional, vpc or custom endpoints. See List of endpoints for [Secrets Manager](https://docs.aws.amazon.com/general/latest/gr/asm.html), [Secure Systems Manager](https://docs.aws.amazon.com/general/latest/gr/ssm.html), [Certificate Manager](https://docs.aws.amazon.com/general/latest/gr/acm.html) and [Security Token Service](https://docs.aws.amazon.com/general/latest/gr/sts.html).
 
 Use the following environment variables to point the controller to your custom endpoints. Note: All resources managed by this controller are affected.
 
@@ -190,6 +190,8 @@ Use the following environment variables to point the controller to your custom e
 | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
 | AWS_SECRETSMANAGER_ENDPOINT | Endpoint for the Secrets Manager Service. The controller uses this endpoint to fetch secrets from AWS Secrets Manager.                                               |
 | AWS_SSM_ENDPOINT            | Endpoint for the AWS Secure Systems Manager. The controller uses this endpoint to fetch secrets from SSM Parameter Store.                                            |
+| AWS_ACM_ENDPOINT            | Endpoint for the AWS Certificate Manager. The controller uses this endpoint to import and export certificates from ACM.                                              |
+| AWS_RESOURCE_GROUPS_TAGGING_API_ENDPOINT | Endpoint for the AWS Resource Groups Tagging API. The controller uses this endpoint to locate ACM certificates by their ESO management tags.           |
 | AWS_STS_ENDPOINT            | Endpoint for the Security Token Service. The controller uses this endpoint when creating a session and when doing `assumeRole` or `assumeRoleWithWebIdentity` calls. |
 | AWS_ECR_ENDPOINT            | Endpoint for the ECR Service. The controller uses this endpoint to fetch authorization tokens from ECR.                                                              |
 | AWS_ECR_PUBLIC_ENDPOINT     | Endpoint for the Public ECR Service. The controller uses this endpoint to fetch authorization tokens from ECR.                                                       |

+ 172 - 0
docs/provider/aws-certificate-manager.md

@@ -0,0 +1,172 @@
+
+## AWS Certificate Manager
+
+External Secrets Operator integrates with [AWS Certificate Manager (ACM)](https://docs.aws.amazon.com/acm/latest/userguide/acm-overview.html) to support:
+
+- **Exporting** certificates from ACM into Kubernetes via `ExternalSecret`.
+- **Importing** TLS certificates into ACM via `PushSecret` (e.g., certificates issued by [cert-manager](https://cert-manager.io/)).
+
+Both public and private ACM certificates can be exported, provided the export option was enabled when the certificate was requested. See [Exportable Certificates](#exportable-certificates) below for details.
+
+### SecretStore
+
+``` yaml
+{% include 'aws-acm-store.yaml' %}
+```
+
+**NOTE:** In case of a `ClusterSecretStore`, be sure to provide `namespace` in `accessKeyIDSecretRef` and `secretAccessKeySecretRef` with the namespaces where the secrets reside.
+
+### IAM Policy
+
+The required IAM permissions depend on whether you use `ExternalSecret` (export), `PushSecret` (import), or both.
+
+#### Export only (`ExternalSecret`)
+
+``` json
+{
+  "Version": "2012-10-17",
+  "Statement": [
+    {
+      "Effect": "Allow",
+      "Action": [
+        "acm:DescribeCertificate",
+        "acm:ExportCertificate",
+        "acm:GetCertificate"
+      ],
+      "Resource": "*"
+    }
+  ]
+}
+```
+
+#### Import only (`PushSecret`)
+
+``` json
+{
+  "Version": "2012-10-17",
+  "Statement": [
+    {
+      "Effect": "Allow",
+      "Action": [
+        "acm:GetCertificate",
+        "acm:ImportCertificate",
+        "acm:DeleteCertificate",
+        "acm:AddTagsToCertificate",
+        "acm:ListTagsForCertificate",
+        "acm:RemoveTagsFromCertificate",
+        "tag:GetResources"
+      ],
+      "Resource": "*"
+    }
+  ]
+}
+```
+
+The `tag:GetResources` permission (Resource Groups Tagging API) is used to locate certificates by their ESO management tags with server-side filtering. `tag:GetResources` does not support resource-level ARNs and must be granted on `"Resource": "*"`.
+
+#### Both export and import
+
+``` json
+{
+  "Version": "2012-10-17",
+  "Statement": [
+    {
+      "Effect": "Allow",
+      "Action": [
+        "acm:GetCertificate",
+        "acm:ImportCertificate",
+        "acm:DeleteCertificate",
+        "acm:DescribeCertificate",
+        "acm:ExportCertificate",
+        "acm:AddTagsToCertificate",
+        "acm:ListTagsForCertificate",
+        "acm:RemoveTagsFromCertificate",
+        "tag:GetResources"
+      ],
+      "Resource": "*"
+    }
+  ]
+}
+```
+
+You can scope the `acm:*` actions to specific certificate ARNs or use conditions to restrict access to certain regions or accounts. `tag:GetResources` must remain on `"Resource": "*"`.
+
+### ExternalSecret
+
+#### Exportable Certificates
+
+Both **public** and **private** ACM certificates can be exported, but the certificate must have been requested with the export option **enabled**. This option cannot be changed after the certificate is created — if it was not enabled at request time, the certificate is not exportable.
+
+Requesting a public certificate with the export option enabled is a **paid** feature — AWS charges a fee upon issuance and again on each renewal. Separately, the `ExportCertificate` API is free for the first 10,000 calls per month per account; exceeding this threshold incurs additional per-call charges. See the [ACM pricing page](https://aws.amazon.com/certificate-manager/pricing/) for current details.
+
+#### Export Caching
+
+To minimize calls to the paid `ExportCertificate` API, the provider caches exported PEM bundles in memory, keyed by certificate ARN. On each access the provider calls the free `GetCertificate` and compares the certificate's fingerprint against the cached entry, which prevents serving stale data; `ExportCertificate` is only called on the first access, when the fingerprint changes (certificate renewal), or when the cached entry has been evicted. The cache is bounded to 128 entries with a 24-hour TTL per entry (LRU eviction above that). It is held on the long-lived AWS provider instance and survives across reconciles, but is in-memory only and is reset when the operator pod restarts or when the underlying `SecretStore` is updated.
+
+#### Usage
+
+Use `remoteRef.key` to specify the certificate ARN. The provider returns the certificate, chain, and decrypted private key as a single PEM bundle. Use [template functions](../guides/templating.md) such as `filterPEM` and `filterCertChain` to extract individual components:
+
+``` yaml
+{% include 'aws-acm-external-secret.yaml' %}
+```
+
+You can also extract just the leaf or intermediate certificates using `filterCertChain`:
+
+``` yaml
+{% include 'aws-acm-external-secret-chain.yaml' %}
+```
+
+### PushSecret
+
+The ACM provider expects the source Kubernetes secret to be a standard `kubernetes.io/tls` secret containing:
+
+- `tls.crt` — PEM-encoded leaf certificate, optionally followed by intermediate CA certificates.
+- `tls.key` — PEM-encoded private key.
+
+The `spec.data` entry must **not** set `secretKey` — the provider always reads the full secret in whole-secret mode because ACM's `ImportCertificate` API requires both the certificate and private key in a single call.
+
+The `remoteKey` field is used as a tag value (`external-secrets-remote-key`) to identify and track the imported certificate in ACM.
+
+``` yaml
+{% include 'aws-acm-push-secret.yaml' %}
+```
+
+#### Certificate Chain Handling
+
+The provider automatically splits the `tls.crt` PEM bundle:
+
+- The **first** PEM block is treated as the leaf certificate and passed as the `Certificate` field.
+- Any subsequent PEM blocks are passed as-is in the `CertificateChain` field.
+
+This matches the output format of cert-manager, which places the leaf certificate first, followed by intermediates.
+
+#### Custom Tags (Metadata)
+
+You can apply custom AWS resource tags to the imported certificate using PushSecret metadata:
+
+``` yaml
+{% include 'aws-acm-push-secret-with-metadata.yaml' %}
+```
+
+The tags `managed-by` and `external-secrets-remote-key` are reserved for ESO internal tracking and cannot be overridden via metadata.
+
+### Usage with cert-manager
+
+A common pattern is to use [cert-manager](https://cert-manager.io/) to provision TLS certificates and then push them to ACM via `PushSecret`. cert-manager stores issued certificates as `kubernetes.io/tls` secrets, which is exactly the format expected by the ACM provider.
+
+**1. Create a `Certificate` resource:**
+
+``` yaml
+{% include 'aws-acm-cert-manager-certificate.yaml' %}
+```
+
+**2. Create a `PushSecret` to import the certificate into ACM:**
+
+``` yaml
+{% include 'aws-acm-cert-manager-push-secret.yaml' %}
+```
+
+When cert-manager renews the certificate, the `PushSecret` controller will detect the change and re-import the updated certificate into ACM using the same ARN (identified by the `external-secrets-remote-key` tag). This keeps the ACM certificate in sync without creating duplicates.
+
+If the `PushSecret` is deleted and `deletionPolicy` is set to `Delete`, the imported certificate will be removed from ACM (provided it is not in use).

+ 11 - 0
docs/snippets/aws-acm-cert-manager-certificate.yaml

@@ -0,0 +1,11 @@
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+  name: my-app-cert
+spec:
+  secretName: my-tls-cert
+  issuerRef:
+    name: letsencrypt-prod
+    kind: ClusterIssuer
+  dnsNames:
+    - myapp.example.com

+ 17 - 0
docs/snippets/aws-acm-cert-manager-push-secret.yaml

@@ -0,0 +1,17 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: acm-my-app-cert
+spec:
+  deletionPolicy: Delete
+  refreshInterval: 1h0m0s
+  secretStoreRefs:
+    - name: aws-certificate-manager
+      kind: SecretStore
+  selector:
+    secret:
+      name: my-tls-cert
+  data:
+    - match:
+        remoteRef:
+          remoteKey: my-app-certificate

+ 23 - 0
docs/snippets/aws-acm-external-secret-chain.yaml

@@ -0,0 +1,23 @@
+{% raw %}
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+  name: acm-exported-cert-chain
+spec:
+  refreshInterval: 1h0m0s
+  secretStoreRef:
+    kind: SecretStore
+    name: aws-certificate-manager
+  target:
+    template:
+      type: kubernetes.io/tls
+      engineVersion: v2
+      data:
+        ca.crt: '{{ .cert | filterPEM "CERTIFICATE" | filterCertChain "intermediate" }}'
+        tls.crt: '{{ .cert | filterPEM "CERTIFICATE" | filterCertChain "leaf" }}'
+        tls.key: '{{ .cert | filterPEM "PRIVATE KEY" }}'
+  data:
+    - secretKey: cert
+      remoteRef:
+        key: arn:aws:acm:us-east-1:123456789012:certificate/abcd-1234
+{% endraw %}

+ 22 - 0
docs/snippets/aws-acm-external-secret.yaml

@@ -0,0 +1,22 @@
+{% raw %}
+apiVersion: external-secrets.io/v1
+kind: ExternalSecret
+metadata:
+  name: acm-exported-cert
+spec:
+  refreshInterval: 1h0m0s
+  secretStoreRef:
+    kind: SecretStore
+    name: aws-certificate-manager
+  target:
+    template:
+      type: kubernetes.io/tls
+      engineVersion: v2
+      data:
+        tls.crt: '{{ .cert | filterPEM "CERTIFICATE" }}'
+        tls.key: '{{ .cert | filterPEM "PRIVATE KEY" }}'
+  data:
+    - secretKey: cert
+      remoteRef:
+        key: arn:aws:acm:us-east-1:123456789012:certificate/abcd-1234
+{% endraw %}

+ 24 - 0
docs/snippets/aws-acm-push-secret-with-metadata.yaml

@@ -0,0 +1,24 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: acm-certificate-with-tags
+spec:
+  deletionPolicy: Delete
+  refreshInterval: 1h0m0s
+  secretStoreRefs:
+    - name: aws-certificate-manager
+      kind: SecretStore
+  selector:
+    secret:
+      name: my-tls-cert
+  data:
+    - match:
+        remoteRef:
+          remoteKey: my-app-certificate
+      metadata:
+        apiVersion: kubernetes.external-secrets.io/v1alpha1
+        kind: PushSecretMetadata
+        spec:
+          tags:
+            environment: production
+            team: platform

+ 17 - 0
docs/snippets/aws-acm-push-secret.yaml

@@ -0,0 +1,17 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: acm-certificate
+spec:
+  deletionPolicy: Delete
+  refreshInterval: 1h0m0s
+  secretStoreRefs:
+    - name: aws-certificate-manager
+      kind: SecretStore
+  selector:
+    secret:
+      name: my-tls-cert
+  data:
+    - match:
+        remoteRef:
+          remoteKey: my-app-certificate

+ 17 - 0
docs/snippets/aws-acm-store.yaml

@@ -0,0 +1,17 @@
+apiVersion: external-secrets.io/v1
+kind: SecretStore
+metadata:
+  name: aws-certificate-manager
+spec:
+  provider:
+    aws:
+      service: CertificateManager
+      region: us-east-1
+      auth:
+        secretRef:
+          accessKeyIDSecretRef:
+            name: awssm-secret
+            key: access-key
+          secretAccessKeySecretRef:
+            name: awssm-secret
+            key: secret-access-key

+ 4 - 2
e2e/go.mod

@@ -48,6 +48,8 @@ require (
 	github.com/aws/aws-sdk-go-v2 v1.41.6
 	github.com/aws/aws-sdk-go-v2/config v1.31.16
 	github.com/aws/aws-sdk-go-v2/credentials v1.18.20
+	github.com/aws/aws-sdk-go-v2/service/acm v1.37.13
+	github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.30.13
 	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.9
 	github.com/aws/aws-sdk-go-v2/service/ssm v1.66.3
 	github.com/cyberark/conjur-api-go v0.13.8
@@ -106,8 +108,8 @@ require (
 	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
 	github.com/aws/aws-sdk-go v1.55.8 // indirect
 	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.12 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.12 // indirect

+ 8 - 4
e2e/go.sum

@@ -93,16 +93,20 @@ github.com/aws/aws-sdk-go-v2/credentials v1.18.20 h1:KFndAnHd9NUuzikHjQ8D5CfFVO+
 github.com/aws/aws-sdk-go-v2/credentials v1.18.20/go.mod h1:9mCi28a+fmBHSQ0UM79omkz6JtN+PEsvLrnG36uoUv0=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.12 h1:VO3FIM2TDbm0kqp6sFNR0PbioXJb/HzCDW6NtIZpIWE=
 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.12/go.mod h1:6C39gB8kg82tx3r72muZSrNhHia9rjGkX7ORaS2GKNE=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
+github.com/aws/aws-sdk-go-v2/service/acm v1.37.13 h1:5AUX6KOEBF20wiIaiI4fKHvVkELEptyLtRTbWGpNQNY=
+github.com/aws/aws-sdk-go-v2/service/acm v1.37.13/go.mod h1:v8E4cAu0qIxpS7IokQilQb60A8IODPxo82VxVtJ+Dgo=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ=
 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.12 h1:MM8imH7NZ0ovIVX7D2RxfMDv7Jt9OiUXkcQ+GqywA7M=
 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.12/go.mod h1:gf4OGwdNkbEsb7elw2Sy76odfhwNktWII3WgvQgQQ6w=
+github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.30.13 h1:bgZMYv1wMOsV1ug0/Hx/rs4fxbcFVipvOAwgsbhIons=
+github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.30.13/go.mod h1:+nL0z6xUm9NK9bOAkam66NQDgNH8Qa6T6SS3f8dkzXU=
 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.9 h1:SateVRwzAULF812BCR6+DZ77n8KBlbQoKNiqJvfbAII=
 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.9/go.mod h1:uyJVFSxMat78YTaaz+ROx+FI+K78Qa7VyEQmt8hBSWI=
 github.com/aws/aws-sdk-go-v2/service/ssm v1.66.3 h1:8LuQ2nZjoZ6Z/m2UwsJ+k6ih0ZrjpmQF3uTl6wZYS40=

+ 53 - 0
e2e/suites/provider/cases/aws/certificatemanager/certificatemanager.go

@@ -0,0 +1,53 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+
+	// nolint
+	"github.com/aws/aws-sdk-go-v2/service/acm/types"
+	. "github.com/onsi/ginkgo/v2"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	awscommon "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+)
+
+const (
+	withStaticAuth = "with static auth"
+)
+
+var _ = Describe("[aws] ", Label("aws", "certificatemanager"), Ordered, func() {
+	f := framework.New("eso-aws-acm")
+	prov := NewFromEnv(f)
+
+	DescribeTable("push ACM secrets",
+		framework.TableFuncWithPushSecret(f, prov, nil),
+		framework.Compose(withStaticAuth, f, PushSecretImport(prov, types.KeyAlgorithmRsa2048), usePushStaticAuth),
+		framework.Compose(withStaticAuth, f, PushSecretImport(prov, types.KeyAlgorithmEcPrime256v1), usePushStaticAuth),
+		framework.Compose(withStaticAuth, f, PushSecretWithTags(prov), usePushStaticAuth),
+		framework.Compose(withStaticAuth, f, PushSecretDelete(prov), usePushStaticAuth),
+	)
+})
+
+func usePushStaticAuth(tc *framework.TestCase) {
+	if tc.PushSecret != nil {
+		tc.PushSecret.Spec.SecretStoreRefs = []esv1alpha1.PushSecretStoreRef{
+			{Name: awscommon.StaticStoreName},
+		}
+	}
+}

+ 95 - 0
e2e/suites/provider/cases/aws/certificatemanager/certificatemanager_managed.go

@@ -0,0 +1,95 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+
+	// nolint
+	"github.com/aws/aws-sdk-go-v2/service/acm/types"
+	. "github.com/onsi/ginkgo/v2"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/external-secrets/external-secrets-e2e/framework/addon"
+	awscommon "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+)
+
+// here we use the global eso instance
+// that uses the service account in the default namespace
+// which was created by terraform.
+var _ = Describe("[awsmanaged] IRSA via referenced service account", Label("aws", "certificatemanager", "managed"), Ordered, func() {
+	f := framework.New("eso-aws-acm-managed")
+	prov := NewFromEnv(f)
+
+	DescribeTable("push ACM secrets",
+		framework.TableFuncWithPushSecret(f, prov, nil),
+		framework.Compose(awscommon.WithReferencedIRSA, f, PushSecretImport(prov, types.KeyAlgorithmRsa2048), usePushClusterSecretStore),
+		framework.Compose(awscommon.WithReferencedIRSA, f, PushSecretImport(prov, types.KeyAlgorithmEcPrime256v1), usePushClusterSecretStore),
+		framework.Compose(awscommon.WithReferencedIRSA, f, PushSecretWithTags(prov), usePushClusterSecretStore),
+		framework.Compose(awscommon.WithReferencedIRSA, f, PushSecretDelete(prov), usePushClusterSecretStore),
+	)
+})
+
+// here we create a central eso instance in the default namespace
+// that mounts the service account which was created by terraform.
+var _ = Describe("[awsmanaged] with mounted IRSA", Label("aws", "certificatemanager", "managed"), Ordered, func() {
+	f := framework.New("eso-aws-acm-managed")
+	prov := NewFromEnv(f)
+
+	// each test case gets its own ESO instance
+	BeforeEach(func() {
+		f.Install(addon.NewESO(
+			addon.WithControllerClass(f.BaseName),
+			addon.WithServiceAccount(prov.ServiceAccountName),
+			addon.WithReleaseName(f.Namespace.Name),
+			addon.WithNamespace("default"),
+			addon.WithoutWebhook(),
+			addon.WithoutCertController(),
+		))
+	})
+
+	DescribeTable("push ACM secrets",
+		framework.TableFuncWithPushSecret(f, prov, nil),
+		framework.Compose(awscommon.WithMountedIRSA, f, PushSecretImport(prov, types.KeyAlgorithmRsa2048), usePushMountedIRSAStore),
+		framework.Compose(awscommon.WithMountedIRSA, f, PushSecretImport(prov, types.KeyAlgorithmEcPrime256v1), usePushMountedIRSAStore),
+		framework.Compose(awscommon.WithMountedIRSA, f, PushSecretWithTags(prov), usePushMountedIRSAStore),
+		framework.Compose(awscommon.WithMountedIRSA, f, PushSecretDelete(prov), usePushMountedIRSAStore),
+	)
+})
+
+func usePushClusterSecretStore(tc *framework.TestCase) {
+	if tc.PushSecret != nil {
+		tc.PushSecret.Spec.SecretStoreRefs = []esv1alpha1.PushSecretStoreRef{
+			{
+				Name: awscommon.ReferencedIRSAStoreName(tc.Framework),
+				Kind: esv1.ClusterSecretStoreKind,
+			},
+		}
+	}
+}
+
+func usePushMountedIRSAStore(tc *framework.TestCase) {
+	if tc.PushSecret != nil {
+		tc.PushSecret.Spec.SecretStoreRefs = []esv1alpha1.PushSecretStoreRef{
+			{
+				Name: awscommon.MountedIRSAStoreName(tc.Framework),
+				Kind: esv1.SecretStoreKind,
+			},
+		}
+	}
+}

+ 285 - 0
e2e/suites/provider/cases/aws/certificatemanager/provider.go

@@ -0,0 +1,285 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"fmt"
+	"math/big"
+	"os"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials"
+	"github.com/aws/aws-sdk-go-v2/service/acm"
+	"github.com/aws/aws-sdk-go-v2/service/acm/types"
+	"github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
+	rgtTypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types"
+
+	//nolint
+	. "github.com/onsi/ginkgo/v2"
+
+	// nolint
+	. "github.com/onsi/gomega"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	"github.com/external-secrets/external-secrets-e2e/framework/log"
+	awscommon "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esmetav1 "github.com/external-secrets/external-secrets/apis/meta/v1"
+)
+
+type Provider struct {
+	ServiceAccountName      string
+	ServiceAccountNamespace string
+
+	region    string
+	client    *acm.Client
+	rgtClient *resourcegroupstaggingapi.Client
+	framework *framework.Framework
+}
+
+func NewProvider(f *framework.Framework, kid, sak, st, region, saName, saNamespace string) *Provider {
+	prov := &Provider{
+		ServiceAccountName:      saName,
+		ServiceAccountNamespace: saNamespace,
+		region:                  region,
+		framework:               f,
+	}
+
+	BeforeAll(func() {
+		cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(region), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(kid, sak, st)))
+		Expect(err).ToNot(HaveOccurred())
+		prov.client = acm.NewFromConfig(cfg)
+		prov.rgtClient = resourcegroupstaggingapi.NewFromConfig(cfg)
+	})
+
+	BeforeEach(func() {
+		awscommon.SetupStaticStore(f, awscommon.AccessOpts{KID: kid, SAK: sak, ST: st, Region: region}, esv1.AWSServiceCertificateManager)
+		awscommon.CreateReferentStaticStore(f, awscommon.AccessOpts{KID: kid, SAK: sak, ST: st, Region: region}, esv1.AWSServiceCertificateManager)
+		prov.SetupReferencedIRSAStore()
+		prov.SetupMountedIRSAStore()
+	})
+
+	AfterEach(func() {
+		prov.TeardownReferencedIRSAStore()
+		prov.TeardownMountedIRSAStore()
+	})
+
+	return prov
+}
+
+func NewFromEnv(f *framework.Framework) *Provider {
+	kid := os.Getenv("AWS_ACCESS_KEY_ID")
+	sak := os.Getenv("AWS_SECRET_ACCESS_KEY")
+	st := os.Getenv("AWS_SESSION_TOKEN")
+	region := os.Getenv("AWS_REGION")
+	saName := os.Getenv("AWS_SA_NAME")
+	saNamespace := os.Getenv("AWS_SA_NAMESPACE")
+	prov := NewProvider(f, kid, sak, st, region, saName, saNamespace)
+	return prov
+}
+
+// CreateSecret imports a self-signed certificate into ACM tagged with the given key.
+// The val.Value field is ignored because ACM operates on certificates, not arbitrary strings.
+func (s *Provider) CreateSecret(key string, _ framework.SecretEntry) {
+	certPEM, keyPEM := generateSelfSignedCert(types.KeyAlgorithmEcPrime256v1)
+	_, err := s.client.ImportCertificate(GinkgoT().Context(), &acm.ImportCertificateInput{
+		Certificate: certPEM,
+		PrivateKey:  keyPEM,
+		Tags: []types.Tag{
+			{Key: aws.String("managed-by"), Value: aws.String("external-secrets")},
+			{Key: aws.String("external-secrets-remote-key"), Value: aws.String(key)},
+		},
+	})
+	Expect(err).ToNot(HaveOccurred())
+}
+
+// DeleteSecret removes a certificate from ACM by looking up the remote-key tag.
+func (s *Provider) DeleteSecret(key string) {
+	arn, err := s.FindCertificateByRemoteKey(key)
+	Expect(err).ToNot(HaveOccurred())
+	if arn == nil {
+		return
+	}
+	_, err = s.client.DeleteCertificate(GinkgoT().Context(), &acm.DeleteCertificateInput{
+		CertificateArn: arn,
+	})
+	Expect(err).ToNot(HaveOccurred(), "failed to delete certificate %s", aws.ToString(arn))
+}
+
+// FindCertificateByRemoteKey searches for a certificate in ACM with the matching
+// remote-key tag using the Resource Groups Tagging API (server-side filtering).
+func (s *Provider) FindCertificateByRemoteKey(remoteKey string) (*string, error) {
+	paginator := resourcegroupstaggingapi.NewGetResourcesPaginator(s.rgtClient, &resourcegroupstaggingapi.GetResourcesInput{
+		ResourceTypeFilters: []string{"acm:certificate"},
+		TagFilters: []rgtTypes.TagFilter{
+			{Key: aws.String("managed-by"), Values: []string{"external-secrets"}},
+			{Key: aws.String("external-secrets-remote-key"), Values: []string{remoteKey}},
+		},
+	})
+	for paginator.HasMorePages() {
+		page, err := paginator.NextPage(GinkgoT().Context())
+		if err != nil {
+			return nil, err
+		}
+		for _, mapping := range page.ResourceTagMappingList {
+			if mapping.ResourceARN != nil && *mapping.ResourceARN != "" {
+				return mapping.ResourceARN, nil
+			}
+		}
+	}
+	return nil, nil
+}
+
+// GetCertificateTags returns the tags for a certificate given its ARN.
+func (s *Provider) GetCertificateTags(arn string) []types.Tag {
+	out, err := s.client.ListTagsForCertificate(GinkgoT().Context(), &acm.ListTagsForCertificateInput{
+		CertificateArn: aws.String(arn),
+	})
+	Expect(err).ToNot(HaveOccurred())
+	return out.Tags
+}
+
+func (s *Provider) SetupMountedIRSAStore() {
+	secretStore := &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      awscommon.MountedIRSAStoreName(s.framework),
+			Namespace: s.framework.Namespace.Name,
+		},
+		Spec: esv1.SecretStoreSpec{
+			Provider: &esv1.SecretStoreProvider{
+				AWS: &esv1.AWSProvider{
+					Service: esv1.AWSServiceCertificateManager,
+					Region:  s.region,
+					Auth:    esv1.AWSAuth{},
+				},
+			},
+		},
+	}
+	err := s.framework.CRClient.Create(GinkgoT().Context(), secretStore)
+	Expect(err).ToNot(HaveOccurred())
+}
+
+func (s *Provider) TeardownMountedIRSAStore() {
+	err := s.framework.CRClient.Delete(GinkgoT().Context(), &esv1.SecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      awscommon.MountedIRSAStoreName(s.framework),
+			Namespace: s.framework.Namespace.Name,
+		},
+	})
+	Expect(err).ToNot(HaveOccurred())
+}
+
+func (s *Provider) SetupReferencedIRSAStore() {
+	log.Logf("creating IRSA ClusterSecretStore %s", s.framework.Namespace.Name)
+	secretStore := &esv1.ClusterSecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: awscommon.ReferencedIRSAStoreName(s.framework),
+		},
+	}
+	_, err := controllerutil.CreateOrUpdate(GinkgoT().Context(), s.framework.CRClient, secretStore, func() error {
+		secretStore.Spec.Provider = &esv1.SecretStoreProvider{
+			AWS: &esv1.AWSProvider{
+				Service: esv1.AWSServiceCertificateManager,
+				Region:  s.region,
+				Auth: esv1.AWSAuth{
+					JWTAuth: &esv1.AWSJWTAuth{
+						ServiceAccountRef: &esmetav1.ServiceAccountSelector{
+							Name:      s.ServiceAccountName,
+							Namespace: &s.ServiceAccountNamespace,
+						},
+					},
+				},
+			},
+		}
+		return nil
+	})
+	Expect(err).ToNot(HaveOccurred())
+}
+
+func (s *Provider) TeardownReferencedIRSAStore() {
+	err := s.framework.CRClient.Delete(GinkgoT().Context(), &esv1.ClusterSecretStore{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: awscommon.ReferencedIRSAStoreName(s.framework),
+		},
+	})
+	Expect(err).ToNot(HaveOccurred())
+}
+
+func hasTagValue(tags []types.Tag, key, value string) bool {
+	for _, t := range tags {
+		if aws.ToString(t.Key) == key && aws.ToString(t.Value) == value {
+			return true
+		}
+	}
+	return false
+}
+
+func generateSelfSignedCert(keyAlgorithm types.KeyAlgorithm) (certPEM, keyPEM []byte) {
+	var rsaKey *rsa.PrivateKey
+	var ecdsaKey *ecdsa.PrivateKey
+	var certDER []byte
+	var keyDER []byte
+	var err error
+
+	tmpl := &x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject:      pkix.Name{CommonName: "e2e.test"},
+		NotBefore:    time.Now().Add(-time.Hour),
+		NotAfter:     time.Now().Add(24 * time.Hour),
+	}
+
+	switch keyAlgorithm {
+	case types.KeyAlgorithmRsa2048:
+		rsaKey, err = rsa.GenerateKey(rand.Reader, 2048)
+		Expect(err).ToNot(HaveOccurred())
+
+		certDER, err = x509.CreateCertificate(rand.Reader, tmpl, tmpl, &rsaKey.PublicKey, rsaKey)
+		Expect(err).ToNot(HaveOccurred())
+
+		keyDER, err = x509.MarshalPKCS8PrivateKey(rsaKey)
+		Expect(err).ToNot(HaveOccurred())
+	case types.KeyAlgorithmEcPrime256v1:
+		ecdsaKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+		Expect(err).ToNot(HaveOccurred())
+
+		certDER, err = x509.CreateCertificate(rand.Reader, tmpl, tmpl, &ecdsaKey.PublicKey, ecdsaKey)
+		Expect(err).ToNot(HaveOccurred())
+
+		keyDER, err = x509.MarshalPKCS8PrivateKey(ecdsaKey)
+		Expect(err).ToNot(HaveOccurred())
+	default:
+		err = fmt.Errorf("unsupported key algorithm: %s", keyAlgorithm)
+		Expect(err).ToNot(HaveOccurred())
+	}
+
+	certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
+	keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
+
+	return certPEM, keyPEM
+}

+ 281 - 0
e2e/suites/provider/cases/aws/certificatemanager/push_secret.go

@@ -0,0 +1,281 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package aws
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	acmtypes "github.com/aws/aws-sdk-go-v2/service/acm/types"
+
+	// nolint
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	v1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+
+	"github.com/external-secrets/external-secrets-e2e/framework"
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+)
+
+const (
+	tlsCrtKey = "tls.crt"
+	tlsKeyKey = "tls.key"
+)
+
+// PushSecretImport tests importing a kubernetes.io/tls secret into ACM via PushSecret.
+func PushSecretImport(prov *Provider, keyAlgorithm acmtypes.KeyAlgorithm) func(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return func(f *framework.Framework) (string, func(*framework.TestCase)) {
+		return fmt.Sprintf("[acm] should import a TLS certificate (%s) into ACM via PushSecret", keyAlgorithm), func(tc *framework.TestCase) {
+			sanitizedKeyAlgorithm := strings.ToLower(strings.ReplaceAll(string(keyAlgorithm), "_", "-"))
+			remoteKey := fmt.Sprintf("e2e-acm-push-%s-%s", f.Namespace.Name, sanitizedKeyAlgorithm)
+			secretName := fmt.Sprintf("tls-source-%s", sanitizedKeyAlgorithm)
+			certPEM, keyPEM := generateSelfSignedCert(keyAlgorithm)
+
+			tc.ExternalSecret = nil
+			tc.ExpectedSecret = nil
+			tc.PushSecretSource = &v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      secretName,
+					Namespace: f.Namespace.Name,
+				},
+				Type: v1.SecretTypeTLS,
+				Data: map[string][]byte{
+					tlsCrtKey: certPEM,
+					tlsKeyKey: keyPEM,
+				},
+			}
+			tc.PushSecret = &esv1alpha1.PushSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      fmt.Sprintf("e2e-ps-acm-%s", sanitizedKeyAlgorithm),
+					Namespace: f.Namespace.Name,
+				},
+				Spec: esv1alpha1.PushSecretSpec{
+					RefreshInterval: &metav1.Duration{Duration: time.Minute * 1},
+					SecretStoreRefs: []esv1alpha1.PushSecretStoreRef{
+						{Name: f.Namespace.Name},
+					},
+					Selector: esv1alpha1.PushSecretSelector{
+						Secret: &esv1alpha1.PushSecretSecret{
+							Name: secretName,
+						},
+					},
+					Data: []esv1alpha1.PushSecretData{
+						{
+							Match: esv1alpha1.PushSecretMatch{
+								RemoteRef: esv1alpha1.PushSecretRemoteRef{
+									RemoteKey: remoteKey,
+								},
+							},
+						},
+					},
+				},
+			}
+			tc.VerifyPushSecretOutcome = func(_ *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				defer prov.DeleteSecret(remoteKey)
+
+				waitForPushSecretReady(tc)
+
+				Eventually(func() bool {
+					arn, err := prov.FindCertificateByRemoteKey(remoteKey)
+					return err == nil && arn != nil
+				}, time.Minute*3, time.Second*10).Should(BeTrue(), "certificate should exist in ACM with remote key %s", remoteKey)
+
+				err := tc.Framework.CRClient.Delete(GinkgoT().Context(), tc.PushSecret)
+				Expect(err).ToNot(HaveOccurred())
+			}
+		}
+	}
+}
+
+// PushSecretWithTags tests importing a certificate with custom metadata tags.
+func PushSecretWithTags(prov *Provider) func(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return func(f *framework.Framework) (string, func(*framework.TestCase)) {
+		return "[acm] should import a TLS certificate with custom tags via PushSecret", func(tc *framework.TestCase) {
+			remoteKey := fmt.Sprintf("e2e-acm-tags-%s", f.Namespace.Name)
+			certPEM, keyPEM := generateSelfSignedCert(acmtypes.KeyAlgorithmEcPrime256v1)
+
+			metadataSpec := map[string]interface{}{
+				"apiVersion": "kubernetes.external-secrets.io/v1alpha1",
+				"kind":       "PushSecretMetadata",
+				"spec": map[string]interface{}{
+					"tags": map[string]string{
+						"environment": "e2e-test",
+						"team":        "platform",
+					},
+				},
+			}
+			metadataBytes, err := json.Marshal(metadataSpec)
+			Expect(err).ToNot(HaveOccurred())
+
+			tc.ExternalSecret = nil
+			tc.ExpectedSecret = nil
+			tc.PushSecretSource = &v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "tls-source-tags",
+					Namespace: f.Namespace.Name,
+				},
+				Type: v1.SecretTypeTLS,
+				Data: map[string][]byte{
+					tlsCrtKey: certPEM,
+					tlsKeyKey: keyPEM,
+				},
+			}
+			tc.PushSecret = &esv1alpha1.PushSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "e2e-ps-acm-tags",
+					Namespace: f.Namespace.Name,
+				},
+				Spec: esv1alpha1.PushSecretSpec{
+					RefreshInterval: &metav1.Duration{Duration: time.Minute * 1},
+					SecretStoreRefs: []esv1alpha1.PushSecretStoreRef{
+						{Name: f.Namespace.Name},
+					},
+					Selector: esv1alpha1.PushSecretSelector{
+						Secret: &esv1alpha1.PushSecretSecret{
+							Name: "tls-source-tags",
+						},
+					},
+					Data: []esv1alpha1.PushSecretData{
+						{
+							Match: esv1alpha1.PushSecretMatch{
+								RemoteRef: esv1alpha1.PushSecretRemoteRef{
+									RemoteKey: remoteKey,
+								},
+							},
+							Metadata: &apiextensionsv1.JSON{Raw: metadataBytes},
+						},
+					},
+				},
+			}
+			tc.VerifyPushSecretOutcome = func(_ *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				defer prov.DeleteSecret(remoteKey)
+
+				waitForPushSecretReady(tc)
+
+				var arn *string
+				var err error
+				Eventually(func() bool {
+					arn, err = prov.FindCertificateByRemoteKey(remoteKey)
+					return err == nil && arn != nil
+				}, time.Minute*3, time.Second*10).Should(BeTrue(), "certificate should exist in ACM with remote key %s", remoteKey)
+
+				tags := prov.GetCertificateTags(aws.ToString(arn))
+				Expect(hasTagValue(tags, "environment", "e2e-test")).To(BeTrue(), "should have environment tag")
+				Expect(hasTagValue(tags, "team", "platform")).To(BeTrue(), "should have team tag")
+
+				err = tc.Framework.CRClient.Delete(GinkgoT().Context(), tc.PushSecret)
+				Expect(err).ToNot(HaveOccurred())
+			}
+		}
+	}
+}
+
+// PushSecretDelete tests that deleting a PushSecret with deletionPolicy=Delete removes the cert from ACM.
+func PushSecretDelete(prov *Provider) func(f *framework.Framework) (string, func(*framework.TestCase)) {
+	return func(f *framework.Framework) (string, func(*framework.TestCase)) {
+		return "[acm] should delete certificate from ACM when PushSecret is deleted", func(tc *framework.TestCase) {
+			remoteKey := fmt.Sprintf("e2e-acm-del-%s", f.Namespace.Name)
+			certPEM, keyPEM := generateSelfSignedCert(acmtypes.KeyAlgorithmEcPrime256v1)
+
+			tc.ExternalSecret = nil
+			tc.ExpectedSecret = nil
+			tc.PushSecretSource = &v1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "tls-source-del",
+					Namespace: f.Namespace.Name,
+				},
+				Type: v1.SecretTypeTLS,
+				Data: map[string][]byte{
+					tlsCrtKey: certPEM,
+					tlsKeyKey: keyPEM,
+				},
+			}
+			tc.PushSecret = &esv1alpha1.PushSecret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "e2e-ps-acm-del",
+					Namespace: f.Namespace.Name,
+				},
+				Spec: esv1alpha1.PushSecretSpec{
+					RefreshInterval: &metav1.Duration{Duration: time.Minute * 1},
+					DeletionPolicy:  esv1alpha1.PushSecretDeletionPolicyDelete,
+					SecretStoreRefs: []esv1alpha1.PushSecretStoreRef{
+						{Name: f.Namespace.Name},
+					},
+					Selector: esv1alpha1.PushSecretSelector{
+						Secret: &esv1alpha1.PushSecretSecret{
+							Name: "tls-source-del",
+						},
+					},
+					Data: []esv1alpha1.PushSecretData{
+						{
+							Match: esv1alpha1.PushSecretMatch{
+								RemoteRef: esv1alpha1.PushSecretRemoteRef{
+									RemoteKey: remoteKey,
+								},
+							},
+						},
+					},
+				},
+			}
+			tc.VerifyPushSecretOutcome = func(_ *esv1alpha1.PushSecret, _ esv1.SecretsClient) {
+				defer prov.DeleteSecret(remoteKey)
+
+				waitForPushSecretReady(tc)
+
+				Eventually(func() bool {
+					arn, err := prov.FindCertificateByRemoteKey(remoteKey)
+					return err == nil && arn != nil
+				}, time.Minute*3, time.Second*10).Should(BeTrue(), "certificate should exist before deletion")
+
+				err := tc.Framework.CRClient.Delete(GinkgoT().Context(), tc.PushSecret)
+				Expect(err).ToNot(HaveOccurred())
+
+				Eventually(func() bool {
+					arn, err := prov.FindCertificateByRemoteKey(remoteKey)
+					return err == nil && arn == nil
+				}, time.Minute*3, time.Second*10).Should(BeTrue(), "certificate should be deleted from ACM")
+			}
+		}
+	}
+}
+
+func waitForPushSecretReady(tc *framework.TestCase) {
+	Eventually(func() bool {
+		s := &esv1alpha1.PushSecret{}
+		err := tc.Framework.CRClient.Get(
+			GinkgoT().Context(),
+			types.NamespacedName{Name: tc.PushSecret.Name, Namespace: tc.PushSecret.Namespace},
+			s,
+		)
+		if err != nil {
+			return false
+		}
+		for _, c := range s.Status.Conditions {
+			if c.Type == esv1alpha1.PushSecretReady && c.Status == v1.ConditionTrue {
+				return true
+			}
+		}
+		return false
+	}, time.Minute*3, time.Second*5).Should(BeTrue(), "PushSecret should become Ready")
+}

+ 1 - 0
e2e/suites/provider/cases/import.go

@@ -19,6 +19,7 @@ package suite
 import (
 
 	// import different e2e test suites.
+	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws/certificatemanager"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws/parameterstore"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/aws/secretsmanager"
 	_ "github.com/external-secrets/external-secrets-e2e/suites/provider/cases/azure"

+ 2 - 0
go.mod

@@ -218,10 +218,12 @@ require (
 	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect
 	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
+	github.com/aws/aws-sdk-go-v2/service/acm v1.37.13 // indirect
 	github.com/aws/aws-sdk-go-v2/service/ecr v1.51.3 // indirect
 	github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.3 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
+	github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.30.13 // indirect
 	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.12 // indirect
 	github.com/aws/aws-sdk-go-v2/service/ssm v1.67.1 // indirect
 	github.com/aws/aws-sdk-go-v2/service/sso v1.30.2 // indirect

+ 4 - 0
go.sum

@@ -201,6 +201,8 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7Fj
 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
+github.com/aws/aws-sdk-go-v2/service/acm v1.37.13 h1:5AUX6KOEBF20wiIaiI4fKHvVkELEptyLtRTbWGpNQNY=
+github.com/aws/aws-sdk-go-v2/service/acm v1.37.13/go.mod h1:v8E4cAu0qIxpS7IokQilQb60A8IODPxo82VxVtJ+Dgo=
 github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o=
 github.com/aws/aws-sdk-go-v2/service/ecr v1.51.3 h1:+0AhrMCsfRxzlojjbJBOOBO1Ka5t1VsF28g+eHYbyEI=
 github.com/aws/aws-sdk-go-v2/service/ecr v1.51.3/go.mod h1:1NVD1KuMjH2GqnPwMotPndQaT/MreKkWpjkF12d6oKU=
@@ -210,6 +212,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/A
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
+github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.30.13 h1:bgZMYv1wMOsV1ug0/Hx/rs4fxbcFVipvOAwgsbhIons=
+github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.30.13/go.mod h1:+nL0z6xUm9NK9bOAkam66NQDgNH8Qa6T6SS3f8dkzXU=
 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.12 h1:xN4mw6Gqim0jMwjmlNST+yXVShFPwSAjt4gXqi43W6I=
 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.12/go.mod h1:QgVIY03/XoQs2iFr0MbQuQ/Tf1RwlkOvuySWMh1wph4=
 github.com/aws/aws-sdk-go-v2/service/ssm v1.67.1 h1:Zl+dJQSS5RogzWXBdS3eo5aVeHm/se5BGR1JrcIU+pA=

+ 1 - 0
hack/api-docs/mkdocs.yml

@@ -123,6 +123,7 @@ nav:
   - Provider:
       - AWS Secrets Manager: provider/aws-secrets-manager.md
       - AWS Parameter Store: provider/aws-parameter-store.md
+      - AWS Certificate Manager: provider/aws-certificate-manager.md
       - AWS Access: provider/aws-access.md
       - Azure Key Vault: provider/azure-key-vault.md
       - Barbican: provider/barbican.md

+ 668 - 0
providers/v1/aws/certificatemanager/certificatemanager.go

@@ -0,0 +1,668 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package certificatemanager implements the AWS Certificate Manager provider for external-secrets.
+package certificatemanager
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/hex"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/service/acm"
+	"github.com/aws/aws-sdk-go-v2/service/acm/types"
+	"github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
+	rgtTypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types"
+	"github.com/aws/smithy-go"
+	"github.com/hashicorp/golang-lru/v2/expirable"
+	"github.com/youmark/pkcs8"
+	corev1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	awsutil "github.com/external-secrets/external-secrets/providers/v1/aws/util"
+	"github.com/external-secrets/external-secrets/runtime/constants"
+	"github.com/external-secrets/external-secrets/runtime/esutils/metadata"
+	"github.com/external-secrets/external-secrets/runtime/metrics"
+)
+
+// PushSecretMetadataSpec contains metadata for pushing certificates to ACM.
+type PushSecretMetadataSpec struct {
+	Tags map[string]string `json:"tags,omitempty"`
+}
+
+const (
+	managedBy            = "managed-by"
+	externalSecrets      = "external-secrets"
+	remoteKeyTag         = "external-secrets-remote-key"
+	acmResourceType      = "acm:certificate"
+	tlsCertKey           = "tls.crt"
+	tlsPrivateKeyKey     = "tls.key"
+	errNotImplemented    = "operation not supported by AWS Certificate Manager provider"
+	errResourceNotFound  = "ResourceNotFoundException"
+	errNotManagedByESO   = "certificate not managed by external-secrets"
+	errSecretKeyNotEmpty = "secretKey must be empty for the AWS Certificate Manager provider: " +
+		"the whole kubernetes.io/tls secret is required (tls.crt and tls.key)"
+	errSyncTags      = "failed to sync certificate tags: %w"
+	passphraseLength = 32
+	// TTLs can be long because cached entries are always validated against
+	// AWS API, which prevents serving stale data.
+	arnCacheTTL     = 24 * time.Hour
+	arnCacheSize    = 512
+	exportCacheTTL  = 24 * time.Hour
+	exportCacheSize = 128
+)
+
+type exportCacheEntry struct {
+	fingerprint string
+	pem         []byte
+}
+
+type certBundle struct {
+	leaf    []byte
+	chain   []byte
+	privKey []byte
+}
+
+var (
+	errCertificateNotFound = errors.New("certificate not found")
+
+	passphraseChars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
+
+	// generatePassphrase returns a random alphanumeric passphrase safe for
+	// the ACM ExportCertificate API (no #, $, or % characters).
+	generatePassphrase = func() ([]byte, error) {
+		b := make([]byte, passphraseLength)
+		if _, err := rand.Read(b); err != nil {
+			return nil, err
+		}
+		for i, v := range b {
+			b[i] = passphraseChars[int(v)%len(passphraseChars)]
+		}
+		return b, nil
+	}
+)
+
+// ACMInterface is a subset of the ACM API used by this provider.
+// see: https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/acm
+type ACMInterface interface {
+	ImportCertificate(ctx context.Context, input *acm.ImportCertificateInput, optFns ...func(*acm.Options)) (*acm.ImportCertificateOutput, error)
+	DeleteCertificate(ctx context.Context, input *acm.DeleteCertificateInput, optFns ...func(*acm.Options)) (*acm.DeleteCertificateOutput, error)
+	DescribeCertificate(ctx context.Context, input *acm.DescribeCertificateInput, optFns ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error)
+	ExportCertificate(ctx context.Context, input *acm.ExportCertificateInput, optFns ...func(*acm.Options)) (*acm.ExportCertificateOutput, error)
+	GetCertificate(ctx context.Context, input *acm.GetCertificateInput, optFns ...func(*acm.Options)) (*acm.GetCertificateOutput, error)
+	AddTagsToCertificate(ctx context.Context, input *acm.AddTagsToCertificateInput, optFns ...func(*acm.Options)) (*acm.AddTagsToCertificateOutput, error)
+	ListTagsForCertificate(ctx context.Context, input *acm.ListTagsForCertificateInput, optFns ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error)
+	RemoveTagsFromCertificate(ctx context.Context, input *acm.RemoveTagsFromCertificateInput, optFns ...func(*acm.Options)) (*acm.RemoveTagsFromCertificateOutput, error)
+}
+
+// ResourceGetter is the subset of the Resource Groups Tagging API used to
+// locate certificates by tag with server-side filtering.
+// see: https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi
+type ResourceGetter interface {
+	GetResources(ctx context.Context, input *resourcegroupstaggingapi.GetResourcesInput, optFns ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error)
+}
+
+// CertificateManager is a provider for AWS ACM.
+type CertificateManager struct {
+	cfg          *aws.Config
+	client       ACMInterface
+	rgtClient    ResourceGetter
+	referentAuth bool
+	prefix       string
+	// arnCache mitigates eventual consistency of ListCertificates by caching
+	// remoteKey → ARN after a successful import.
+	arnCache *expirable.LRU[string, string]
+	// exportCache caches ExportCertificate results keyed by ARN to avoid
+	// repeated paid API calls.
+	exportCache *expirable.LRU[string, exportCacheEntry]
+}
+
+// https://github.com/external-secrets/external-secrets/issues/644
+var _ esv1.SecretsClient = &CertificateManager{}
+
+var log = ctrl.Log.WithName("provider").WithName("aws").WithName("certificatemanager")
+
+// New constructs a CertificateManager Provider that is specific to a store.
+func New(_ context.Context, cfg *aws.Config, prefix string, referentAuth bool) (*CertificateManager, error) {
+	return &CertificateManager{
+		cfg:          cfg,
+		referentAuth: referentAuth,
+		client: acm.NewFromConfig(*cfg, func(o *acm.Options) {
+			o.EndpointResolverV2 = customEndpointResolver{}
+		}),
+		rgtClient: resourcegroupstaggingapi.NewFromConfig(*cfg, func(o *resourcegroupstaggingapi.Options) {
+			o.EndpointResolverV2 = customTaggingEndpointResolver{}
+		}),
+		prefix:      prefix,
+		arnCache:    expirable.NewLRU[string, string](arnCacheSize, nil, arnCacheTTL),
+		exportCache: expirable.NewLRU[string, exportCacheEntry](exportCacheSize, nil, exportCacheTTL),
+	}, nil
+}
+
+// PushSecret imports a kubernetes.io/tls secret into AWS Certificate Manager.
+func (cm *CertificateManager) PushSecret(ctx context.Context, secret *corev1.Secret, psd esv1.PushSecretData) error {
+	if psd.GetSecretKey() != "" {
+		return errors.New(errSecretKeyNotEmpty)
+	}
+
+	meta, err := constructMetadata(psd.GetMetadata())
+	if err != nil {
+		return fmt.Errorf("failed to parse push secret metadata: %w", err)
+	}
+
+	certPEM, ok := secret.Data[tlsCertKey]
+	if !ok || len(certPEM) == 0 {
+		return fmt.Errorf("key %q not found or empty in secret %s/%s", tlsCertKey, secret.Namespace, secret.Name)
+	}
+	privKeyPEM, ok := secret.Data[tlsPrivateKeyKey]
+	if !ok || len(privKeyPEM) == 0 {
+		return fmt.Errorf("key %q not found or empty in secret %s/%s", tlsPrivateKeyKey, secret.Namespace, secret.Name)
+	}
+
+	leaf, chain, err := splitCertificatePEM(certPEM)
+	if err != nil {
+		return fmt.Errorf("failed to parse %q: %w", tlsCertKey, err)
+	}
+
+	bundle := certBundle{leaf: leaf, chain: chain, privKey: privKeyPEM}
+	remoteKey := cm.prefix + psd.GetRemoteKey()
+
+	existingARN, err := cm.findCertificateARN(ctx, remoteKey)
+	if err != nil {
+		return fmt.Errorf("failed to search for existing certificate: %w", err)
+	}
+
+	if existingARN != "" {
+		return cm.reimportCertificate(ctx, existingARN, bundle, remoteKey, meta.Spec.Tags)
+	}
+	return cm.importNewCertificate(ctx, bundle, remoteKey, meta.Spec.Tags)
+}
+
+func (cm *CertificateManager) reimportCertificate(ctx context.Context, arn string, b certBundle, remoteKey string, tags map[string]string) error {
+	getCertOut, err := cm.getCertificate(ctx, arn)
+	if errors.Is(err, errCertificateNotFound) {
+		cm.arnCache.Remove(remoteKey)
+		return cm.importNewCertificate(ctx, b, remoteKey, tags)
+	}
+	if err != nil {
+		return fmt.Errorf("failed to get certificate %s: %w", arn, err)
+	}
+
+	bundleLeaf := string(b.leaf)
+	bundleChain := string(b.chain)
+	if len(b.chain) == 0 {
+		// if the certificate was imported without a chain, AWS still
+		// returns CertificateChain with the same value as Certificate.
+		bundleChain = bundleLeaf
+	}
+
+	acmFingerprint := computeCertFingerprint(aws.ToString(getCertOut.Certificate), aws.ToString(getCertOut.CertificateChain))
+	k8sFingerprint := computeCertFingerprint(bundleLeaf, bundleChain)
+	if acmFingerprint == k8sFingerprint {
+		log.Info("certificate unchanged, skipping re-import", "arn", arn, "remoteKey", remoteKey)
+		if err := cm.syncTags(ctx, arn, tags); err != nil {
+			return fmt.Errorf(errSyncTags, err)
+		}
+		return nil
+	}
+
+	input := &acm.ImportCertificateInput{
+		Certificate:    b.leaf,
+		PrivateKey:     b.privKey,
+		CertificateArn: aws.String(arn),
+	}
+	if len(b.chain) > 0 {
+		input.CertificateChain = b.chain
+	}
+	log.Info("re-importing existing ACM certificate", "arn", arn, "remoteKey", remoteKey)
+
+	_, err = cm.client.ImportCertificate(ctx, input)
+	metrics.ObserveAPICall(constants.ProviderAWSACM, constants.CallAWSACMImportCertificate, err)
+	if err != nil {
+		return fmt.Errorf("failed to import certificate: %w", awsutil.SanitizeErr(err))
+	}
+
+	if err := cm.syncTags(ctx, arn, tags); err != nil {
+		return fmt.Errorf(errSyncTags, err)
+	}
+	return nil
+}
+
+func (cm *CertificateManager) importNewCertificate(ctx context.Context, b certBundle, remoteKey string, tags map[string]string) error {
+	input := &acm.ImportCertificateInput{
+		Certificate: b.leaf,
+		PrivateKey:  b.privKey,
+		Tags: []types.Tag{
+			{Key: aws.String(managedBy), Value: aws.String(externalSecrets)},
+			{Key: aws.String(remoteKeyTag), Value: aws.String(remoteKey)},
+		},
+	}
+	if len(b.chain) > 0 {
+		input.CertificateChain = b.chain
+	}
+	log.Info("importing new ACM certificate", "remoteKey", remoteKey)
+
+	out, err := cm.client.ImportCertificate(ctx, input)
+	metrics.ObserveAPICall(constants.ProviderAWSACM, constants.CallAWSACMImportCertificate, err)
+	if err != nil {
+		return fmt.Errorf("failed to import certificate: %w", awsutil.SanitizeErr(err))
+	}
+
+	certARN := aws.ToString(out.CertificateArn)
+	cm.arnCache.Add(remoteKey, certARN)
+
+	if err := cm.syncTags(ctx, certARN, tags); err != nil {
+		return fmt.Errorf(errSyncTags, err)
+	}
+	return nil
+}
+
+// SecretExists checks if a certificate with the given remoteKey exists in ACM.
+func (cm *CertificateManager) SecretExists(ctx context.Context, remoteRef esv1.PushSecretRemoteRef) (bool, error) {
+	remoteKey := cm.prefix + remoteRef.GetRemoteKey()
+	arn, err := cm.findCertificateARN(ctx, remoteKey)
+	if err != nil {
+		return false, err
+	}
+	if arn == "" {
+		return false, nil
+	}
+	if _, err := cm.listTags(ctx, arn); err != nil {
+		if errors.Is(err, errCertificateNotFound) {
+			cm.arnCache.Remove(remoteKey)
+			return false, nil
+		}
+		return false, err
+	}
+	return true, nil
+}
+
+// DeleteSecret deletes the ACM certificate identified by remoteKey.
+func (cm *CertificateManager) DeleteSecret(ctx context.Context, remoteRef esv1.PushSecretRemoteRef) error {
+	remoteKey := cm.prefix + remoteRef.GetRemoteKey()
+	arn, err := cm.findCertificateARN(ctx, remoteKey)
+	if err != nil {
+		return fmt.Errorf("failed to search for certificate to delete: %w", err)
+	}
+	if arn == "" {
+		return nil
+	}
+
+	tags, err := cm.listTags(ctx, arn)
+	if errors.Is(err, errCertificateNotFound) {
+		cm.arnCache.Remove(remoteKey)
+		return nil
+	}
+	if err != nil {
+		return fmt.Errorf("failed to list tags for certificate %s: %w", arn, err)
+	}
+	if !isManagedByESO(tags) {
+		return errors.New(errNotManagedByESO)
+	}
+
+	_, err = cm.client.DeleteCertificate(ctx, &acm.DeleteCertificateInput{
+		CertificateArn: aws.String(arn),
+	})
+	metrics.ObserveAPICall(constants.ProviderAWSACM, constants.CallAWSACMDeleteCertificate, err)
+	if err != nil {
+		return fmt.Errorf("failed to delete certificate %s: %w", arn, awsutil.SanitizeErr(err))
+	}
+	cm.arnCache.Remove(remoteKey)
+	log.Info("deleted ACM certificate", "arn", arn, "remoteKey", remoteKey)
+	return nil
+}
+
+// GetSecret returns a certificate from ACM as a concatenated PEM bundle.
+// ref.Key must be the certificate ARN. The certificate must have export enabled.
+func (cm *CertificateManager) GetSecret(ctx context.Context, ref esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
+	certARN := ref.Key
+	if certARN == "" {
+		return nil, fmt.Errorf("certificate ARN must be specified in remoteRef.key")
+	}
+
+	getCertOut, err := cm.getCertificate(ctx, certARN)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get certificate %s: %w", certARN, err)
+	}
+
+	fingerprint := computeCertFingerprint(aws.ToString(getCertOut.Certificate), aws.ToString(getCertOut.CertificateChain))
+	if entry, ok := cm.exportCache.Get(certARN); ok && entry.fingerprint == fingerprint {
+		return entry.pem, nil
+	}
+
+	descCertOut, err := cm.client.DescribeCertificate(ctx, &acm.DescribeCertificateInput{
+		CertificateArn: aws.String(certARN),
+	})
+	metrics.ObserveAPICall(constants.ProviderAWSACM, constants.CallAWSACMDescribeCertificate, err)
+	if err != nil {
+		return nil, fmt.Errorf("failed to describe certificate %s: %w", certARN, awsutil.SanitizeErr(err))
+	}
+
+	detail := descCertOut.Certificate
+	if detail.Options == nil || detail.Options.Export != types.CertificateExportEnabled {
+		return nil, fmt.Errorf("certificate %s is not exportable (Options.Export is not ENABLED)", certARN)
+	}
+
+	passphrase, err := generatePassphrase()
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate passphrase: %w", err)
+	}
+
+	exportOut, err := cm.client.ExportCertificate(ctx, &acm.ExportCertificateInput{
+		CertificateArn: aws.String(certARN),
+		Passphrase:     passphrase,
+	})
+	metrics.ObserveAPICall(constants.ProviderAWSACM, constants.CallAWSACMExportCertificate, err)
+	if err != nil {
+		return nil, fmt.Errorf("failed to export certificate %s: %w", certARN, awsutil.SanitizeErr(err))
+	}
+
+	var parts []string
+	if exportOut.Certificate != nil {
+		parts = append(parts, strings.TrimRight(*exportOut.Certificate, "\r\n"))
+	}
+	if exportOut.CertificateChain != nil {
+		parts = append(parts, strings.TrimRight(*exportOut.CertificateChain, "\r\n"))
+	}
+	if exportOut.PrivateKey != nil {
+		dk, dkErr := decryptPKCS8PEM([]byte(*exportOut.PrivateKey), passphrase)
+		if dkErr != nil {
+			return nil, fmt.Errorf("failed to decrypt exported private key: %w", dkErr)
+		}
+		parts = append(parts, strings.TrimRight(string(dk), "\r\n"))
+	}
+	if len(parts) == 0 {
+		return nil, fmt.Errorf("no data returned from ExportCertificate for %s", certARN)
+	}
+	result := strings.Join(parts, "\n") + "\n"
+
+	cm.exportCache.Add(certARN, exportCacheEntry{fingerprint: fingerprint, pem: []byte(result)})
+	return []byte(result), nil
+}
+
+// GetSecretMap is not supported by the ACM provider.
+func (cm *CertificateManager) GetSecretMap(_ context.Context, _ esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
+	return nil, errors.New(errNotImplemented)
+}
+
+// GetAllSecrets is not supported by the ACM provider.
+func (cm *CertificateManager) GetAllSecrets(_ context.Context, _ esv1.ExternalSecretFind) (map[string][]byte, error) {
+	return nil, errors.New(errNotImplemented)
+}
+
+// Close is a no-op for the ACM provider.
+func (cm *CertificateManager) Close(_ context.Context) error {
+	return nil
+}
+
+// Validate checks if the provider is configured correctly.
+func (cm *CertificateManager) Validate() (esv1.ValidationResult, error) {
+	// skip validation stack because we do not have the client set with referent auth
+	if cm.referentAuth {
+		return esv1.ValidationResultUnknown, nil
+	}
+	_, err := cm.cfg.Credentials.Retrieve(context.Background())
+	if err != nil {
+		return esv1.ValidationResultError, awsutil.SanitizeErr(err)
+	}
+	return esv1.ValidationResultReady, nil
+}
+
+func (cm *CertificateManager) findCertificateARN(ctx context.Context, remoteKey string) (string, error) {
+	if cached, ok := cm.arnCache.Get(remoteKey); ok {
+		return cached, nil
+	}
+	return cm.searchCertificatesByTag(ctx, remoteKey)
+}
+
+func (cm *CertificateManager) searchCertificatesByTag(ctx context.Context, remoteKey string) (string, error) {
+	paginator := resourcegroupstaggingapi.NewGetResourcesPaginator(cm.rgtClient, &resourcegroupstaggingapi.GetResourcesInput{
+		ResourceTypeFilters: []string{acmResourceType},
+		TagFilters: []rgtTypes.TagFilter{
+			{Key: aws.String(managedBy), Values: []string{externalSecrets}},
+			{Key: aws.String(remoteKeyTag), Values: []string{remoteKey}},
+		},
+	})
+
+	var arns []string
+	for paginator.HasMorePages() {
+		out, err := paginator.NextPage(ctx)
+		metrics.ObserveAPICall(constants.ProviderAWSACM, constants.CallAWSACMGetResources, err)
+		if err != nil {
+			return "", awsutil.SanitizeErr(err)
+		}
+		for _, mapping := range out.ResourceTagMappingList {
+			if certARN := aws.ToString(mapping.ResourceARN); certARN != "" {
+				arns = append(arns, certARN)
+			}
+		}
+	}
+
+	if len(arns) == 0 {
+		return "", nil
+	}
+	sort.Strings(arns)
+	if len(arns) > 1 {
+		log.Error(nil, "multiple certificates match the same remoteKey tag, using the lexically smallest",
+			"remoteKey", remoteKey, "selected", arns[0], "matches", arns)
+	}
+	cm.arnCache.Add(remoteKey, arns[0])
+	return arns[0], nil
+}
+
+func (cm *CertificateManager) getCertificate(ctx context.Context, arn string) (*acm.GetCertificateOutput, error) {
+	out, err := cm.client.GetCertificate(ctx, &acm.GetCertificateInput{
+		CertificateArn: aws.String(arn),
+	})
+	metrics.ObserveAPICall(constants.ProviderAWSACM, constants.CallAWSACMGetCertificate, err)
+	if err != nil {
+		var aerr smithy.APIError
+		if errors.As(err, &aerr) && aerr.ErrorCode() == errResourceNotFound {
+			return nil, errCertificateNotFound
+		}
+		return nil, awsutil.SanitizeErr(err)
+	}
+	return out, nil
+}
+
+func (cm *CertificateManager) listTags(ctx context.Context, arn string) ([]types.Tag, error) {
+	out, err := cm.client.ListTagsForCertificate(ctx, &acm.ListTagsForCertificateInput{
+		CertificateArn: aws.String(arn),
+	})
+	metrics.ObserveAPICall(constants.ProviderAWSACM, constants.CallAWSACMListTagsForCertificate, err)
+	if err != nil {
+		var aerr smithy.APIError
+		if errors.As(err, &aerr) && aerr.ErrorCode() == errResourceNotFound {
+			return nil, errCertificateNotFound
+		}
+		return nil, awsutil.SanitizeErr(err)
+	}
+	return out.Tags, nil
+}
+
+func (cm *CertificateManager) syncTags(ctx context.Context, arn string, desiredTags map[string]string) error {
+	current, err := cm.listTags(ctx, arn)
+	if err != nil {
+		return err
+	}
+
+	currentMap := tagsToMap(current)
+
+	if toRemove := tagsToRemove(currentMap, desiredTags); len(toRemove) > 0 {
+		_, err = cm.client.RemoveTagsFromCertificate(ctx, &acm.RemoveTagsFromCertificateInput{
+			CertificateArn: aws.String(arn),
+			Tags:           toRemove,
+		})
+		metrics.ObserveAPICall(constants.ProviderAWSACM, constants.CallAWSACMRemoveTagsFromCertificate, err)
+		if err != nil {
+			return awsutil.SanitizeErr(err)
+		}
+	}
+
+	if toAdd := tagsToAdd(currentMap, desiredTags); len(toAdd) > 0 {
+		_, err = cm.client.AddTagsToCertificate(ctx, &acm.AddTagsToCertificateInput{
+			CertificateArn: aws.String(arn),
+			Tags:           toAdd,
+		})
+		metrics.ObserveAPICall(constants.ProviderAWSACM, constants.CallAWSACMAddTagsToCertificate, err)
+		if err != nil {
+			return awsutil.SanitizeErr(err)
+		}
+	}
+	return nil
+}
+
+func tagsToMap(tags []types.Tag) map[string]string {
+	m := make(map[string]string, len(tags))
+	for _, t := range tags {
+		m[aws.ToString(t.Key)] = aws.ToString(t.Value)
+	}
+	return m
+}
+
+func tagsToRemove(current, desired map[string]string) []types.Tag {
+	var tags []types.Tag
+	for k, v := range current {
+		if isReservedTag(k) {
+			continue
+		}
+		if _, ok := desired[k]; !ok {
+			tags = append(tags, types.Tag{Key: aws.String(k), Value: aws.String(v)})
+		}
+	}
+	return tags
+}
+
+func tagsToAdd(current, desired map[string]string) []types.Tag {
+	var tags []types.Tag
+	for k, v := range desired {
+		if isReservedTag(k) {
+			continue
+		}
+		if current[k] != v {
+			tags = append(tags, types.Tag{Key: aws.String(k), Value: aws.String(v)})
+		}
+	}
+	return tags
+}
+
+func isManagedByESO(tags []types.Tag) bool {
+	for _, t := range tags {
+		if aws.ToString(t.Key) == managedBy && aws.ToString(t.Value) == externalSecrets {
+			return true
+		}
+	}
+	return false
+}
+
+func isReservedTag(key string) bool {
+	return key == managedBy || key == remoteKeyTag
+}
+
+// computeCertFingerprint returns a hex-encoded SHA-256 digest of the certificate
+// and, if present, chain PEM, with trailing CR/LF stripped. This is a content
+// fingerprint used to detect changes and avoid redundant API calls.
+func computeCertFingerprint(cert, chain string) string {
+	h := sha256.New()
+	h.Write([]byte(strings.TrimRight(cert, "\r\n")))
+	if trimmedChain := strings.TrimRight(chain, "\r\n"); trimmedChain != "" {
+		h.Write([]byte(trimmedChain))
+	}
+	return hex.EncodeToString(h.Sum(nil))
+}
+
+// splitCertificatePEM splits a PEM bundle into the leaf certificate and the remaining chain.
+func splitCertificatePEM(certPEM []byte) (leaf, chain []byte, err error) {
+	var blocks []*pem.Block
+	rest := certPEM
+	for {
+		var block *pem.Block
+		block, rest = pem.Decode(rest)
+		if block == nil {
+			break
+		}
+		if block.Type == "CERTIFICATE" {
+			blocks = append(blocks, block)
+		}
+	}
+	if len(blocks) == 0 {
+		return nil, nil, errors.New("no CERTIFICATE PEM blocks found")
+	}
+
+	if _, err := x509.ParseCertificate(blocks[0].Bytes); err != nil {
+		return nil, nil, fmt.Errorf("failed to parse leaf certificate: %w", err)
+	}
+
+	leaf = pem.EncodeToMemory(blocks[0])
+
+	for _, b := range blocks[1:] {
+		chain = append(chain, pem.EncodeToMemory(b)...)
+	}
+	return leaf, chain, nil
+}
+
+func constructMetadata(data *apiextensionsv1.JSON) (*metadata.PushSecretMetadata[PushSecretMetadataSpec], error) {
+	meta, err := metadata.ParseMetadataParameters[PushSecretMetadataSpec](data)
+	if err != nil {
+		return nil, err
+	}
+	if meta == nil {
+		meta = &metadata.PushSecretMetadata[PushSecretMetadataSpec]{}
+	}
+	return meta, nil
+}
+
+// decryptPKCS8PEM decodes an "ENCRYPTED PRIVATE KEY" PEM block and returns the
+// decrypted key as a "PRIVATE KEY" PEM block. If the PEM is already unencrypted
+// it is returned as-is.
+func decryptPKCS8PEM(encryptedPEM, passphrase []byte) ([]byte, error) {
+	block, _ := pem.Decode(encryptedPEM)
+	if block == nil {
+		return nil, errors.New("failed to decode PEM block")
+	}
+	if block.Type == "PRIVATE KEY" {
+		return encryptedPEM, nil
+	}
+	if block.Type != "ENCRYPTED PRIVATE KEY" {
+		return nil, fmt.Errorf("unexpected PEM type: %s", block.Type)
+	}
+
+	privateKey, err := pkcs8.ParsePKCS8PrivateKey(block.Bytes, passphrase)
+	if err != nil {
+		return nil, fmt.Errorf("failed to decrypt PKCS#8 private key: %w", err)
+	}
+
+	derBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal decrypted private key: %w", err)
+	}
+
+	return pem.EncodeToMemory(&pem.Block{
+		Type:  "PRIVATE KEY",
+		Bytes: derBytes,
+	}), nil
+}

+ 1030 - 0
providers/v1/aws/certificatemanager/certificatemanager_test.go

@@ -0,0 +1,1030 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package certificatemanager
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"fmt"
+	"math/big"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/service/acm"
+	"github.com/aws/aws-sdk-go-v2/service/acm/types"
+	"github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
+	rgtTypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types"
+	"github.com/aws/smithy-go"
+	"github.com/hashicorp/golang-lru/v2/expirable"
+	"github.com/youmark/pkcs8"
+	corev1 "k8s.io/api/core/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	fakeacm "github.com/external-secrets/external-secrets/providers/v1/aws/certificatemanager/fake"
+)
+
+const (
+	testRemoteKey  = "my-cert"
+	pemPrivateKey  = "PRIVATE KEY"
+	errPushSecret  = "PushSecret: %v"
+	errGetSecret   = "GetSecret: %v"
+	errExpectedTag = "expected tag %q=%q, got %q"
+)
+
+type testCerts struct {
+	LeafPEM         []byte
+	IntermediatePEM []byte
+	RootPEM         []byte
+	PrivateKeyPEM   []byte
+	TLSCrt          []byte
+}
+
+func generateTestCerts(t *testing.T) testCerts {
+	t.Helper()
+
+	rootKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	rootTmpl := &x509.Certificate{
+		SerialNumber:          big.NewInt(1),
+		Subject:               pkix.Name{CommonName: "Test Root CA"},
+		NotBefore:             time.Now().Add(-time.Hour),
+		NotAfter:              time.Now().Add(24 * time.Hour),
+		IsCA:                  true,
+		BasicConstraintsValid: true,
+	}
+	rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, &rootKey.PublicKey, rootKey)
+	if err != nil {
+		t.Fatalf("create root cert: %v", err)
+	}
+	rootCert, _ := x509.ParseCertificate(rootDER)
+
+	interKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	interTmpl := &x509.Certificate{
+		SerialNumber:          big.NewInt(2),
+		Subject:               pkix.Name{CommonName: "Test Intermediate CA"},
+		NotBefore:             time.Now().Add(-time.Hour),
+		NotAfter:              time.Now().Add(24 * time.Hour),
+		IsCA:                  true,
+		BasicConstraintsValid: true,
+		SubjectKeyId:          []byte{2},
+		AuthorityKeyId:        rootCert.SubjectKeyId,
+	}
+	interDER, err := x509.CreateCertificate(rand.Reader, interTmpl, rootCert, &interKey.PublicKey, rootKey)
+	if err != nil {
+		t.Fatalf("create intermediate cert: %v", err)
+	}
+	interCert, _ := x509.ParseCertificate(interDER)
+
+	leafKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	leafTmpl := &x509.Certificate{
+		SerialNumber:   big.NewInt(3),
+		Subject:        pkix.Name{CommonName: "test.example.com"},
+		NotBefore:      time.Now().Add(-time.Hour),
+		NotAfter:       time.Now().Add(24 * time.Hour),
+		SubjectKeyId:   []byte{3},
+		AuthorityKeyId: interCert.SubjectKeyId,
+	}
+	leafDER, err := x509.CreateCertificate(rand.Reader, leafTmpl, interCert, &leafKey.PublicKey, interKey)
+	if err != nil {
+		t.Fatalf("create leaf cert: %v", err)
+	}
+
+	leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER})
+	interPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: interDER})
+	rootPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER})
+
+	leafKeyDER, _ := x509.MarshalPKCS8PrivateKey(leafKey)
+	privKeyPEM := pem.EncodeToMemory(&pem.Block{Type: pemPrivateKey, Bytes: leafKeyDER})
+
+	tlsCrt := make([]byte, 0, len(leafPEM)+len(interPEM))
+	tlsCrt = append(tlsCrt, leafPEM...)
+	tlsCrt = append(tlsCrt, interPEM...)
+
+	return testCerts{
+		LeafPEM:         leafPEM,
+		IntermediatePEM: interPEM,
+		RootPEM:         rootPEM,
+		PrivateKeyPEM:   privKeyPEM,
+		TLSCrt:          tlsCrt,
+	}
+}
+
+type pushSecretData struct {
+	remoteKey string
+	secretKey string
+	property  string
+	metadata  *apiextensionsv1.JSON
+}
+
+func (p *pushSecretData) GetRemoteKey() string               { return p.remoteKey }
+func (p *pushSecretData) GetSecretKey() string               { return p.secretKey }
+func (p *pushSecretData) GetProperty() string                { return p.property }
+func (p *pushSecretData) GetMetadata() *apiextensionsv1.JSON { return p.metadata }
+
+type remoteRef struct {
+	remoteKey string
+	property  string
+}
+
+func (r remoteRef) GetRemoteKey() string { return r.remoteKey }
+func (r remoteRef) GetProperty() string  { return r.property }
+
+func tlsSecret(tlsCrt, tlsKey []byte) *corev1.Secret {
+	return &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{Name: "my-tls", Namespace: "default"},
+		Data: map[string][]byte{
+			"tls.crt": tlsCrt,
+			"tls.key": tlsKey,
+		},
+	}
+}
+
+func managedTags(remoteKey string) []types.Tag {
+	return []types.Tag{
+		{Key: aws.String(managedBy), Value: aws.String(externalSecrets)},
+		{Key: aws.String(remoteKeyTag), Value: aws.String(remoteKey)},
+	}
+}
+
+func newProvider(fc *fakeacm.Client) *CertificateManager {
+	return &CertificateManager{
+		client:       fc,
+		rgtClient:    stubRgt(""),
+		referentAuth: false,
+		cfg:          &aws.Config{},
+		arnCache:     expirable.NewLRU[string, string](arnCacheSize, nil, arnCacheTTL),
+		exportCache:  expirable.NewLRU[string, exportCacheEntry](exportCacheSize, nil, exportCacheTTL),
+	}
+}
+
+func stubRgt(certARN string) *fakeacm.RgtClient {
+	return &fakeacm.RgtClient{
+		GetResourcesFn: func(_ context.Context, _ *resourcegroupstaggingapi.GetResourcesInput, _ ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) {
+			out := &resourcegroupstaggingapi.GetResourcesOutput{}
+			if certARN != "" {
+				out.ResourceTagMappingList = []rgtTypes.ResourceTagMapping{
+					{ResourceARN: aws.String(certARN)},
+				}
+			}
+			return out, nil
+		},
+	}
+}
+
+func exportableDescribe() fakeacm.DescribeCertificateFn {
+	return func(_ context.Context, _ *acm.DescribeCertificateInput, _ ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
+		return &acm.DescribeCertificateOutput{
+			Certificate: &types.CertificateDetail{
+				Options: &types.CertificateOptions{
+					Export: types.CertificateExportEnabled,
+				},
+			},
+		}, nil
+	}
+}
+
+func TestSplitCertificatePEM_LeafOnly(t *testing.T) {
+	certs := generateTestCerts(t)
+
+	leaf, chain, err := splitCertificatePEM(certs.LeafPEM)
+	if err != nil {
+		t.Fatalf("splitCertificatePEM: %v", err)
+	}
+	if !bytes.Equal(leaf, certs.LeafPEM) {
+		t.Error("leaf does not match input")
+	}
+	if len(chain) != 0 {
+		t.Errorf("expected empty chain for leaf-only input, got %d bytes", len(chain))
+	}
+}
+
+func TestSplitCertificatePEM_LeafAndIntermediate(t *testing.T) {
+	certs := generateTestCerts(t)
+	leaf, chain, err := splitCertificatePEM(certs.TLSCrt)
+	if err != nil {
+		t.Fatalf("splitCertificatePEM: %v", err)
+	}
+	if !bytes.Equal(leaf, certs.LeafPEM) {
+		t.Error("leaf does not match first PEM block")
+	}
+	if !bytes.Equal(chain, certs.IntermediatePEM) {
+		t.Error("chain does not match intermediate PEM block")
+	}
+}
+
+func TestSplitCertificatePEM_NoCertificates(t *testing.T) {
+	_, _, err := splitCertificatePEM([]byte("not a certificate"))
+	if err == nil {
+		t.Fatal("expected error for input with no CERTIFICATE blocks")
+	}
+}
+
+func TestSplitCertificatePEM_InvalidLeafCertificate(t *testing.T) {
+	badPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("not valid DER")})
+	_, _, err := splitCertificatePEM(badPEM)
+	if err == nil {
+		t.Fatal("expected error for invalid leaf certificate DER")
+	}
+	if got := err.Error(); !strings.Contains(got, "failed to parse leaf certificate") {
+		t.Errorf("expected error to mention leaf certificate, got: %s", got)
+	}
+}
+
+func TestPushSecret_NewCertificate(t *testing.T) {
+	certs := generateTestCerts(t)
+	const arn = "arn:aws:acm:us-east-1:123456789012:certificate/new"
+
+	var gotCert, gotChain []byte
+	var gotTags []types.Tag
+	fake := &fakeacm.Client{
+		ImportCertificateFn: func(_ context.Context, p *acm.ImportCertificateInput, _ ...func(*acm.Options)) (*acm.ImportCertificateOutput, error) {
+			if p.CertificateArn != nil {
+				t.Error("expected no CertificateArn on first import")
+			}
+			gotCert = p.Certificate
+			gotChain = p.CertificateChain
+			gotTags = p.Tags
+			return &acm.ImportCertificateOutput{CertificateArn: aws.String(arn)}, nil
+		},
+		AddTagsToCertificateFn: func(_ context.Context, _ *acm.AddTagsToCertificateInput, _ ...func(*acm.Options)) (*acm.AddTagsToCertificateOutput, error) {
+			return &acm.AddTagsToCertificateOutput{}, nil
+		},
+		ListTagsForCertificateFn: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+			return &acm.ListTagsForCertificateOutput{Tags: managedTags(testRemoteKey)}, nil
+		},
+		RemoveTagsFromCertificateFn: func(_ context.Context, _ *acm.RemoveTagsFromCertificateInput, _ ...func(*acm.Options)) (*acm.RemoveTagsFromCertificateOutput, error) {
+			return &acm.RemoveTagsFromCertificateOutput{}, nil
+		},
+	}
+
+	psd := &pushSecretData{remoteKey: testRemoteKey}
+	secret := tlsSecret(certs.TLSCrt, certs.PrivateKeyPEM)
+
+	if err := newProvider(fake).PushSecret(context.Background(), secret, psd); err != nil {
+		t.Fatalf(errPushSecret, err)
+	}
+	if !bytes.Equal(gotCert, certs.LeafPEM) {
+		t.Error("Certificate field should be the leaf certificate only")
+	}
+	if !bytes.Equal(gotChain, certs.IntermediatePEM) {
+		t.Error("CertificateChain field should be the intermediate certificate")
+	}
+	if len(gotTags) != 2 {
+		t.Fatalf("expected 2 management tags on new import, got %d", len(gotTags))
+	}
+	tagMap := make(map[string]string, len(gotTags))
+	for _, tag := range gotTags {
+		tagMap[aws.ToString(tag.Key)] = aws.ToString(tag.Value)
+	}
+	if tagMap[managedBy] != externalSecrets {
+		t.Errorf(errExpectedTag, managedBy, externalSecrets, tagMap[managedBy])
+	}
+	if tagMap[remoteKeyTag] != testRemoteKey {
+		t.Errorf(errExpectedTag, remoteKeyTag, testRemoteKey, tagMap[remoteKeyTag])
+	}
+}
+
+func TestPushSecret_LeafOnly_NoChainSent(t *testing.T) {
+	certs := generateTestCerts(t)
+	const arn = "arn:aws:acm:us-east-1:123456789012:certificate/leaf-only"
+
+	var gotChain []byte
+	fake := &fakeacm.Client{
+		ImportCertificateFn: func(_ context.Context, p *acm.ImportCertificateInput, _ ...func(*acm.Options)) (*acm.ImportCertificateOutput, error) {
+			gotChain = p.CertificateChain
+			return &acm.ImportCertificateOutput{CertificateArn: aws.String(arn)}, nil
+		},
+		AddTagsToCertificateFn: func(_ context.Context, _ *acm.AddTagsToCertificateInput, _ ...func(*acm.Options)) (*acm.AddTagsToCertificateOutput, error) {
+			return &acm.AddTagsToCertificateOutput{}, nil
+		},
+		ListTagsForCertificateFn: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+			return &acm.ListTagsForCertificateOutput{Tags: managedTags(testRemoteKey)}, nil
+		},
+		RemoveTagsFromCertificateFn: func(_ context.Context, _ *acm.RemoveTagsFromCertificateInput, _ ...func(*acm.Options)) (*acm.RemoveTagsFromCertificateOutput, error) {
+			return &acm.RemoveTagsFromCertificateOutput{}, nil
+		},
+	}
+
+	psd := &pushSecretData{remoteKey: testRemoteKey}
+	secret := tlsSecret(certs.LeafPEM, certs.PrivateKeyPEM)
+
+	if err := newProvider(fake).PushSecret(context.Background(), secret, psd); err != nil {
+		t.Fatalf(errPushSecret, err)
+	}
+	if len(gotChain) != 0 {
+		t.Error("CertificateChain should be nil when tls.crt contains only the leaf")
+	}
+}
+
+func TestPushSecret_ReimportExisting(t *testing.T) {
+	certs := generateTestCerts(t)
+	const arn = "arn:aws:acm:us-east-1:123456789012:certificate/existing"
+	var importedARN string
+
+	fake := &fakeacm.Client{
+		GetCertificateFn: func(_ context.Context, _ *acm.GetCertificateInput, _ ...func(*acm.Options)) (*acm.GetCertificateOutput, error) {
+			return &acm.GetCertificateOutput{Certificate: aws.String(string(certs.RootPEM))}, nil
+		},
+		ListTagsForCertificateFn: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+			return &acm.ListTagsForCertificateOutput{Tags: managedTags(testRemoteKey)}, nil
+		},
+		ImportCertificateFn: func(_ context.Context, p *acm.ImportCertificateInput, _ ...func(*acm.Options)) (*acm.ImportCertificateOutput, error) {
+			importedARN = aws.ToString(p.CertificateArn)
+			if len(p.Tags) > 0 {
+				t.Error("Tags must not be set on re-import")
+			}
+			return &acm.ImportCertificateOutput{CertificateArn: p.CertificateArn}, nil
+		},
+		AddTagsToCertificateFn: func(_ context.Context, _ *acm.AddTagsToCertificateInput, _ ...func(*acm.Options)) (*acm.AddTagsToCertificateOutput, error) {
+			return &acm.AddTagsToCertificateOutput{}, nil
+		},
+		RemoveTagsFromCertificateFn: func(_ context.Context, _ *acm.RemoveTagsFromCertificateInput, _ ...func(*acm.Options)) (*acm.RemoveTagsFromCertificateOutput, error) {
+			return &acm.RemoveTagsFromCertificateOutput{}, nil
+		},
+	}
+
+	psd := &pushSecretData{remoteKey: testRemoteKey}
+	secret := tlsSecret(certs.TLSCrt, certs.PrivateKeyPEM)
+
+	provider := newProvider(fake)
+	provider.rgtClient = stubRgt(arn)
+	if err := provider.PushSecret(context.Background(), secret, psd); err != nil {
+		t.Fatalf(errPushSecret, err)
+	}
+	if importedARN != arn {
+		t.Errorf("expected CertificateArn %q, got %q", arn, importedARN)
+	}
+}
+
+func TestPushSecret_SkipsReimportWhenUnchanged(t *testing.T) {
+	certs := generateTestCerts(t)
+	const arn = "arn:aws:acm:us-east-1:123456789012:certificate/unchanged"
+
+	importCalled := false
+
+	fake := &fakeacm.Client{
+		GetCertificateFn: func(_ context.Context, _ *acm.GetCertificateInput, _ ...func(*acm.Options)) (*acm.GetCertificateOutput, error) {
+			return &acm.GetCertificateOutput{
+				Certificate:      aws.String(string(certs.LeafPEM)),
+				CertificateChain: aws.String(string(certs.IntermediatePEM)),
+			}, nil
+		},
+		ListTagsForCertificateFn: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+			return &acm.ListTagsForCertificateOutput{Tags: managedTags(testRemoteKey)}, nil
+		},
+		ImportCertificateFn: func(_ context.Context, _ *acm.ImportCertificateInput, _ ...func(*acm.Options)) (*acm.ImportCertificateOutput, error) {
+			importCalled = true
+			t.Error("ImportCertificate should not be called when content is unchanged")
+			return &acm.ImportCertificateOutput{CertificateArn: aws.String(arn)}, nil
+		},
+		AddTagsToCertificateFn: func(_ context.Context, _ *acm.AddTagsToCertificateInput, _ ...func(*acm.Options)) (*acm.AddTagsToCertificateOutput, error) {
+			return &acm.AddTagsToCertificateOutput{}, nil
+		},
+		RemoveTagsFromCertificateFn: func(_ context.Context, _ *acm.RemoveTagsFromCertificateInput, _ ...func(*acm.Options)) (*acm.RemoveTagsFromCertificateOutput, error) {
+			return &acm.RemoveTagsFromCertificateOutput{}, nil
+		},
+	}
+
+	psd := &pushSecretData{remoteKey: testRemoteKey}
+	secret := tlsSecret(certs.TLSCrt, certs.PrivateKeyPEM)
+
+	provider := newProvider(fake)
+	provider.rgtClient = stubRgt(arn)
+	if err := provider.PushSecret(context.Background(), secret, psd); err != nil {
+		t.Fatalf(errPushSecret, err)
+	}
+	if importCalled {
+		t.Error("ImportCertificate was called despite unchanged content")
+	}
+}
+
+func TestPushSecret_MissingCertKey(t *testing.T) {
+	fake := &fakeacm.Client{}
+
+	psd := &pushSecretData{remoteKey: testRemoteKey}
+	secret := &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{Name: "bad-secret", Namespace: "default"},
+		Data: map[string][]byte{
+			"tls.key": []byte("KEY"),
+		},
+	}
+
+	if err := newProvider(fake).PushSecret(context.Background(), secret, psd); err == nil {
+		t.Fatal("expected error when tls.crt is missing")
+	}
+}
+
+func TestPushSecret_SecretKeyNotEmpty_Rejected(t *testing.T) {
+	certs := generateTestCerts(t)
+
+	psd := &pushSecretData{
+		remoteKey: testRemoteKey,
+		secretKey: "tls.crt",
+	}
+	secret := tlsSecret(certs.TLSCrt, certs.PrivateKeyPEM)
+
+	err := newProvider(&fakeacm.Client{}).PushSecret(context.Background(), secret, psd)
+	if err == nil {
+		t.Fatal("expected error when secretKey is non-empty")
+	}
+	if err.Error() != errSecretKeyNotEmpty {
+		t.Errorf("expected error %q, got %q", errSecretKeyNotEmpty, err.Error())
+	}
+}
+
+func TestPushSecret_CachePreventsDoubleImport(t *testing.T) {
+	certs := generateTestCerts(t)
+	const arn = "arn:aws:acm:us-east-1:123456789012:certificate/cached"
+
+	fake := &fakeacm.Client{
+		ImportCertificateFn: func(_ context.Context, _ *acm.ImportCertificateInput, _ ...func(*acm.Options)) (*acm.ImportCertificateOutput, error) {
+			return &acm.ImportCertificateOutput{CertificateArn: aws.String(arn)}, nil
+		},
+		GetCertificateFn: func(_ context.Context, _ *acm.GetCertificateInput, _ ...func(*acm.Options)) (*acm.GetCertificateOutput, error) {
+			return &acm.GetCertificateOutput{
+				Certificate:      aws.String(string(certs.LeafPEM)),
+				CertificateChain: aws.String(string(certs.IntermediatePEM)),
+			}, nil
+		},
+		AddTagsToCertificateFn: func(_ context.Context, _ *acm.AddTagsToCertificateInput, _ ...func(*acm.Options)) (*acm.AddTagsToCertificateOutput, error) {
+			return &acm.AddTagsToCertificateOutput{}, nil
+		},
+		ListTagsForCertificateFn: func(_ context.Context, p *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+			if aws.ToString(p.CertificateArn) == arn {
+				return &acm.ListTagsForCertificateOutput{Tags: managedTags(testRemoteKey)}, nil
+			}
+			return &acm.ListTagsForCertificateOutput{}, nil
+		},
+		RemoveTagsFromCertificateFn: func(_ context.Context, _ *acm.RemoveTagsFromCertificateInput, _ ...func(*acm.Options)) (*acm.RemoveTagsFromCertificateOutput, error) {
+			return &acm.RemoveTagsFromCertificateOutput{}, nil
+		},
+	}
+
+	tagLookupCount := 0
+	fakeRgt := &fakeacm.RgtClient{
+		GetResourcesFn: func(_ context.Context, _ *resourcegroupstaggingapi.GetResourcesInput, _ ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) {
+			tagLookupCount++
+			return &resourcegroupstaggingapi.GetResourcesOutput{}, nil
+		},
+	}
+
+	psd := &pushSecretData{remoteKey: testRemoteKey}
+	secret := tlsSecret(certs.TLSCrt, certs.PrivateKeyPEM)
+
+	provider := newProvider(fake)
+	provider.rgtClient = fakeRgt
+	if err := provider.PushSecret(context.Background(), secret, psd); err != nil {
+		t.Fatalf("first PushSecret: %v", err)
+	}
+	if tagLookupCount != 1 {
+		t.Fatalf("expected GetResources to be called once on first push, got %d", tagLookupCount)
+	}
+
+	// Second push on the same provider: cached ARN must skip the lookup via RGT API.
+	if err := provider.PushSecret(context.Background(), secret, psd); err != nil {
+		t.Fatalf("second PushSecret: %v", err)
+	}
+	if tagLookupCount != 1 {
+		t.Errorf("expected cached ARN to skip GetResources, got %d lookups", tagLookupCount)
+	}
+}
+
+func TestPushSecret_CacheClearedOnDelete(t *testing.T) {
+	certs := generateTestCerts(t)
+	const arn = "arn:aws:acm:us-east-1:123456789012:certificate/to-delete-cache"
+
+	importCount := 0
+	fake := &fakeacm.Client{
+		GetCertificateFn: func(_ context.Context, _ *acm.GetCertificateInput, _ ...func(*acm.Options)) (*acm.GetCertificateOutput, error) {
+			return &acm.GetCertificateOutput{Certificate: aws.String(string(certs.RootPEM))}, nil
+		},
+		ImportCertificateFn: func(_ context.Context, p *acm.ImportCertificateInput, _ ...func(*acm.Options)) (*acm.ImportCertificateOutput, error) {
+			importCount++
+			return &acm.ImportCertificateOutput{CertificateArn: aws.String(arn)}, nil
+		},
+		AddTagsToCertificateFn: func(_ context.Context, _ *acm.AddTagsToCertificateInput, _ ...func(*acm.Options)) (*acm.AddTagsToCertificateOutput, error) {
+			return &acm.AddTagsToCertificateOutput{}, nil
+		},
+		ListTagsForCertificateFn: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+			return &acm.ListTagsForCertificateOutput{Tags: managedTags(testRemoteKey)}, nil
+		},
+		RemoveTagsFromCertificateFn: func(_ context.Context, _ *acm.RemoveTagsFromCertificateInput, _ ...func(*acm.Options)) (*acm.RemoveTagsFromCertificateOutput, error) {
+			return &acm.RemoveTagsFromCertificateOutput{}, nil
+		},
+		DeleteCertificateFn: func(_ context.Context, _ *acm.DeleteCertificateInput, _ ...func(*acm.Options)) (*acm.DeleteCertificateOutput, error) {
+			return &acm.DeleteCertificateOutput{}, nil
+		},
+	}
+
+	psd := &pushSecretData{remoteKey: testRemoteKey}
+	secret := tlsSecret(certs.TLSCrt, certs.PrivateKeyPEM)
+
+	provider := newProvider(fake)
+	provider.rgtClient = stubRgt(arn)
+	if err := provider.PushSecret(context.Background(), secret, psd); err != nil {
+		t.Fatalf(errPushSecret, err)
+	}
+	if _, ok := provider.arnCache.Get(testRemoteKey); !ok {
+		t.Fatal("expected ARN to be cached after PushSecret")
+	}
+
+	if err := provider.DeleteSecret(context.Background(), remoteRef{remoteKey: testRemoteKey}); err != nil {
+		t.Fatalf("DeleteSecret: %v", err)
+	}
+	if _, ok := provider.arnCache.Get(testRemoteKey); ok {
+		t.Error("expected ARN cache to be cleared after DeleteSecret")
+	}
+}
+
+func TestSecretExists_Found(t *testing.T) {
+	const arn = "arn:aws:acm:us-east-1:123456789012:certificate/abc"
+	fake := &fakeacm.Client{
+		ListTagsForCertificateFn: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+			return &acm.ListTagsForCertificateOutput{Tags: managedTags(testRemoteKey)}, nil
+		},
+	}
+
+	provider := newProvider(fake)
+	provider.rgtClient = stubRgt(arn)
+	exists, err := provider.SecretExists(context.Background(), remoteRef{remoteKey: testRemoteKey})
+	if err != nil {
+		t.Fatalf("SecretExists: %v", err)
+	}
+	if !exists {
+		t.Error("expected certificate to exist")
+	}
+}
+
+func TestSecretExists_StaleCacheEvictedOnNotFound(t *testing.T) {
+	const arn = "arn:aws:acm:us-east-1:123456789012:certificate/stale"
+
+	fake := &fakeacm.Client{
+		ListTagsForCertificateFn: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+			return nil, &smithyFakeNotFound{}
+		},
+	}
+	fakeRgt := &fakeacm.RgtClient{
+		GetResourcesFn: func(_ context.Context, _ *resourcegroupstaggingapi.GetResourcesInput, _ ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) {
+			t.Error("GetResources must not be called when the ARN is served from cache")
+			return &resourcegroupstaggingapi.GetResourcesOutput{}, nil
+		},
+	}
+
+	provider := newProvider(fake)
+	provider.rgtClient = fakeRgt
+	provider.arnCache.Add(testRemoteKey, arn)
+
+	exists, err := provider.SecretExists(context.Background(), remoteRef{remoteKey: testRemoteKey})
+	if err != nil {
+		t.Fatalf("SecretExists: %v", err)
+	}
+	if exists {
+		t.Error("expected SecretExists to report false when the cert is gone")
+	}
+	if _, ok := provider.arnCache.Get(testRemoteKey); ok {
+		t.Error("expected stale cache entry to be evicted")
+	}
+}
+
+func TestSearchCertificatesByTag_Duplicates(t *testing.T) {
+	const (
+		arnA = "arn:aws:acm:us-east-1:123456789012:certificate/aaa"
+		arnB = "arn:aws:acm:us-east-1:123456789012:certificate/bbb"
+		arnC = "arn:aws:acm:us-east-1:123456789012:certificate/ccc"
+	)
+
+	call := 0
+	fakeRgt := &fakeacm.RgtClient{
+		GetResourcesFn: func(_ context.Context, in *resourcegroupstaggingapi.GetResourcesInput, _ ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) {
+			call++
+			switch call {
+			case 1:
+				if in.PaginationToken != nil {
+					t.Errorf("expected no pagination token on first call, got %q", *in.PaginationToken)
+				}
+				next := "page2"
+				return &resourcegroupstaggingapi.GetResourcesOutput{
+					ResourceTagMappingList: []rgtTypes.ResourceTagMapping{
+						{ResourceARN: aws.String(arnC)},
+						{ResourceARN: aws.String(arnA)},
+					},
+					PaginationToken: &next,
+				}, nil
+			case 2:
+				if in.PaginationToken == nil || *in.PaginationToken != "page2" {
+					t.Errorf("expected pagination token %q on second call, got %v", "page2", in.PaginationToken)
+				}
+				return &resourcegroupstaggingapi.GetResourcesOutput{
+					ResourceTagMappingList: []rgtTypes.ResourceTagMapping{
+						{ResourceARN: aws.String(arnB)},
+					},
+				}, nil
+			}
+			t.Fatalf("unexpected extra GetResources call: %d", call)
+			return nil, nil
+		},
+	}
+
+	provider := newProvider(&fakeacm.Client{})
+	provider.rgtClient = fakeRgt
+
+	got, err := provider.searchCertificatesByTag(context.Background(), testRemoteKey)
+	if err != nil {
+		t.Fatalf("searchCertificatesByTag: %v", err)
+	}
+	if got != arnA {
+		t.Errorf("expected lexically smallest ARN %q, got %q", arnA, got)
+	}
+	if call != 2 {
+		t.Errorf("expected 2 GetResources calls (full pagination), got %d", call)
+	}
+}
+
+func TestSecretExists_NotFound(t *testing.T) {
+	exists, err := newProvider(&fakeacm.Client{}).SecretExists(context.Background(), remoteRef{remoteKey: "missing"})
+	if err != nil {
+		t.Fatalf("SecretExists: %v", err)
+	}
+	if exists {
+		t.Error("expected certificate to not exist")
+	}
+}
+
+func TestDeleteSecret_Managed(t *testing.T) {
+	const arn = "arn:aws:acm:us-east-1:123456789012:certificate/to-delete"
+	deleted := false
+
+	fake := &fakeacm.Client{
+		ListTagsForCertificateFn: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+			return &acm.ListTagsForCertificateOutput{Tags: managedTags(testRemoteKey)}, nil
+		},
+		DeleteCertificateFn: func(_ context.Context, p *acm.DeleteCertificateInput, _ ...func(*acm.Options)) (*acm.DeleteCertificateOutput, error) {
+			if aws.ToString(p.CertificateArn) != arn {
+				t.Errorf("expected ARN %q, got %q", arn, aws.ToString(p.CertificateArn))
+			}
+			deleted = true
+			return &acm.DeleteCertificateOutput{}, nil
+		},
+	}
+
+	provider := newProvider(fake)
+	provider.rgtClient = stubRgt(arn)
+	if err := provider.DeleteSecret(context.Background(), remoteRef{remoteKey: testRemoteKey}); err != nil {
+		t.Fatalf("DeleteSecret: %v", err)
+	}
+	if !deleted {
+		t.Error("expected DeleteCertificate to be called")
+	}
+}
+
+func TestDeleteSecret_NotFound(t *testing.T) {
+	if err := newProvider(&fakeacm.Client{}).DeleteSecret(context.Background(), remoteRef{remoteKey: "gone"}); err != nil {
+		t.Fatalf("DeleteSecret on missing cert: %v", err)
+	}
+}
+
+func TestDeleteSecret_NotManagedByESO(t *testing.T) {
+	const arn = "arn:aws:acm:us-east-1:123456789012:certificate/external"
+
+	fake := &fakeacm.Client{
+		ListTagsForCertificateFn: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+			return &acm.ListTagsForCertificateOutput{Tags: []types.Tag{
+				{Key: aws.String(remoteKeyTag), Value: aws.String("ext-cert")},
+			}}, nil
+		},
+	}
+
+	provider := newProvider(fake)
+	provider.rgtClient = stubRgt(arn)
+	err := provider.DeleteSecret(context.Background(), remoteRef{remoteKey: "ext-cert"})
+	if err == nil || err.Error() != errNotManagedByESO {
+		t.Fatalf("expected %q, got: %v", errNotManagedByESO, err)
+	}
+}
+
+func TestDeleteSecret_DeletedBetweenFindAndListTags(t *testing.T) {
+	const arn = "arn:aws:acm:us-east-1:123456789012:certificate/race"
+
+	fake := &fakeacm.Client{
+		ListTagsForCertificateFn: func(_ context.Context, _ *acm.ListTagsForCertificateInput, _ ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+			return nil, &smithyFakeNotFound{}
+		},
+		DeleteCertificateFn: func(_ context.Context, _ *acm.DeleteCertificateInput, _ ...func(*acm.Options)) (*acm.DeleteCertificateOutput, error) {
+			t.Error("DeleteCertificate should not be called when cert is already gone")
+			return &acm.DeleteCertificateOutput{}, nil
+		},
+	}
+
+	provider := newProvider(fake)
+	provider.rgtClient = stubRgt(arn)
+	if err := provider.DeleteSecret(context.Background(), remoteRef{remoteKey: testRemoteKey}); err != nil {
+		t.Fatalf("expected no-op when cert disappears between find and verify, got: %v", err)
+	}
+}
+
+type smithyFakeNotFound struct{}
+
+func (e *smithyFakeNotFound) Error() string                 { return errResourceNotFound }
+func (e *smithyFakeNotFound) ErrorCode() string             { return errResourceNotFound }
+func (e *smithyFakeNotFound) ErrorMessage() string          { return "certificate not found" }
+func (e *smithyFakeNotFound) ErrorFault() smithy.ErrorFault { return smithy.FaultClient }
+
+func encryptPKCS8ForTest(t *testing.T, privateDER, passphrase []byte) []byte {
+	t.Helper()
+
+	privKey, err := x509.ParsePKCS8PrivateKey(privateDER)
+	if err != nil {
+		t.Fatalf("parse pkcs8: %v", err)
+	}
+
+	encryptedDER, err := pkcs8.MarshalPrivateKey(privKey, passphrase, nil)
+	if err != nil {
+		t.Fatalf("encrypt pkcs8: %v", err)
+	}
+
+	return pem.EncodeToMemory(&pem.Block{Type: "ENCRYPTED PRIVATE KEY", Bytes: encryptedDER})
+}
+
+func TestGetSecret_ReturnsConcatenatedBundle(t *testing.T) {
+	certs := generateTestCerts(t)
+
+	pkcs8DER, _ := x509.MarshalPKCS8PrivateKey(func() *ecdsa.PrivateKey {
+		key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+		return key
+	}())
+
+	fake := &fakeacm.Client{
+		GetCertificateFn: func(_ context.Context, _ *acm.GetCertificateInput, _ ...func(*acm.Options)) (*acm.GetCertificateOutput, error) {
+			return &acm.GetCertificateOutput{
+				Certificate:      aws.String(string(certs.LeafPEM)),
+				CertificateChain: aws.String(string(certs.IntermediatePEM)),
+			}, nil
+		},
+		DescribeCertificateFn: exportableDescribe(),
+		ExportCertificateFn: func(_ context.Context, p *acm.ExportCertificateInput, _ ...func(*acm.Options)) (*acm.ExportCertificateOutput, error) {
+			encPEM := encryptPKCS8ForTest(t, pkcs8DER, p.Passphrase)
+			return &acm.ExportCertificateOutput{
+				Certificate:      aws.String(string(certs.LeafPEM)),
+				CertificateChain: aws.String(string(certs.IntermediatePEM)),
+				PrivateKey:       aws.String(string(encPEM)),
+			}, nil
+		},
+	}
+
+	result, err := newProvider(fake).GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{
+		Key: "arn:aws:acm:us-east-1:123456789012:certificate/test",
+	})
+	if err != nil {
+		t.Fatalf(errGetSecret, err)
+	}
+	if !strings.Contains(string(result), "-----BEGIN CERTIFICATE-----") {
+		t.Error("expected certificate in output")
+	}
+	if !strings.Contains(string(result), "-----BEGIN PRIVATE KEY-----") {
+		t.Error("expected decrypted private key in output")
+	}
+	if strings.Contains(string(result), "ENCRYPTED") {
+		t.Error("private key should be decrypted")
+	}
+}
+
+func TestGetSecret_CertOnly(t *testing.T) {
+	certs := generateTestCerts(t)
+
+	fake := &fakeacm.Client{
+		GetCertificateFn: func(_ context.Context, _ *acm.GetCertificateInput, _ ...func(*acm.Options)) (*acm.GetCertificateOutput, error) {
+			return &acm.GetCertificateOutput{Certificate: aws.String(string(certs.LeafPEM))}, nil
+		},
+		DescribeCertificateFn: exportableDescribe(),
+		ExportCertificateFn: func(_ context.Context, _ *acm.ExportCertificateInput, _ ...func(*acm.Options)) (*acm.ExportCertificateOutput, error) {
+			return &acm.ExportCertificateOutput{
+				Certificate: aws.String(string(certs.LeafPEM)),
+			}, nil
+		},
+	}
+
+	result, err := newProvider(fake).GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{
+		Key: "arn:aws:acm:us-east-1:123456789012:certificate/test",
+	})
+	if err != nil {
+		t.Fatalf(errGetSecret, err)
+	}
+	if !bytes.Equal(result, certs.LeafPEM) {
+		t.Error("expected only the leaf certificate")
+	}
+}
+
+func TestGetSecret_EmptyARN(t *testing.T) {
+	_, err := newProvider(&fakeacm.Client{}).GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{})
+	if err == nil {
+		t.Fatal("expected error for empty ARN")
+	}
+}
+
+func TestGetSecret_NotExportable(t *testing.T) {
+	certs := generateTestCerts(t)
+	exportCalled := false
+	fake := &fakeacm.Client{
+		GetCertificateFn: func(_ context.Context, _ *acm.GetCertificateInput, _ ...func(*acm.Options)) (*acm.GetCertificateOutput, error) {
+			return &acm.GetCertificateOutput{Certificate: aws.String(string(certs.LeafPEM))}, nil
+		},
+		DescribeCertificateFn: func(_ context.Context, _ *acm.DescribeCertificateInput, _ ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
+			return &acm.DescribeCertificateOutput{
+				Certificate: &types.CertificateDetail{
+					Options: &types.CertificateOptions{
+						Export: types.CertificateExportDisabled,
+					},
+				},
+			}, nil
+		},
+		ExportCertificateFn: func(_ context.Context, _ *acm.ExportCertificateInput, _ ...func(*acm.Options)) (*acm.ExportCertificateOutput, error) {
+			exportCalled = true
+			return nil, &smithyFakeValidation{}
+		},
+	}
+
+	_, err := newProvider(fake).GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{
+		Key: "arn:aws:acm:us-east-1:123456789012:certificate/public",
+	})
+	if err == nil {
+		t.Fatal("expected error for non-exportable certificate")
+	}
+	if !strings.Contains(err.Error(), "not exportable") {
+		t.Errorf("expected not-exportable error message, got: %s", err.Error())
+	}
+	if exportCalled {
+		t.Error("ExportCertificate should not be called for non-exportable certificates")
+	}
+}
+
+func TestGetSecret_NotExportable_NilOptions(t *testing.T) {
+	certs := generateTestCerts(t)
+	fake := &fakeacm.Client{
+		GetCertificateFn: func(_ context.Context, _ *acm.GetCertificateInput, _ ...func(*acm.Options)) (*acm.GetCertificateOutput, error) {
+			return &acm.GetCertificateOutput{Certificate: aws.String(string(certs.LeafPEM))}, nil
+		},
+		DescribeCertificateFn: func(_ context.Context, _ *acm.DescribeCertificateInput, _ ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
+			return &acm.DescribeCertificateOutput{
+				Certificate: &types.CertificateDetail{},
+			}, nil
+		},
+	}
+
+	_, err := newProvider(fake).GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{
+		Key: "arn:aws:acm:us-east-1:123456789012:certificate/no-options",
+	})
+	if err == nil {
+		t.Fatal("expected error when Options is nil")
+	}
+	if !strings.Contains(err.Error(), "not exportable") {
+		t.Errorf("expected not-exportable error, got: %s", err.Error())
+	}
+}
+
+func TestGetSecret_CacheHit(t *testing.T) {
+	certs := generateTestCerts(t)
+	const certARN = "arn:aws:acm:us-east-1:123456789012:certificate/cached"
+	cachedPEM := []byte("cached-pem-bundle")
+
+	fingerprint := computeCertFingerprint(string(certs.LeafPEM), string(certs.IntermediatePEM))
+
+	exportCalled := false
+	fake := &fakeacm.Client{
+		GetCertificateFn: func(_ context.Context, _ *acm.GetCertificateInput, _ ...func(*acm.Options)) (*acm.GetCertificateOutput, error) {
+			return &acm.GetCertificateOutput{
+				Certificate:      aws.String(string(certs.LeafPEM)),
+				CertificateChain: aws.String(string(certs.IntermediatePEM)),
+			}, nil
+		},
+		ExportCertificateFn: func(_ context.Context, _ *acm.ExportCertificateInput, _ ...func(*acm.Options)) (*acm.ExportCertificateOutput, error) {
+			exportCalled = true
+			return nil, fmt.Errorf("should not be called")
+		},
+	}
+
+	provider := newProvider(fake)
+	provider.exportCache.Add(certARN, exportCacheEntry{fingerprint: fingerprint, pem: cachedPEM})
+
+	result, err := provider.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{
+		Key: certARN,
+	})
+	if err != nil {
+		t.Fatalf(errGetSecret, err)
+	}
+	if !bytes.Equal(result, cachedPEM) {
+		t.Errorf("expected cached PEM, got %q", string(result))
+	}
+	if exportCalled {
+		t.Error("ExportCertificate should not be called on cache hit")
+	}
+}
+
+func TestGetSecret_CacheMissOnFingerprintChange(t *testing.T) {
+	certs := generateTestCerts(t)
+	const certARN = "arn:aws:acm:us-east-1:123456789012:certificate/renewed"
+
+	fake := &fakeacm.Client{
+		GetCertificateFn: func(_ context.Context, _ *acm.GetCertificateInput, _ ...func(*acm.Options)) (*acm.GetCertificateOutput, error) {
+			return &acm.GetCertificateOutput{Certificate: aws.String(string(certs.LeafPEM))}, nil
+		},
+		DescribeCertificateFn: exportableDescribe(),
+		ExportCertificateFn: func(_ context.Context, _ *acm.ExportCertificateInput, _ ...func(*acm.Options)) (*acm.ExportCertificateOutput, error) {
+			return &acm.ExportCertificateOutput{
+				Certificate: aws.String(string(certs.LeafPEM)),
+			}, nil
+		},
+	}
+
+	provider := newProvider(fake)
+	provider.exportCache.Add(certARN, exportCacheEntry{fingerprint: "old-fingerprint", pem: []byte("old-pem")})
+
+	result, err := provider.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{
+		Key: certARN,
+	})
+	if err != nil {
+		t.Fatalf(errGetSecret, err)
+	}
+	if !bytes.Equal(result, certs.LeafPEM) {
+		t.Error("expected fresh export after fingerprint change")
+	}
+
+	cached, ok := provider.exportCache.Get(certARN)
+	if !ok {
+		t.Fatal("expected cache to be updated after export")
+	}
+	expectedFingerprint := computeCertFingerprint(string(certs.LeafPEM), "")
+	if cached.fingerprint != expectedFingerprint {
+		t.Error("cached fingerprint should be updated")
+	}
+}
+
+func TestDecryptPKCS8PEM_Roundtrip(t *testing.T) {
+	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatalf("generate key: %v", err)
+	}
+	pkcs8DER, err := x509.MarshalPKCS8PrivateKey(key)
+	if err != nil {
+		t.Fatalf("marshal pkcs8: %v", err)
+	}
+
+	passphrase := []byte("roundtrip-test-passphrase")
+	encPEM := encryptPKCS8ForTest(t, pkcs8DER, passphrase)
+
+	decPEM, err := decryptPKCS8PEM(encPEM, passphrase)
+	if err != nil {
+		t.Fatalf("decryptPKCS8PEM: %v", err)
+	}
+
+	block, _ := pem.Decode(decPEM)
+	if block == nil || block.Type != pemPrivateKey {
+		t.Fatalf("expected PRIVATE KEY PEM block, got %v", block)
+	}
+	if _, err := x509.ParsePKCS8PrivateKey(block.Bytes); err != nil {
+		t.Fatalf("decrypted key is not valid PKCS#8: %v", err)
+	}
+}
+
+func TestDecryptPKCS8PEM_AlreadyUnencrypted(t *testing.T) {
+	key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	pkcs8DER, _ := x509.MarshalPKCS8PrivateKey(key)
+	unencPEM := pem.EncodeToMemory(&pem.Block{Type: pemPrivateKey, Bytes: pkcs8DER})
+
+	result, err := decryptPKCS8PEM(unencPEM, []byte("unused"))
+	if err != nil {
+		t.Fatalf("decryptPKCS8PEM: %v", err)
+	}
+	if !bytes.Equal(result, unencPEM) {
+		t.Error("unencrypted PEM should be returned as-is")
+	}
+}
+
+type smithyFakeValidation struct{}
+
+func (e *smithyFakeValidation) Error() string                 { return "ValidationException" }
+func (e *smithyFakeValidation) ErrorCode() string             { return "ValidationException" }
+func (e *smithyFakeValidation) ErrorMessage() string          { return "certificate is not exportable" }
+func (e *smithyFakeValidation) ErrorFault() smithy.ErrorFault { return smithy.FaultClient }

+ 109 - 0
providers/v1/aws/certificatemanager/fake/fake.go

@@ -0,0 +1,109 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package fake implements mocks for AWS Certificate Manager service clients.
+package fake
+
+import (
+	"context"
+
+	"github.com/aws/aws-sdk-go-v2/service/acm"
+	"github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
+)
+
+// ImportCertificateFn defines a function type for mocking ImportCertificate API.
+type ImportCertificateFn func(context.Context, *acm.ImportCertificateInput, ...func(*acm.Options)) (*acm.ImportCertificateOutput, error)
+
+// DeleteCertificateFn defines a function type for mocking DeleteCertificate API.
+type DeleteCertificateFn func(context.Context, *acm.DeleteCertificateInput, ...func(*acm.Options)) (*acm.DeleteCertificateOutput, error)
+
+// DescribeCertificateFn defines a function type for mocking DescribeCertificate API.
+type DescribeCertificateFn func(context.Context, *acm.DescribeCertificateInput, ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error)
+
+// ExportCertificateFn defines a function type for mocking ExportCertificate API.
+type ExportCertificateFn func(context.Context, *acm.ExportCertificateInput, ...func(*acm.Options)) (*acm.ExportCertificateOutput, error)
+
+// GetCertificateFn defines a function type for mocking GetCertificate API.
+type GetCertificateFn func(context.Context, *acm.GetCertificateInput, ...func(*acm.Options)) (*acm.GetCertificateOutput, error)
+
+// AddTagsToCertificateFn defines a function type for mocking AddTagsToCertificate API.
+type AddTagsToCertificateFn func(context.Context, *acm.AddTagsToCertificateInput, ...func(*acm.Options)) (*acm.AddTagsToCertificateOutput, error)
+
+// ListTagsForCertificateFn defines a function type for mocking ListTagsForCertificate API.
+type ListTagsForCertificateFn func(context.Context, *acm.ListTagsForCertificateInput, ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error)
+
+// RemoveTagsFromCertificateFn defines a function type for mocking RemoveTagsFromCertificate API.
+type RemoveTagsFromCertificateFn func(context.Context, *acm.RemoveTagsFromCertificateInput, ...func(*acm.Options)) (*acm.RemoveTagsFromCertificateOutput, error)
+
+// Client implements the ACM interface for testing.
+type Client struct {
+	ImportCertificateFn         ImportCertificateFn
+	DeleteCertificateFn         DeleteCertificateFn
+	DescribeCertificateFn       DescribeCertificateFn
+	ExportCertificateFn         ExportCertificateFn
+	GetCertificateFn            GetCertificateFn
+	AddTagsToCertificateFn      AddTagsToCertificateFn
+	ListTagsForCertificateFn    ListTagsForCertificateFn
+	RemoveTagsFromCertificateFn RemoveTagsFromCertificateFn
+}
+
+func (c *Client) ImportCertificate(ctx context.Context, input *acm.ImportCertificateInput, opts ...func(*acm.Options)) (*acm.ImportCertificateOutput, error) {
+	return c.ImportCertificateFn(ctx, input, opts...)
+}
+
+func (c *Client) DeleteCertificate(ctx context.Context, input *acm.DeleteCertificateInput, opts ...func(*acm.Options)) (*acm.DeleteCertificateOutput, error) {
+	return c.DeleteCertificateFn(ctx, input, opts...)
+}
+
+func (c *Client) DescribeCertificate(ctx context.Context, input *acm.DescribeCertificateInput, opts ...func(*acm.Options)) (*acm.DescribeCertificateOutput, error) {
+	return c.DescribeCertificateFn(ctx, input, opts...)
+}
+
+func (c *Client) ExportCertificate(ctx context.Context, input *acm.ExportCertificateInput, opts ...func(*acm.Options)) (*acm.ExportCertificateOutput, error) {
+	return c.ExportCertificateFn(ctx, input, opts...)
+}
+
+func (c *Client) GetCertificate(ctx context.Context, input *acm.GetCertificateInput, opts ...func(*acm.Options)) (*acm.GetCertificateOutput, error) {
+	return c.GetCertificateFn(ctx, input, opts...)
+}
+
+func (c *Client) AddTagsToCertificate(ctx context.Context, input *acm.AddTagsToCertificateInput, opts ...func(*acm.Options)) (*acm.AddTagsToCertificateOutput, error) {
+	return c.AddTagsToCertificateFn(ctx, input, opts...)
+}
+
+func (c *Client) ListTagsForCertificate(ctx context.Context, input *acm.ListTagsForCertificateInput, opts ...func(*acm.Options)) (*acm.ListTagsForCertificateOutput, error) {
+	return c.ListTagsForCertificateFn(ctx, input, opts...)
+}
+
+func (c *Client) RemoveTagsFromCertificate(ctx context.Context, input *acm.RemoveTagsFromCertificateInput, opts ...func(*acm.Options)) (*acm.RemoveTagsFromCertificateOutput, error) {
+	return c.RemoveTagsFromCertificateFn(ctx, input, opts...)
+}
+
+// GetResourcesFn defines a function type for mocking GetResources API.
+type GetResourcesFn func(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error)
+
+// RgtClient implements the ResourceGetter interface for testing.
+type RgtClient struct {
+	GetResourcesFn GetResourcesFn
+}
+
+func (c *RgtClient) GetResources(
+	ctx context.Context,
+	input *resourcegroupstaggingapi.GetResourcesInput,
+	opts ...func(*resourcegroupstaggingapi.Options),
+) (*resourcegroupstaggingapi.GetResourcesOutput, error) {
+	return c.GetResourcesFn(ctx, input, opts...)
+}

+ 72 - 0
providers/v1/aws/certificatemanager/resolver.go

@@ -0,0 +1,72 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package certificatemanager implements AWS Certificate Manager provider for External Secrets Operator
+package certificatemanager
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"os"
+
+	"github.com/aws/aws-sdk-go-v2/service/acm"
+	"github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
+	smithyendpoints "github.com/aws/smithy-go/endpoints"
+)
+
+const (
+	// ACMEndpointEnv is the environment variable for specifying a custom ACM endpoint.
+	ACMEndpointEnv = "AWS_ACM_ENDPOINT"
+	// ResourceGroupsTaggingAPIEndpointEnv is the environment variable for specifying a custom Resource Groups Tagging API endpoint.
+	ResourceGroupsTaggingAPIEndpointEnv = "AWS_RESOURCE_GROUPS_TAGGING_API_ENDPOINT"
+)
+
+// customEndpointResolver is a custom resolver for AWS Certificate Manager endpoint.
+type customEndpointResolver struct{}
+
+// ResolveEndpoint resolves the endpoint for the Certificate Manager service.
+func (c customEndpointResolver) ResolveEndpoint(ctx context.Context, params acm.EndpointParameters) (smithyendpoints.Endpoint, error) {
+	endpoint := smithyendpoints.Endpoint{}
+	if v := os.Getenv(ACMEndpointEnv); v != "" {
+		url, err := url.Parse(v)
+		if err != nil {
+			return endpoint, fmt.Errorf("failed to parse acm endpoint %s: %w", v, err)
+		}
+		endpoint.URI = *url
+		return endpoint, nil
+	}
+	defaultResolver := acm.NewDefaultEndpointResolverV2()
+	return defaultResolver.ResolveEndpoint(ctx, params)
+}
+
+// customTaggingEndpointResolver is a custom resolver for the AWS Resource Groups Tagging API endpoint.
+type customTaggingEndpointResolver struct{}
+
+// ResolveEndpoint resolves the endpoint for the Resource Groups Tagging API.
+func (c customTaggingEndpointResolver) ResolveEndpoint(ctx context.Context, params resourcegroupstaggingapi.EndpointParameters) (smithyendpoints.Endpoint, error) {
+	endpoint := smithyendpoints.Endpoint{}
+	if v := os.Getenv(ResourceGroupsTaggingAPIEndpointEnv); v != "" {
+		url, err := url.Parse(v)
+		if err != nil {
+			return endpoint, fmt.Errorf("failed to parse resource groups tagging api endpoint %s: %w", v, err)
+		}
+		endpoint.URI = *url
+		return endpoint, nil
+	}
+	defaultResolver := resourcegroupstaggingapi.NewDefaultEndpointResolverV2()
+	return defaultResolver.ResolveEndpoint(ctx, params)
+}

+ 5 - 0
providers/v1/aws/go.mod

@@ -6,6 +6,8 @@ require (
 	github.com/aws/aws-sdk-go-v2 v1.41.6
 	github.com/aws/aws-sdk-go-v2/config v1.31.19
 	github.com/aws/aws-sdk-go-v2/credentials v1.18.23
+	github.com/aws/aws-sdk-go-v2/service/acm v1.37.13
+	github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.30.13
 	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.12
 	github.com/aws/aws-sdk-go-v2/service/ssm v1.67.1
 	github.com/aws/aws-sdk-go-v2/service/sts v1.40.1
@@ -14,9 +16,11 @@ require (
 	github.com/external-secrets/external-secrets/runtime v0.0.0
 	github.com/google/go-cmp v0.7.0
 	github.com/google/uuid v1.6.0
+	github.com/hashicorp/golang-lru/v2 v2.0.7
 	github.com/stretchr/testify v1.11.1
 	github.com/tidwall/gjson v1.18.0
 	github.com/tidwall/sjson v1.2.5
+	github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78
 	k8s.io/api v0.35.2
 	k8s.io/apiextensions-apiserver v0.35.2
 	k8s.io/apimachinery v0.35.2
@@ -62,6 +66,7 @@ require (
 	github.com/goccy/go-json v0.10.5 // indirect
 	github.com/google/btree v1.1.3 // indirect
 	github.com/google/gnostic-models v0.7.1 // indirect
+	github.com/hashicorp/golang-lru v1.0.2 // indirect
 	github.com/huandu/xstrings v1.5.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/lestrrat-go/blackmagic v1.0.4 // indirect

+ 8 - 0
providers/v1/aws/go.sum

@@ -18,10 +18,14 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7Fj
 github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
+github.com/aws/aws-sdk-go-v2/service/acm v1.37.13 h1:5AUX6KOEBF20wiIaiI4fKHvVkELEptyLtRTbWGpNQNY=
+github.com/aws/aws-sdk-go-v2/service/acm v1.37.13/go.mod h1:v8E4cAu0qIxpS7IokQilQb60A8IODPxo82VxVtJ+Dgo=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
 github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
+github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.30.13 h1:bgZMYv1wMOsV1ug0/Hx/rs4fxbcFVipvOAwgsbhIons=
+github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.30.13/go.mod h1:+nL0z6xUm9NK9bOAkam66NQDgNH8Qa6T6SS3f8dkzXU=
 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.12 h1:xN4mw6Gqim0jMwjmlNST+yXVShFPwSAjt4gXqi43W6I=
 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.39.12/go.mod h1:QgVIY03/XoQs2iFr0MbQuQ/Tf1RwlkOvuySWMh1wph4=
 github.com/aws/aws-sdk-go-v2/service/ssm v1.67.1 h1:Zl+dJQSS5RogzWXBdS3eo5aVeHm/se5BGR1JrcIU+pA=
@@ -113,6 +117,10 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/v
 github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
+github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
 github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=

+ 51 - 5
providers/v1/aws/provider.go

@@ -15,7 +15,7 @@ limitations under the License.
 */
 
 // Package aws implements AWS provider interfaces for External Secrets Operator,
-// supporting SecretManager and ParameterStore services.
+// supporting SecretsManager, ParameterStore, and CertificateManager services.
 package aws
 
 import (
@@ -26,6 +26,7 @@ import (
 	"github.com/aws/aws-sdk-go-v2/aws"
 	"github.com/aws/aws-sdk-go-v2/aws/retry"
 	"github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/service/acm"
 	awssm "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
 	"github.com/aws/aws-sdk-go-v2/service/ssm"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -33,9 +34,11 @@ import (
 
 	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
 	awsauth "github.com/external-secrets/external-secrets/providers/v1/aws/auth"
+	"github.com/external-secrets/external-secrets/providers/v1/aws/certificatemanager"
 	"github.com/external-secrets/external-secrets/providers/v1/aws/parameterstore"
 	"github.com/external-secrets/external-secrets/providers/v1/aws/secretsmanager"
 	awsutil "github.com/external-secrets/external-secrets/providers/v1/aws/util"
+	"github.com/external-secrets/external-secrets/runtime/cache"
 	"github.com/external-secrets/external-secrets/runtime/esutils"
 )
 
@@ -43,7 +46,9 @@ import (
 var _ esv1.Provider = &Provider{}
 
 // Provider satisfies the provider interface.
-type Provider struct{}
+type Provider struct {
+	acmClientCache *cache.Cache[*certificatemanager.CertificateManager]
+}
 
 const (
 	errUnableCreateSession    = "unable to create session: %w"
@@ -51,6 +56,7 @@ const (
 	errRegionNotFound         = "region not found: %s"
 	errInitAWSProvider        = "unable to initialize aws provider: %s"
 	errInvalidSecretsManager  = "invalid SecretsManager settings: %s"
+	acmClientCacheSize        = 100
 )
 
 // Capabilities return the provider supported capabilities (ReadOnly, WriteOnly, ReadWrite).
@@ -60,7 +66,7 @@ func (p *Provider) Capabilities() esv1.SecretStoreCapabilities {
 
 // NewClient constructs a new secrets client based on the provided store.
 func (p *Provider) NewClient(ctx context.Context, store esv1.GenericStore, kube client.Client, namespace string) (esv1.SecretsClient, error) {
-	return newClient(ctx, store, kube, namespace, awsauth.DefaultSTSProvider)
+	return p.newClient(ctx, store, kube, namespace, awsauth.DefaultSTSProvider)
 }
 
 // ValidateStore validates the configuration of the AWS SecretStore.
@@ -123,6 +129,15 @@ func validateRegion(prov *esv1.AWSProvider) error {
 			return fmt.Errorf(errRegionNotFound, prov.Region)
 		}
 		return nil
+	case esv1.AWSServiceCertificateManager:
+		resolver := acm.NewDefaultEndpointResolverV2()
+		_, err := resolver.ResolveEndpoint(context.TODO(), acm.EndpointParameters{
+			Region: &prov.Region,
+		})
+		if err != nil {
+			return fmt.Errorf(errRegionNotFound, prov.Region)
+		}
+		return nil
 	}
 	return fmt.Errorf(errUnknownProviderService, prov.Service)
 }
@@ -137,7 +152,7 @@ func validateSecretsManagerConfig(prov *esv1.AWSProvider) error {
 	})
 }
 
-func newClient(ctx context.Context, store esv1.GenericStore, kube client.Client, namespace string, assumeRoler awsauth.STSProvider) (esv1.SecretsClient, error) {
+func (p *Provider) newClient(ctx context.Context, store esv1.GenericStore, kube client.Client, namespace string, assumeRoler awsauth.STSProvider) (esv1.SecretsClient, error) {
 	prov, err := awsutil.GetAWSProvider(store)
 	if err != nil {
 		return nil, err
@@ -170,6 +185,8 @@ func newClient(ctx context.Context, store esv1.GenericStore, kube client.Client,
 			return secretsmanager.New(ctx, &cfg, prov.SecretsManager, storeSpec.Provider.AWS.Prefix, true, kube, namespace)
 		case esv1.AWSServiceParameterStore:
 			return parameterstore.New(ctx, &cfg, storeSpec.Provider.AWS.Prefix, true)
+		case esv1.AWSServiceCertificateManager:
+			return p.newACMClient(ctx, store, namespace, &cfg, storeSpec.Provider.AWS.Prefix, true)
 		}
 		return nil, fmt.Errorf(errUnknownProviderService, prov.Service)
 	}
@@ -225,10 +242,37 @@ func newClient(ctx context.Context, store esv1.GenericStore, kube client.Client,
 		return secretsmanager.New(ctx, cfg, prov.SecretsManager, storeSpec.Provider.AWS.Prefix, false, kube, namespace)
 	case esv1.AWSServiceParameterStore:
 		return parameterstore.New(ctx, cfg, storeSpec.Provider.AWS.Prefix, false)
+	case esv1.AWSServiceCertificateManager:
+		return p.newACMClient(ctx, store, namespace, cfg, storeSpec.Provider.AWS.Prefix, false)
 	}
 	return nil, fmt.Errorf(errUnknownProviderService, prov.Service)
 }
 
+func (p *Provider) newACMClient(ctx context.Context, store esv1.GenericStore, namespace string, cfg *aws.Config, prefix string, referentAuth bool) (*certificatemanager.CertificateManager, error) {
+	if p.acmClientCache == nil {
+		return certificatemanager.New(ctx, cfg, prefix, referentAuth)
+	}
+
+	key := cache.Key{
+		Name:      store.GetObjectMeta().Name,
+		Namespace: namespace,
+		Kind:      store.GetTypeMeta().Kind,
+	}
+
+	if cachedClient, ok := p.acmClientCache.Get(store.GetObjectMeta().ResourceVersion, key); ok {
+		return cachedClient, nil
+	}
+
+	client, err := certificatemanager.New(ctx, cfg, prefix, referentAuth)
+	if err != nil {
+		return nil, err
+	}
+
+	p.acmClientCache.Add(store.GetObjectMeta().ResourceVersion, key, client)
+
+	return client, nil
+}
+
 // buildSessionTags constructs a list of session tags for the AWS STS session.
 // It always includes the esoNamespace and esoStoreName tags.
 // When customTags is provided (Custom mode), those are merged in as well.
@@ -266,7 +310,9 @@ func (f fixedDelayer) BackoffDelay(int, error) (time.Duration, error) {
 
 // NewProvider creates a new AWS Provider instance.
 func NewProvider() esv1.Provider {
-	return &Provider{}
+	return &Provider{
+		acmClientCache: cache.Must[*certificatemanager.CertificateManager](acmClientCacheSize, nil),
+	}
 }
 
 // ProviderSpec returns the provider specification for registration.

+ 18 - 2
providers/v1/aws/provider_test.go

@@ -38,7 +38,7 @@ import (
 
 func TestProvider(t *testing.T) {
 	cl := clientfake.NewClientBuilder().Build()
-	p := Provider{}
+	p := NewProvider().(*Provider)
 
 	// inject fake static credentials because we test
 	// if we are able to get credentials when constructing the client
@@ -471,6 +471,21 @@ func TestValidateStore(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "valid region certificate manager",
+			args: args{
+				store: &esv1.SecretStore{
+					Spec: esv1.SecretStoreSpec{
+						Provider: &esv1.SecretStoreProvider{
+							AWS: &esv1.AWSProvider{
+								Region:  validRegion,
+								Service: esv1.AWSServiceCertificateManager,
+							},
+						},
+					},
+				},
+			},
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -525,7 +540,8 @@ func TestValidRetryInput(t *testing.T) {
 	}).Build()
 	provider := func(*aws.Config) awsauth.STSprovider { return nil }
 
-	_, err := newClient(ctx, spec, kube, "default", provider)
+	p := NewProvider().(*Provider)
+	_, err := p.newClient(ctx, spec, kube, "default", provider)
 
 	if !ErrorContains(err, expected) {
 		t.Errorf("CheckValidRetryInput unexpected error: %s, expected: '%s'", err.Error(), expected)

+ 13 - 1
runtime/constants/constants.go

@@ -19,6 +19,18 @@ package constants
 
 // These constants are used for identifying providers and calls to them.
 const (
+	ProviderAWSACM                      = "AWS/CertificateManager"
+	CallAWSACMImportCertificate         = "ImportCertificate"
+	CallAWSACMDeleteCertificate         = "DeleteCertificate"
+	CallAWSACMListCertificates          = "ListCertificates"
+	CallAWSACMAddTagsToCertificate      = "AddTagsToCertificate"
+	CallAWSACMListTagsForCertificate    = "ListTagsForCertificate"
+	CallAWSACMRemoveTagsFromCertificate = "RemoveTagsFromCertificate"
+	CallAWSACMDescribeCertificate       = "DescribeCertificate"
+	CallAWSACMExportCertificate         = "ExportCertificate"
+	CallAWSACMGetCertificate            = "GetCertificate"
+	CallAWSACMGetResources              = "GetResources"
+
 	ProviderAWSSM                         = "AWS/SecretsManager"
 	CallAWSSMGetSecretValue               = "GetSecretValue"
 	CallAWSPSGetParametersByPath          = "GetParametersByPath"
@@ -35,8 +47,8 @@ const (
 	CallAWSSMDeleteResourcePolicy         = "DeleteResourcePolicy"
 	CallAWSSMReplicateSecretToRegions     = "ReplicateSecretToRegions"
 	CallAWSSMRemoveRegionsFromReplication = "RemoveRegionsFromReplication"
-	ProviderAWSPS                         = "AWS/ParameterStore"
 
+	ProviderAWSPS                = "AWS/ParameterStore"
 	CallAWSPSGetParameter        = "GetParameter"
 	CallAWSPSPutParameter        = "PutParameter"
 	CallAWSPSDeleteParameter     = "DeleteParameter"

+ 25 - 0
terraform/aws/infrastructure/modules/cluster/irsa.tf

@@ -35,6 +35,24 @@ data "aws_iam_policy_document" "ssm_parameterstore" {
   }
 }
 
+# Create the IAM policy document for ACM access
+data "aws_iam_policy_document" "acm" {
+  statement {
+    actions = [
+      "acm:GetCertificate",
+      "acm:ImportCertificate",
+      "acm:DeleteCertificate",
+      "acm:DescribeCertificate",
+      "acm:ListTagsForCertificate",
+      "acm:AddTagsToCertificate",
+      "acm:RemoveTagsFromCertificate",
+      "tag:GetResources"
+    ]
+    effect    = "Allow"
+    resources = ["*"]
+  }
+}
+
 resource "aws_iam_role" "eso-e2e-irsa" {
   name               = "eso-e2e-irsa"
   path               = "/"
@@ -53,3 +71,10 @@ resource "aws_iam_role_policy" "ssm_parameterstore" {
   role   = aws_iam_role.eso-e2e-irsa.id
   policy = data.aws_iam_policy_document.ssm_parameterstore.json
 }
+
+# Create and attach the inline policy for ACM
+resource "aws_iam_role_policy" "acm" {
+  name   = "aws_acm"
+  role   = aws_iam_role.eso-e2e-irsa.id
+  policy = data.aws_iam_policy_document.acm.json
+}

+ 1 - 1
tests/__snapshot__/clustersecretstore-v1.yaml

@@ -80,7 +80,7 @@ spec:
       secretsManager:
         forceDeleteWithoutRecovery: true
         recoveryWindowInDays: 1
-      service: "SecretsManager" # "SecretsManager", "ParameterStore"
+      service: "SecretsManager" # "SecretsManager", "ParameterStore", "CertificateManager"
       sessionTags:
       - key: string
         value: string

+ 1 - 1
tests/__snapshot__/secretstore-v1.yaml

@@ -80,7 +80,7 @@ spec:
       secretsManager:
         forceDeleteWithoutRecovery: true
         recoveryWindowInDays: 1
-      service: "SecretsManager" # "SecretsManager", "ParameterStore"
+      service: "SecretsManager" # "SecretsManager", "ParameterStore", "CertificateManager"
       sessionTags:
       - key: string
         value: string