Browse Source

feat: add ngrok provider (#5160)

* feat: Add ngrok provider

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* chore: Update stability support

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* Update pkg/provider/ngrok/provider.go

Co-authored-by: Gustavo Fernandes de Carvalho <17139678+gusfcarvalho@users.noreply.github.com>
Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* feat(ngrok): Move to a more flexible auth

As suggested during code review, our API only supports key based authentication, but let's leave it flexible in case that changes in the future

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* fix(ngrok): Remove unneeded ctx.Done()

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* refactor(ngrok): Only marshall the whole secret if the secret key is not provided

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* refactor(ngrok): Reduce error nesting

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* refactor(ngrok): Make vault configuration more easily changed later

I'm not sure if we'll need to support finding vaults by things like identifiers in the future or not

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* fix(ngrok): Parse push secret metadata correctly

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* tests(ngrok): Add more tests

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* Update pkg/provider/ngrok/client.go

Co-authored-by: Jakob Möller <contact@jakob-moeller.com>
Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* fix(ngrok): Consider secrets deleted if they no longer exist

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* refactor(ngrok): Reduce error nesting again

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* chore: Upgrade ngrok-api-go version

This newer version supports getting secrets scoped to a particular vault

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* refactor(ngrok): Cache the vault ID

Cache the vault ID to reduce the number of API calls needed to be made when pushing secrets.
Also, change the tests to ginkgo as it makes some of the more complex
scenairos easier to test.

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* feat(ngrok): Add codeowners entry for ngrok provider

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* fix(ngrok): Update license for check

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* fix(ngrok): Address code review feedback

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* refactor(ngrok): Remove defensive guard checks

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* fix(ngrok): Address code review feedback pt.2

Address more code review comments:
* Add a context with timeout for list calls
* Remove redundant set for vault ID
* Add //+optional code marker

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* fix(ngrok): re-generate docs to fix diff

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>

* run make test.crds.update

Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>

---------

Signed-off-by: Jonathan Stacks <jonstacks@users.noreply.github.com>
Signed-off-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Co-authored-by: Gustavo Fernandes de Carvalho <17139678+gusfcarvalho@users.noreply.github.com>
Co-authored-by: Jakob Möller <contact@jakob-moeller.com>
Co-authored-by: Gergely Brautigam <skarlso777@gmail.com>
Co-authored-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Jonathan Stacks 6 months ago
parent
commit
a7e4dfa470

+ 1 - 0
CODEOWNERS.md

@@ -41,6 +41,7 @@ pkg/provider/ibm/             @external-secrets/provider-ibm-reviewers
 pkg/provider/infisical/       @external-secrets/provider-infisical-reviewers
 pkg/provider/keepersecurity/  @external-secrets/provider-keepersecurity-reviewers
 pkg/provider/kubernetes/      @external-secrets/provider-kubernetes-reviewers
+pkg/provider/ngrok/           @external-secrets/provider-ngrok-reviewers
 pkg/provider/onboardbase/     @external-secrets/provider-onboardbase-reviewers
 pkg/provider/onepassword/     @external-secrets/provider-onepassword-reviewers
 pkg/provider/onepasswordsdk/  @external-secrets/provider-onepasswordsdk-reviewers

+ 57 - 0
apis/externalsecrets/v1/secretstore_ngrok_types.go

@@ -0,0 +1,57 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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 v1
+
+import (
+	esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
+)
+
+// NgrokProvider configures a store to sync secrets with a ngrok vault to use in traffic policies.
+// See: https://ngrok.com/blog-post/secrets-for-traffic-policy
+type NgrokProvider struct {
+	// APIURL is the URL of the ngrok API.
+	// +kubebuilder:default="https://api.ngrok.com"
+	APIURL string `json:"apiUrl,omitempty"`
+
+	// Auth configures how the ngrok provider authenticates with the ngrok API.
+	// +kubebuilder:validation:Required
+	Auth NgrokAuth `json:"auth"`
+
+	// Vault configures the ngrok vault to sync secrets with.
+	// +kubebuilder:validation:Required
+	Vault NgrokVault `json:"vault"`
+}
+
+// +kubebuilder:validation:MinProperties=1
+// +kubebuilder:validation:MaxProperties=1
+type NgrokAuth struct {
+	// APIKey is the API Key used to authenticate with ngrok. See https://ngrok.com/docs/api/#authentication
+	// +optional
+	APIKey *NgrokProviderSecretRef `json:"apiKey,omitempty"`
+}
+
+type NgrokVault struct {
+	// Name is the name of the ngrok vault to sync secrets with.
+	// +kubebuilder:validation:Required
+	Name string `json:"name"`
+}
+
+type NgrokProviderSecretRef struct {
+	// SecretRef is a reference to a secret containing the ngrok API key.
+	// +optional
+	SecretRef *esmeta.SecretKeySelector `json:"secretRef,omitempty"`
+}

+ 3 - 0
apis/externalsecrets/v1/secretstore_types.go

@@ -215,6 +215,9 @@ type SecretStoreProvider struct {
 	// Volcengine configures this store to sync secrets using the Volcengine provider
 	// +optional
 	Volcengine *VolcengineProvider `json:"volcengine,omitempty"`
+	// Ngrok configures this store to sync secrets using the ngrok provider.
+	// +optional
+	Ngrok *NgrokProvider `json:"ngrok,omitempty"`
 }
 
 type CAProviderType string

+ 77 - 0
apis/externalsecrets/v1/zz_generated.deepcopy.go

@@ -2522,6 +2522,78 @@ func (in *NTLMProtocol) DeepCopy() *NTLMProtocol {
 }
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NgrokAuth) DeepCopyInto(out *NgrokAuth) {
+	*out = *in
+	if in.APIKey != nil {
+		in, out := &in.APIKey, &out.APIKey
+		*out = new(NgrokProviderSecretRef)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NgrokAuth.
+func (in *NgrokAuth) DeepCopy() *NgrokAuth {
+	if in == nil {
+		return nil
+	}
+	out := new(NgrokAuth)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NgrokProvider) DeepCopyInto(out *NgrokProvider) {
+	*out = *in
+	in.Auth.DeepCopyInto(&out.Auth)
+	out.Vault = in.Vault
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NgrokProvider.
+func (in *NgrokProvider) DeepCopy() *NgrokProvider {
+	if in == nil {
+		return nil
+	}
+	out := new(NgrokProvider)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NgrokProviderSecretRef) DeepCopyInto(out *NgrokProviderSecretRef) {
+	*out = *in
+	if in.SecretRef != nil {
+		in, out := &in.SecretRef, &out.SecretRef
+		*out = new(apismetav1.SecretKeySelector)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NgrokProviderSecretRef.
+func (in *NgrokProviderSecretRef) DeepCopy() *NgrokProviderSecretRef {
+	if in == nil {
+		return nil
+	}
+	out := new(NgrokProviderSecretRef)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NgrokVault) DeepCopyInto(out *NgrokVault) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NgrokVault.
+func (in *NgrokVault) DeepCopy() *NgrokVault {
+	if in == nil {
+		return nil
+	}
+	out := new(NgrokVault)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *NoSecretError) DeepCopyInto(out *NoSecretError) {
 	*out = *in
 }
@@ -3309,6 +3381,11 @@ func (in *SecretStoreProvider) DeepCopyInto(out *SecretStoreProvider) {
 		*out = new(VolcengineProvider)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.Ngrok != nil {
+		in, out := &in.Ngrok, &out.Ngrok
+		*out = new(NgrokProvider)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretStoreProvider.

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

@@ -3370,6 +3370,69 @@ spec:
                             type: string
                         type: object
                     type: object
+                  ngrok:
+                    description: Ngrok configures this store to sync secrets using
+                      the ngrok provider.
+                    properties:
+                      apiUrl:
+                        default: https://api.ngrok.com
+                        description: APIURL is the URL of the ngrok API.
+                        type: string
+                      auth:
+                        description: Auth configures how the ngrok provider authenticates
+                          with the ngrok API.
+                        maxProperties: 1
+                        minProperties: 1
+                        properties:
+                          apiKey:
+                            description: APIKey is the API Key used to authenticate
+                              with ngrok. See https://ngrok.com/docs/api/#authentication
+                            properties:
+                              secretRef:
+                                description: SecretRef is a reference to a secret
+                                  containing the ngrok API key.
+                                properties:
+                                  key:
+                                    description: |-
+                                      A key in the referenced Secret.
+                                      Some instances of this field may be defaulted, in others it may be required.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[-._a-zA-Z0-9]+$
+                                    type: string
+                                  name:
+                                    description: The name of the Secret resource being
+                                      referred to.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                  namespace:
+                                    description: |-
+                                      The namespace of the Secret resource being referred to.
+                                      Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                    maxLength: 63
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                    type: string
+                                type: object
+                            type: object
+                        type: object
+                      vault:
+                        description: Vault configures the ngrok vault to sync secrets
+                          with.
+                        properties:
+                          name:
+                            description: Name is the name of the ngrok vault to sync
+                              secrets with.
+                            type: string
+                        required:
+                        - name
+                        type: object
+                    required:
+                    - auth
+                    - vault
+                    type: object
                   onboardbase:
                     description: Onboardbase configures this store to sync secrets
                       using the Onboardbase provider

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

@@ -3370,6 +3370,69 @@ spec:
                             type: string
                         type: object
                     type: object
+                  ngrok:
+                    description: Ngrok configures this store to sync secrets using
+                      the ngrok provider.
+                    properties:
+                      apiUrl:
+                        default: https://api.ngrok.com
+                        description: APIURL is the URL of the ngrok API.
+                        type: string
+                      auth:
+                        description: Auth configures how the ngrok provider authenticates
+                          with the ngrok API.
+                        maxProperties: 1
+                        minProperties: 1
+                        properties:
+                          apiKey:
+                            description: APIKey is the API Key used to authenticate
+                              with ngrok. See https://ngrok.com/docs/api/#authentication
+                            properties:
+                              secretRef:
+                                description: SecretRef is a reference to a secret
+                                  containing the ngrok API key.
+                                properties:
+                                  key:
+                                    description: |-
+                                      A key in the referenced Secret.
+                                      Some instances of this field may be defaulted, in others it may be required.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[-._a-zA-Z0-9]+$
+                                    type: string
+                                  name:
+                                    description: The name of the Secret resource being
+                                      referred to.
+                                    maxLength: 253
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                    type: string
+                                  namespace:
+                                    description: |-
+                                      The namespace of the Secret resource being referred to.
+                                      Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                    maxLength: 63
+                                    minLength: 1
+                                    pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                    type: string
+                                type: object
+                            type: object
+                        type: object
+                      vault:
+                        description: Vault configures the ngrok vault to sync secrets
+                          with.
+                        properties:
+                          name:
+                            description: Name is the name of the ngrok vault to sync
+                              secrets with.
+                            type: string
+                        required:
+                        - name
+                        type: object
+                    required:
+                    - auth
+                    - vault
+                    type: object
                   onboardbase:
                     description: Onboardbase configures this store to sync secrets
                       using the Onboardbase provider

+ 112 - 0
deploy/crds/bundle.yaml

@@ -5185,6 +5185,62 @@ spec:
                               type: string
                           type: object
                       type: object
+                    ngrok:
+                      description: Ngrok configures this store to sync secrets using the ngrok provider.
+                      properties:
+                        apiUrl:
+                          default: https://api.ngrok.com
+                          description: APIURL is the URL of the ngrok API.
+                          type: string
+                        auth:
+                          description: Auth configures how the ngrok provider authenticates with the ngrok API.
+                          maxProperties: 1
+                          minProperties: 1
+                          properties:
+                            apiKey:
+                              description: APIKey is the API Key used to authenticate with ngrok. See https://ngrok.com/docs/api/#authentication
+                              properties:
+                                secretRef:
+                                  description: SecretRef is a reference to a secret containing the ngrok API key.
+                                  properties:
+                                    key:
+                                      description: |-
+                                        A key in the referenced Secret.
+                                        Some instances of this field may be defaulted, in others it may be required.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[-._a-zA-Z0-9]+$
+                                      type: string
+                                    name:
+                                      description: The name of the Secret resource being referred to.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                      type: string
+                                    namespace:
+                                      description: |-
+                                        The namespace of the Secret resource being referred to.
+                                        Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                      maxLength: 63
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                      type: string
+                                  type: object
+                              type: object
+                          type: object
+                        vault:
+                          description: Vault configures the ngrok vault to sync secrets with.
+                          properties:
+                            name:
+                              description: Name is the name of the ngrok vault to sync secrets with.
+                              type: string
+                          required:
+                            - name
+                          type: object
+                      required:
+                        - auth
+                        - vault
+                      type: object
                     onboardbase:
                       description: Onboardbase configures this store to sync secrets using the Onboardbase provider
                       properties:
@@ -16329,6 +16385,62 @@ spec:
                               type: string
                           type: object
                       type: object
+                    ngrok:
+                      description: Ngrok configures this store to sync secrets using the ngrok provider.
+                      properties:
+                        apiUrl:
+                          default: https://api.ngrok.com
+                          description: APIURL is the URL of the ngrok API.
+                          type: string
+                        auth:
+                          description: Auth configures how the ngrok provider authenticates with the ngrok API.
+                          maxProperties: 1
+                          minProperties: 1
+                          properties:
+                            apiKey:
+                              description: APIKey is the API Key used to authenticate with ngrok. See https://ngrok.com/docs/api/#authentication
+                              properties:
+                                secretRef:
+                                  description: SecretRef is a reference to a secret containing the ngrok API key.
+                                  properties:
+                                    key:
+                                      description: |-
+                                        A key in the referenced Secret.
+                                        Some instances of this field may be defaulted, in others it may be required.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[-._a-zA-Z0-9]+$
+                                      type: string
+                                    name:
+                                      description: The name of the Secret resource being referred to.
+                                      maxLength: 253
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+                                      type: string
+                                    namespace:
+                                      description: |-
+                                        The namespace of the Secret resource being referred to.
+                                        Ignored if referent is not cluster-scoped, otherwise defaults to the namespace of the referent.
+                                      maxLength: 63
+                                      minLength: 1
+                                      pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
+                                      type: string
+                                  type: object
+                              type: object
+                          type: object
+                        vault:
+                          description: Vault configures the ngrok vault to sync secrets with.
+                          properties:
+                            name:
+                              description: Name is the name of the ngrok vault to sync secrets with.
+                              type: string
+                          required:
+                            - name
+                          type: object
+                      required:
+                        - auth
+                        - vault
+                      type: object
                     onboardbase:
                       description: Onboardbase configures this store to sync secrets using the Onboardbase provider
                       properties:

+ 164 - 0
docs/api/spec.md

@@ -6815,6 +6815,156 @@ External Secrets meta/v1.SecretKeySelector
 </tr>
 </tbody>
 </table>
+<h3 id="external-secrets.io/v1.NgrokAuth">NgrokAuth
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.NgrokProvider">NgrokProvider</a>)
+</p>
+<p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>apiKey</code></br>
+<em>
+<a href="#external-secrets.io/v1.NgrokProviderSecretRef">
+NgrokProviderSecretRef
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>APIKey is the API Key used to authenticate with ngrok. See <a href="https://ngrok.com/docs/api/#authentication">https://ngrok.com/docs/api/#authentication</a></p>
+</td>
+</tr>
+</tbody>
+</table>
+<h3 id="external-secrets.io/v1.NgrokProvider">NgrokProvider
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.SecretStoreProvider">SecretStoreProvider</a>)
+</p>
+<p>
+<p>NgrokProvider configures a store to sync secrets with a ngrok vault to use in traffic policies.
+See: <a href="https://ngrok.com/blog-post/secrets-for-traffic-policy">https://ngrok.com/blog-post/secrets-for-traffic-policy</a></p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>apiUrl</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<p>APIURL is the URL of the ngrok API.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>auth</code></br>
+<em>
+<a href="#external-secrets.io/v1.NgrokAuth">
+NgrokAuth
+</a>
+</em>
+</td>
+<td>
+<p>Auth configures how the ngrok provider authenticates with the ngrok API.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>vault</code></br>
+<em>
+<a href="#external-secrets.io/v1.NgrokVault">
+NgrokVault
+</a>
+</em>
+</td>
+<td>
+<p>Vault configures the ngrok vault to sync secrets with.</p>
+</td>
+</tr>
+</tbody>
+</table>
+<h3 id="external-secrets.io/v1.NgrokProviderSecretRef">NgrokProviderSecretRef
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.NgrokAuth">NgrokAuth</a>)
+</p>
+<p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>secretRef</code></br>
+<em>
+<a href="https://pkg.go.dev/github.com/external-secrets/external-secrets/apis/meta/v1#SecretKeySelector">
+External Secrets meta/v1.SecretKeySelector
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>SecretRef is a reference to a secret containing the ngrok API key.</p>
+</td>
+</tr>
+</tbody>
+</table>
+<h3 id="external-secrets.io/v1.NgrokVault">NgrokVault
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.NgrokProvider">NgrokProvider</a>)
+</p>
+<p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>name</code></br>
+<em>
+string
+</em>
+</td>
+<td>
+<p>Name is the name of the ngrok vault to sync secrets with.</p>
+</td>
+</tr>
+</tbody>
+</table>
 <h3 id="external-secrets.io/v1.NoSecretError">NoSecretError
 </h3>
 <p>
@@ -8896,6 +9046,20 @@ VolcengineProvider
 <p>Volcengine configures this store to sync secrets using the Volcengine provider</p>
 </td>
 </tr>
+<tr>
+<td>
+<code>ngrok</code></br>
+<em>
+<a href="#external-secrets.io/v1.NgrokProvider">
+NgrokProvider
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Ngrok configures this store to sync secrets using the ngrok provider.</p>
+</td>
+</tr>
 </tbody>
 </table>
 <h3 id="external-secrets.io/v1.SecretStoreRef">SecretStoreRef

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

@@ -92,6 +92,7 @@ The following table describes the stability level of each provider and who's res
 | [Previder](https://external-secrets.io/latest/provider/previder)                                           | stable    | [@previder](https://github.com/previder)                                                            |
 | [Cloud.ru](https://external-secrets.io/latest/provider/cloudru)                                            | alpha     | [@default23](https://github.com/default23)                                                          |
 | [Volcengine](https://external-secrets.io/latest/provider/volcengine)                                       | alpha     | [@kevinyancn](https://github.com/kevinyancn)                                                        |
+| [ngrok](https://external-secrets.io/latest/provider/ngrok)                                                 | alpha     | [@jonstacks](https://github.com/jonstacks)                                                          |
 
 
 ## Provider Feature Support
@@ -131,6 +132,7 @@ The following table show the support for features across different providers.
 | Previder                  |      x       |              |                      |                         |        x         |             |                             |
 | Cloud.ru                  |      x       |      x       |                      |            x            |        x         |             |              x              |
 | Volcengine                |              |              |                      |                         |        x         |             |                             |
+| ngrok                     |              |              |                      |                         |        x         |      x      |                             |
 
 ## Support Policy
 

+ 29 - 0
docs/provider/ngrok.md

@@ -0,0 +1,29 @@
+## ngrok
+
+External Secrets Operator integrates with [ngrok](https://ngrok.com/) to sync Kubernetes secrets with [ngrok Secrets for Traffic Policy](https://ngrok.com/blog-post/secrets-for-traffic-policy).
+Currently, only pushing secrets is supported.
+
+### Configuring ngrok Provider
+
+Verify that `ngrok` provider is listed in the `Kind=SecretStore`. The properties `vault` and `auth` are required. The `apiURL` is optional and defaults to `https://api.ngrok.com`.
+
+
+```yaml
+{% include 'ngrok-secret-store.yaml' %}
+```
+
+### Pushing secrets to ngrok
+
+To sync a Kubernetes secret with an external ngrok secret we need to create a PushSecret, this means a `Kind=PushSecret` is needed.
+
+```yaml
+{% include 'ngrok-push-secret.yaml' %}:
+```
+
+#### PushSecret Metadata
+
+Additionally, you can control the description and metadata of the secret in ngrok like so:
+
+```yaml
+{% include 'ngrok-push-secret-with-metadata.yaml' %}
+```

+ 31 - 0
docs/snippets/ngrok-push-secret-with-metadata.yaml

@@ -0,0 +1,31 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: ngrok-push-secret-example
+spec:
+  deletionPolicy: Delete
+  refreshInterval: 10m # Refresh interval for which push secret will reconcile
+  secretStoreRefs: # A list of secret stores to push secrets to
+    - name: ngrok # Must match SecretStore on the cluster
+      kind: SecretStore
+  selector:
+    secret:
+      name: SECRET_NAME # Source Kubernetes secret to be pushed
+  data:
+    - match:
+        # The key in the Kubernetes secret to push. Leave empty to push all keys, JSON encoded.
+        # secretKey: ""
+        secretKey: MY_K8S_SECRET_KEY
+        remoteRef:
+          remoteKey: MY_NGROK_SECRET_NAME # The name of the secret in the ngrok vault
+      metadata:
+        apiVersion: kubernetes.external-secrets.io/v1alpha1
+        kind: PushSecretMetadata
+        spec:
+          # See https://ngrok.com/docs/api/resources/secrets/#parameters
+          # We currently support customizing the description and metadata for the secret.
+          description: "This is a secret for the API credentials"
+          # Metadata for the secret in the ngrok vault. This will be merged with auto-generated metadata.
+          metadata:
+            environment: production
+            team: devops

+ 20 - 0
docs/snippets/ngrok-push-secret.yaml

@@ -0,0 +1,20 @@
+apiVersion: external-secrets.io/v1alpha1
+kind: PushSecret
+metadata:
+  name: ngrok-push-secret-example
+spec:
+  deletionPolicy: Delete
+  refreshInterval: 10m # Refresh interval for which push secret will reconcile
+  secretStoreRefs: # A list of secret stores to push secrets to
+    - name: ngrok # Must match SecretStore on the cluster
+      kind: SecretStore
+  selector:
+    secret:
+      name: SECRET_NAME # Source Kubernetes secret to be pushed
+  data:
+    - match:
+        # The key in the Kubernetes secret to push. Leave empty to push all keys, JSON encoded.
+        # secretKey: ""
+        secretKey: MY_K8S_SECRET_KEY
+        remoteRef:
+          remoteKey: MY_NGROK_SECRET_NAME # The name of the secret in the ngrok vault

+ 15 - 0
docs/snippets/ngrok-secret-store.yaml

@@ -0,0 +1,15 @@
+apiVersion: external-secrets.io/v1
+kind: SecretStore
+metadata:
+  name: ngrok
+spec:
+  provider:
+    ngrok:
+      # apiURL: Default "https://api.ngrok.com", for enterprise ngrok instances uncomment and use your API URL.
+      auth:
+        apiKey:
+          secretRef:
+            name: ngrok-credentials
+            key: api-key
+      vault:
+        name: my-vault # Name of the ngrok vault to use for storing secrets

+ 1 - 0
go.mod

@@ -107,6 +107,7 @@ require (
 	github.com/keeper-security/secrets-manager-go/core v1.6.4
 	github.com/lestrrat-go/jwx/v2 v2.1.6
 	github.com/maxbrunsfeld/counterfeiter/v6 v6.12.0
+	github.com/ngrok/ngrok-api-go/v7 v7.6.0
 	github.com/passbolt/go-passbolt v0.7.2
 	github.com/previder/vault-cli v0.1.3
 	github.com/pulumi/esc-sdk/sdk v0.12.2

+ 2 - 0
go.sum

@@ -923,6 +923,8 @@ github.com/nats-io/nats.go v1.12.1/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/
 github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s=
 github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
 github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
+github.com/ngrok/ngrok-api-go/v7 v7.6.0 h1:DW9FqEgSN6+Dgl25O8ha1LS49CqX2c9vO0Z53CN8Vqs=
+github.com/ngrok/ngrok-api-go/v7 v7.6.0/go.mod h1:Si/pYAJmbCuo4Fb3xz0MF6N5ubRvPdUixETBwhFvBf0=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
 github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=

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

@@ -154,6 +154,7 @@ nav:
       - Previder: provider/previder.md
       - OpenBao: provider/openbao.md
       - Volcengine: provider/volcengine.md
+      - ngrok: provider/ngrok.md
   - Examples:
       - FluxCD: examples/gitops-using-fluxcd.md
       - Anchore Engine: examples/anchore-engine-credentials.md

+ 1 - 2
pkg/provider/github/client.go

@@ -145,8 +145,7 @@ func (g *Client) GetSecretMap(ctx context.Context, ref esv1.ExternalSecretDataRe
 	return nil, fmt.Errorf("not implemented - this provider supports write-only operations")
 }
 
-func (g *Client) Close(ctx context.Context) error {
-	ctx.Done()
+func (g *Client) Close(_ context.Context) error {
 	return nil
 }
 

+ 329 - 0
pkg/provider/ngrok/client.go

@@ -0,0 +1,329 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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 ngrok
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/ngrok/ngrok-api-go/v7"
+	corev1 "k8s.io/api/core/v1"
+	v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	"k8s.io/utils/ptr"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	"github.com/external-secrets/external-secrets/pkg/utils/metadata"
+)
+
+const (
+	defaultDescription = "Managed by External Secrets Operator"
+	defaultListTimeout = 1 * time.Minute
+)
+
+var (
+	errWriteOnlyOperations     = errors.New("not implemented - the ngrok provider only supports write operations")
+	errVaultDoesNotExist       = errors.New("vault does not exist")
+	errVaultSecretDoesNotExist = errors.New("vault secret does not exist")
+)
+
+type PushSecretMetadataSpec struct {
+	// The description of the secret in the ngrok API.
+	Description string `json:"description,omitempty"`
+	// Custom metadata to be merged with generated metadata for the secret in the ngrok API.
+	// This metadata is different from Kubernetes metadata.
+	Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+type VaultClient interface {
+	Create(context.Context, *ngrok.VaultCreate) (*ngrok.Vault, error)
+	Get(context.Context, string) (*ngrok.Vault, error)
+	GetSecretsByVault(string, *ngrok.Paging) ngrok.Iter[*ngrok.Secret]
+	List(*ngrok.Paging) ngrok.Iter[*ngrok.Vault]
+}
+
+type SecretsClient interface {
+	Create(context.Context, *ngrok.SecretCreate) (*ngrok.Secret, error)
+	Delete(context.Context, string) error
+	Get(context.Context, string) (*ngrok.Secret, error)
+	List(*ngrok.Paging) ngrok.Iter[*ngrok.Secret]
+	Update(context.Context, *ngrok.SecretUpdate) (*ngrok.Secret, error)
+}
+
+type client struct {
+	vaultClient   VaultClient
+	secretsClient SecretsClient
+	vaultName     string
+	vaultID       string
+	vaultIDMu     sync.RWMutex
+}
+
+func (c *client) PushSecret(ctx context.Context, secret *corev1.Secret, data esv1.PushSecretData) error {
+	// First, make sure the vault name still matches the ID we have stored. If not, we have to look it up again.
+	err := c.verifyVaultNameStillMatchesID(ctx)
+	if err != nil {
+		return fmt.Errorf("failed to verify vault name still matches ID: %w", err)
+	}
+
+	// Prepare the secret data for pushing
+	var value []byte
+
+	// If key is specified, get the value from the secret data
+	if data.GetSecretKey() != "" {
+		var ok bool
+		value, ok = secret.Data[data.GetSecretKey()]
+		if !ok {
+			return fmt.Errorf("key %s not found in secret", data.GetSecretKey())
+		}
+	} else { // otherwise, marshal the entire secret data as JSON
+		value, err = json.Marshal(secret.Data)
+		if err != nil {
+			return fmt.Errorf("json.Marshal failed with error: %w", err)
+		}
+	}
+
+	// Calculate the checksum of the value to add to metadata
+	valueChecksum := sha256.Sum256(value)
+
+	psmd, err := parseAndDefaultMetadata(data.GetMetadata())
+	if err != nil {
+		return fmt.Errorf("failed to parse push secret metadata: %w", err)
+	}
+
+	psmd.Metadata["_sha256"] = hex.EncodeToString(valueChecksum[:])
+	metadataJSON, err := json.Marshal(psmd.Metadata)
+	if err != nil {
+		return fmt.Errorf("failed to marshal metadata for ngrok api: %w", err)
+	}
+
+	// Check if the secret already exists in the vault
+	existingSecret, err := c.getSecretByVaultIDAndName(ctx, c.getVaultID(), data.GetRemoteKey())
+	if err != nil {
+		if !errors.Is(err, errVaultSecretDoesNotExist) {
+			return fmt.Errorf("failed to get secret: %w", err)
+		}
+
+		// If the secret does not exist, create it
+		_, err = c.secretsClient.Create(ctx, &ngrok.SecretCreate{
+			VaultID:     c.getVaultID(),
+			Name:        data.GetRemoteKey(),
+			Value:       string(value),
+			Metadata:    string(metadataJSON),
+			Description: psmd.Description,
+		})
+		return err
+	}
+
+	// If the secret exists, update it
+	_, err = c.secretsClient.Update(ctx, &ngrok.SecretUpdate{
+		ID:          existingSecret.ID,
+		Value:       ptr.To(string(value)),
+		Metadata:    ptr.To(string(metadataJSON)),
+		Description: ptr.To(psmd.Description),
+	})
+	return err
+}
+
+func (c *client) SecretExists(ctx context.Context, ref esv1.PushSecretRemoteRef) (bool, error) {
+	err := c.verifyVaultNameStillMatchesID(ctx)
+	if errors.Is(err, errVaultDoesNotExist) {
+		return false, nil
+	}
+	if err != nil {
+		return false, err
+	}
+
+	// Implementation for checking if a secret exists in ngrok
+	secret, err := c.getSecretByVaultIDAndName(ctx, c.getVaultID(), ref.GetRemoteKey())
+	if errors.Is(err, errVaultDoesNotExist) || errors.Is(err, errVaultSecretDoesNotExist) {
+		return false, nil
+	}
+
+	if err != nil {
+		return false, fmt.Errorf("error fetching secret: %w", err)
+	}
+
+	return (secret != nil), nil
+}
+
+// DeleteSecret deletes a secret from ngrok by its reference.
+func (c *client) DeleteSecret(ctx context.Context, ref esv1.PushSecretRemoteRef) error {
+	err := c.verifyVaultNameStillMatchesID(ctx)
+	if errors.Is(err, errVaultDoesNotExist) {
+		return nil
+	} else if err != nil {
+		return err
+	}
+
+	secret, err := c.getSecretByVaultIDAndName(ctx, c.getVaultID(), ref.GetRemoteKey())
+	if errors.Is(err, errVaultDoesNotExist) || errors.Is(err, errVaultSecretDoesNotExist) {
+		// If the secret or vault do not exist, we can consider it deleted.
+		return nil
+	}
+
+	if err != nil {
+		return err
+	}
+
+	if secret == nil {
+		return nil
+	}
+
+	return c.secretsClient.Delete(ctx, secret.ID)
+}
+
+func (c *client) Validate() (esv1.ValidationResult, error) {
+	// Validate the client can list secrets with a timeout. If we
+	// can list secrets, we assume the client is valid(API keys, URL, etc.)
+	iter := c.secretsClient.List(nil)
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	for iter.Next(ctx) {
+		return esv1.ValidationResultReady, nil
+	}
+
+	if iter.Err() != nil {
+		return esv1.ValidationResultError, fmt.Errorf("store is not allowed to list secrets: %w", iter.Err())
+	}
+
+	return esv1.ValidationResultReady, nil
+}
+
+func (c *client) GetSecret(ctx context.Context, _ esv1.ExternalSecretDataRemoteRef) ([]byte, error) {
+	// Implementation for getting a secret from ngrok
+	return nil, errWriteOnlyOperations
+}
+
+func (c *client) GetSecretMap(ctx context.Context, _ esv1.ExternalSecretDataRemoteRef) (map[string][]byte, error) {
+	// Implementation for getting a map of secrets from ngrok
+	return nil, errWriteOnlyOperations
+}
+
+func (c *client) GetAllSecrets(ctx context.Context, _ esv1.ExternalSecretFind) (map[string][]byte, error) {
+	// Implementation for getting all secrets from ngrok
+	return nil, errWriteOnlyOperations
+}
+
+func (c *client) Close(_ context.Context) error {
+	return nil
+}
+
+func (c *client) verifyVaultNameStillMatchesID(ctx context.Context) error {
+	vaultID := c.getVaultID()
+	if vaultID == "" {
+		return c.refreshVaultID(ctx)
+	}
+
+	vault, err := c.vaultClient.Get(ctx, vaultID)
+	if err != nil || vault.Name != c.vaultName {
+		return c.refreshVaultID(ctx)
+	}
+
+	return nil
+}
+
+// getVaultID safely retrieves the current vault ID.
+func (c *client) getVaultID() string {
+	c.vaultIDMu.RLock()
+	defer c.vaultIDMu.RUnlock()
+	return c.vaultID
+}
+
+// setVaultID safely sets the vault ID.
+func (c *client) setVaultID(vaultID string) {
+	c.vaultIDMu.Lock()
+	defer c.vaultIDMu.Unlock()
+	c.vaultID = vaultID
+}
+
+func (c *client) refreshVaultID(ctx context.Context) error {
+	v, err := c.getVaultByName(ctx, c.vaultName)
+	if err != nil {
+		return fmt.Errorf("failed to refresh vault ID: %w", err)
+	}
+
+	c.setVaultID(v.ID)
+	return nil
+}
+
+func (c *client) getVaultByName(ctx context.Context, name string) (*ngrok.Vault, error) {
+	listCtx, cancel := context.WithTimeout(ctx, defaultListTimeout)
+	defer cancel()
+
+	iter := c.vaultClient.List(nil)
+	for iter.Next(listCtx) {
+		vault := iter.Item()
+		if vault.Name == name {
+			return vault, nil
+		}
+	}
+
+	if iter.Err() != nil {
+		return nil, iter.Err()
+	}
+
+	return nil, errVaultDoesNotExist
+}
+
+// getSecretByVaultIDAndName retrieves a secret by its vault ID and secret name.
+func (c *client) getSecretByVaultIDAndName(ctx context.Context, vaultID, name string) (*ngrok.Secret, error) {
+	iter := c.vaultClient.GetSecretsByVault(vaultID, nil)
+	for iter.Next(ctx) {
+		secret := iter.Item()
+		if secret.Name == name {
+			return secret, nil
+		}
+	}
+
+	if iter.Err() != nil {
+		return nil, iter.Err()
+	}
+
+	return nil, fmt.Errorf("secret '%s' does not exist: %w", name, errVaultSecretDoesNotExist)
+}
+
+func parseAndDefaultMetadata(data *v1.JSON) (PushSecretMetadataSpec, error) {
+	def := PushSecretMetadataSpec{
+		Description: defaultDescription,
+		Metadata:    make(map[string]string),
+	}
+
+	res, err := metadata.ParseMetadataParameters[PushSecretMetadataSpec](data)
+	if err != nil {
+		return def, err
+	}
+
+	if res == nil {
+		return def, nil
+	}
+
+	if res.Spec.Description != "" {
+		def.Description = res.Spec.Description
+	}
+
+	if res.Spec.Metadata != nil {
+		def.Metadata = res.Spec.Metadata
+	}
+
+	return def, nil
+}

+ 539 - 0
pkg/provider/ngrok/client_test.go

@@ -0,0 +1,539 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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 ngrok
+
+import (
+	"encoding/json"
+	"errors"
+
+	"github.com/ngrok/ngrok-api-go/v7"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	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"
+	"github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
+	"github.com/external-secrets/external-secrets/pkg/provider/ngrok/fake"
+)
+
+type pushSecretRemoteRef struct {
+	remoteKey string
+	property  string
+}
+
+func (p pushSecretRemoteRef) GetRemoteKey() string {
+	return p.remoteKey
+}
+func (p pushSecretRemoteRef) GetProperty() string {
+	return p.property
+}
+
+type testClientOpts struct {
+	vaults         []*ngrok.Vault
+	secrets        []*ngrok.Secret
+	secretsListErr error
+	vaultName      string
+}
+
+type testClientOpt func(opts *testClientOpts)
+
+func WithVaults(vaults ...*ngrok.Vault) testClientOpt {
+	return func(opts *testClientOpts) {
+		opts.vaults = vaults
+	}
+}
+
+func WithSecrets(secrets ...*ngrok.Secret) testClientOpt {
+	return func(opts *testClientOpts) {
+		opts.secrets = secrets
+	}
+}
+
+func WithSecretsListError(err error) testClientOpt {
+	return func(opts *testClientOpts) {
+		opts.secretsListErr = err
+	}
+}
+
+func WithVaultName(vaultName string) testClientOpt {
+	return func(opts *testClientOpts) {
+		opts.vaultName = vaultName
+	}
+}
+
+var _ = Describe("client", func() {
+	var (
+		s         *fake.Store
+		c         *client
+		vaultName string
+
+		listVaultsErr  error
+		listSecretsErr error
+	)
+
+	BeforeEach(func() {
+		vaultName = "test-vault"
+		listSecretsErr = nil
+		listVaultsErr = nil
+		s = fake.NewStore()
+	})
+
+	JustBeforeEach(func() {
+		c = &client{
+			vaultClient:   s.VaultClient().WithListError(listVaultsErr),
+			secretsClient: s.SecretsClient().WithListError(listSecretsErr),
+			vaultName:     vaultName,
+		}
+	})
+
+	Describe("PushSecret", func() {
+		var (
+			k8Secret        *corev1.Secret
+			pushData        v1alpha1.PushSecretData
+			vault           *ngrok.Vault
+			secret          *ngrok.Secret
+			ngrokSecretName string
+
+			pushErr error
+		)
+
+		BeforeEach(func() {
+			ngrokSecretName = "secret-" + fake.GenerateRandomString(10)
+			k8Secret = &corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "my-secret",
+				},
+				Data: map[string][]byte{
+					"key": []byte("new value"),
+					"foo": []byte("bar"),
+				},
+			}
+			pushData = v1alpha1.PushSecretData{
+				Match: v1alpha1.PushSecretMatch{
+					SecretKey: "key",
+					RemoteRef: v1alpha1.PushSecretRemoteRef{
+						RemoteKey: ngrokSecretName,
+					},
+				},
+			}
+			vault = nil
+		})
+
+		JustBeforeEach(func(ctx SpecContext) {
+			if vault != nil {
+				// Set the client's vault ID. This is normally initialized by the provider's NewClient method.
+				c.setVaultID(vault.ID)
+			}
+			pushErr = c.PushSecret(ctx, k8Secret, pushData)
+		})
+
+		When("the vault exists", func() {
+			var (
+				getSecretErr error
+			)
+
+			BeforeEach(func(ctx SpecContext) {
+				var vaultCreateErr error
+				vault, vaultCreateErr = s.VaultClient().Create(ctx, &ngrok.VaultCreate{
+					Name: vaultName,
+				})
+				Expect(vaultCreateErr).ToNot(HaveOccurred())
+			})
+
+			// Re-fetch the secret after the push to verify it was updated
+			JustBeforeEach(func(ctx SpecContext) {
+				secret = nil
+				iter := s.SecretsClient().List(nil)
+				for iter.Next(ctx) {
+					if iter.Item().Name == ngrokSecretName && iter.Item().Vault.ID == vault.ID {
+						secret = iter.Item()
+						break
+					}
+				}
+				getSecretErr = iter.Err()
+			})
+
+			When("the secret does not exist", func() {
+				It("should not return an error", func(ctx SpecContext) {
+					Expect(pushErr).ToNot(HaveOccurred())
+				})
+
+				It("should create the ngrok secret", func(ctx SpecContext) {
+					Expect(getSecretErr).ToNot(HaveOccurred())
+					Expect(secret).ToNot(BeNil())
+					Expect(secret.Name).To(Equal(ngrokSecretName))
+					Expect(secret.ID).ToNot(BeEmpty())
+					Expect(secret.Description).To(Equal(defaultDescription))
+				})
+			})
+
+			When("the secret exists", func() {
+				BeforeEach(func(ctx SpecContext) {
+					var createErr error
+					secret, createErr = s.SecretsClient().Create(ctx, &ngrok.SecretCreate{
+						VaultID: vault.ID,
+						Name:    ngrokSecretName,
+						Value:   "old-value",
+					})
+					Expect(createErr).ToNot(HaveOccurred())
+				})
+
+				It("should not return an error", func(ctx SpecContext) {
+					Expect(pushErr).ToNot(HaveOccurred())
+				})
+
+				It("should update the ngrok secret description", func(ctx SpecContext) {
+					Expect(secret.Description).To(Equal(defaultDescription))
+				})
+
+				It("should update the ngrok secret metadata", func(ctx SpecContext) {
+					// The metadata should include the sha256 of the new value.
+					// sha256sum "new value" = 9c51d0b0f64dfb3662ed85ce945dd1e8f6130665c289754e4e9257a58013e61d
+					Expect(secret.Metadata).To(Equal(`{"_sha256":"9c51d0b0f64dfb3662ed85ce945dd1e8f6130665c289754e4e9257a58013e61d"}`))
+				})
+
+				When("The secret key is not specified on the push data", func() {
+					BeforeEach(func() {
+						pushData.Match.SecretKey = ""
+					})
+
+					It("should marshal the entire secret data as JSON", func(ctx SpecContext) {
+						data := map[string]string{}
+						err := json.Unmarshal([]byte(secret.Metadata), &data)
+
+						Expect(err).ToNot(HaveOccurred())
+						Expect(data).To(HaveKeyWithValue("_sha256", "146ed8bb7a977ee78ee11cf262924e3ae93423c413ab6d612a8d159a0ae4e1ad"))
+					})
+				})
+
+				When("the secret key does not exist in the k8s secret", func() {
+					BeforeEach(func() {
+						pushData.Match.SecretKey = "nonexistent-key"
+					})
+
+					It("should return an error", func(ctx SpecContext) {
+						Expect(pushErr).To(HaveOccurred())
+						Expect(pushErr.Error()).To(ContainSubstring("key nonexistent-key not found in secret"))
+					})
+				})
+
+				When("push metadata is provided", func() {
+					When("the metadata is valid", func() {
+						BeforeEach(func() {
+							pushData.Metadata = &apiextensionsv1.JSON{
+								Raw: []byte(`
+apiVersion: kubernetes.external-secrets.io/v1alpha1
+kind: PushSecretMetadata
+spec:
+  metadata:
+    environment: production
+    team: frontend
+  description: "my custom description"`),
+							}
+						})
+
+						It("should update the ngrok secret description", func(ctx SpecContext) {
+							Expect(secret.Description).To(Equal("my custom description"))
+						})
+
+						It("should update the ngrok secret metadata", func(ctx SpecContext) {
+							data := map[string]string{}
+							err := json.Unmarshal([]byte(secret.Metadata), &data)
+							Expect(err).ToNot(HaveOccurred())
+							Expect(data).To(HaveKeyWithValue("environment", "production"))
+							Expect(data).To(HaveKeyWithValue("team", "frontend"))
+							Expect(data).To(HaveKeyWithValue("_sha256", "9c51d0b0f64dfb3662ed85ce945dd1e8f6130665c289754e4e9257a58013e61d"))
+						})
+					})
+
+					When("the metadata is invalid", func() {
+						BeforeEach(func() {
+							pushData.Metadata = &apiextensionsv1.JSON{
+								Raw: []byte(`{ this is not valid json`),
+							}
+						})
+
+						It("should return an error", func(ctx SpecContext) {
+							Expect(pushErr).To(HaveOccurred())
+							Expect(pushErr.Error()).To(ContainSubstring("failed to parse push secret metadata"))
+						})
+					})
+				})
+			})
+		})
+	})
+
+	Describe("SecretExists", func() {
+		var (
+			secretName string
+
+			exists bool
+			err    error
+		)
+
+		BeforeEach(func() {
+			secretName = "my-secret"
+		})
+
+		JustBeforeEach(func(ctx SpecContext) {
+			exists, err = c.SecretExists(ctx, pushSecretRemoteRef{
+				remoteKey: secretName,
+			})
+		})
+
+		When("the vault does not exist", func() {
+			It("should return exists as false without an error", func(ctx SpecContext) {
+				Expect(err).ToNot(HaveOccurred())
+				Expect(exists).To(BeFalse())
+			})
+		})
+
+		When("the vault exists", func() {
+			var (
+				vault *ngrok.Vault
+			)
+			BeforeEach(func(ctx SpecContext) {
+				vault, err = s.VaultClient().Create(ctx, &ngrok.VaultCreate{
+					Name: c.vaultName,
+				})
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			When("the secret does not exist", func() {
+				It("should return exists as false without an error", func(ctx SpecContext) {
+					Expect(err).ToNot(HaveOccurred())
+					Expect(exists).To(BeFalse())
+				})
+			})
+
+			When("the secret exists", func() {
+				BeforeEach(func(ctx SpecContext) {
+					_, err = s.SecretsClient().Create(ctx, &ngrok.SecretCreate{
+						VaultID: vault.ID,
+						Name:    secretName,
+						Value:   "supersecret",
+					})
+					Expect(err).ToNot(HaveOccurred())
+				})
+
+				It("should return exists as true without an error", func(ctx SpecContext) {
+					Expect(err).ToNot(HaveOccurred())
+					Expect(exists).To(BeTrue())
+				})
+			})
+		})
+
+		When("an error occurs listing vaults", func() {
+			BeforeEach(func() {
+				listVaultsErr = errors.New("failed to list vaults")
+			})
+
+			It("should return exists as false", func() {
+				Expect(exists).To(BeFalse())
+			})
+
+			It("should return the listing error", func() {
+				Expect(err).To(HaveOccurred())
+				Expect(err.Error()).To(ContainSubstring("failed to list vaults"))
+			})
+		})
+	})
+
+	Describe("DeleteSecret", func() {
+		var (
+			secretName string
+
+			err error
+		)
+
+		BeforeEach(func() {
+			secretName = "my-secret"
+		})
+
+		JustBeforeEach(func(ctx SpecContext) {
+			err = c.DeleteSecret(ctx, pushSecretRemoteRef{
+				remoteKey: secretName,
+			})
+		})
+
+		When("the vault does not exist", func() {
+			It("should not return an error", func(ctx SpecContext) {
+				Expect(err).ToNot(HaveOccurred())
+			})
+		})
+
+		When("the vault exists but the secret does not", func() {
+			BeforeEach(func(ctx SpecContext) {
+				_, err := c.vaultClient.Create(ctx, &ngrok.VaultCreate{
+					Name: c.vaultName,
+				})
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("should not return an error", func(ctx SpecContext) {
+				Expect(err).ToNot(HaveOccurred())
+			})
+		})
+
+		When("the vault and secret both exist", func() {
+			BeforeEach(func(ctx SpecContext) {
+				vault, err := s.VaultClient().Create(ctx, &ngrok.VaultCreate{
+					Name: c.vaultName,
+				})
+				Expect(err).ToNot(HaveOccurred())
+				_, err = s.SecretsClient().Create(ctx, &ngrok.SecretCreate{
+					VaultID: vault.ID,
+					Name:    secretName,
+					Value:   "supersecret",
+				})
+				Expect(err).ToNot(HaveOccurred())
+			})
+
+			It("should not return an error", func(ctx SpecContext) {
+				Expect(err).ToNot(HaveOccurred())
+			})
+		})
+
+		When("an error occurs listing vaults", func() {
+			BeforeEach(func() {
+				listVaultsErr = errors.New("failed to list vaults")
+			})
+
+			It("should return the listing error", func() {
+				Expect(err).To(HaveOccurred())
+				Expect(err.Error()).To(ContainSubstring("failed to list vaults"))
+			})
+		})
+	})
+
+	Describe("Validate", func() {
+		var (
+			result esv1.ValidationResult
+			err    error
+		)
+
+		JustBeforeEach(func(ctx SpecContext) {
+			result, err = c.Validate()
+		})
+
+		When("the client can list secrets", func() {
+			When("there are no secrets", func() {
+				It("should return ValidationResultReady without an error", func() {
+					Expect(err).To(BeNil())
+					Expect(result).To(Equal(esv1.ValidationResultReady))
+				})
+			})
+
+			When("there are some secrets", func() {
+				BeforeEach(func(ctx SpecContext) {
+					vault, err := s.VaultClient().Create(ctx, &ngrok.VaultCreate{
+						Name: c.vaultName,
+					})
+					Expect(err).ToNot(HaveOccurred())
+					_, err = s.SecretsClient().Create(ctx, &ngrok.SecretCreate{
+						VaultID: vault.ID,
+						Name:    "my-secret",
+						Value:   "supersecret",
+					})
+					Expect(err).ToNot(HaveOccurred())
+				})
+
+				It("should return ValidationResultReady without an error", func() {
+					Expect(err).To(BeNil())
+					Expect(result).To(Equal(esv1.ValidationResultReady))
+				})
+			})
+		})
+
+		When("the client cannot list secrets", func() {
+			BeforeEach(func() {
+				listSecretsErr = errors.New("failed to list secrets")
+			})
+
+			It("should return ValidationResultError with the listing error", func() {
+				Expect(err).ToNot(BeNil())
+				Expect(err.Error()).To(ContainSubstring("failed to list secrets"))
+				Expect(result).To(Equal(esv1.ValidationResultError))
+			})
+		})
+	})
+
+	Describe("GetSecret", func() {
+		var (
+			ref esv1.ExternalSecretDataRemoteRef
+
+			secret []byte
+			err    error
+		)
+
+		JustBeforeEach(func(ctx SpecContext) {
+			secret, err = c.GetSecret(ctx, ref)
+		})
+
+		It("should always return an error indicating write-only operations", func(ctx SpecContext) {
+			Expect(secret).To(BeNil())
+			Expect(err).To(HaveOccurred())
+			Expect(err).To(Equal(errWriteOnlyOperations))
+		})
+	})
+
+	Describe("GetSecretMap", func() {
+		var (
+			ref esv1.ExternalSecretDataRemoteRef
+
+			secretMap map[string][]byte
+			err       error
+		)
+
+		JustBeforeEach(func(ctx SpecContext) {
+			secretMap, err = c.GetSecretMap(ctx, ref)
+		})
+
+		It("should always return an error indicating write-only operations", func(ctx SpecContext) {
+			Expect(secretMap).To(BeNil())
+			Expect(err).To(HaveOccurred())
+			Expect(err).To(Equal(errWriteOnlyOperations))
+		})
+	})
+
+	Describe("GetAllSecrets", func() {
+		var (
+			find esv1.ExternalSecretFind
+
+			secrets map[string][]byte
+			err     error
+		)
+
+		JustBeforeEach(func(ctx SpecContext) {
+			secrets, err = c.GetAllSecrets(ctx, find)
+		})
+
+		It("should always return an error indicating write-only operations", func(ctx SpecContext) {
+			Expect(secrets).To(BeNil())
+			Expect(err).To(HaveOccurred())
+			Expect(err).To(Equal(errWriteOnlyOperations))
+		})
+	})
+
+	Describe("Close", func() {
+		It("should not return an error", func(ctx SpecContext) {
+			Expect(c.Close(ctx)).To(BeNil())
+		})
+	})
+})

+ 606 - 0
pkg/provider/ngrok/fake/fake.go

@@ -0,0 +1,606 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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
+
+import (
+	"context"
+	"fmt"
+	"maps"
+	"math/rand"
+	"net/http"
+	"slices"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/ngrok/ngrok-api-go/v7"
+)
+
+func GenerateRandomString(length int) string {
+	const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+	seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) /* #nosec G404 */
+
+	sb := strings.Builder{}
+	sb.Grow(length)
+	for i := 0; i < length; i++ {
+		sb.WriteByte(charset[seededRand.Intn(len(charset))])
+	}
+	return sb.String()
+}
+
+func VaultNameEmpty() *ngrok.Error {
+	return &ngrok.Error{
+		ErrorCode:  "ERR_NGROK_23001",
+		StatusCode: http.StatusBadRequest,
+		Msg:        "The vault name cannot be empty.",
+	}
+}
+
+func VaultNamesMustBeUniqueWithinAccount() *ngrok.Error {
+	return &ngrok.Error{
+		ErrorCode:  "ERR_NGROK_23004",
+		StatusCode: http.StatusBadRequest,
+		Msg:        "Vault names must be unique within an account.",
+	}
+}
+
+func VaultNameInvalid(name string) *ngrok.Error {
+	return &ngrok.Error{
+		ErrorCode:  "ERR_NGROK_23002",
+		StatusCode: http.StatusBadRequest,
+		Msg:        fmt.Sprintf("The vault name %q is invalid. Must only contain the characters \"a-zA-Z0-9_/.\".", name),
+	}
+}
+
+func SecretNameEmpty() *ngrok.Error {
+	return &ngrok.Error{
+		ErrorCode:  "ERR_NGROK_24001",
+		StatusCode: http.StatusBadRequest,
+		Msg:        "The secret name cannot be empty.",
+	}
+}
+
+func SecretValueEmpty() *ngrok.Error {
+	return &ngrok.Error{
+		ErrorCode:  "ERR_NGROK_24003",
+		StatusCode: http.StatusBadRequest,
+		Msg:        "The secret value cannot be empty.",
+	}
+}
+
+func SecretNameMustBeUniqueWithinVault() *ngrok.Error {
+	return &ngrok.Error{
+		ErrorCode:  "ERR_NGROK_24005",
+		StatusCode: http.StatusBadRequest,
+		Msg:        "Secret names must be unique within a vault.",
+	}
+}
+
+func SecretVaultNotFound(id string) *ngrok.Error {
+	return &ngrok.Error{
+		ErrorCode:  "ERR_NGROK_24006",
+		StatusCode: http.StatusNotFound,
+		Msg:        fmt.Sprintf("Vault with ID %s not found.", id),
+	}
+}
+
+func NotFound(id string) *ngrok.Error {
+	return &ngrok.Error{
+		StatusCode: http.StatusNotFound,
+		Msg:        fmt.Sprintf("Resource with ID %s not found.", id),
+	}
+}
+
+func VaultNotEmpty() *ngrok.Error {
+	return &ngrok.Error{
+		ErrorCode:  "ERR_NGROK_23003",
+		StatusCode: http.StatusBadRequest,
+		Msg:        "A Vault must be empty before it can be deleted. Please remove all secrets from the vault and try again.",
+	}
+}
+
+type vault struct {
+	vault *ngrok.Vault
+
+	mu          sync.RWMutex
+	secretsByID map[string]*ngrok.Secret
+}
+
+// newVault creates a new vault instance with an empty secrets map.
+// given the ngrok.Vault to wrap.
+func newVault(v *ngrok.Vault) *vault {
+	return &vault{
+		vault:       v,
+		secretsByID: make(map[string]*ngrok.Secret),
+	}
+}
+
+func (v *vault) setSecret(id string, secret *ngrok.Secret) {
+	v.mu.Lock()
+	defer v.mu.Unlock()
+	v.secretsByID[id] = secret
+}
+
+func (v *vault) getSecret(id string) (*ngrok.Secret, bool) {
+	v.mu.RLock()
+	defer v.mu.RUnlock()
+	val, ok := v.secretsByID[id]
+	return val, ok
+}
+
+func (v *vault) deleteSecret(id string) {
+	v.mu.Lock()
+	defer v.mu.Unlock()
+	delete(v.secretsByID, id)
+}
+
+// CreateSecret creates a new secret in the vault.
+func (v *vault) CreateSecret(s *ngrok.SecretCreate) (*ngrok.Secret, error) {
+	if s.Name == "" {
+		return nil, SecretNameEmpty()
+	}
+
+	if s.Value == "" {
+		return nil, SecretValueEmpty()
+	}
+
+	existing := v.GetSecretByName(s.Name)
+	if existing != nil {
+		return nil, SecretNameMustBeUniqueWithinVault()
+	}
+
+	ts := time.Now()
+	newSecret := &ngrok.Secret{
+		ID: "secret_" + GenerateRandomString(20),
+		Vault: ngrok.Ref{
+			ID:  v.vault.ID,
+			URI: v.vault.URI,
+		},
+		Name:        s.Name,
+		Description: s.Description,
+		Metadata:    s.Metadata,
+		CreatedAt:   ts.Format(time.RFC3339),
+		UpdatedAt:   ts.Format(time.RFC3339),
+	}
+
+	v.setSecret(newSecret.ID, newSecret)
+	return newSecret, nil
+}
+
+// DeleteSecret deletes a secret from the vault by ID.
+func (v *vault) DeleteSecret(id string) error {
+	_, exists := v.getSecret(id)
+
+	if exists {
+		v.deleteSecret(id)
+		return nil
+	}
+
+	return NotFound(id)
+}
+
+// ListSecrets returns all secrets in the vault.
+func (v *vault) ListSecrets() []*ngrok.Secret {
+	v.mu.RLock()
+	defer v.mu.RUnlock()
+
+	return slices.Collect(maps.Values(v.secretsByID))
+}
+
+// GetSecretByID returns the secret with the given ID, or nil if not found.
+func (v *vault) GetSecretByID(id string) *ngrok.Secret {
+	val, _ := v.getSecret(id)
+	return val
+}
+
+// GetSecretByName returns the secret with the given name, or nil if not found.
+func (v *vault) GetSecretByName(name string) *ngrok.Secret {
+	for _, secret := range v.ListSecrets() {
+		if secret.Name == name {
+			return secret
+		}
+	}
+	return nil
+}
+
+// UpdateSecret updates an existing secret in the vault.
+func (v *vault) UpdateSecret(s *ngrok.SecretUpdate) (*ngrok.Secret, error) {
+	secret := v.GetSecretByID(s.ID)
+	if secret == nil {
+		return nil, NotFound(s.ID)
+	}
+
+	if s.Name != nil {
+		if *s.Name == "" {
+			return nil, SecretNameEmpty()
+		}
+
+		existing := v.GetSecretByName(*s.Name)
+		if existing != nil && existing.ID != s.ID {
+			return nil, SecretNameMustBeUniqueWithinVault()
+		}
+	}
+
+	if s.Value != nil {
+		if *s.Value == "" {
+			return nil, SecretValueEmpty()
+		}
+	}
+
+	ts := time.Now()
+	secret.UpdatedAt = ts.Format(time.RFC3339)
+	if s.Name != nil {
+		secret.Name = *s.Name
+	}
+	if s.Description != nil {
+		secret.Description = *s.Description
+	}
+	if s.Metadata != nil {
+		secret.Metadata = *s.Metadata
+	}
+
+	return secret, nil
+}
+
+type Store struct {
+	mu         sync.RWMutex
+	vaultsByID map[string]*vault
+}
+
+func NewStore() *Store {
+	return &Store{
+		vaultsByID: make(map[string]*vault),
+	}
+}
+
+func (s *Store) setVault(id string, v *vault) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.vaultsByID[id] = v
+}
+
+func (s *Store) getVault(id string) (*vault, bool) {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	val, ok := s.vaultsByID[id]
+	return val, ok
+}
+
+func (s *Store) deleteVault(id string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	delete(s.vaultsByID, id)
+}
+
+// CreateVault creates a new vault in the store.
+func (s *Store) CreateVault(v *ngrok.VaultCreate) (*ngrok.Vault, error) {
+	if v.Name == "" {
+		return nil, VaultNameEmpty()
+	}
+
+	for _, vault := range s.ListVaults() {
+		if vault.Name == v.Name {
+			return nil, VaultNamesMustBeUniqueWithinAccount()
+		}
+	}
+
+	ts := time.Now()
+	ngrokVault := &ngrok.Vault{
+		ID:          "vault_" + GenerateRandomString(20),
+		Name:        v.Name,
+		Description: v.Description,
+		Metadata:    v.Metadata,
+		CreatedAt:   ts.Format(time.RFC3339),
+		UpdatedAt:   ts.Format(time.RFC3339),
+	}
+
+	s.setVault(ngrokVault.ID, newVault(ngrokVault))
+	return ngrokVault, nil
+}
+
+func (s *Store) CreateSecret(secret *ngrok.SecretCreate) (*ngrok.Secret, error) {
+	v, _ := s.getVault(secret.VaultID)
+
+	if v == nil {
+		return nil, SecretVaultNotFound(secret.VaultID)
+	}
+	return v.CreateSecret(secret)
+}
+
+// DeleteVault deletes a vault from the store by ID.
+func (s *Store) DeleteVault(id string) error {
+	v, _ := s.getVault(id)
+
+	if v == nil {
+		return NotFound(id)
+	}
+
+	if len(v.ListSecrets()) > 0 {
+		return VaultNotEmpty()
+	}
+
+	s.deleteVault(id)
+	return nil
+}
+
+// GetVaultByID returns the vault with the given ID, or nil if not found.
+func (s *Store) GetVaultByID(id string) (*ngrok.Vault, error) {
+	v, _ := s.getVault(id)
+	if v == nil {
+		return nil, NotFound(id)
+	}
+	return v.vault, nil
+}
+
+func (s *Store) GetVaultByName(name string) *ngrok.Vault {
+	for _, v := range s.ListVaults() {
+		if v.Name == name {
+			return v
+		}
+	}
+	return nil
+}
+
+// ListSecrets returns all secrets in the store.
+func (s *Store) ListSecrets() []*ngrok.Secret {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	secrets := []*ngrok.Secret{}
+	for _, v := range s.vaultsByID {
+		secrets = append(secrets, v.ListSecrets()...)
+	}
+	return secrets
+}
+
+// ListVaults returns all vaults in the store.
+func (s *Store) ListVaults() []*ngrok.Vault {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	vaults := make([]*ngrok.Vault, 0, len(s.vaultsByID))
+	for _, v := range s.vaultsByID {
+		vaults = append(vaults, v.vault)
+	}
+	return vaults
+}
+
+func (s *Store) ListVaultSecrets(vaultID string) ([]*ngrok.Secret, error) {
+	v, _ := s.getVault(vaultID)
+
+	if v == nil {
+		return nil, NotFound(vaultID)
+	}
+
+	return v.ListSecrets(), nil
+}
+
+func (s *Store) UpdateSecret(secret *ngrok.SecretUpdate) (*ngrok.Secret, error) {
+	var found *ngrok.Secret
+
+	for _, sec := range s.ListSecrets() {
+		if sec.ID == secret.ID {
+			found = sec
+			break
+		}
+	}
+
+	if found == nil {
+		return nil, NotFound(secret.ID)
+	}
+
+	v, ok := s.getVault(found.Vault.ID)
+	if !ok {
+		return nil, SecretVaultNotFound(found.Vault.ID)
+	}
+
+	return v.UpdateSecret(secret)
+}
+
+func (s *Store) DeleteSecret(secretID string) error {
+	secret, vault, err := s.GetSecretAndVaultByID(secretID)
+	if err != nil {
+		return err
+	}
+	if secret == nil || vault == nil {
+		return NotFound(secretID)
+	}
+	return vault.DeleteSecret(secretID)
+}
+
+func (s *Store) GetSecretAndVaultByID(secretID string) (*ngrok.Secret, *vault, error) {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	for _, v := range s.vaultsByID {
+		if sec := v.GetSecretByID(secretID); sec != nil {
+			return sec, v, nil
+		}
+	}
+	return nil, nil, NotFound(secretID)
+}
+
+func (s *Store) VaultClient() *VaultClient {
+	return &VaultClient{
+		store: s,
+	}
+}
+
+func (s *Store) SecretsClient() *SecretsClient {
+	return &SecretsClient{
+		store: s,
+	}
+}
+
+// VaultClient is a mock implementation which implements the ngrok.VaultsClient interface.
+type VaultClient struct {
+	store     *Store
+	createErr error
+	listErr   error
+}
+
+// WithCreateError sets an error to be returned when Create is called.
+// This is useful for testing error handling in the client.
+func (m *VaultClient) WithCreateError(err error) *VaultClient {
+	m.createErr = err
+	return m
+}
+
+// Create creates a new vault and returns it. If an error is set, it will return that error instead of the vault.
+func (m *VaultClient) Create(_ context.Context, vault *ngrok.VaultCreate) (*ngrok.Vault, error) {
+	if m.createErr != nil {
+		return nil, m.createErr
+	}
+	return m.store.CreateVault(vault)
+}
+
+// Get retrieves a vault by its ID. If the vault does not exist, it returns an error.
+func (m *VaultClient) Get(_ context.Context, vaultID string) (*ngrok.Vault, error) {
+	return m.store.GetVaultByID(vaultID)
+}
+
+func (m *VaultClient) GetSecretsByVault(id string, paging *ngrok.Paging) ngrok.Iter[*ngrok.Secret] {
+	secrets, err := m.store.ListVaultSecrets(id)
+	return NewIter(secrets, err)
+}
+
+// WithListError sets an error to be returned when List is called.
+func (m *VaultClient) WithListError(err error) *VaultClient {
+	m.listErr = err
+	return m
+}
+
+// List returns an iterator over the vaults.
+// If an error is set, it will return that error instead of the vaults.
+func (m *VaultClient) List(paging *ngrok.Paging) ngrok.Iter[*ngrok.Vault] {
+	return NewIter(m.store.ListVaults(), m.listErr)
+}
+
+// SecretsClient is a mock implementation of the SecretsClient interface.
+// It allows you to create, update, delete, and list secrets.
+// It can be used to test the client without needing a real ngrok API.
+type SecretsClient struct {
+	store     *Store
+	createErr error
+	updateErr error
+	deleteErr error
+	listErr   error
+}
+
+// WithCreateError sets an error to be returned when Create is called.
+// This is useful for testing error handling in the client.
+func (m *SecretsClient) WithCreateError(err error) *SecretsClient {
+	m.createErr = err
+	return m
+}
+
+// Create creates a new secret and returns it. If an error is set, it will return that error instead of the secret.
+func (m *SecretsClient) Create(_ context.Context, secret *ngrok.SecretCreate) (*ngrok.Secret, error) {
+	if m.createErr != nil {
+		return nil, m.createErr
+	}
+
+	return m.store.CreateSecret(secret)
+}
+
+// WithUpdateError sets an error to be returned when Update is called.
+// This is useful for testing error handling in the client.
+func (m *SecretsClient) WithUpdateError(err error) *SecretsClient {
+	m.updateErr = err
+	return m
+}
+
+// Update updates an existing secret and returns it. If an error is set, it will return that error instead of the secret.
+func (m *SecretsClient) Update(_ context.Context, secret *ngrok.SecretUpdate) (*ngrok.Secret, error) {
+	if m.updateErr != nil {
+		return nil, m.updateErr
+	}
+
+	return m.store.UpdateSecret(secret)
+}
+
+// WithDeleteError sets an error to be returned when Delete is called.
+// This is useful for testing error handling in the client.
+func (m *SecretsClient) WithDeleteError(err error) *SecretsClient {
+	m.deleteErr = err
+	return m
+}
+
+// Delete deletes a secret by its ID. If an error is set, it will return that error instead of deleting the secret.
+// If the secret does not exist, it returns an error.
+func (m *SecretsClient) Delete(_ context.Context, secretID string) error {
+	if m.deleteErr != nil {
+		return m.deleteErr
+	}
+	return m.store.DeleteSecret(secretID)
+}
+
+// Get retrieves a secret by its ID. If the secret does not exist, it returns an error.
+func (m *SecretsClient) Get(_ context.Context, secretID string) (*ngrok.Secret, error) {
+	s, _, err := m.store.GetSecretAndVaultByID(secretID) // to check existence
+	return s, err
+}
+
+// WithListError sets an error to be returned when List is called.
+// This is useful for testing error handling in the client.
+func (m *SecretsClient) WithListError(err error) *SecretsClient {
+	m.listErr = err
+	return m
+}
+
+// List returns an iterator over the secrets.
+// If an error is set, it will return that error instead of the secrets.
+func (m *SecretsClient) List(paging *ngrok.Paging) ngrok.Iter[*ngrok.Secret] {
+	return NewIter(m.store.ListSecrets(), m.listErr)
+}
+
+// Iter is a mock iterator that implements the ngrok.Iter[T] interface.
+type Iter[T any] struct {
+	items []T
+	err   error
+	n     int
+}
+
+func (m *Iter[T]) Next(_ context.Context) bool {
+	// If there is an error, stop iteration
+	if m.err != nil {
+		return false
+	}
+
+	// Increment the index
+	m.n++
+
+	return m.n < len(m.items) && m.n >= 0
+}
+
+func (m *Iter[T]) Item() T {
+	if m.n >= 0 && m.n < len(m.items) {
+		return m.items[m.n]
+	}
+	return *new(T)
+}
+
+func (m *Iter[T]) Err() error {
+	return m.err
+}
+
+func NewIter[T any](items []T, err error) *Iter[T] {
+	return &Iter[T]{
+		items: items,
+		err:   err,
+		n:     -1,
+	}
+}

+ 186 - 0
pkg/provider/ngrok/provider.go

@@ -0,0 +1,186 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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 ngrok
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/url"
+
+	kubeClient "sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	"github.com/external-secrets/external-secrets/pkg/utils/resolvers"
+	"github.com/ngrok/ngrok-api-go/v7"
+	"github.com/ngrok/ngrok-api-go/v7/secrets"
+	"github.com/ngrok/ngrok-api-go/v7/vaults"
+)
+
+var (
+	defaultAPIURL = "https://api.ngrok.com"
+	userAgent     = "external-secrets"
+
+	errClusterStoreRequiresNamespace = errors.New("cluster store requires namespace")
+	errInvalidStore                  = errors.New("invalid store")
+	errInvalidStoreSpec              = errors.New("invalid store spec")
+	errInvalidStoreProv              = errors.New("invalid store provider")
+	errInvalidNgrokProv              = errors.New("invalid ngrok provider")
+	errInvalidAuthAPIKeyRequired     = errors.New("ngrok provider auth APIKey is required")
+	errInvalidAPIURL                 = errors.New("invalid API URL")
+	errMissingVaultName              = errors.New("ngrok provider vault name is required")
+)
+
+type vaultClientFactory func(cfg *ngrok.ClientConfig) VaultClient
+type secretsClientFactory func(cfg *ngrok.ClientConfig) SecretsClient
+
+var getVaultsClient vaultClientFactory = func(cfg *ngrok.ClientConfig) VaultClient {
+	return vaults.NewClient(cfg)
+}
+
+var getSecretsClient secretsClientFactory = func(cfg *ngrok.ClientConfig) SecretsClient {
+	return secrets.NewClient(cfg)
+}
+
+type Provider struct{}
+
+// Capabilities returns the provider supported capabilities (ReadOnly, WriteOnly, ReadWrite). Currently,
+// ngrok only supports WriteOnly capabilities.
+func (p *Provider) Capabilities() esv1.SecretStoreCapabilities {
+	return esv1.SecretStoreWriteOnly
+}
+
+// NewClient implements the Client interface.
+func (p *Provider) NewClient(ctx context.Context, store esv1.GenericStore, kubeClient kubeClient.Client, namespace string) (esv1.SecretsClient, error) {
+	cfg, err := getConfig(store)
+	if err != nil {
+		return nil, err
+	}
+
+	if store.GetKind() == esv1.ClusterSecretStoreKind && doesConfigDependOnNamespace(cfg) {
+		return nil, errClusterStoreRequiresNamespace
+	}
+
+	apiKey, err := loadAPIKeySecret(ctx, cfg.Auth.APIKey, kubeClient, store.GetKind(), namespace)
+	if err != nil {
+		return nil, err
+	}
+
+	clientConfig := ngrok.NewClientConfig(
+		apiKey,
+		ngrok.WithBaseURL(cfg.APIURL),
+		ngrok.WithUserAgent(userAgent),
+	)
+
+	vaultClient := getVaultsClient(clientConfig)
+	secretsClient := getSecretsClient(clientConfig)
+
+	listCtx, cancel := context.WithTimeout(ctx, defaultListTimeout)
+	defer cancel()
+
+	var vault *ngrok.Vault
+	vaultIter := vaultClient.List(nil)
+	for vaultIter.Next(listCtx) {
+		if vaultIter.Item().Name == cfg.Vault.Name {
+			vault = vaultIter.Item()
+			break
+		}
+	}
+
+	if err := vaultIter.Err(); err != nil {
+		return nil, fmt.Errorf("error listing vaults: %w", err)
+	}
+
+	if vault == nil {
+		return nil, fmt.Errorf("vault %q not found", cfg.Vault.Name)
+	}
+
+	return &client{
+		vaultClient:   vaultClient,
+		secretsClient: secretsClient,
+		vaultName:     cfg.Vault.Name,
+		vaultID:       vault.ID,
+	}, nil
+}
+
+// ValidateStore validates the store configuration.
+func (p *Provider) ValidateStore(store esv1.GenericStore) (admission.Warnings, error) {
+	_, err := getConfig(store)
+	return nil, err
+}
+
+func loadAPIKeySecret(ctx context.Context, ref *esv1.NgrokProviderSecretRef, kube kubeClient.Client, storeKind, namespace string) (string, error) {
+	return resolvers.SecretKeyRef(
+		ctx,
+		kube,
+		storeKind,
+		namespace,
+		ref.SecretRef,
+	)
+}
+
+func doesConfigDependOnNamespace(cfg *esv1.NgrokProvider) bool {
+	ref := cfg.Auth.APIKey
+	return ref != nil && ref.SecretRef != nil && ref.SecretRef.Namespace == nil
+}
+
+func getConfig(store esv1.GenericStore) (*esv1.NgrokProvider, error) {
+	if store == nil {
+		return nil, errInvalidStore
+	}
+
+	storeSpec := store.GetSpec()
+	if storeSpec == nil {
+		return nil, errInvalidStoreSpec
+	}
+
+	if storeSpec.Provider == nil {
+		return nil, errInvalidStoreProv
+	}
+
+	cfg := storeSpec.Provider.Ngrok
+	if cfg == nil {
+		return nil, errInvalidNgrokProv
+	}
+
+	if cfg.APIURL == "" {
+		cfg.APIURL = defaultAPIURL
+	} else if _, err := url.Parse(cfg.APIURL); err != nil {
+		return nil, fmt.Errorf("%q: %w", cfg.APIURL, errInvalidAPIURL)
+	}
+
+	if cfg.Auth.APIKey == nil {
+		return nil, errInvalidAuthAPIKeyRequired
+	}
+
+	if cfg.Vault.Name == "" {
+		return nil, errMissingVaultName
+	}
+
+	return cfg, nil
+}
+
+func init() {
+	esv1.Register(
+		&Provider{},
+		&esv1.SecretStoreProvider{
+			Ngrok: &esv1.NgrokProvider{},
+		},
+		esv1.MaintenanceStatusMaintained,
+	)
+}

+ 433 - 0
pkg/provider/ngrok/provider_test.go

@@ -0,0 +1,433 @@
+/*
+Copyright © 2025 ESO Maintainer Team
+
+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 ngrok
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/ngrok/ngrok-api-go/v7"
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/utils/ptr"
+	kubeClient "sigs.k8s.io/controller-runtime/pkg/client"
+	clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
+	v1 "github.com/external-secrets/external-secrets/apis/meta/v1"
+	"github.com/external-secrets/external-secrets/pkg/provider/ngrok/fake"
+)
+
+func newTestClusterSecretStore(provider *esv1.SecretStoreProvider) esv1.GenericStore {
+	return &esv1.ClusterSecretStore{
+		TypeMeta: metav1.TypeMeta{
+			Kind: "ClusterSecretStore",
+		},
+		Spec: esv1.SecretStoreSpec{
+			Provider: provider,
+		},
+	}
+}
+
+func newTestNgrokClusterSecretStore(ngrokProv *esv1.NgrokProvider) esv1.GenericStore {
+	return newTestClusterSecretStore(&esv1.SecretStoreProvider{
+		Ngrok: ngrokProv,
+	})
+}
+
+func newTestSecretStore(provider *esv1.SecretStoreProvider) esv1.GenericStore {
+	return &esv1.SecretStore{
+		TypeMeta: metav1.TypeMeta{
+			Kind: "SecretStore",
+		},
+		Spec: esv1.SecretStoreSpec{
+			Provider: provider,
+		},
+	}
+}
+
+func newTestNgrokSecretStore(ngrokProv *esv1.NgrokProvider) esv1.GenericStore {
+	return newTestSecretStore(&esv1.SecretStoreProvider{
+		Ngrok: ngrokProv,
+	})
+}
+
+func newNgrokAPICredentials(name, namespace, apiKey string) *corev1.Secret {
+	return &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Data: map[string][]byte{
+			"API_KEY": []byte(apiKey),
+		},
+	}
+}
+
+var _ = Describe("Provider", func() {
+	var (
+		provider *Provider
+	)
+
+	BeforeEach(func() {
+		provider = &Provider{}
+	})
+
+	Describe("Capabilities", func() {
+		It("should return write-only capability", func() {
+			cap := provider.Capabilities()
+			Expect(cap).To(Equal(esv1.SecretStoreWriteOnly))
+		})
+	})
+
+	Describe("NewClient", func() {
+		var (
+			store            esv1.GenericStore
+			ngrokStore       *fake.Store
+			namespace        string
+			kubeClient       kubeClient.Client
+			ngrokCredentials *corev1.Secret
+			vaultName        string
+
+			// Injected errors
+			vaultListErr error
+
+			// Outputs
+			err    error
+			client esv1.SecretsClient
+		)
+
+		BeforeEach(func() {
+			namespace = "default"
+			vaultName = "vault-" + fake.GenerateRandomString(5)
+			ngrokCredentials = newNgrokAPICredentials("ngrok-credentials", namespace, "secret-api-key")
+			kubeClient = clientfake.NewClientBuilder().WithObjects(ngrokCredentials).Build()
+			ngrokStore = fake.NewStore()
+			vaultListErr = nil
+		})
+
+		JustBeforeEach(func() {
+			getVaultsClient = func(_ *ngrok.ClientConfig) VaultClient {
+				return ngrokStore.VaultClient().WithListError(vaultListErr)
+			}
+			getSecretsClient = func(_ *ngrok.ClientConfig) SecretsClient {
+				return ngrokStore.SecretsClient()
+			}
+			client, err = provider.NewClient(GinkgoT().Context(), store, kubeClient, namespace)
+		})
+
+		Context("SecretStore", func() {
+			When("the secret does not exist", func() {
+				BeforeEach(func() {
+					store = newTestNgrokSecretStore(&esv1.NgrokProvider{
+						Vault: esv1.NgrokVault{
+							Name: vaultName,
+						},
+						Auth: esv1.NgrokAuth{
+							APIKey: &esv1.NgrokProviderSecretRef{
+								SecretRef: &v1.SecretKeySelector{
+									Key:  "API_KEY",
+									Name: "non-existent-secret",
+								},
+							},
+						},
+					})
+				})
+
+				It("should return an error that the secret does not exist", func() {
+					Expect(err).To(HaveOccurred())
+					Expect(err.Error()).To(ContainSubstring("secrets \"non-existent-secret\" not found"))
+					Expect(client).To(BeNil())
+				})
+			})
+
+			When("the store is valid", func() {
+				BeforeEach(func() {
+					store = newTestNgrokSecretStore(&esv1.NgrokProvider{
+						Vault: esv1.NgrokVault{
+							Name: vaultName,
+						},
+						Auth: esv1.NgrokAuth{
+							APIKey: &esv1.NgrokProviderSecretRef{
+								SecretRef: &v1.SecretKeySelector{
+									Key:  "API_KEY",
+									Name: ngrokCredentials.Name,
+								},
+							},
+						},
+					})
+				})
+
+				When("the vault does not exist", func() {
+					It("Should return an error that the vault is not found", func() {
+						Expect(err).To(HaveOccurred())
+						Expect(err.Error()).To(ContainSubstring(fmt.Sprintf("vault %q not found", vaultName)))
+						Expect(client).To(BeNil())
+					})
+				})
+
+				When("the vault exists", func() {
+					BeforeEach(func() {
+						_, createErr := ngrokStore.CreateVault(&ngrok.VaultCreate{
+							Name: vaultName,
+						})
+						Expect(createErr).To(BeNil())
+					})
+
+					It("should not return an error", func() {
+						Expect(err).To(BeNil())
+					})
+
+					It("should return a non-nil client", func() {
+						Expect(client).NotTo(BeNil())
+					})
+				})
+
+				When("there is an error listing vaults", func() {
+					BeforeEach(func() {
+						vaultListErr = fmt.Errorf("some error listing vaults")
+					})
+
+					It("should return the list error", func() {
+						Expect(err).To(HaveOccurred())
+						Expect(err.Error()).To(ContainSubstring("some error listing vaults"))
+						Expect(client).To(BeNil())
+					})
+				})
+			})
+		})
+
+		Context("ClusterSecretStore", func() {
+			When("the store does not specify a namespace", func() {
+				BeforeEach(func() {
+					store = newTestNgrokClusterSecretStore(&esv1.NgrokProvider{
+						Vault: esv1.NgrokVault{
+							Name: vaultName,
+						},
+						Auth: esv1.NgrokAuth{
+							APIKey: &esv1.NgrokProviderSecretRef{
+								SecretRef: &v1.SecretKeySelector{
+									Key:  "API_KEY",
+									Name: ngrokCredentials.Name,
+								},
+							},
+						},
+					})
+				})
+
+				It("should return an error that the cluster store requires a namespace", func() {
+					Expect(err).To(MatchError(errClusterStoreRequiresNamespace))
+					Expect(client).To(BeNil())
+				})
+			})
+
+			When("the secret does not exist", func() {
+				BeforeEach(func() {
+					store = newTestNgrokClusterSecretStore(&esv1.NgrokProvider{
+						Vault: esv1.NgrokVault{
+							Name: vaultName,
+						},
+						Auth: esv1.NgrokAuth{
+							APIKey: &esv1.NgrokProviderSecretRef{
+								SecretRef: &v1.SecretKeySelector{
+									Key:       "API_KEY",
+									Name:      "non-existent-secret",
+									Namespace: ptr.To("some-other-namespace"),
+								},
+							},
+						},
+					})
+				})
+
+				It("should return an error that the secret does not exist", func() {
+					Expect(err).To(HaveOccurred())
+					Expect(err.Error()).To(ContainSubstring("secrets \"non-existent-secret\" not found"))
+					Expect(client).To(BeNil())
+				})
+			})
+
+			When("the store is valid", func() {
+				BeforeEach(func() {
+					store = newTestNgrokClusterSecretStore(&esv1.NgrokProvider{
+						Vault: esv1.NgrokVault{
+							Name: vaultName,
+						},
+						Auth: esv1.NgrokAuth{
+							APIKey: &esv1.NgrokProviderSecretRef{
+								SecretRef: &v1.SecretKeySelector{
+									Key:       "API_KEY",
+									Name:      ngrokCredentials.Name,
+									Namespace: ptr.To(namespace),
+								},
+							},
+						},
+					})
+				})
+
+				When("the vault does not exist", func() {
+					It("Should return an error that the vault is not found", func() {
+						Expect(err).To(HaveOccurred())
+						Expect(err.Error()).To(ContainSubstring(fmt.Sprintf("vault %q not found", vaultName)))
+						Expect(client).To(BeNil())
+					})
+				})
+
+				When("the vault exists", func() {
+					BeforeEach(func() {
+						_, createErr := ngrokStore.CreateVault(&ngrok.VaultCreate{
+							Name: vaultName,
+						})
+						Expect(createErr).To(BeNil())
+					})
+
+					It("should not return an error", func() {
+						Expect(err).To(BeNil())
+					})
+
+					It("should return a non-nil client", func() {
+						Expect(client).NotTo(BeNil())
+					})
+				})
+			})
+		})
+	})
+
+	Describe("ValidateStore", func() {
+		var (
+			store esv1.GenericStore
+
+			err      error
+			warnings admission.Warnings
+		)
+
+		JustBeforeEach(func() {
+			warnings, err = provider.ValidateStore(store)
+		})
+
+		When("the store is nil", func() {
+			BeforeEach(func() { store = nil })
+
+			It("Should return an invalid store error", func() {
+				Expect(err).To(MatchError(errInvalidStore))
+				Expect(warnings).To(BeNil())
+			})
+		})
+
+		When("the provider is nil", func() {
+			BeforeEach(func() { store = newTestSecretStore(nil) })
+
+			It("Should return an invalid ngrok provider error", func() {
+				Expect(err).To(MatchError(errInvalidStoreProv))
+				Expect(warnings).To(BeNil())
+			})
+		})
+
+		When("the ngrok provider is nil", func() {
+			BeforeEach(func() { store = newTestNgrokSecretStore(nil) })
+
+			It("Should return an invalid ngrok provider error", func() {
+				Expect(err).To(MatchError(errInvalidNgrokProv))
+				Expect(warnings).To(BeNil())
+			})
+		})
+
+		When("the API URL is invalid", func() {
+			BeforeEach(func() {
+				store = newTestNgrokSecretStore(&esv1.NgrokProvider{
+					APIURL: "http://example.com/path\n",
+				})
+			})
+
+			It("Should return an invalid API URL error", func() {
+				Expect(err).To(MatchError(errInvalidAPIURL))
+				Expect(warnings).To(BeNil())
+			})
+		})
+
+		When("the auth APIKey is missing", func() {
+			BeforeEach(func() {
+				store = newTestNgrokSecretStore(&esv1.NgrokProvider{
+					Vault: esv1.NgrokVault{
+						Name: "test-vault",
+					},
+					Auth: esv1.NgrokAuth{
+						APIKey: nil,
+					},
+				})
+			})
+
+			It("Should return an invalid auth APIKey required error", func() {
+				Expect(err).To(MatchError(errInvalidAuthAPIKeyRequired))
+				Expect(warnings).To(BeNil())
+			})
+		})
+
+		When("the vault name is missing", func() {
+			BeforeEach(func() {
+				store = newTestNgrokSecretStore(&esv1.NgrokProvider{
+					Auth: esv1.NgrokAuth{
+						APIKey: &esv1.NgrokProviderSecretRef{
+							SecretRef: &v1.SecretKeySelector{
+								Key:  "apiKey",
+								Name: "ngrok-credentials",
+							},
+						},
+					},
+					Vault: esv1.NgrokVault{},
+				})
+			})
+
+			It("Should return a missing vault name error", func() {
+				Expect(err).To(MatchError(errMissingVaultName))
+				Expect(warnings).To(BeNil())
+			})
+		})
+
+		When("the store is valid", func() {
+			BeforeEach(func() {
+				store = newTestNgrokSecretStore(&esv1.NgrokProvider{
+					Vault: esv1.NgrokVault{
+						Name: "test-vault",
+					},
+					Auth: esv1.NgrokAuth{
+						APIKey: &esv1.NgrokProviderSecretRef{
+							SecretRef: &v1.SecretKeySelector{
+								Key:  "apiKey",
+								Name: "ngrok-credentials",
+							},
+						},
+					},
+				})
+			})
+
+			It("Should not return an error", func() {
+				Expect(err).To(BeNil())
+				Expect(warnings).To(BeNil())
+			})
+		})
+	})
+
+	// Add more Ginkgo tests here for ValidateStore, NewClient, etc.
+})
+
+func TestNgrokProvider(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Ngrok Provider Suite")
+}

+ 1 - 0
pkg/provider/register/register.go

@@ -40,6 +40,7 @@ import (
 	_ "github.com/external-secrets/external-secrets/pkg/provider/infisical"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/keepersecurity"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/kubernetes"
+	_ "github.com/external-secrets/external-secrets/pkg/provider/ngrok"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/onboardbase"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/onepassword"
 	_ "github.com/external-secrets/external-secrets/pkg/provider/onepasswordsdk"

+ 1 - 0
tests/__snapshot__/clusterexternalsecret-v1.yaml

@@ -44,6 +44,7 @@ spec:
           conflictPolicy: "Error"
           into: ""
           priority: [] # minItems 0 of type string
+          priorityPolicy: "Strict"
           strategy: "Extract"
         regexp:
           source: string

+ 11 - 0
tests/__snapshot__/clustersecretstore-v1.yaml

@@ -319,6 +319,7 @@ spec:
             namespace: string
       location: string
       projectID: string
+      secretVersionSelectionPolicy: "LatestOrFail"
     github:
       appID: 1
       auth:
@@ -512,6 +513,16 @@ spec:
           namespace: string
           type: "Secret" # "Secret", "ConfigMap"
         url: "kubernetes.default"
+    ngrok:
+      apiUrl: "https://api.ngrok.com"
+      auth:
+        apiKey:
+          secretRef:
+            key: string
+            name: string
+            namespace: string
+      vault:
+        name: string
     onboardbase:
       apiHost: "https://public.onboardbase.com/api/v1/"
       auth:

+ 2 - 1
tests/__snapshot__/externalsecret-v1.yaml

@@ -39,6 +39,7 @@ spec:
         conflictPolicy: "Error"
         into: ""
         priority: [] # minItems 0 of type string
+        priorityPolicy: "Strict"
         strategy: "Extract"
       regexp:
         source: string
@@ -93,6 +94,6 @@ status:
     message: string
     reason: string
     status: string
-    type: string
+    type: "Ready" # "Ready", "Deleted"
   refreshTime: 2024-10-11T12:48:44Z
   syncedResourceVersion: string

+ 11 - 0
tests/__snapshot__/secretstore-v1.yaml

@@ -319,6 +319,7 @@ spec:
             namespace: string
       location: string
       projectID: string
+      secretVersionSelectionPolicy: "LatestOrFail"
     github:
       appID: 1
       auth:
@@ -512,6 +513,16 @@ spec:
           namespace: string
           type: "Secret" # "Secret", "ConfigMap"
         url: "kubernetes.default"
+    ngrok:
+      apiUrl: "https://api.ngrok.com"
+      auth:
+        apiKey:
+          secretRef:
+            key: string
+            name: string
+            namespace: string
+      vault:
+        name: string
     onboardbase:
       apiHost: "https://public.onboardbase.com/api/v1/"
       auth: