Repository: ratify-project/ratify
Pull request #2244 (2025-05-19 02:11:19)

feat!: support mutation webhook
# Description

## What this PR does / why we need it:
1. Add mutate flags to main.go
2. Support mutate handler and register it if required when initializing the server.
3. Update the helm chart to support configuring the mutation endpoint.

## Which issue(s) this PR fixes *(optional, using `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close the issue(s) when the PR gets merged)*:
Fixes #

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Helm Chart Change (any edit/addition/update that is necessary for changes merged to the `main` branch)
- [ ] This change requires a documentation update

# How Has This Been Tested?

Added unit tests and run manual test on cluster.

# Checklist:

- [ ] Does the affected code have corresponding tests?
- [ ] Are the changes documented, not just with inline documentation, but also with conceptual documentation such as an overview of a new feature, or task-based documentation like a tutorial? Consider if this change should be announced on your project blog.
- [ ] Does this introduce breaking changes that would require an announcement or bumping the major version?
- [ ] Do all new files have appropriate license header?

# Post Merge Requirements
- [ ] MAINTAINERS: manually trigger the "Publish Package" workflow after merging any PR that indicates `Helm Chart Change`


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project) Report
Attention: Patch coverage is `81.73913%` with `21 lines` in your changes missing coverage. Please review.

| [Files with missing lines](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project) | Patch % | Lines |
|---|---|---|
| [internal/httpserver/server.go](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?src=pr&el=tree&filepath=internal%2Fhttpserver%2Fserver.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project#diff-aW50ZXJuYWwvaHR0cHNlcnZlci9zZXJ2ZXIuZ28=) | 70.37% | [5 Missing and 3 partials :warning: ](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project) |
| [internal/manager/manager.go](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?src=pr&el=tree&filepath=internal%2Fmanager%2Fmanager.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project#diff-aW50ZXJuYWwvbWFuYWdlci9tYW5hZ2VyLmdv) | 0.00% | [7 Missing :warning: ](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project) |
| [internal/httpserver/handlers.go](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?src=pr&el=tree&filepath=internal%2Fhttpserver%2Fhandlers.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project#diff-aW50ZXJuYWwvaHR0cHNlcnZlci9oYW5kbGVycy5nbw==) | 92.30% | [4 Missing and 2 partials :warning: ](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project) |

| [Files with missing lines](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project) | Coverage Δ | |
|---|---|---|
| [cmd/ratify-gatekeeper-provider/main.go](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?src=pr&el=tree&filepath=cmd%2Fratify-gatekeeper-provider%2Fmain.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project#diff-Y21kL3JhdGlmeS1nYXRla2VlcGVyLXByb3ZpZGVyL21haW4uZ28=) | `92.10% <100.00%> (+0.43%)` | :arrow_up: |
| [internal/httpserver/handlers.go](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?src=pr&el=tree&filepath=internal%2Fhttpserver%2Fhandlers.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project#diff-aW50ZXJuYWwvaHR0cHNlcnZlci9oYW5kbGVycy5nbw==) | `92.30% <92.30%> (ø)` | |
| [internal/manager/manager.go](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?src=pr&el=tree&filepath=internal%2Fmanager%2Fmanager.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project#diff-aW50ZXJuYWwvbWFuYWdlci9tYW5hZ2VyLmdv) | `0.00% <0.00%> (ø)` | |
| [internal/httpserver/server.go](https://app.codecov.io/gh/ratify-project/ratify/pull/2244?src=pr&el=tree&filepath=internal%2Fhttpserver%2Fserver.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project#diff-aW50ZXJuYWwvaHR0cHNlcnZlci9zZXJ2ZXIuZ28=) | `64.60% <70.37%> (-8.40%)` | :arrow_down: |

... and [1 file with indirect coverage changes](https://app.codecov.io/gh/ratify-project/ratify/pull/2244/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ratify-project)

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/charts/ratify/templates/provider.yaml b/charts/ratify/templates/provider.yaml
deleted file mode 100644
index e5b48e8..0000000
--- a/charts/ratify/templates/provider.yaml
+++ /dev/null
@@ -1,26 +0,0 @@
-{{ include "ratify.providerGKVersion" . }}
-kind: Provider
-metadata:
-  name: ratify-provider
-  annotations:
-    helm.sh/hook: pre-install,pre-upgrade
-    helm.sh/hook-weight: "5"
-spec:
-  url: https://{{ include "ratify.fullname" .}}.{{ .Release.Namespace }}:6001/ratify/gatekeeper/v1/verify
-  timeout: {{ required "You must provide .Values.provider.timeout.validationTimeoutSeconds" .Values.provider.timeout.validationTimeoutSeconds }}
-  {{ include "ratify.providerCabundle" . | nindent 2}}
-
-{{- if .Values.provider.enableMutation }}
----
-{{ include "ratify.providerGKVersion" . }}
-kind: Provider
-metadata:
-  name: ratify-mutation-provider
-  annotations:
-    helm.sh/hook: pre-install,pre-upgrade
-    helm.sh/hook-weight: "5"
-spec:
-  url: https://{{ include "ratify.fullname" .}}.{{ .Release.Namespace }}:6001/ratify/gatekeeper/v1/mutate
-  timeout: {{ required "You must provide .Values.provider.timeout.mutationTimeoutSeconds" .Values.provider.timeout.mutationTimeoutSeconds }}
-  {{ include "ratify.providerCabundle" . | nindent 2}}
-{{- end }}
diff --git a/cmd/ratify-gatekeeper-provider/main.go b/cmd/ratify-gatekeeper-provider/main.go
index c4311f2..1f1704e 100644
--- a/cmd/ratify-gatekeeper-provider/main.go
+++ b/cmd/ratify-gatekeeper-provider/main.go
@@ -40,7 +40,9 @@ type options struct {
 	keyFile              string
 	gatekeeperCACertFile string
 	disableCertRotation  bool
+	disableMutation      bool
 	verifyTimeout        time.Duration
+	mutateTimeout        time.Duration
 }
 
 func parse() *options {
@@ -51,7 +53,9 @@ func parse() *options {
 	flag.StringVar(&opts.keyFile, "key-file", "", "Path to the TLS key file")
 	flag.StringVar(&opts.gatekeeperCACertFile, "gatekeeper-ca-cert-file", "", "Path to the Gatekeeper CA certificate file")
 	flag.DurationVar(&opts.verifyTimeout, "verify-timeout", 5*time.Second, "Verification timeout duration (e.g. 5s, 1m), default is 5 seconds")
+	flag.DurationVar(&opts.mutateTimeout, "mutate-timeout", 2*time.Second, "Mutation timeout duration (e.g. 5s, 1m), default is 2 seconds")
 	flag.BoolVar(&opts.disableCertRotation, "disable-cert-rotation", false, "Disable certificate rotation")
+	flag.BoolVar(&opts.disableMutation, "disable-mutation", false, "Disable mutation wehbook")
 
 	flag.Parse()
 	logrus.Infof("Starting Ratify with options: %+v", opts)
@@ -72,9 +76,11 @@ func startRatify(opts *options) error {
 		KeyFile:              opts.keyFile,
 		GatekeeperCACertFile: opts.gatekeeperCACertFile,
 		VerifyTimeout:        opts.verifyTimeout,
+		MutateTimeout:        opts.mutateTimeout,
+		DisableMutation:      opts.disableMutation,
 		CertRotatorReady:     certRotatorReady,
 	}
 
-	go manager.StartManager(certRotatorReady)
+	go manager.StartManager(certRotatorReady, serverOpts.DisableMutation)
 	return httpserver.StartServer(serverOpts, opts.configFilePath)
 }
diff --git a/deployments/ratify-gatekeeper-provider/templates/_helper.tpl b/deployments/ratify-gatekeeper-provider/templates/_helper.tpl
index 4164431..d00cc4f 100644
--- a/deployments/ratify-gatekeeper-provider/templates/_helper.tpl
+++ b/deployments/ratify-gatekeeper-provider/templates/_helper.tpl
@@ -103,4 +103,16 @@ Create chart name and version as used by the chart label.
 */}}
 {{- define "ratify.chart" -}}
 {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Set the namespace exclusions for Assign
+*/}}
+{{- define "ratify.assignExcludedNamespaces" -}}
+{{- $gkNamespace := default "gatekeeper-system" .Values.gatekeeper.namespace -}}
+- {{ $gkNamespace | quote}}
+- "kube-system"
+{{- if and (ne .Release.Namespace $gkNamespace) (ne .Release.Namespace "kube-system") }}
+- {{ .Release.Namespace | quote}}
+{{- end }}
 {{- end }}
\ No newline at end of file
diff --git a/charts/ratify/templates/assign.yaml b/deployments/ratify-gatekeeper-provider/templates/assign.yaml
similarity index 83%
rename from charts/ratify/templates/assign.yaml
rename to deployments/ratify-gatekeeper-provider/templates/assign.yaml
index 531ca46..fd16e15 100644
--- a/charts/ratify/templates/assign.yaml
+++ b/deployments/ratify-gatekeeper-provider/templates/assign.yaml
@@ -1,5 +1,5 @@
-{{- if .Values.provider.enableMutation -}}
-{{ include "ratify.assignGKVersion" . }}
+{{- if not .Values.provider.disableMutation -}}
+apiVersion: mutations.gatekeeper.sh/v1
 kind: Assign
 metadata:
   name: mutate-pod-image
@@ -19,9 +19,9 @@ spec:
   parameters:
     assign:
       externalData:
-        provider: ratify-mutation-provider
+        provider: ratify-gatekeeper-mutation-provider
 ---
-{{ include "ratify.assignGKVersion" . }}
+apiVersion: mutations.gatekeeper.sh/v1
 kind: Assign
 metadata:
   name: mutate-workload-image
@@ -41,9 +41,9 @@ spec:
   parameters:
     assign:
       externalData:
-        provider: ratify-mutation-provider
+        provider: ratify-gatekeeper-mutation-provider
 ---
-{{ include "ratify.assignGKVersion" . }}
+apiVersion: mutations.gatekeeper.sh/v1
 kind: Assign
 metadata:
   name: mutate-cronjob-image
@@ -63,9 +63,9 @@ spec:
   parameters:
     assign:
       externalData:
-        provider: ratify-mutation-provider
+        provider: ratify-gatekeeper-mutation-provider
 ---
-{{ include "ratify.assignGKVersion" . }}
+apiVersion: mutations.gatekeeper.sh/v1
 kind: Assign
 metadata:
   name: mutate-pod-image-init
@@ -85,9 +85,9 @@ spec:
   parameters:
     assign:
       externalData:
-        provider: ratify-mutation-provider
+        provider: ratify-gatekeeper-mutation-provider
 ---
-{{ include "ratify.assignGKVersion" . }}
+apiVersion: mutations.gatekeeper.sh/v1
 kind: Assign
 metadata:
   name: mutate-workload-image-init
@@ -107,9 +107,9 @@ spec:
   parameters:
     assign:
       externalData:
-        provider: ratify-mutation-provider
+        provider: ratify-gatekeeper-mutation-provider
 ---
-{{ include "ratify.assignGKVersion" . }}
+apiVersion: mutations.gatekeeper.sh/v1
 kind: Assign
 metadata:
   name: mutate-cronjob-image-init
@@ -129,9 +129,9 @@ spec:
   parameters:
     assign:
       externalData:
-        provider: ratify-mutation-provider
+        provider: ratify-gatekeeper-mutation-provider
 ---
-{{ include "ratify.assignGKVersion" . }}
+apiVersion: mutations.gatekeeper.sh/v1
 kind: Assign
 metadata:
   name: mutate-pod-image-ephemeral
@@ -151,9 +151,9 @@ spec:
   parameters:
     assign:
       externalData:
-        provider: ratify-mutation-provider
+        provider: ratify-gatekeeper-mutation-provider
 ---
-{{ include "ratify.assignGKVersion" . }}
+apiVersion: mutations.gatekeeper.sh/v1
 kind: Assign
 metadata:
   name: mutate-workload-image-ephemeral
@@ -173,9 +173,9 @@ spec:
   parameters:
     assign:
       externalData:
-        provider: ratify-mutation-provider
+        provider: ratify-gatekeeper-mutation-provider
 ---
-{{ include "ratify.assignGKVersion" . }}
+apiVersion: mutations.gatekeeper.sh/v1
 kind: Assign
 metadata:
   name: mutate-cronjob-image-ephemeral
@@ -195,5 +195,5 @@ spec:
   parameters:
     assign:
       externalData:
-        provider: ratify-mutation-provider
+        provider: ratify-gatekeeper-mutation-provider
 {{- end }}
diff --git a/deployments/ratify-gatekeeper-provider/templates/deployment.yaml b/deployments/ratify-gatekeeper-provider/templates/deployment.yaml
index bba9067..42a0daf 100644
--- a/deployments/ratify-gatekeeper-provider/templates/deployment.yaml
+++ b/deployments/ratify-gatekeeper-provider/templates/deployment.yaml
@@ -39,13 +39,22 @@ spec:
             - ":6001"
             - "--config"
             - "/usr/local/config.json"
+            {{- if .Values.provider.timeout.validationTimeoutSeconds }}
             - "--verify-timeout"
-            - "5s"
+            - {{ printf "%.1fs" (subf .Values.provider.timeout.validationTimeoutSeconds 0.1) }}
+            {{- end }}
+            {{- if .Values.provider.timeout.mutationTimeoutSeconds }}
+            - "--mutate-timeout"
+            - {{ printf "%.2fs" (subf .Values.provider.timeout.mutationTimeoutSeconds 0.05) }}
+            {{- end }}
             - "--cert-file=/usr/local/tls/tls.crt"
             - "--key-file=/usr/local/tls/tls.key"
             {{- if .Values.provider.tls.disableCertRotation }}
             - "--disable-cert-rotation"
             {{- end }}
+            {{- if .Values.provider.disableMutation }}
+            - "--disable-mutation"
+            {{- end }}
             {{- if (lookup "v1" "Secret" .Release.Namespace "gatekeeper-webhook-server-cert") }}
             - "--gatekeeper-ca-cert-file=/usr/local/tls/client-ca/ca.crt"
             {{- end }}
diff --git a/deployments/ratify-gatekeeper-provider/templates/provider.yaml b/deployments/ratify-gatekeeper-provider/templates/provider.yaml
index e7a01a0..bc39bb9 100644
--- a/deployments/ratify-gatekeeper-provider/templates/provider.yaml
+++ b/deployments/ratify-gatekeeper-provider/templates/provider.yaml
@@ -8,4 +8,18 @@ metadata:
 spec:
   url: https://{{ include "ratify.fullname" .}}.{{ .Release.Namespace }}:6001/ratify/gatekeeper/v2/verify
   timeout: {{ required "You must provide .Values.provider.timeout.validationTimeoutSeconds" .Values.provider.timeout.validationTimeoutSeconds }}
-  {{ include "ratify.providerCabundle" . | nindent 2}}
\ No newline at end of file
+  {{ include "ratify.providerCabundle" . | nindent 2}}
+---
+{{- if not .Values.provider.disableMutation }}
+apiVersion: externaldata.gatekeeper.sh/v1beta1
+kind: Provider
+metadata:
+  name: ratify-gatekeeper-mutation-provider
+  annotations:
+    helm.sh/hook: pre-install,pre-upgrade
+    helm.sh/hook-weight: "5"
+spec:
+  url: https://{{ include "ratify.fullname" .}}.{{ .Release.Namespace }}:6001/ratify/gatekeeper/v2/mutate
+  timeout: {{ required "You must provide .Values.provider.timeout.mutationTimeoutSeconds" .Values.provider.timeout.mutationTimeoutSeconds }}
+  {{ include "ratify.providerCabundle" . | nindent 2}}
+{{- end }}
\ No newline at end of file
diff --git a/deployments/ratify-gatekeeper-provider/values.yaml b/deployments/ratify-gatekeeper-provider/values.yaml
index aad620c..2eb7eec 100644
--- a/deployments/ratify-gatekeeper-provider/values.yaml
+++ b/deployments/ratify-gatekeeper-provider/values.yaml
@@ -27,9 +27,14 @@ provider:
     caCert: "" # CA crt used by ratify (httpserver), please provide your own CA crt
     cabundle: "" # base64 encoded CA bundle used for the 'caBundle' property for the ratify provider within gatekeeper
     disableCertRotation: false
+  disableMutation: false
   timeout:
     # timeout values must match gatekeeper webhook timeouts
     validationTimeoutSeconds: 5
+    mutationTimeoutSeconds: 2
+
+gatekeeper:
+  namespace: "gatekeeper-system"
 
 serviceAccount:
   create: true
diff --git a/internal/httpserver/handlers.go b/internal/httpserver/handlers.go
new file mode 100644
index 0000000..063405a
--- /dev/null
+++ b/internal/httpserver/handlers.go
@@ -0,0 +1,122 @@
+/*
+Copyright The Ratify Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://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 httpserver
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+
+	"github.com/open-policy-agent/frameworks/constraint/pkg/externaldata"
+	"github.com/ratify-project/ratify-go"
+	"oras.land/oras-go/v2/registry"
+)
+
+// verify handles the verification request from Gatekeeper.
+func (s *server) verify(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
+	defer r.Body.Close()
+	body, err := io.ReadAll(r.Body)
+	if err != nil {
+		return fmt.Errorf("failed to read request body: %w", err)
+	}
+
+	var providerRequest externaldata.ProviderRequest
+	if err = json.Unmarshal(body, &providerRequest); err != nil {
+		return fmt.Errorf("failed to unmarshal request body to provider request: %w", err)
+	}
+
+	results := make([]externaldata.Item, len(providerRequest.Request.Keys))
+	for idx, key := range providerRequest.Request.Keys {
+		item := externaldata.Item{
+			Key: key,
+		}
+		opts := ratify.ValidateArtifactOptions{
+			Subject: key,
+		}
+		result, err := s.executor.ValidateArtifact(ctx, opts)
+		if err != nil {
+			item.Error = err.Error()
+		}
+		item.Value = convertResult(result)
+		results[idx] = item
+	}
+
+	return sendResponse(results, w, http.StatusOK, false)
+}
+
+func (s *server) mutate(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
+	defer r.Body.Close()
+	body, err := io.ReadAll(r.Body)
+	if err != nil {
+		return fmt.Errorf("failed to read request body: %w", err)
+	}
+
+	var providerRequest externaldata.ProviderRequest
+	if err = json.Unmarshal(body, &providerRequest); err != nil {
+		return fmt.Errorf("failed to unmarshal request body to provider request: %w", err)
+	}
+	results := make([]externaldata.Item, len(providerRequest.Request.Keys))
+	for idx, key := range providerRequest.Request.Keys {
+		results[idx] = s.resolveReference(ctx, key)
+	}
+
+	return sendResponse(results, w, http.StatusOK, true)
+}
+
+func (s *server) resolveReference(ctx context.Context, reference string) externaldata.Item {
+	item := externaldata.Item{
+		Key:   reference,
+		Value: reference,
+	}
+
+	ref, err := registry.ParseReference(reference)
+	if err != nil {
+		item.Error = fmt.Sprintf("failed to parse reference: %v", err)
+		return item
+	}
+	if _, err = ref.Digest(); err == nil {
+		item.Value = ref.String()
+		return item
+	}
+
+	desc, err := s.executor.Store.Resolve(ctx, ref.String())
+	if err != nil {
+		item.Error = fmt.Sprintf("failed to resolve reference: %v", err)
+		return item
+	}
+	ref.Reference = desc.Digest.String()
+	item.Value = ref.String()
+	return item
+}
+
+func sendResponse(results []externaldata.Item, w http.ResponseWriter, respCode int, isMutation bool) error {
+	response := externaldata.ProviderResponse{
+		APIVersion: "externaldata.gatekeeper.sh/v1beta1",
+		Kind:       "ProviderResponse",
+		Response: externaldata.Response{
+			Idempotent: true,
+			Items:      results,
+		},
+	}
+
+	// Only mutation webhook can be invoked multiple times and thus must be idempotent.
+	response.Response.Idempotent = isMutation
+
+	w.WriteHeader(respCode)
+	return json.NewEncoder(w).Encode(response)
+}
diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go
index 6a57eb6..bd99e8f 100644
--- a/internal/httpserver/server.go
+++ b/internal/httpserver/server.go
@@ -18,9 +18,7 @@ package httpserver
 import (
 	"context"
 	"crypto/tls"
-	"encoding/json"
 	"fmt"
-	"io"
 	"net/http"
 	"net/url"
 	"os"
@@ -29,7 +27,6 @@ import (
 	"time"
 
 	"github.com/gorilla/mux"
-	"github.com/open-policy-agent/frameworks/constraint/pkg/externaldata"
 	"github.com/ratify-project/ratify-go"
 	"github.com/ratify-project/ratify/v2/internal/executor"
 	"github.com/ratify-project/ratify/v2/internal/httpserver/config"
@@ -40,20 +37,18 @@ import (
 const (
 	serverRootURL        = "/ratify/gatekeeper/v2"
 	verifyPath           = "verify"
+	mutatePath           = "mutate"
 	defaultVerifyTimeout = 5 * time.Second
+	defaultMutateTimeout = 2 * time.Second
 	readTimeout          = 5 * time.Second
 	writeTimeout         = 5 * time.Second
 	idleTimeout          = 60 * time.Second
 )
 
 type server struct {
-	address              string
-	certFile             string
-	keyFile              string
-	gatekeeperCACertFile string
-	router               *mux.Router
-	executor             *ratify.Executor
-	verifyRequestTimeout time.Duration
+	router   *mux.Router
+	executor *ratify.Executor
+	ServerOptions
 }
 
 // ServerOptions holds the configuration options for the Ratify server.
@@ -83,6 +78,16 @@ type ServerOptions struct {
 	// Optional.
 	VerifyTimeout time.Duration
 
+	// MutateTimeout is the duration to wait for a mutation request to
+	// complete before timing out. Default is 2 seconds if not specified.
+	// Optional.
+	MutateTimeout time.Duration
+
+	// DisableMutation indicates whether to disable the mutation handler.
+	// If set to true, the mutation handler will not be registered.
+	// Optional.
+	DisableMutation bool
+
 	// CertRotatorReady is a channel that signals when the certificate rotator
 	// is ready. If not provided, the server will run without rotating the TLS
 	// certificates.
@@ -115,16 +120,15 @@ func newServer(serverOpts *ServerOptions, executorOpts *executor.Options) (*serv
 		return nil, fmt.Errorf("failed to create executor: %w", err)
 	}
 	server := &server{
-		router:               mux.NewRouter(),
-		address:              serverOpts.HTTPServerAddress,
-		certFile:             serverOpts.CertFile,
-		keyFile:              serverOpts.KeyFile,
-		gatekeeperCACertFile: serverOpts.GatekeeperCACertFile,
-		verifyRequestTimeout: serverOpts.VerifyTimeout,
-		executor:             e,
+		router:        mux.NewRouter(),
+		executor:      e,
+		ServerOptions: *serverOpts,
+	}
+	if server.VerifyTimeout == 0 {
+		server.VerifyTimeout = defaultVerifyTimeout
 	}
-	if server.verifyRequestTimeout == 0 {
-		server.verifyRequestTimeout = defaultVerifyTimeout
+	if server.MutateTimeout == 0 {
+		server.MutateTimeout = defaultMutateTimeout
 	}
 	if err := server.registerHandlers(); err != nil {
 		return nil, fmt.Errorf("failed to register handlers: %w", err)
@@ -136,14 +140,22 @@ func (s *server) registerHandlers() error {
 	if err := s.registerVerifyHandler(); err != nil {
 		return err
 	}
-	if err := s.registerMutateHandler(); err != nil {
-		return err
+
+	if !s.DisableMutation {
+		if err := s.registerMutateHandler(); err != nil {
+			return err
+		}
 	}
 	return nil
 }
 
 // TODO: implement mutate handler.
 func (s *server) registerMutateHandler() error {
+	mutateURL, err := url.JoinPath(serverRootURL, mutatePath)
+	if err != nil {
+		return err
+	}
+	s.router.Methods(http.MethodPost).PathPrefix(mutateURL).Handler(middlewareWithTimeout(s.mutateHandler(), s.MutateTimeout))
 	return nil
 }
 
@@ -152,7 +164,7 @@ func (s *server) registerVerifyHandler() error {
 	if err != nil {
 		return err
 	}
-	s.router.Methods(http.MethodPost).Path(verifyURL).Handler(middlewareWithTimeout(s.verifyHandler(), s.verifyRequestTimeout))
+	s.router.Methods(http.MethodPost).Path(verifyURL).Handler(middlewareWithTimeout(s.verifyHandler(), s.VerifyTimeout))
 	return nil
 }
 
@@ -162,44 +174,10 @@ func (s *server) verifyHandler() http.HandlerFunc {
 	}
 }
 
-// verify handles the verification request from Gatekeeper.
-func (s *server) verify(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
-	defer r.Body.Close()
-	body, err := io.ReadAll(r.Body)
-	if err != nil {
-		return fmt.Errorf("failed to read request body: %w", err)
-	}
-
-	var providerRequest externaldata.ProviderRequest
-	if err = json.Unmarshal(body, &providerRequest); err != nil {
-		return fmt.Errorf("failed to unmarshal request body to provider request: %w", err)
-	}
-
-	results := make([]externaldata.Item, len(providerRequest.Request.Keys))
-	for idx, key := range providerRequest.Request.Keys {
-		item := externaldata.Item{
-			Key: key,
-		}
-		opts := ratify.ValidateArtifactOptions{
-			Subject: key,
-		}
-		result, err := s.executor.ValidateArtifact(ctx, opts)
-		if err != nil {
-			item.Error = err.Error()
-		}
-		item.Value = convertResult(result)
-		results[idx] = item
-	}
-
-	response := externaldata.ProviderResponse{
-		APIVersion: "externaldata.gatekeeper.sh/v1beta1",
-		Kind:       "ProviderResponse",
-		Response: externaldata.Response{
-			Items: results,
-		},
+func (s *server) mutateHandler() http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		_ = s.mutate(r.Context(), w, r)
 	}
-	w.WriteHeader(http.StatusOK)
-	return json.NewEncoder(w).Encode(response)
 }
 
 func middlewareWithTimeout(next http.Handler, timeout time.Duration) http.Handler {
@@ -214,21 +192,21 @@ func middlewareWithTimeout(next http.Handler, timeout time.Duration) http.Handle
 // It also handles graceful shutdown on receiving an interrupt signal.
 func (s *server) Run(certRotatorReady chan struct{}) error {
 	srv := &http.Server{
-		Addr:         s.address,
+		Addr:         s.HTTPServerAddress,
 		Handler:      s.router,
 		WriteTimeout: writeTimeout,
 		ReadTimeout:  readTimeout,
 		IdleTimeout:  idleTimeout,
 	}
 	go func() {
-		if s.certFile != "" && s.keyFile != "" {
-			logrus.Infof("starting server with TLS at %s", s.address)
+		if s.CertFile != "" && s.KeyFile != "" {
+			logrus.Infof("starting server with TLS at %s", s.HTTPServerAddress)
 			if certRotatorReady != nil {
 				<-certRotatorReady
 				logrus.Infof("cert rotator is ready")
 			}
 
-			certWatcher, err := tlssecret.NewTLSSecretWatcher(s.gatekeeperCACertFile, s.certFile, s.keyFile)
+			certWatcher, err := tlssecret.NewTLSSecretWatcher(s.GatekeeperCACertFile, s.CertFile, s.KeyFile)
 			if err != nil {
 				logrus.Errorf("failed to create TLS secret watcher: %v", err)
 				return
@@ -242,7 +220,7 @@ func (s *server) Run(certRotatorReady chan struct{}) error {
 				logrus.Errorf("failed to start server: %v", err)
 			}
 		} else {
-			logrus.Infof("starting server without TLS at %s", s.address)
+			logrus.Infof("starting server without TLS at %s", s.HTTPServerAddress)
 			if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
 				logrus.Errorf("failed to start server: %v", err)
 			}
@@ -254,7 +232,7 @@ func (s *server) Run(certRotatorReady chan struct{}) error {
 	signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
 	<-quit
 
-	ctx, cancel := context.WithTimeout(context.Background(), s.verifyRequestTimeout)
+	ctx, cancel := context.WithTimeout(context.Background(), s.VerifyTimeout)
 	defer cancel()
 	if err := srv.Shutdown(ctx); err != nil {
 		logrus.Errorf("failed to shutdown server: %v", err)
diff --git a/internal/manager/manager.go b/internal/manager/manager.go
index ac572d3..7cc7ddc 100644
--- a/internal/manager/manager.go
+++ b/internal/manager/manager.go
@@ -34,7 +34,7 @@ const (
 
 // StartManager creates a new Manager which is responsible for creating
 // Controllers.
-func StartManager(certRotatorReady chan struct{}) {
+func StartManager(certRotatorReady chan struct{}, disableMutation bool) {
 	log := ctrl.Log.WithName("ratify-manager")
 	if certRotatorReady == nil {
 		log.Info("cert rotator is disabled")
@@ -55,6 +55,13 @@ func StartManager(certRotatorReady chan struct{}) {
 			Type: rotator.ExternalDataProvider,
 		},
 	}
+	if !disableMutation {
+		webhooks = append(webhooks, rotator.WebhookInfo{
+			Name: "ratify-gatekeeper-mutation-provider",
+			Type: rotator.ExternalDataProvider,
+		})
+	}
+
 	namespace := pod.GetNamespace()
 	serviceName := pod.GetServiceName()
 


Test patch:
diff --git a/cmd/ratify-gatekeeper-provider/main_test.go b/cmd/ratify-gatekeeper-provider/main_test.go
index 97588bc..84618d3 100644
--- a/cmd/ratify-gatekeeper-provider/main_test.go
+++ b/cmd/ratify-gatekeeper-provider/main_test.go
@@ -66,15 +66,18 @@ func TestParse(t *testing.T) {
 				certFile:          "cert.pem",
 				keyFile:           "key.pem",
 				verifyTimeout:     10 * time.Second,
+				mutateTimeout:     2 * time.Second,
 			},
 		},
 		{
 			name: "only timeout provided",
 			args: []string{
 				"-verify-timeout=30s",
+				"-mutate-timeout=10s",
 			},
 			expected: &options{
 				verifyTimeout: 30 * time.Second,
+				mutateTimeout: 10 * time.Second,
 			},
 		},
 		{
@@ -82,6 +85,7 @@ func TestParse(t *testing.T) {
 			args: []string{},
 			expected: &options{
 				verifyTimeout: 5 * time.Second,
+				mutateTimeout: 2 * time.Second,
 			},
 		},
 	}
diff --git a/internal/httpserver/handlers_test.go b/internal/httpserver/handlers_test.go
new file mode 100644
index 0000000..78a90db
--- /dev/null
+++ b/internal/httpserver/handlers_test.go
@@ -0,0 +1,207 @@
+/*
+Copyright The Ratify Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://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 httpserver
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/open-policy-agent/frameworks/constraint/pkg/externaldata"
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+	"github.com/ratify-project/ratify-go"
+)
+
+func TestVerify(t *testing.T) {
+	executor := ratify.Executor{}
+	server := &server{
+		executor: &executor,
+	}
+
+	tests := []struct {
+		name          string
+		requestBody   string
+		expectedError bool
+		expectedItems []externaldata.Item
+	}{
+		{
+			name: "Valid request",
+			requestBody: `{
+				"request": {
+					"keys": ["artifact1"]
+				}
+			}`,
+			expectedError: false,
+			expectedItems: []externaldata.Item{
+				{
+					Key:   "artifact1",
+					Value: nil,
+					Error: "store must be configured",
+				},
+			},
+		},
+		{
+			name:          "Invalid JSON",
+			requestBody:   `{invalid-json}`,
+			expectedError: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodPost, "/verify", strings.NewReader(test.requestBody))
+			w := httptest.NewRecorder()
+
+			err := server.verify(context.Background(), w, req)
+			if (err != nil) != test.expectedError {
+				t.Errorf("expected error: %v, got: %v", test.expectedError, err)
+			}
+
+			if !test.expectedError {
+				var response externaldata.ProviderResponse
+				if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
+					t.Fatalf("failed to decode response: %v", err)
+				}
+
+				if !reflect.DeepEqual(response.Response.Items, test.expectedItems) {
+					t.Errorf("expected items: %v, got: %v", test.expectedItems, response.Response.Items)
+				}
+			}
+		})
+	}
+}
+func TestMutate(t *testing.T) {
+	tests := []struct {
+		name          string
+		requestBody   string
+		expectedError bool
+		store         ratify.Store
+		expectedItems []externaldata.Item
+	}{
+		{
+			name: "Valid mutate request",
+			requestBody: `{
+				"request": {
+					"keys": ["testrepo/testimage@sha256:498138d40d54f0fc20cd271e215366d3d8803f814b8f565b47c101480bbaaa88"]
+				}
+			}`,
+			expectedError: false,
+			expectedItems: []externaldata.Item{
+				{
+					Key:   "testrepo/testimage@sha256:498138d40d54f0fc20cd271e215366d3d8803f814b8f565b47c101480bbaaa88",
+					Value: "testrepo/testimage@sha256:498138d40d54f0fc20cd271e215366d3d8803f814b8f565b47c101480bbaaa88",
+				},
+			},
+		},
+		{
+			name:          "Invalid JSON mutate",
+			requestBody:   `{invalid-json}`,
+			expectedError: true,
+		},
+		{
+			name: "Invalid reference",
+			requestBody: `{
+				"request": {
+					"keys": ["testrepo"]
+				}
+			}`,
+			expectedError: false,
+			expectedItems: []externaldata.Item{
+				{
+					Key:   "testrepo",
+					Value: "testrepo",
+					Error: "failed to parse reference: invalid reference: missing registry or repository",
+				},
+			},
+		},
+		{
+			name: "Store fails to resolve reference",
+			requestBody: `{
+				"request": {
+					"keys": ["testrepo/testimage:v1"]
+				}
+			}`,
+			store: &mockStore{
+				returnResolveErr: true,
+			},
+			expectedError: false,
+			expectedItems: []externaldata.Item{
+				{
+					Key:   "testrepo/testimage:v1",
+					Value: "testrepo/testimage:v1",
+					Error: "failed to resolve reference: mock error",
+				},
+			},
+		},
+		{
+			name: "Store resolves reference successfully",
+			requestBody: `{
+				"request": {
+					"keys": ["testrepo/testimage:v1"]
+				}
+			}`,
+			store: &mockStore{
+				resolveMap: map[string]ocispec.Descriptor{
+					"testrepo/testimage:v1": {
+						Digest: "sha256:498138d40d54f0fc20cd271e215366d3d8803f814b8f565b47c101480bbaaa88",
+					},
+				},
+			},
+			expectedError: false,
+			expectedItems: []externaldata.Item{
+				{
+					Key:   "testrepo/testimage:v1",
+					Value: "testrepo/testimage@sha256:498138d40d54f0fc20cd271e215366d3d8803f814b8f565b47c101480bbaaa88",
+				},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodPost, "/mutate", strings.NewReader(test.requestBody))
+			w := httptest.NewRecorder()
+
+			server := &server{
+				executor: &ratify.Executor{
+					Store: test.store,
+				},
+			}
+			err := server.mutate(context.Background(), w, req)
+			if (err != nil) != test.expectedError {
+				t.Errorf("expected error: %v, got: %v", test.expectedError, err)
+			}
+
+			if !test.expectedError {
+				var response externaldata.ProviderResponse
+				if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
+					t.Fatalf("failed to decode response: %v", err)
+				}
+
+				if !reflect.DeepEqual(response.Response.Items, test.expectedItems) {
+					t.Errorf("expected items: %v, got: %v", test.expectedItems, response.Response.Items)
+				}
+				if !response.Response.Idempotent {
+					t.Errorf("expected Idempotent to be true for mutate, got false")
+				}
+			}
+		})
+	}
+}
diff --git a/internal/httpserver/server_test.go b/internal/httpserver/server_test.go
index e4e6956..9ce3a8f 100644
--- a/internal/httpserver/server_test.go
+++ b/internal/httpserver/server_test.go
@@ -26,17 +26,12 @@ import (
 	"encoding/pem"
 	"fmt"
 	"math/big"
-	"net/http"
-	"net/http/httptest"
 	"os"
 	"path/filepath"
-	"reflect"
-	"strings"
 	"syscall"
 	"testing"
 	"time"
 
-	"github.com/open-policy-agent/frameworks/constraint/pkg/externaldata"
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
 	"github.com/ratify-project/ratify-go"
 	"github.com/ratify-project/ratify/v2/internal/executor"
@@ -56,9 +51,20 @@ func createMockVerifier(factory.NewVerifierOptions) (ratify.Verifier, error) {
 	return &mockVerifier{}, nil
 }
 
-type mockStore struct{}
+type mockStore struct {
+	resolveMap       map[string]ocispec.Descriptor
+	returnResolveErr bool
+}
 
-func (m *mockStore) Resolve(_ context.Context, _ string) (ocispec.Descriptor, error) {
+func (m *mockStore) Resolve(_ context.Context, ref string) (ocispec.Descriptor, error) {
+	if m.returnResolveErr {
+		return ocispec.Descriptor{}, fmt.Errorf("mock error")
+	}
+	if m.resolveMap != nil {
+		if desc, ok := m.resolveMap[ref]; ok {
+			return desc, nil
+		}
+	}
 	return ocispec.Descriptor{}, nil
 }
 
@@ -330,61 +336,3 @@ func createTLSCertAndKey(dir string) (string, string, error) {
 
 	return certPath, keyPath, nil
 }
-func TestVerify(t *testing.T) {
-	executor := ratify.Executor{}
-	server := &server{
-		executor: &executor,
-	}
-
-	tests := []struct {
-		name          string
-		requestBody   string
-		expectedError bool
-		expectedItems []externaldata.Item
-	}{
-		{
-			name: "Valid request",
-			requestBody: `{
-				"request": {
-					"keys": ["artifact1"]
-				}
-			}`,
-			expectedError: false,
-			expectedItems: []externaldata.Item{
-				{
-					Key:   "artifact1",
-					Value: nil,
-					Error: "store must be configured",
-				},
-			},
-		},
-		{
-			name:          "Invalid JSON",
-			requestBody:   `{invalid-json}`,
-			expectedError: true,
-		},
-	}
-
-	for _, test := range tests {
-		t.Run(test.name, func(t *testing.T) {
-			req := httptest.NewRequest(http.MethodPost, "/verify", strings.NewReader(test.requestBody))
-			w := httptest.NewRecorder()
-
-			err := server.verify(context.Background(), w, req)
-			if (err != nil) != test.expectedError {
-				t.Errorf("expected error: %v, got: %v", test.expectedError, err)
-			}
-
-			if !test.expectedError {
-				var response externaldata.ProviderResponse
-				if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
-					t.Fatalf("failed to decode response: %v", err)
-				}
-
-				if !reflect.DeepEqual(response.Response.Items, test.expectedItems) {
-					t.Errorf("expected items: %v, got: %v", test.expectedItems, response.Response.Items)
-				}
-			}
-		})
-	}
-}