Repository: opendatahub-io/model-registry-operator
Pull request #220: feat: add support for openshift oauth proxy, fixes RHOAIENG-22109
Author: login: dhirajsb
Created: 2025-04-04T11:07:26Z | Merged: 2025-04-05T03:52:11Z

<!--- Provide a general summary of your changes in the Title above -->

## Description
<!--- Describe your changes in detail -->
Add support for openshift oauth proxy
fixes RHOAIENG-22109
fixes RHOAIENG-23152
fixes RHOAIENG-23153
fixes RHOAIENG-23154
fixes RHOAIENG-23155
fixes RHOAIENG-23156
fixes RHOAIENG-23158
fixes RHOAIENG-23160
fixes RHOAIENG-23164
fixes RHOAIENG-23192
fixes RHOAIENG-23193

## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->
Added webhook tests for new webhook rules for oauthProxy config in CRD
Added samples for oauth proxy model registries
Tested with new samples after installing local operator image.

After deploying an MR sample, test it using the following commands:
```shell
# create a local pod called sleep to test the MR service authn and authz
oc apply -n odh-model-registries -f https://raw.githubusercontent.com/istio/istio/master/samples/sleep/sleep.yaml
# call service from sleep pod using user's token in env variable TOKEN
oc exec $(kc get pods -l app=sleep -o jsonpath='{.items[0].metadata.name}') -c sleep -- curl -k -s -H "Authorization: Bearer $TOKEN" https://modelregistry-sample.odh-model-registries.svc.cluster.local:8443/api/model_registry/v1alpha3/registered_models
```

Try using the sleep serviceaccount's token instead, which will fail due to unauthorized error. To enable access use the command:
```shell
oc create rolebinding sleep-rb --serviceaccount odh-model-registries:sleep --role registry-user-modelregistry-sample
```

## Merge criteria:
<!--- This PR will be merged by any repository approver when it meets all the points in the checklist -->
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->

- [X] The commits and have meaningful messages; the author will squash them [after approval](https://github.com/opendatahub-io/opendatahub-community/blob/main/contributor-cheatsheet.md#:~:text=Usually%20this%20is%20done%20in%20last%20phase%20of%20a%20PR%20revision) or will ask to merge with squash.
- [X] Testing instructions have been added in the PR body (for PRs involving changes that are not immediately obvious).
- [X] The developer has manually tested the changes and verified that the changes work


Diff:
diff --git a/api/v1alpha1/modelregistry_types.go b/api/v1alpha1/modelregistry_types.go
index db912d0e..0ab46ecf 100644
--- a/api/v1alpha1/modelregistry_types.go
+++ b/api/v1alpha1/modelregistry_types.go
@@ -309,6 +309,38 @@ type IstioConfig struct {
 	Audiences []string `json:"audiences,omitempty"`
 }
 
+// +kubebuilder:validation:XValidation:rule="has(self.tlsCertificateSecret) == has(self.tlsKeySecret)",message="tlsCertificateSecret and tlsKeySecret MUST be set together"
+type OAuthProxyConfig struct {
+	//+kubebuilder:default=8443
+	//+kubebuilder:validation:Minimum=0
+	//+kubebuilder:validation:Maximum=65535
+
+	// Listen port for OAuth Proxy connections, defaults to 8443
+	Port *int32 `json:"port,omitempty"`
+
+	// This parameter specifies the Kubernetes Secret name and key of the proxy public key certificate.
+	// If this option is not set, the operator uses OpenShift Serving Certificate by default in OpenShift clusters.
+	// In non-OpenShift clusters, a secret named `<modelregistry-name>-oauth-proxy` MUST be provided with data keys
+	// `tls.crt` and `tls.key` for the certificate and secret key respectively.
+	//+optional
+	TLSCertificateSecret *SecretKeyValue `json:"tlsCertificateSecret,omitempty"`
+	// This parameter specifies the optional Kubernetes Secret name and key used for the
+	// proxy private key if `SSLCertificateSecret` is set.
+	//+optional
+	TLSKeySecret *SecretKeyValue `json:"tlsKeySecret,omitempty"`
+
+	//+kubebuilder:validation:Enum=disabled;enabled
+	//+kubebuilder:default=enabled
+
+	// Create an OpenShift Route for REST proxy Service, enabled by default
+	ServiceRoute string `json:"serviceRoute,omitempty"`
+
+	// Optional image to support overriding the image deployed by the operator.
+	//+optional
+	Image string `json:"image,omitempty"`
+}
+
+// +kubebuilder:validation:XValidation:rule="!(has(self.istio) && has(self.oauthProxy))",message="Cannot use both istio and oauthProxy"
 // ModelRegistrySpec defines the desired state of ModelRegistry.
 // One of `postgres` or `mysql` database configurations MUST be provided!
 type ModelRegistrySpec struct {
@@ -347,6 +379,10 @@ type ModelRegistrySpec struct {
 	// Istio servicemesh configuration options
 	//+optional
 	Istio *IstioConfig `json:"istio,omitempty"`
+
+	// OpenShift OAuth proxy configuration options
+	//+optional
+	OAuthProxy *OAuthProxyConfig `json:"oauthProxy,omitempty"`
 }
 
 // ModelRegistryStatus defines the observed state of ModelRegistry
@@ -373,6 +409,7 @@ type ModelRegistryStatus struct {
 //+kubebuilder:printcolumn:name="Available",type=string,JSONPath=`.status.conditions[?(@.type=="Available")].status`
 //+kubebuilder:printcolumn:name="Istio",type=string,JSONPath=`.status.conditions[?(@.type=="IstioAvailable")].status`,priority=2
 //+kubebuilder:printcolumn:name="Gateway",type=string,JSONPath=`.status.conditions[?(@.type=="GatewayAvailable")].status`,priority=2
+//+kubebuilder:printcolumn:name="OAuthProxy",type=string,JSONPath=`.status.conditions[?(@.type=="OAuthProxyAvailable")].status`,priority=2
 //+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
 //+kubebuilder:printcolumn:name="Hosts",type=string,JSONPath=`.status.hostsStr`,priority=2
 //+kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Available")].message`,priority=2
diff --git a/api/v1alpha1/modelregistry_webhook.go b/api/v1alpha1/modelregistry_webhook.go
index 78c9e555..a5b0863c 100644
--- a/api/v1alpha1/modelregistry_webhook.go
+++ b/api/v1alpha1/modelregistry_webhook.go
@@ -119,6 +119,11 @@ func (r *ModelRegistry) Default() {
 		}
 	}
 
+	// enable oauth proxy route by default
+	if r.Spec.OAuthProxy != nil && len(r.Spec.OAuthProxy.ServiceRoute) == 0 {
+		r.Spec.OAuthProxy.ServiceRoute = config.RouteEnabled
+	}
+
 	// handle runtime default properties for https://issues.redhat.com/browse/RHOAIENG-15033
 	r.CleanupRuntimeDefaults()
 }
@@ -274,6 +279,25 @@ func (r *ModelRegistry) RuntimeDefaults() {
 			}
 		}
 	}
+
+	// oauth proxy defaults
+	if r.Spec.OAuthProxy != nil {
+		// set default cert and key if not provided
+		if r.Spec.OAuthProxy.TLSCertificateSecret == nil {
+			secretName := r.Name + "-oauth-proxy"
+			r.Spec.OAuthProxy.TLSCertificateSecret = &SecretKeyValue{
+				Name: secretName,
+				Key:  "tls.crt",
+			}
+			r.Spec.OAuthProxy.TLSKeySecret = &SecretKeyValue{
+				Name: secretName,
+				Key:  "tls.key",
+			}
+		}
+		if len(r.Spec.OAuthProxy.Image) == 0 {
+			r.Spec.OAuthProxy.Image = config.GetStringConfigWithDefault(config.OAuthProxyImage, config.DefaultOAuthProxyImage)
+		}
+	}
 }
 
 // ValidateRegistry validates registry spec
diff --git a/api/v1alpha1/webhook_suite_test.go b/api/v1alpha1/webhook_suite_test.go
index c717cf66..ddee3ef3 100644
--- a/api/v1alpha1/webhook_suite_test.go
+++ b/api/v1alpha1/webhook_suite_test.go
@@ -203,6 +203,30 @@ var _ = Describe("Model Registry validating webhook", func() {
 		mr.Namespace = namespaceBase + "-invalid"
 		Expect(k8sClient.Create(ctx, mr)).ShouldNot(Succeed())
 	})
+
+	It("Should support creating MR instance with Istio configured", func(ctx context.Context) {
+		config.SetRegistriesNamespace(namespaceBase)
+		mr := newModelRegistry(ctx, mrNameBase, namespaceBase)
+		mr.Spec.Istio = &v1alpha1.IstioConfig{}
+		Expect(k8sClient.Create(ctx, mr)).Should(Succeed())
+		Expect(k8sClient.Delete(ctx, mr)).Should(Succeed())
+	})
+
+	It("Should support creating MR instance with OAuth Proxy configured", func(ctx context.Context) {
+		config.SetRegistriesNamespace(namespaceBase)
+		mr := newModelRegistry(ctx, mrNameBase, namespaceBase)
+		mr.Spec.OAuthProxy = &v1alpha1.OAuthProxyConfig{}
+		Expect(k8sClient.Create(ctx, mr)).Should(Succeed())
+		Expect(k8sClient.Delete(ctx, mr)).Should(Succeed())
+	})
+
+	It("Should not allow creating MR instance with both Istio and OAuth Proxy set", func(ctx context.Context) {
+		config.SetRegistriesNamespace(namespaceBase)
+		mr := newModelRegistry(ctx, mrNameBase, namespaceBase)
+		mr.Spec.Istio = &v1alpha1.IstioConfig{}
+		mr.Spec.OAuthProxy = &v1alpha1.OAuthProxyConfig{}
+		Expect(k8sClient.Create(ctx, mr)).ShouldNot(Succeed())
+	})
 })
 
 func newModelRegistry(ctx context.Context, name string, namespace string) *v1alpha1.ModelRegistry {
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 06f8059c..15c5a497 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -214,6 +214,11 @@ func (in *ModelRegistrySpec) DeepCopyInto(out *ModelRegistrySpec) {
 		*out = new(IstioConfig)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.OAuthProxy != nil {
+		in, out := &in.OAuthProxy, &out.OAuthProxy
+		*out = new(OAuthProxyConfig)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelRegistrySpec.
@@ -318,6 +323,36 @@ func (in *MySQLConfig) DeepCopy() *MySQLConfig {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OAuthProxyConfig) DeepCopyInto(out *OAuthProxyConfig) {
+	*out = *in
+	if in.Port != nil {
+		in, out := &in.Port, &out.Port
+		*out = new(int32)
+		**out = **in
+	}
+	if in.TLSCertificateSecret != nil {
+		in, out := &in.TLSCertificateSecret, &out.TLSCertificateSecret
+		*out = new(SecretKeyValue)
+		**out = **in
+	}
+	if in.TLSKeySecret != nil {
+		in, out := &in.TLSKeySecret, &out.TLSKeySecret
+		*out = new(SecretKeyValue)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OAuthProxyConfig.
+func (in *OAuthProxyConfig) DeepCopy() *OAuthProxyConfig {
+	if in == nil {
+		return nil
+	}
+	out := new(OAuthProxyConfig)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *PostgresConfig) DeepCopyInto(out *PostgresConfig) {
 	*out = *in
diff --git a/config/crd/bases/modelregistry.opendatahub.io_modelregistries.yaml b/config/crd/bases/modelregistry.opendatahub.io_modelregistries.yaml
index 0ac9d6bd..bbca9343 100644
--- a/config/crd/bases/modelregistry.opendatahub.io_modelregistries.yaml
+++ b/config/crd/bases/modelregistry.opendatahub.io_modelregistries.yaml
@@ -28,6 +28,10 @@ spec:
       name: Gateway
       priority: 2
       type: string
+    - jsonPath: .status.conditions[?(@.type=="OAuthProxyAvailable")].status
+      name: OAuthProxy
+      priority: 2
+      type: string
     - jsonPath: .metadata.creationTimestamp
       name: Age
       type: date
@@ -473,6 +477,65 @@ spec:
                 - host
                 - username
                 type: object
+              oauthProxy:
+                description: OpenShift OAuth proxy configuration options
+                properties:
+                  image:
+                    description: Optional image to support overriding the image deployed
+                      by the operator.
+                    type: string
+                  port:
+                    default: 8443
+                    description: Listen port for OAuth Proxy connections, defaults
+                      to 8443
+                    format: int32
+                    maximum: 65535
+                    minimum: 0
+                    type: integer
+                  serviceRoute:
+                    default: enabled
+                    description: Create an OpenShift Route for REST proxy Service,
+                      enabled by default
+                    enum:
+                    - disabled
+                    - enabled
+                    type: string
+                  tlsCertificateSecret:
+                    description: |-
+                      This parameter specifies the Kubernetes Secret name and key of the proxy public key certificate.
+                      If this option is not set, the operator uses OpenShift Serving Certificate by default in OpenShift clusters.
+                      In non-OpenShift clusters, a secret named `<modelregistry-name>-oauth-proxy` MUST be provided with data keys
+                      `tls.crt` and `tls.key` for the certificate and secret key respectively.
+                    properties:
+                      key:
+                        description: Key name in secret
+                        type: string
+                      name:
+                        description: Kubernetes secret name
+                        type: string
+                    required:
+                    - key
+                    - name
+                    type: object
+                  tlsKeySecret:
+                    description: |-
+                      This parameter specifies the optional Kubernetes Secret name and key used for the
+                      proxy private key if `SSLCertificateSecret` is set.
+                    properties:
+                      key:
+                        description: Key name in secret
+                        type: string
+                      name:
+                        description: Kubernetes secret name
+                        type: string
+                    required:
+                    - key
+                    - name
+                    type: object
+                type: object
+                x-kubernetes-validations:
+                - message: tlsCertificateSecret and tlsKeySecret MUST be set together
+                  rule: has(self.tlsCertificateSecret) == has(self.tlsKeySecret)
               postgres:
                 description: PostgreSQL configuration options
                 properties:
@@ -690,6 +753,9 @@ spec:
             - grpc
             - rest
             type: object
+            x-kubernetes-validations:
+            - message: Cannot use both istio and oauthProxy
+              rule: '!(has(self.istio) && has(self.oauthProxy))'
           status:
             description: ModelRegistryStatus defines the observed state of ModelRegistry
             properties:
diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml
index b86fcdb8..cd1bb428 100644
--- a/config/manager/manager.yaml
+++ b/config/manager/manager.yaml
@@ -77,6 +77,8 @@ spec:
             value: quay.io/opendatahub/mlmd-grpc-server:latest
           - name: REST_IMAGE
             value: quay.io/opendatahub/model-registry:latest
+          - name: OAUTH_PROXY_IMAGE
+            value: quay.io/openshift/origin-oauth-proxy:latest
           - name: ENABLE_WEBHOOKS
             value: "false"
           - name: CREATE_AUTH_RESOURCES
diff --git a/config/overlays/odh/params.env b/config/overlays/odh/params.env
index 38468e9a..62045aa0 100644
--- a/config/overlays/odh/params.env
+++ b/config/overlays/odh/params.env
@@ -1,6 +1,7 @@
 IMAGES_MODELREGISTRY_OPERATOR=quay.io/opendatahub/model-registry-operator:latest
 IMAGES_GRPC_SERVICE=quay.io/opendatahub/mlmd-grpc-server:latest
 IMAGES_REST_SERVICE=quay.io/opendatahub/model-registry:latest
+IMAGES_OAUTH_PROXY=quay.io/openshift/origin-oauth-proxy:latest
 CREATE_AUTH_RESOURCES=true
 DEFAULT_CERT=
 REGISTRIES_NAMESPACE=
\ No newline at end of file
diff --git a/config/overlays/odh/replacements.yaml b/config/overlays/odh/replacements.yaml
index bbaf1340..015b30f3 100644
--- a/config/overlays/odh/replacements.yaml
+++ b/config/overlays/odh/replacements.yaml
@@ -29,6 +29,16 @@
         name: controller-manager
       fieldPaths:
         - spec.template.spec.containers.[name=manager].env.[name=REST_IMAGE].value
+- source:
+    kind: ConfigMap
+    name: model-registry-operator-parameters
+    fieldPath: data.IMAGES_OAUTH_PROXY
+  targets:
+    - select:
+        kind: Deployment
+        name: controller-manager
+      fieldPaths:
+        - spec.template.spec.containers.[name=manager].env.[name=OAUTH_PROXY_IMAGE].value
 - source:
     kind: ConfigMap
     name: model-registry-operator-parameters
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 531bd655..ac7f9314 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -16,6 +16,12 @@ rules:
   - patch
   - update
   - watch
+- apiGroups:
+  - authentication.k8s.io
+  resources:
+  - tokenreviews
+  verbs:
+  - create
 - apiGroups:
   - authorino.kuadrant.io
   resources:
@@ -28,6 +34,12 @@ rules:
   - patch
   - update
   - watch
+- apiGroups:
+  - authorization.k8s.io
+  resources:
+  - subjectaccessreviews
+  verbs:
+  - create
 - apiGroups:
   - config.openshift.io
   resources:
@@ -135,9 +147,22 @@ rules:
   - patch
   - update
   - watch
+- apiGroups:
+  - networking.k8s.io
+  resources:
+  - networkpolicies
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
 - apiGroups:
   - rbac.authorization.k8s.io
   resources:
+  - clusterrolebindings
   - rolebindings
   - roles
   verbs:
diff --git a/config/samples/oauth/components/kustomization.yaml b/config/samples/oauth/components/kustomization.yaml
new file mode 100644
index 00000000..2704a3e3
--- /dev/null
+++ b/config/samples/oauth/components/kustomization.yaml
@@ -0,0 +1,8 @@
+apiVersion: kustomize.config.k8s.io/v1alpha1
+kind: Component
+
+# OAuth config patch
+patches:
+  - path: oauth_modelregistry.yaml
+
+#+kubebuilder:scaffold:manifestskustomizesamples
diff --git a/config/samples/oauth/components/oauth_modelregistry.yaml b/config/samples/oauth/components/oauth_modelregistry.yaml
new file mode 100644
index 00000000..62595726
--- /dev/null
+++ b/config/samples/oauth/components/oauth_modelregistry.yaml
@@ -0,0 +1,6 @@
+apiVersion: modelregistry.opendatahub.io/v1alpha1
+kind: ModelRegistry
+metadata:
+  name: modelregistry-sample
+spec:
+  oauthProxy: {}
\ No newline at end of file
diff --git a/config/samples/oauth/mysql/kustomization.yaml b/config/samples/oauth/mysql/kustomization.yaml
new file mode 100644
index 00000000..eaa1000d
--- /dev/null
+++ b/config/samples/oauth/mysql/kustomization.yaml
@@ -0,0 +1,10 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+## Append samples of your project ##
+resources:
+- ../../mysql
+
+components:
+- ../components
+
+#+kubebuilder:scaffold:manifestskustomizesamples
diff --git a/config/samples/oauth/postgres/kustomization.yaml b/config/samples/oauth/postgres/kustomization.yaml
new file mode 100644
index 00000000..8f773cc9
--- /dev/null
+++ b/config/samples/oauth/postgres/kustomization.yaml
@@ -0,0 +1,10 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+## Append samples of your project ##
+resources:
+  - ../../postgres
+
+components:
+  - ../components
+
+#+kubebuilder:scaffold:manifestskustomizesamples
diff --git a/config/samples/secure-db/mysql-oauth/kustomization.yaml b/config/samples/secure-db/mysql-oauth/kustomization.yaml
new file mode 100644
index 00000000..a717999a
--- /dev/null
+++ b/config/samples/secure-db/mysql-oauth/kustomization.yaml
@@ -0,0 +1,11 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+## Append samples of your project ##
+resources:
+  - ../../oauth/mysql
+
+components:
+  - ../components
+  - ../components/mysql
+
+#+kubebuilder:scaffold:manifestskustomizesamples
diff --git a/internal/controller/config/defaults.go b/internal/controller/config/defaults.go
index 3b2cbf1d..1c6cea19 100644
--- a/internal/controller/config/defaults.go
+++ b/internal/controller/config/defaults.go
@@ -35,13 +35,16 @@ import (
 
 //go:embed templates/*.yaml.tmpl
 //go:embed templates/istio/*.yaml.tmpl
+//go:embed templates/oauth-proxy/*.yaml.tmpl
 var templateFS embed.FS
 
 const (
 	GrpcImage               = "GRPC_IMAGE"
 	RestImage               = "REST_IMAGE"
+	OAuthProxyImage         = "OAUTH_PROXY_IMAGE"
 	DefaultGrpcImage        = "quay.io/opendatahub/mlmd-grpc-server:latest"
 	DefaultRestImage        = "quay.io/opendatahub/model-registry:latest"
+	DefaultOAuthProxyImage  = "quay.io/openshift/origin-oauth-proxy:latest"
 	RouteDisabled           = "disabled"
 	RouteEnabled            = "enabled"
 	DefaultIstioIngressName = "ingressgateway"
@@ -99,7 +102,8 @@ func GetStringConfigWithDefault(configName, value string) string {
 }
 
 func ParseTemplates() (*template.Template, error) {
-	template, err := template.ParseFS(templateFS, "templates/*.yaml.tmpl", "templates/istio/*.yaml.tmpl")
+	template, err := template.ParseFS(templateFS, "templates/*.yaml.tmpl",
+		"templates/istio/*.yaml.tmpl", "templates/oauth-proxy/*.yaml.tmpl")
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/controller/config/defaults_test.go b/internal/controller/config/defaults_test.go
index de9dbe22..2e873f14 100644
--- a/internal/controller/config/defaults_test.go
+++ b/internal/controller/config/defaults_test.go
@@ -18,15 +18,23 @@ import (
 
 func TestGetStringConfigWithDefault(t *testing.T) {
 	tests := []struct {
-		name       string
-		configName string
-		want       string
+		name         string
+		configName   string
+		defaultValue string
+		want         string
 	}{
-		{name: "test " + config.GrpcImage, configName: config.GrpcImage, want: "success1"},
-		{name: "test " + config.RestImage, configName: config.RestImage, want: "success2"},
+		{name: "test " + config.GrpcImage, configName: config.GrpcImage, defaultValue: config.DefaultGrpcImage, want: "success1"},
+		{name: "test " + config.RestImage, configName: config.RestImage, defaultValue: config.DefaultRestImage, want: "success2"},
+		{name: "test " + config.OAuthProxyImage, configName: config.OAuthProxyImage, defaultValue: config.DefaultOAuthProxyImage, want: "success3"},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			// test env variable not set or blank
+			os.Setenv(tt.configName, "")
+			if got := config.GetStringConfigWithDefault(tt.configName, tt.defaultValue); got != tt.defaultValue {
+				t.Errorf("GetStringConfigWithDefault() = %v, want %v", got, tt.want)
+			}
+			// test env variable set
 			os.Setenv(tt.configName, tt.want)
 			if got := config.GetStringConfigWithDefault(tt.configName, "fail"); got != tt.want {
 				t.Errorf("GetStringConfigWithDefault() = %v, want %v", got, tt.want)
diff --git a/internal/controller/config/templates/deployment.yaml.tmpl b/internal/controller/config/templates/deployment.yaml.tmpl
index a6cdacb5..1d5925a0 100644
--- a/internal/controller/config/templates/deployment.yaml.tmpl
+++ b/internal/controller/config/templates/deployment.yaml.tmpl
@@ -248,6 +248,56 @@ spec:
             capabilities:
               drop:
                 - ALL
+        {{ if .Spec.OAuthProxy }}
+        - name: oauth-proxy
+          args:
+            - --https-address=:{{.Spec.OAuthProxy.Port}}
+            - --provider=openshift
+            - --openshift-service-account={{.Name}}
+            - --upstream=http://localhost:{{.Spec.Rest.Port}}
+            - --tls-cert=/etc/tls/private-cert/{{.Spec.OAuthProxy.TLSCertificateSecret.Key}}
+            - --tls-key=/etc/tls/private-key/{{.Spec.OAuthProxy.TLSKeySecret.Key}}
+            - --cookie-secret=MODEL_REGISTRY_OAUTH_SECRET
+            - '--openshift-delegate-urls={"/":{"group":"","resource":"services","verb":"get","name":"{{.Name}}","namespace":"{{.Namespace}}"}}'
+            - --skip-auth-regex='(^/metrics|^/oauth/healthz)'
+          image: {{.Spec.OAuthProxy.Image}}
+          ports:
+            - containerPort: 8443
+              name: oauth2-proxy
+              protocol: TCP
+          livenessProbe:
+            httpGet:
+              path: /oauth/healthz
+              port: oauth2-proxy
+              scheme: HTTPS
+            initialDelaySeconds: 30
+            timeoutSeconds: 1
+            periodSeconds: 5
+            successThreshold: 1
+            failureThreshold: 3
+          readinessProbe:
+            httpGet:
+              path: /oauth/healthz
+              port: oauth2-proxy
+              scheme: HTTPS
+            initialDelaySeconds: 5
+            timeoutSeconds: 1
+            periodSeconds: 5
+            successThreshold: 1
+            failureThreshold: 3
+          resources:
+            limits:
+              cpu: 100m
+              memory: 256Mi
+            requests:
+              cpu: 100m
+              memory: 256Mi
+          volumeMounts:
+            - mountPath: /etc/tls/private-cert
+              name: oauth-proxy-cert
+            - mountPath: /etc/tls/private-key
+              name: oauth-proxy-key
+        {{ end }}
       serviceAccountName: {{.Name}}
       volumes:
         {{- if .Spec.Postgres}}
@@ -310,3 +360,13 @@ spec:
             defaultMode: 0600
             {{- end}}
         {{- end}}
+        {{- if .Spec.OAuthProxy }}
+        - name: oauth-proxy-cert
+          secret:
+            secretName: {{.Spec.OAuthProxy.TLSCertificateSecret.Name}}
+            defaultMode: 0600
+        - name: oauth-proxy-key
+          secret:
+            secretName: {{.Spec.OAuthProxy.TLSKeySecret.Name}}
+            defaultMode: 0600
+        {{- end}}
diff --git a/internal/controller/config/templates/oauth-proxy/https-route.yaml.tmpl b/internal/controller/config/templates/oauth-proxy/https-route.yaml.tmpl
new file mode 100644
index 00000000..8a5ab29a
--- /dev/null
+++ b/internal/controller/config/templates/oauth-proxy/https-route.yaml.tmpl
@@ -0,0 +1,23 @@
+apiVersion: route.openshift.io/v1
+kind: Route
+metadata:
+  name: {{.Name}}-https
+  namespace: {{.Namespace}}
+  labels:
+    app: {{.Name}}
+    component: model-registry
+    app.kubernetes.io/name: {{.Name}}
+    app.kubernetes.io/instance: {{.Name}}-https
+    app.kubernetes.io/component: model-registry
+    app.kubernetes.io/created-by: model-registry-operator
+    app.kubernetes.io/part-of: model-registry
+    app.kubernetes.io/managed-by: model-registry-operator
+spec:
+  to:
+    kind: Service
+    name: {{.Name}}
+  port:
+    targetPort: https-api
+  tls:
+    termination: reencrypt
+  wildcardPolicy: None
diff --git a/internal/controller/config/templates/oauth-proxy/proxy-network-policy.yaml.tmpl b/internal/controller/config/templates/oauth-proxy/proxy-network-policy.yaml.tmpl
new file mode 100644
index 00000000..ed98bd95
--- /dev/null
+++ b/internal/controller/config/templates/oauth-proxy/proxy-network-policy.yaml.tmpl
@@ -0,0 +1,29 @@
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+  labels:
+    app: {{.Name}}
+    component: model-registry
+    app.kubernetes.io/name: {{.Name}}
+    app.kubernetes.io/instance: {{.Name}}-https-route
+    app.kubernetes.io/component: model-registry
+    app.kubernetes.io/created-by: model-registry-operator
+    app.kubernetes.io/part-of: model-registry
+    app.kubernetes.io/managed-by: model-registry-operator
+  name: {{.Name}}-https-route
+  namespace: {{.Namespace}}
+spec:
+  ingress:
+  - from:
+    - namespaceSelector:
+        matchLabels:
+          network.openshift.io/policy-group: ingress
+    ports:
+      - protocol: TCP
+        port: {{.Spec.OAuthProxy.Port}}
+  podSelector:
+    matchLabels:
+      app: {{.Name}}
+      component: model-registry
+  policyTypes:
+  - Ingress
diff --git a/internal/controller/config/templates/oauth-proxy/proxy-role-binding.yaml.tmpl b/internal/controller/config/templates/oauth-proxy/proxy-role-binding.yaml.tmpl
new file mode 100644
index 00000000..38a1c6a7
--- /dev/null
+++ b/internal/controller/config/templates/oauth-proxy/proxy-role-binding.yaml.tmpl
@@ -0,0 +1,22 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: {{.Name}}-auth-delegator
+  labels:
+    app: {{.Name}}
+    component: model-registry
+    modelregistry.opendatahub.io/namespace: {{.Namespace}}
+    app.kubernetes.io/name: {{.Name}}
+    app.kubernetes.io/instance: {{.Name}}-auth-delegator
+    app.kubernetes.io/component: model-registry
+    app.kubernetes.io/created-by: model-registry-operator
+    app.kubernetes.io/part-of: model-registry
+    app.kubernetes.io/managed-by: model-registry-operator
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: system:auth-delegator
+subjects:
+- kind: ServiceAccount
+  name: {{.Name}}
+  namespace: {{.Namespace}}
diff --git a/internal/controller/config/templates/service.yaml.tmpl b/internal/controller/config/templates/service.yaml.tmpl
index d5e56ce7..bc6b7235 100644
--- a/internal/controller/config/templates/service.yaml.tmpl
+++ b/internal/controller/config/templates/service.yaml.tmpl
@@ -17,8 +17,12 @@ metadata:
     routing.opendatahub.io/external-address-rest: {{.Name}}-rest.{{.Spec.Istio.Gateway.Domain}}:{{.Spec.Istio.Gateway.Rest.Port}}
     routing.opendatahub.io/external-address-grpc: {{.Name}}-grpc.{{.Spec.Istio.Gateway.Domain}}:{{.Spec.Istio.Gateway.Grpc.Port}}
     {{- end}}
+    {{- if and .Spec.OAuthProxy (not .OriginalSpec.OAuthProxy.TLSCertificateSecret)}}
+    service.beta.openshift.io/serving-cert-secret-name: {{.Name}}-oauth-proxy
+    {{- end}}
 spec:
   ports:
+    {{- if not .Spec.OAuthProxy}}
     - name: grpc-api
       port: {{.Spec.Grpc.Port}}
       protocol: TCP
@@ -27,6 +31,12 @@ spec:
       port: {{.Spec.Rest.Port}}
       protocol: TCP
       appProtocol: http
+    {{- else}}
+    - name: https-api
+      port: {{.Spec.OAuthProxy.Port}}
+      protocol: TCP
+      appProtocol: https
+    {{- end}}
   selector:
     app: {{.Name}}
     component: model-registry
diff --git a/internal/controller/config/templates/serviceaccount.yaml.tmpl b/internal/controller/config/templates/serviceaccount.yaml.tmpl
index fca98d10..71bd8963 100644
--- a/internal/controller/config/templates/serviceaccount.yaml.tmpl
+++ b/internal/controller/config/templates/serviceaccount.yaml.tmpl
@@ -11,4 +11,4 @@ metadata:
     app.kubernetes.io/component: model-registry
     app.kubernetes.io/created-by: model-registry-operator
     app.kubernetes.io/part-of: model-registry
-    app.kubernetes.io/managed-by: model-registry-operator
+    app.kubernetes.io/managed-by: model-registry-operator
\ No newline at end of file
diff --git a/internal/controller/modelregistry_controller.go b/internal/controller/modelregistry_controller.go
index 88b15830..6d4e0175 100644
--- a/internal/controller/modelregistry_controller.go
+++ b/internal/controller/modelregistry_controller.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	errors2 "errors"
 	"fmt"
+	networkingv1 "k8s.io/api/networking/v1"
 	"strings"
 	"text/template"
 
@@ -182,6 +183,8 @@ func (r *ModelRegistryReconciler) Reconcile(ctx context.Context, req ctrl.Reques
 		return ctrl.Result{}, nil
 	}
 
+	// remember original spec
+	originalSpec := modelRegistry.Spec.DeepCopy()
 	// set runtime default properties in memory for reconciliation
 	modelRegistry.RuntimeDefaults()
 
@@ -198,9 +201,10 @@ func (r *ModelRegistryReconciler) Reconcile(ctx context.Context, req ctrl.Reques
 	}
 
 	params := &ModelRegistryParams{
-		Name:      req.Name,
-		Namespace: req.Namespace,
-		Spec:      &modelRegistry.Spec,
+		Name:         req.Name,
+		Namespace:    req.Namespace,
+		Spec:         &modelRegistry.Spec,
+		OriginalSpec: originalSpec,
 	}
 
 	// update registry service
@@ -240,7 +244,8 @@ func (r *ModelRegistryReconciler) SetupWithManager(mgr ctrl.Manager) error {
 		Owns(&corev1.Service{}).
 		Owns(&corev1.ServiceAccount{}).
 		Owns(&appsv1.Deployment{}).
-		Owns(&rbac.Role{})
+		Owns(&rbac.Role{}).
+		Owns(&networkingv1.NetworkPolicy{})
 	if r.IsOpenShift {
 		builder = builder.Owns(&rbac.RoleBinding{})
 
@@ -257,6 +262,10 @@ func (r *ModelRegistryReconciler) SetupWithManager(mgr ctrl.Manager) error {
 			&routev1.Route{},
 			handler.EnqueueRequestsFromMapFunc(r.GetRegistryForRoute),
 			pkgbuilder.WithPredicates(labelsPredicate))
+		builder = builder.Watches(
+			&rbac.ClusterRoleBinding{},
+			handler.EnqueueRequestsFromMapFunc(r.GetRegistryForClusterRoleBinding),
+			pkgbuilder.WithPredicates(labelsPredicate))
 	}
 	if r.HasIstio {
 		if r.CreateAuthResources {
@@ -284,8 +293,33 @@ func (r *ModelRegistryReconciler) GetRegistryForRoute(ctx context.Context, objec
 
 	namespace := labels["maistra.io/gateway-namespace"]
 	if len(namespace) == 0 {
-		logger := klog.FromContext(ctx)
-		logger.Error(nil, "missing 'maistra.io/gateway-namespace' label in model registry route", "route", route.Name)
+		// if label is not set, registry is in the same namespace as the route
+		namespace = object.GetNamespace()
+	}
+
+	return []reconcile.Request{
+		{NamespacedName: types.NamespacedName{Name: name, Namespace: namespace}},
+	}
+}
+
+// GetRegistryForClusterRoleBinding maps role binding to model registry reconcile request
+func (r *ModelRegistryReconciler) GetRegistryForClusterRoleBinding(ctx context.Context, object client.Object) []reconcile.Request {
+	clusterRoleBinding := object.(*rbac.ClusterRoleBinding)
+
+	logger := klog.FromContext(ctx)
+	labels := clusterRoleBinding.GetObjectMeta().GetLabels()
+	name := labels["app"]
+	if len(name) == 0 {
+		logger.Error(nil, "missing 'app' label in model registry clusterRoleBinding",
+			"clusterRoleBinding", clusterRoleBinding.Name)
+		return nil
+	}
+
+	namespace := labels["modelregistry.opendatahub.io/namespace"]
+	if len(namespace) == 0 {
+		logger.V(5).Info("missing 'modelregistry.opendatahub.io/namespace' label in model registry clusterRoleBinding",
+			"clusterRoleBinding", clusterRoleBinding.Name)
+		// ignore this cluster role binding
 		return nil
 	}
 
@@ -306,12 +340,15 @@ func (r *ModelRegistryReconciler) GetRegistryForRoute(ctx context.Context, objec
 // +kubebuilder:rbac:groups=config.openshift.io,resources=ingresses,verbs=get;list;watch
 // +kubebuilder:rbac:groups=route.openshift.io,resources=routes;routes/custom-host,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=user.openshift.io,resources=groups,verbs=get;list;watch;create;update;patch;delete
-// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings;clusterrolebindings,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=authorino.kuadrant.io,resources=authconfigs,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=security.istio.io,resources=authorizationpolicies,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=networking.istio.io,resources=destinationrules,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=networking.istio.io,resources=gateways,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=networking.istio.io,resources=virtualservices,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=authentication.k8s.io,resources=tokenreviews,verbs=create
+// +kubebuilder:rbac:groups=authorization.k8s.io,resources=subjectaccessreviews,verbs=create
+// +kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,verbs=get;list;watch;create;update;patch;delete
 
 func (r *ModelRegistryReconciler) updateRegistryResources(ctx context.Context, params *ModelRegistryParams, registry *modelregistryv1alpha1.ModelRegistry) (OperationResult, error) {
 	var result, result2 OperationResult
@@ -365,7 +402,8 @@ func (r *ModelRegistryReconciler) updateRegistryResources(ctx context.Context, p
 		}
 
 		// create simple openshift service route, if configured
-		result2, err = r.createOrUpdateRoute(ctx, params, registry, "http-route.yaml.tmpl")
+		result2, err = r.createOrUpdateRoute(ctx, params, registry,
+			"http-route.yaml.tmpl", registry.Spec.Rest.ServiceRoute)
 		if err != nil {
 			return result2, err
 		}
@@ -394,6 +432,15 @@ func (r *ModelRegistryReconciler) updateRegistryResources(ctx context.Context, p
 		}
 	}
 
+	// create or update oauth proxy config if enabled, delete if disabled
+	result2, err = r.createOrUpdateOAuthConfig(ctx, params, registry)
+	if err != nil {
+		return result2, err
+	}
+	if result2 != ResourceUnchanged {
+		result = result2
+	}
+
 	return result, nil
 }
 
@@ -497,6 +544,61 @@ func (r *ModelRegistryReconciler) deleteIstioConfig(ctx context.Context, params
 	return ResourceUnchanged, nil
 }
 
+func (r *ModelRegistryReconciler) createOrUpdateOAuthConfig(ctx context.Context, params *ModelRegistryParams,
+	registry *modelregistryv1alpha1.ModelRegistry) (result OperationResult, err error) {
+
+	result = ResourceUnchanged
+	result2 := result
+
+	// create oauth proxy resources
+	if registry.Spec.OAuthProxy != nil {
+
+		// create oauth proxy rolebinding
+		result, err = r.createOrUpdateClusterRoleBinding(ctx, params, registry, "proxy-role-binding.yaml.tmpl")
+		if err != nil {
+			return result, err
+		}
+
+		// create oauth proxy service route if enabled, delete if disabled
+		result2, err = r.createOrUpdateRoute(ctx, params, registry,
+			"https-route.yaml.tmpl", registry.Spec.OAuthProxy.ServiceRoute)
+		if err != nil {
+			return result2, err
+		}
+		if result2 != ResourceUnchanged {
+			result = result2
+		}
+
+		if registry.Spec.OAuthProxy.ServiceRoute == config.RouteEnabled {
+			// create oauth proxy networkpolicy to ensure route is exposed
+			result2, err = r.createOrUpdateNetworkPolicy(ctx, params, registry, "proxy-network-policy.yaml.tmpl")
+			if err != nil {
+				return result2, err
+			}
+			if result2 != ResourceUnchanged {
+				result = result2
+			}
+		} else {
+			// remove oauth proxy networkpolicy if it exists
+			if err = r.deleteOAuthNetworkPolicy(ctx, params); err != nil {
+				return result, err
+			}
+		}
+
+	} else {
+		// remove oauth proxy rolebinding if it exists
+		if err = r.deleteOAuthClusterRoleBinding(ctx, params); err != nil {
+			return result, err
+		}
+		// remove oauth proxy networkpolicy if it exists
+		if err = r.deleteOAuthNetworkPolicy(ctx, params); err != nil {
+			return result, err
+		}
+	}
+
+	return result, nil
+}
+
 func (r *ModelRegistryReconciler) deleteGatewayConfig(ctx context.Context, params *ModelRegistryParams) error {
 	gateway := networking.Gateway{ObjectMeta: metav1.ObjectMeta{Name: params.Name, Namespace: params.Namespace}}
 	if err := r.Client.Delete(ctx, &gateway); client.IgnoreNotFound(err) != nil {
@@ -529,6 +631,16 @@ func getRouteLabels(name string) client.MatchingLabels {
 	return labels
 }
 
+func (r *ModelRegistryReconciler) deleteOAuthClusterRoleBinding(ctx context.Context, params *ModelRegistryParams) error {
+	roleBinding := rbac.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: params.Name + "-auth-delegator", Namespace: params.Namespace}}
+	return client.IgnoreNotFound(r.Client.Delete(ctx, &roleBinding))
+}
+
+func (r *ModelRegistryReconciler) deleteOAuthNetworkPolicy(ctx context.Context, params *ModelRegistryParams) error {
+	networkPolicy := networkingv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Name: params.Name + "-https-route", Namespace: params.Namespace}}
+	return client.IgnoreNotFound(r.Client.Delete(ctx, &networkPolicy))
+}
+
 func (r *ModelRegistryReconciler) createOrUpdateGatewayRoutes(ctx context.Context, params *ModelRegistryParams,
 	registry *modelregistryv1alpha1.ModelRegistry, templateName string) (result OperationResult, err error) {
 	result = ResourceUnchanged
@@ -549,13 +661,15 @@ func (r *ModelRegistryReconciler) createOrUpdateGatewayRoutes(ctx context.Contex
 	params.IngressService = &serviceList.Items[0]
 
 	// create/update REST route
-	result, err = r.handleGatewayRoute(ctx, params, registry, templateName, "-rest", params.Spec.Istio.Gateway.Rest.TLS)
+	result, err = r.handleGatewayRoute(ctx, params, templateName, "-rest",
+		params.Spec.Istio.Gateway.Rest.GatewayRoute, params.Spec.Istio.Gateway.Rest.TLS)
 	if err != nil {
 		return result, err
 	}
 
 	// create/update gRPC route
-	result2, err := r.handleGatewayRoute(ctx, params, registry, templateName, "-grpc", params.Spec.Istio.Gateway.Grpc.TLS)
+	result2, err := r.handleGatewayRoute(ctx, params, templateName, "-grpc",
+		params.Spec.Istio.Gateway.Grpc.GatewayRoute, params.Spec.Istio.Gateway.Grpc.TLS)
 	if err != nil {
 		return result2, err
 	}
@@ -566,9 +680,8 @@ func (r *ModelRegistryReconciler) createOrUpdateGatewayRoutes(ctx context.Contex
 	return result, nil
 }
 
-func (r *ModelRegistryReconciler) handleGatewayRoute(ctx context.Context, params *ModelRegistryParams,
-	registry *modelregistryv1alpha1.ModelRegistry, templateName string, suffix string,
-	tls *modelregistryv1alpha1.TLSServerSettings) (result OperationResult, err error) {
+func (r *ModelRegistryReconciler) handleGatewayRoute(ctx context.Context, params *ModelRegistryParams, templateName string,
+	suffix string, gatewayRoute string, tls *modelregistryv1alpha1.TLSServerSettings) (result OperationResult, err error) {
 
 	// route specific params
 	params.Host = params.Name + suffix
@@ -579,7 +692,7 @@ func (r *ModelRegistryReconciler) handleGatewayRoute(ctx context.Context, params
 		return result, err
 	}
 
-	if registry.Spec.Istio.Gateway.Rest.GatewayRoute == config.RouteEnabled {
+	if gatewayRoute == config.RouteEnabled {
 		result, err = r.createOrUpdate(ctx, &routev1.Route{}, &route)
 		if err != nil {
 			return result, err
@@ -722,6 +835,31 @@ func (r *ModelRegistryReconciler) createOrUpdateRoleBinding(ctx context.Context,
 	return result, nil
 }
 
+func (r *ModelRegistryReconciler) createOrUpdateClusterRoleBinding(ctx context.Context, params *ModelRegistryParams,
+	registry *modelregistryv1alpha1.ModelRegistry, templateName string) (result OperationResult, err error) {
+	result = ResourceUnchanged
+	var roleBinding rbac.ClusterRoleBinding
+	if err = r.Apply(params, templateName, &roleBinding); err != nil {
+		return result, err
+	}
+
+	return r.createOrUpdate(ctx, &rbac.ClusterRoleBinding{}, &roleBinding)
+}
+
+func (r *ModelRegistryReconciler) createOrUpdateNetworkPolicy(ctx context.Context, params *ModelRegistryParams,
+	registry *modelregistryv1alpha1.ModelRegistry, templateName string) (result OperationResult, err error) {
+	result = ResourceUnchanged
+	var networkPolicy networkingv1.NetworkPolicy
+	if err = r.Apply(params, templateName, &networkPolicy); err != nil {
+		return result, err
+	}
+	if err = ctrl.SetControllerReference(registry, &networkPolicy, r.Scheme); err != nil {
+		return result, err
+	}
+
+	return r.createOrUpdate(ctx, &networkingv1.NetworkPolicy{}, &networkPolicy)
+}
+
 func (r *ModelRegistryReconciler) createOrUpdateRole(ctx context.Context, params *ModelRegistryParams,
 	registry *modelregistryv1alpha1.ModelRegistry, templateName string) (result OperationResult, err error) {
 	result = ResourceUnchanged
@@ -774,7 +912,7 @@ func (r *ModelRegistryReconciler) createOrUpdateDeployment(ctx context.Context,
 }
 
 func (r *ModelRegistryReconciler) createOrUpdateRoute(ctx context.Context, params *ModelRegistryParams,
-	registry *modelregistryv1alpha1.ModelRegistry, templateName string) (result OperationResult, err error) {
+	registry *modelregistryv1alpha1.ModelRegistry, templateName string, serviceRoute string) (result OperationResult, err error) {
 	result = ResourceUnchanged
 	var route routev1.Route
 	if err = r.Apply(params, templateName, &route); err != nil {
@@ -784,7 +922,7 @@ func (r *ModelRegistryReconciler) createOrUpdateRoute(ctx context.Context, param
 		return result, err
 	}
 
-	if registry.Spec.Rest.ServiceRoute == config.RouteEnabled {
+	if serviceRoute == config.RouteEnabled {
 		if result, err = r.createOrUpdate(ctx, &routev1.Route{}, &route); err != nil {
 			return result, err
 		}
@@ -939,9 +1077,10 @@ func (r *ModelRegistryReconciler) doFinalizerOperationsForModelRegistry(ctx cont
 
 // ModelRegistryParams is a wrapper for template parameters
 type ModelRegistryParams struct {
-	Name      string
-	Namespace string
-	Spec      *modelregistryv1alpha1.ModelRegistrySpec
+	Name         string
+	Namespace    string
+	Spec         *modelregistryv1alpha1.ModelRegistrySpec
+	OriginalSpec *modelregistryv1alpha1.ModelRegistrySpec
 
 	// gateway route parameters
 	Host           string
diff --git a/internal/controller/modelregistry_controller_status.go b/internal/controller/modelregistry_controller_status.go
index e27e199a..854b5272 100644
--- a/internal/controller/modelregistry_controller_status.go
+++ b/internal/controller/modelregistry_controller_status.go
@@ -54,6 +54,8 @@ const (
 	ConditionTypeIstio = "IstioAvailable"
 	// ConditionTypeGateway represents the status of Istio Gateway configuration.
 	ConditionTypeGateway = "GatewayAvailable"
+	// ConditionTypeOauthProxy represents the status of OAuth Proxy configuration.
+	ConditionTypeOAuthProxy = "OAuthProxyAvailable"
 
 	ReasonDeploymentCreated     = "CreatedDeployment"
 	ReasonDeploymentCreating    = "CreatingDeployment"
@@ -176,8 +178,13 @@ func (r *ModelRegistryReconciler) setRegistryStatus(ctx context.Context, req ctr
 		status = metav1.ConditionFalse
 	}
 
-	if available && r.HasIstio {
-		status, reason, message = r.SetIstioAndGatewayConditions(ctx, req, modelRegistry, status, reason, message)
+	if available {
+		if r.HasIstio {
+			status, reason, message = r.SetIstioAndGatewayConditions(ctx, req, modelRegistry, status, reason, message)
+		}
+		if modelRegistry.Spec.OAuthProxy != nil {
+			status, reason, message = r.SetOauthProxyCondition(ctx, req, modelRegistry, status, reason, message)
+		}
 	}
 
 	meta.SetStatusCondition(&modelRegistry.Status.Conditions, metav1.Condition{Type: ConditionTypeAvailable,
@@ -379,7 +386,7 @@ func (r *ModelRegistryReconciler) SetIstioCondition(ctx context.Context, req ctr
 			status = metav1.ConditionTrue
 		}
 		// additionally verify that Deployment pod has 3 containers including the istio-envoy proxy
-		message, reason, status = r.CheckDeploymentPods(ctx, name, log, message, reason, status)
+		message, reason, status = r.CheckDeploymentPods(ctx, name, "Istio", log, message, reason, status)
 	} else {
 		status = metav1.ConditionFalse
 		reason = ReasonResourcesUnavailable
@@ -391,7 +398,37 @@ func (r *ModelRegistryReconciler) SetIstioCondition(ctx context.Context, req ctr
 	return status == metav1.ConditionTrue
 }
 
-func (r *ModelRegistryReconciler) CheckDeploymentPods(ctx context.Context, name types.NamespacedName,
+func (r *ModelRegistryReconciler) SetOauthProxyCondition(ctx context.Context, req ctrl.Request,
+	modelRegistry *modelregistryv1alpha1.ModelRegistry,
+	status metav1.ConditionStatus, reason string, message string) (metav1.ConditionStatus, string, string) {
+
+	log := klog.FromContext(ctx)
+
+	if modelRegistry.Spec.OAuthProxy != nil {
+
+		// verify that Deployment pod has 3 containers including the oauth proxy
+		name := req.NamespacedName
+		reason2 := ReasonResourcesCreated
+		message2 := "OAuth Proxy was successfully created"
+		message2, reason2, status2 := r.CheckDeploymentPods(ctx, name, "OAuth", log, message2, reason2, status)
+		meta.SetStatusCondition(&modelRegistry.Status.Conditions, metav1.Condition{Type: ConditionTypeOAuthProxy,
+			Status: status2, Reason: reason2,
+			Message: message2})
+
+		// set OAuth proxy available condition
+		if status2 == metav1.ConditionFalse {
+			status = status2
+			reason = reason2
+			message = "OAuth Proxy resources are unavailable"
+		}
+	} else {
+		meta.RemoveStatusCondition(&modelRegistry.Status.Conditions, ConditionTypeOAuthProxy)
+	}
+
+	return status, reason, message
+}
+
+func (r *ModelRegistryReconciler) CheckDeploymentPods(ctx context.Context, name types.NamespacedName, proxyType string,
 	log logr.Logger, message string, reason string, status metav1.ConditionStatus) (string, string, metav1.ConditionStatus) {
 	pods := corev1.PodList{}
 	if err := r.Client.List(ctx, &pods,
@@ -417,7 +454,7 @@ func (r *ModelRegistryReconciler) CheckDeploymentPods(ctx context.Context, name
 	// check that pods have 3 containers
 	for _, pod := range pods.Items {
 		if len(pod.Spec.Containers) != 3 {
-			message = fmt.Sprintf("Istio proxy unavailable in Pod %s", pod.Name)
+			message = fmt.Sprintf("%s proxy unavailable in Pod %s", proxyType, pod.Name)
 			reason = ReasonResourcesUnavailable
 			status = metav1.ConditionFalse
 			break
diff --git a/internal/controller/modelregistry_controller_test.go b/internal/controller/modelregistry_controller_test.go
index 194bb457..f78236e9 100644
--- a/internal/controller/modelregistry_controller_test.go
+++ b/internal/controller/modelregistry_controller_test.go
@@ -19,6 +19,7 @@ package controller
 import (
 	"context"
 	"fmt"
+	v1 "k8s.io/api/networking/v1"
 	"os"
 	"strings"
 	"text/template"
@@ -74,6 +75,8 @@ var _ = Describe("ModelRegistry controller", func() {
 				Expect(err).To(Not(HaveOccurred()))
 				err = os.Setenv(config.RestImage, config.DefaultRestImage)
 				Expect(err).To(Not(HaveOccurred()))
+				err = os.Setenv(config.OAuthProxyImage, config.DefaultOAuthProxyImage)
+				Expect(err).To(Not(HaveOccurred()))
 
 				By("Setting the Istio ENV VARs which store istio config")
 				err = os.Setenv(config.DefaultControlPlane, TestSmcp)
@@ -408,6 +411,72 @@ var _ = Describe("ModelRegistry controller", func() {
 					time.Minute, time.Second).Should(Succeed())
 			})
 
+			// Oauth Proxy config tests
+			var oauthProxyConfig *v1alpha1.OAuthProxyConfig
+			oauthValidate := func() {
+				specInit()
+
+				var mySQLPort int32 = 3306
+				modelRegistry.Spec.Postgres = nil
+				modelRegistry.Spec.MySQL = &v1alpha1.MySQLConfig{
+					Host:     "model-registry-db",
+					Port:     &mySQLPort,
+					Database: "model_registry",
+					Username: "mlmduser",
+					PasswordSecret: &v1alpha1.SecretKeyValue{
+						Name: "model-registry-db",
+						Key:  "database-password",
+					},
+				}
+				modelRegistry.Spec.OAuthProxy = oauthProxyConfig
+
+				err = k8sClient.Create(ctx, modelRegistry)
+				Expect(err).To(Not(HaveOccurred()))
+
+				modelRegistryReconciler := initModelRegistryReconciler(template)
+				modelRegistryReconciler.IsOpenShift = true
+
+				Eventually(validateRegistryOauthProxy(ctx, typeNamespaceName, modelRegistry, modelRegistryReconciler),
+					time.Minute, time.Second).Should(Succeed())
+			}
+
+			It("When using default Oauth Proxy config on openshift", func() {
+				registryName = "model-registry-oauth-proxy"
+				oauthProxyConfig = &v1alpha1.OAuthProxyConfig{}
+				oauthValidate()
+			})
+
+			It("When using Oauth Proxy with custom certs on openshift", func() {
+				registryName = "model-registry-oauth-certs"
+				oauthProxyConfig = &v1alpha1.OAuthProxyConfig{
+					TLSCertificateSecret: &v1alpha1.SecretKeyValue{
+						Name: "test-cert-secret",
+						Key:  "test-cert-key",
+					},
+					TLSKeySecret: &v1alpha1.SecretKeyValue{
+						Name: "test-key-secret",
+						Key:  "test-key-key",
+					},
+				}
+				oauthValidate()
+			})
+
+			It("When using Oauth Proxy without route config on openshift", func() {
+				registryName = "model-registry-oauth-noroute"
+				oauthProxyConfig = &v1alpha1.OAuthProxyConfig{
+					ServiceRoute: config.RouteDisabled,
+				}
+				oauthValidate()
+			})
+
+			It("When using Oauth Proxy with custom image on openshift", func() {
+				registryName = "model-registry-oauth-image"
+				oauthProxyConfig = &v1alpha1.OAuthProxyConfig{
+					Image: "test-proxy-image",
+				}
+				oauthValidate()
+			})
+
 			AfterEach(func() {
 				By("removing the custom resource for the Kind ModelRegistry")
 				found := &v1alpha1.ModelRegistry{}
@@ -478,6 +547,7 @@ func validateRegistryBase(ctx context.Context, typeNamespaceName types.Namespace
 			},
 		}
 
+		// mock istio proxy container
 		if modelRegistryReconciler.HasIstio {
 			mrPod.Spec.Containers = append(mrPod.Spec.Containers, corev1.Container{
 				Name:  "istio-proxy",
@@ -485,6 +555,18 @@ func validateRegistryBase(ctx context.Context, typeNamespaceName types.Namespace
 			})
 		}
 
+		// mock oauth proxy container
+		if modelRegistry.Spec.OAuthProxy != nil {
+			image := modelRegistry.Spec.OAuthProxy.Image
+			if len(image) == 0 {
+				image = config.DefaultOAuthProxyImage
+			}
+			mrPod.Spec.Containers = append(mrPod.Spec.Containers, corev1.Container{
+				Name:  "oauth-proxy",
+				Image: image,
+			})
+		}
+
 		err := k8sClient.Create(ctx, mrPod)
 		Expect(err).To(Not(HaveOccurred()))
 
@@ -737,3 +819,93 @@ func validateRegistryAuth(ctx context.Context, typeNamespaceName types.Namespace
 		return nil
 	}
 }
+
+func validateRegistryOauthProxy(ctx context.Context, typeNamespaceName types.NamespacedName, modelRegistry *v1alpha1.ModelRegistry, modelRegistryReconciler *ModelRegistryReconciler) func() error {
+	return func() error {
+		modelRegistryReconciler.IsOpenShift = true
+
+		Eventually(validateRegistryOpenshift(ctx, typeNamespaceName, modelRegistry, modelRegistryReconciler)).Should(Succeed())
+
+		By("Checking if the OAuth Proxy Deployment was configured correctly in the reconciliation")
+		Eventually(func() error {
+			found := &appsv1.Deployment{}
+
+			err := k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("%s", modelRegistry.Name), Namespace: modelRegistry.Namespace}, found)
+			Expect(err).To(Not(HaveOccurred()))
+
+			proxyContainer := found.Spec.Template.Spec.Containers[2]
+
+			// check image
+			if len(modelRegistry.Spec.OAuthProxy.Image) != 0 {
+				Expect(proxyContainer.Image).Should(Equal(modelRegistry.Spec.OAuthProxy.Image))
+			}
+
+			// check cert args
+			certKey := "tls.crt"
+			keyKey := "tls.key"
+			certSecretName := modelRegistry.Name + "-oauth-proxy"
+			keySecretName := modelRegistry.Name + "-oauth-proxy"
+			if modelRegistry.Spec.OAuthProxy.TLSCertificateSecret != nil {
+				certKey = modelRegistry.Spec.OAuthProxy.TLSCertificateSecret.Key
+				certSecretName = modelRegistry.Spec.OAuthProxy.TLSCertificateSecret.Name
+			}
+			if modelRegistry.Spec.OAuthProxy.TLSKeySecret != nil {
+				keyKey = modelRegistry.Spec.OAuthProxy.TLSKeySecret.Key
+				keySecretName = modelRegistry.Spec.OAuthProxy.TLSKeySecret.Name
+			}
+			Expect(proxyContainer.Args).Should(ContainElements(
+				fmt.Sprintf("--tls-cert=/etc/tls/private-cert/%s", certKey),
+				fmt.Sprintf("--tls-key=/etc/tls/private-key/%s", keyKey)))
+
+			// check cert volumes
+			var defaultMode int32 = 0o600
+			Expect(found.Spec.Template.Spec.Volumes).Should(ContainElements(
+				corev1.Volume{
+					Name: "oauth-proxy-cert",
+					VolumeSource: corev1.VolumeSource{
+						Secret: &corev1.SecretVolumeSource{
+							SecretName:  certSecretName,
+							DefaultMode: &defaultMode,
+						},
+					}},
+				corev1.Volume{
+					Name: "oauth-proxy-key",
+					VolumeSource: corev1.VolumeSource{
+						Secret: &corev1.SecretVolumeSource{
+							SecretName:  keySecretName,
+							DefaultMode: &defaultMode,
+						},
+					}}))
+
+			return nil
+		}, 5*time.Second, time.Second).Should(Succeed())
+
+		By("Checking if the OAuth Proxy Route was successfully created in the reconciliation")
+		matchRoute := Succeed()
+		if modelRegistry.Spec.OAuthProxy.ServiceRoute == config.RouteDisabled {
+			matchRoute = Not(Succeed())
+		}
+		Eventually(func() error {
+			found := &routev1.Route{}
+
+			return k8sClient.Get(ctx, types.NamespacedName{
+				Name: fmt.Sprintf("%s-https", modelRegistry.Name), Namespace: modelRegistry.Namespace}, found)
+		}, 5*time.Second, time.Second).Should(matchRoute)
+
+		By("Checking if the OAuth Proxy ClusterRoleBinding was successfully created in the reconciliation")
+		Eventually(func() error {
+			found := &rbacv1.ClusterRoleBinding{}
+
+			return k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("%s-auth-delegator", modelRegistry.Name)}, found)
+		}, 5*time.Second, time.Second).Should(Succeed())
+
+		By("Checking if the OAuth Proxy NetworkPolicy was successfully created in the reconciliation")
+		Eventually(func() error {
+			found := &v1.NetworkPolicy{}
+
+			return k8sClient.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("%s-https-route", modelRegistry.Name), Namespace: modelRegistry.Namespace}, found)
+		}, 5*time.Second, time.Second).Should(matchRoute)
+
+		return nil
+	}
+}
diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go
index 94d3066e..c83a6dbe 100644
--- a/internal/controller/suite_test.go
+++ b/internal/controller/suite_test.go
@@ -18,6 +18,7 @@ package controller
 
 import (
 	"fmt"
+	networkingscheme "k8s.io/api/networking/v1"
 	"os"
 	"path/filepath"
 	"runtime"
@@ -118,6 +119,9 @@ var _ = BeforeSuite(func() {
 	err = modelregistryv1alpha1.AddToScheme(schm)
 	Expect(err).NotTo(HaveOccurred())
 
+	err = networkingscheme.AddToScheme(schm)
+	Expect(err).NotTo(HaveOccurred())
+
 	//+kubebuilder:scaffold:scheme
 
 	// Download CRDs


Reviews:
user: login: pboyd
body: Looks good to me.
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T12:31:25Z
user: login: dhirajsb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T16:13:46Z
user: login: pboyd
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T17:14:10Z
user: login: pboyd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T17:29:20Z

Review comments:
diff_hunk: @@ -248,6 +248,56 @@ spec:
             capabilities:
               drop:
                 - ALL
+        {{ if .Spec.OAuthProxy }}
+        - name: oauth-proxy
+          args:
+            - --https-address=:{{.Spec.OAuthProxy.Port}}
+            - --provider=openshift
+            - --openshift-service-account={{.Name}}
+            - --upstream=http://localhost:{{.Spec.Rest.Port}}
+            - --tls-cert=/etc/tls/private-cert/{{.Spec.OAuthProxy.TLSCertificateSecret.Key}}
+            - --tls-key=/etc/tls/private-key/{{.Spec.OAuthProxy.TLSKeySecret.Key}}
+            - --cookie-secret=MODEL_REGISTRY_OAUTH_SECRET
+            - '--openshift-delegate-urls={"/":{"group":"","resource":"services","verb":"get","name":"{{.Name}}","namespace":"{{.Namespace}}"}}'
+            - --skip-auth-regex='(^/metrics|^/oauth/healthz)'
+          image: {{.Spec.OAuthProxy.Image}}
+          ports:
+            - containerPort: 8443
+              name: oauth2-proxy
+              protocol: TCP
+          livenessProbe:
+            httpGet:
+              path: /oauth/healthz
+              port: oauth2-proxy
+              scheme: HTTPS
+            initialDelaySeconds: 30
+            timeoutSeconds: 1
+            periodSeconds: 5
+            successThreshold: 1
+            failureThreshold: 3
+          readinessProbe:
+            httpGet:
+              path: /oauth/healthz
+              port: oauth2-proxy
+              scheme: HTTPS
+            initialDelaySeconds: 5
+            timeoutSeconds: 1
+            periodSeconds: 5
+            successThreshold: 1
+            failureThreshold: 3
+          resources:
+            limits:
+              cpu: 100m
path: internal/controller/config/templates/deployment.yaml.tmpl
user: login: pboyd
body: The tone is a bit over the top, but consider this article: https://home.robusta.dev/blog/stop-using-cpu-limits
created_at: 2025-04-04T12:21:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -722,6 +747,24 @@ func (r *ModelRegistryReconciler) createOrUpdateRoleBinding(ctx context.Context,
 	return result, nil
 }
 
+func (r *ModelRegistryReconciler) createOrUpdateClusterRoleBinding(ctx context.Context, params *ModelRegistryParams,
+	registry *modelregistryv1alpha1.ModelRegistry, templateName string) (result OperationResult, err error) {
+	result = ResourceUnchanged
+	var roleBinding rbac.ClusterRoleBinding
+	if err = r.Apply(params, templateName, &roleBinding); err != nil {
+		return result, err
+	}
+	if err = ctrl.SetControllerReference(registry, &roleBinding, r.Scheme); err != nil {
+		return result, err
+	}
+
+	result, err = r.createOrUpdate(ctx, &rbac.ClusterRoleBinding{}, &roleBinding)
+	if err != nil {
+		return result, err
+	}
+	return result, nil
path: internal/controller/modelregistry_controller.go
user: login: pboyd
body: ```suggestion
	return r.createOrUpdate(ctx, &rbac.ClusterRoleBinding{}, &roleBinding)
```
created_at: 2025-04-04T12:26:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -248,6 +248,56 @@ spec:
             capabilities:
               drop:
                 - ALL
+        {{ if .Spec.OAuthProxy }}
+        - name: oauth-proxy
+          args:
+            - --https-address=:{{.Spec.OAuthProxy.Port}}
+            - --provider=openshift
+            - --openshift-service-account={{.Name}}
+            - --upstream=http://localhost:{{.Spec.Rest.Port}}
+            - --tls-cert=/etc/tls/private-cert/{{.Spec.OAuthProxy.TLSCertificateSecret.Key}}
+            - --tls-key=/etc/tls/private-key/{{.Spec.OAuthProxy.TLSKeySecret.Key}}
+            - --cookie-secret=MODEL_REGISTRY_OAUTH_SECRET
+            - '--openshift-delegate-urls={"/":{"group":"","resource":"services","verb":"get","name":"{{.Name}}","namespace":"{{.Namespace}}"}}'
+            - --skip-auth-regex='(^/metrics|^/oauth/healthz)'
+          image: {{.Spec.OAuthProxy.Image}}
+          ports:
+            - containerPort: 8443
+              name: oauth2-proxy
+              protocol: TCP
+          livenessProbe:
+            httpGet:
+              path: /oauth/healthz
+              port: oauth2-proxy
+              scheme: HTTPS
+            initialDelaySeconds: 30
+            timeoutSeconds: 1
+            periodSeconds: 5
+            successThreshold: 1
+            failureThreshold: 3
+          readinessProbe:
+            httpGet:
+              path: /oauth/healthz
+              port: oauth2-proxy
+              scheme: HTTPS
+            initialDelaySeconds: 5
+            timeoutSeconds: 1
+            periodSeconds: 5
+            successThreshold: 1
+            failureThreshold: 3
+          resources:
+            limits:
+              cpu: 100m
path: internal/controller/config/templates/deployment.yaml.tmpl
user: login: dhirajsb
body: That's a fair analysis. Do you want to create an issue and test it out for all the MR containers in another PR? 
created_at: 2025-04-04T16:13:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -248,6 +248,56 @@ spec:
             capabilities:
               drop:
                 - ALL
+        {{ if .Spec.OAuthProxy }}
+        - name: oauth-proxy
+          args:
+            - --https-address=:{{.Spec.OAuthProxy.Port}}
+            - --provider=openshift
+            - --openshift-service-account={{.Name}}
+            - --upstream=http://localhost:{{.Spec.Rest.Port}}
+            - --tls-cert=/etc/tls/private-cert/{{.Spec.OAuthProxy.TLSCertificateSecret.Key}}
+            - --tls-key=/etc/tls/private-key/{{.Spec.OAuthProxy.TLSKeySecret.Key}}
+            - --cookie-secret=MODEL_REGISTRY_OAUTH_SECRET
+            - '--openshift-delegate-urls={"/":{"group":"","resource":"services","verb":"get","name":"{{.Name}}","namespace":"{{.Namespace}}"}}'
+            - --skip-auth-regex='(^/metrics|^/oauth/healthz)'
+          image: {{.Spec.OAuthProxy.Image}}
+          ports:
+            - containerPort: 8443
+              name: oauth2-proxy
+              protocol: TCP
+          livenessProbe:
+            httpGet:
+              path: /oauth/healthz
+              port: oauth2-proxy
+              scheme: HTTPS
+            initialDelaySeconds: 30
+            timeoutSeconds: 1
+            periodSeconds: 5
+            successThreshold: 1
+            failureThreshold: 3
+          readinessProbe:
+            httpGet:
+              path: /oauth/healthz
+              port: oauth2-proxy
+              scheme: HTTPS
+            initialDelaySeconds: 5
+            timeoutSeconds: 1
+            periodSeconds: 5
+            successThreshold: 1
+            failureThreshold: 3
+          resources:
+            limits:
+              cpu: 100m
path: internal/controller/config/templates/deployment.yaml.tmpl
user: login: pboyd
body: Yeah, here's the issue: https://issues.redhat.com/browse/RHOAIENG-23177
created_at: 2025-04-04T17:29:20Z
author_association: CONTRIBUTOR

PR comments:
user: login: dhirajsb
created_at: 2025-04-04T11:10:46Z
body: Need to add docs and readme next. It is also having some trouble with external routes for Oauth proxy MRs. 
author_association: CONTRIBUTOR
user: login: dhirajsb
created_at: 2025-04-05T02:30:13Z
body: @Al-Pragliola @pboyd I've added a bunch of tests to make sure all the options are being correctly handled. 
author_association: CONTRIBUTOR
user: login: dhirajsb
created_at: 2025-04-05T03:17:06Z
body: Since the extensive set of tests are passing, I'm going to merge it in to test on Monday for ODH release on Apr 7th. 
In the meantime, please review the changes when you get a chance to check whether the tests missed anything. 
author_association: CONTRIBUTOR