Skip to content

Commit cbf6433

Browse files
committed
UPSTREAM: <carry>: Enforce secure MLflow TLS with required caBundlePath
Signed-off-by: Vani Haripriya Mudadla <vmudadla@redhat.com>
1 parent fd9107e commit cbf6433

4 files changed

Lines changed: 73 additions & 81 deletions

File tree

.github/resources/scripts/configure-mlflow.sh

Lines changed: 9 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ KFP_NAMESPACE="${1:?KFP namespace required}"
2929
MLFLOW_NAMESPACE="${2:?MLflow namespace required}"
3030
CONFIG_JSON_PATH="${3:?Path to source config.json required}"
3131

32+
MLFLOW_CA_CONFIGMAP="mlflow-ca-cert"
33+
KFP_CA_BUNDLE_DIR="/kfp/certs"
34+
KFP_CA_BUNDLE_PATH="${KFP_CA_BUNDLE_DIR}/ca.crt"
35+
3236
echo "Services in ${MLFLOW_NAMESPACE} namespace:"
3337
kubectl get svc -n "$MLFLOW_NAMESPACE" --no-headers
3438
MLFLOW_SVC=$(kubectl get svc -n "$MLFLOW_NAMESPACE" --no-headers -o custom-columns=":metadata.name" | grep -i mlflow | head -1)
@@ -43,7 +47,6 @@ MLFLOW_ENDPOINT="https://${MLFLOW_HOST}:${MLFLOW_PORT}${MLFLOW_STATIC_PREFIX}"
4347
echo "MLflow service: $MLFLOW_SVC port=$MLFLOW_PORT endpoint=$MLFLOW_ENDPOINT"
4448

4549
# --- Extract CA certificate from the MLflow TLS secret ---
46-
CA_MOUNT_PATH="/etc/mlflow/tls/ca.crt"
4750
CA_CERT_FILE="/tmp/mlflow-ca.crt"
4851

4952
echo "Extracting MLflow CA certificate..."
@@ -70,71 +73,12 @@ fi
7073
echo "$CA_DATA" | base64 -d > "$CA_CERT_FILE"
7174
echo "CA certificate extracted to $CA_CERT_FILE ($(wc -l < "$CA_CERT_FILE") lines)"
7275

73-
# Create a ConfigMap with the CA cert in the KFP namespace for workflow pods
74-
kubectl create configmap mlflow-ca-cert -n "$KFP_NAMESPACE" \
76+
# ConfigMap consumed by the API server (plugin config) and compiler (driver/launcher pods).
77+
kubectl create configmap "$MLFLOW_CA_CONFIGMAP" -n "$KFP_NAMESPACE" \
7578
--from-file=ca.crt="$CA_CERT_FILE" --dry-run=client -o yaml | kubectl apply -f -
7679

77-
# --- Patch Argo workflow-controller-configmap to mount the CA cert into all workflow pods ---
78-
echo "Patching workflow-controller-configmap to inject MLflow CA into workflow pods..."
79-
80-
EXISTING_MAIN_CONTAINER=$(kubectl get configmap workflow-controller-configmap -n "$KFP_NAMESPACE" \
81-
-o jsonpath='{.data.mainContainer}' 2>/dev/null || echo "")
82-
if [ -n "$EXISTING_MAIN_CONTAINER" ]; then
83-
MAIN_CONTAINER_PATCH=$(echo "$EXISTING_MAIN_CONTAINER" | \
84-
python3 -c "
85-
import sys, json
86-
raw = sys.stdin.read().strip()
87-
try:
88-
cfg = json.loads(raw)
89-
except json.JSONDecodeError:
90-
import yaml
91-
cfg = yaml.safe_load(raw) or {}
92-
vms = cfg.get('volumeMounts', [])
93-
vms = [vm for vm in vms if vm.get('name') != 'mlflow-ca']
94-
vms.append({'name': 'mlflow-ca', 'mountPath': '/etc/mlflow/tls', 'readOnly': True})
95-
cfg['volumeMounts'] = vms
96-
print(json.dumps(cfg))
97-
")
98-
else
99-
MAIN_CONTAINER_PATCH='{"volumeMounts":[{"name":"mlflow-ca","mountPath":"/etc/mlflow/tls","readOnly":true}]}'
100-
fi
101-
102-
EXISTING_WF_DEFAULTS=$(kubectl get configmap workflow-controller-configmap -n "$KFP_NAMESPACE" \
103-
-o jsonpath='{.data.workflowDefaults}' 2>/dev/null || echo "")
104-
if [ -n "$EXISTING_WF_DEFAULTS" ]; then
105-
WF_DEFAULTS_PATCH=$(echo "$EXISTING_WF_DEFAULTS" | \
106-
python3 -c "
107-
import sys, yaml
108-
cfg = yaml.safe_load(sys.stdin) or {}
109-
spec = cfg.setdefault('spec', {})
110-
volumes = spec.setdefault('volumes', [])
111-
volumes = [v for v in volumes if v.get('name') != 'mlflow-ca']
112-
volumes.append({'name': 'mlflow-ca', 'configMap': {'name': 'mlflow-ca-cert'}})
113-
spec['volumes'] = volumes
114-
cfg['spec'] = spec
115-
print(yaml.dump(cfg, default_flow_style=False))
116-
")
117-
else
118-
WF_DEFAULTS_PATCH=$(cat <<'YAML'
119-
spec:
120-
volumes:
121-
- name: mlflow-ca
122-
configMap:
123-
name: mlflow-ca-cert
124-
YAML
125-
)
126-
fi
127-
128-
kubectl patch configmap workflow-controller-configmap -n "$KFP_NAMESPACE" --type=merge \
129-
-p "$(jq -n --arg mc "$MAIN_CONTAINER_PATCH" --arg wd "$WF_DEFAULTS_PATCH" \
130-
'{"data":{"mainContainer":$mc,"workflowDefaults":$wd}}')"
131-
132-
# Restart workflow controller to pick up configmap changes
133-
kubectl rollout restart deployment/workflow-controller -n "$KFP_NAMESPACE"
134-
kubectl rollout status deployment/workflow-controller -n "$KFP_NAMESPACE" --timeout=120s
135-
13680
# --- Build the MLflow plugin config with caBundlePath ---
137-
MLFLOW_PATCH=$(jq -n --arg endpoint "$MLFLOW_ENDPOINT" --arg caBundlePath "$CA_MOUNT_PATH" '{
81+
MLFLOW_PATCH=$(jq -n --arg endpoint "$MLFLOW_ENDPOINT" --arg caBundlePath "$KFP_CA_BUNDLE_PATH" '{
13882
endpoint: $endpoint,
13983
tls: { caBundlePath: $caBundlePath },
14084
settings: { workspacesEnabled: true }
@@ -146,11 +90,11 @@ jq --argjson mlflow "$MLFLOW_PATCH" '. + { plugins: { mlflow: $mlflow } }' \
14690
echo "Patched config.json plugins.mlflow:"
14791
jq '.plugins.mlflow' /tmp/kfp-config.json
14892

149-
# --- Deploy the config and mount the CA cert into the API server ---
93+
# --- Deploy plugin config and wire CA trust on the API server + compiler ---
15094
kubectl create configmap kfp-mlflow-config -n "$KFP_NAMESPACE" \
15195
--from-file=config.json=/tmp/kfp-config.json --dry-run=client -o yaml | kubectl apply -f -
15296
kubectl patch deployment ml-pipeline -n "$KFP_NAMESPACE" --type=strategic -p \
153-
'{"spec":{"template":{"spec":{"volumes":[{"name":"mlflow-cfg","configMap":{"name":"kfp-mlflow-config"}},{"name":"mlflow-ca","configMap":{"name":"mlflow-ca-cert"}}],"containers":[{"name":"ml-pipeline-api-server","volumeMounts":[{"name":"mlflow-cfg","mountPath":"/config/config.json","subPath":"config.json"},{"name":"mlflow-ca","mountPath":"/etc/mlflow/tls","readOnly":true}]}]}}}}'
97+
'{"spec":{"template":{"spec":{"volumes":[{"name":"mlflow-cfg","configMap":{"name":"kfp-mlflow-config"}},{"name":"mlflow-ca","configMap":{"name":"'"$MLFLOW_CA_CONFIGMAP"'"}}],"containers":[{"name":"ml-pipeline-api-server","env":[{"name":"CABUNDLE_CONFIGMAP_NAME","value":"'"$MLFLOW_CA_CONFIGMAP"'"}],"volumeMounts":[{"name":"mlflow-cfg","mountPath":"/config/config.json","subPath":"config.json"},{"name":"mlflow-ca","mountPath":"'"$KFP_CA_BUNDLE_DIR"'","readOnly":true}]}]}}}}'
15498
kubectl rollout status deployment/ml-pipeline -n "$KFP_NAMESPACE" --timeout=180s
15599

156100
pkill -f "kubectl port-forward.*ml-pipeline.*8888" || true

backend/src/v2/common/plugins/mlflow/config.go

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"fmt"
66
"strings"
77

8-
commonplugins "github.com/kubeflow/pipelines/backend/src/common/plugins"
98
commonmlflow "github.com/kubeflow/pipelines/backend/src/common/plugins/mlflow"
109
"github.com/spf13/viper"
1110
)
@@ -59,22 +58,11 @@ func ParseKfpMLflowRuntimeConfig() (*commonmlflow.MLflowRuntimeConfig, error) {
5958
if cfg.AuthType != "kubernetes" {
6059
return nil, fmt.Errorf("unsupported auth type: %s", cfg.AuthType)
6160
}
62-
// Disabling TLS verification is not supported in the driver/launcher
63-
// to prevent CWE-295 (improper certificate validation).
64-
if cfg.InsecureSkipVerify {
61+
if cfg.InsecureSkipVerify || (cfg.TLS != nil && cfg.TLS.InsecureSkipVerify) {
6562
return nil, fmt.Errorf("insecureSkipVerify is not supported")
6663
}
67-
// Preserve the CABundlePath from the runtime config (propagated by the
68-
// API server) so the driver/launcher can verify certificates signed by
69-
// an internal CA (e.g., cert-manager). InsecureSkipVerify is always
70-
// forced to false.
71-
caBundlePath := ""
72-
if cfg.TLS != nil {
73-
caBundlePath = cfg.TLS.CABundlePath
74-
}
75-
cfg.TLS = &commonplugins.TLSConfig{
76-
InsecureSkipVerify: false,
77-
CABundlePath: caBundlePath,
64+
if cfg.TLS == nil || cfg.TLS.CABundlePath == "" {
65+
return nil, fmt.Errorf("tls.caBundlePath is required")
7866
}
7967
return &cfg, nil
8068
}

backend/src/v2/common/plugins/mlflow/config_test.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,18 @@ func TestExecutionStateToMLflowTerminalStatus(t *testing.T) {
2727
assert.Equal(t, "FAILED", ExecutionStateToMLflowTerminalStatus("UNKNOWN"))
2828
}
2929

30+
const testCABundlePath = "/etc/pki/tls/certs/ca-bundle.crt"
31+
3032
func TestParseKfpMLflowRuntimeConfig_Success(t *testing.T) {
3133
cfg := commonmlflow.MLflowRuntimeConfig{
3234
Endpoint: "http://localhost",
3335
ParentRunID: "test-parent-run-id",
3436
ExperimentID: "test-exp",
3537
AuthType: "kubernetes",
3638
Timeout: "10s",
39+
TLS: &commonplugins.TLSConfig{
40+
CABundlePath: testCABundlePath,
41+
},
3742
}
3843
expectedCfg := &commonmlflow.MLflowRuntimeConfig{
3944
Endpoint: "http://localhost",
@@ -46,7 +51,7 @@ func TestParseKfpMLflowRuntimeConfig_Success(t *testing.T) {
4651
InsecureSkipVerify: false,
4752
InjectUserEnvVars: false,
4853
TLS: &commonplugins.TLSConfig{
49-
InsecureSkipVerify: false,
54+
CABundlePath: testCABundlePath,
5055
},
5156
}
5257

@@ -171,6 +176,39 @@ func TestParseKfpMLflowRuntimeConfig_InsecureSkipVerify_Failure(t *testing.T) {
171176
assert.Equal(t, "insecureSkipVerify is not supported", err.Error())
172177
}
173178

179+
func TestParseKfpMLflowRuntimeConfig_MissingTLS_Failure(t *testing.T) {
180+
cfg := commonmlflow.MLflowRuntimeConfig{
181+
Endpoint: "http://localhost",
182+
ParentRunID: "test-parent-run-id",
183+
ExperimentID: "test-exp",
184+
AuthType: "kubernetes",
185+
Timeout: "10s",
186+
}
187+
setRuntimeCfg(cfg)
188+
runtimeCfg, err := ParseKfpMLflowRuntimeConfig()
189+
190+
assert.Nil(t, runtimeCfg)
191+
assert.Error(t, err)
192+
assert.Equal(t, "tls.caBundlePath is required", err.Error())
193+
}
194+
195+
func TestParseKfpMLflowRuntimeConfig_EmptyCABundlePath_Failure(t *testing.T) {
196+
cfg := commonmlflow.MLflowRuntimeConfig{
197+
Endpoint: "http://localhost",
198+
ParentRunID: "test-parent-run-id",
199+
ExperimentID: "test-exp",
200+
AuthType: "kubernetes",
201+
Timeout: "10s",
202+
TLS: &commonplugins.TLSConfig{},
203+
}
204+
setRuntimeCfg(cfg)
205+
runtimeCfg, err := ParseKfpMLflowRuntimeConfig()
206+
207+
assert.Nil(t, runtimeCfg)
208+
assert.Error(t, err)
209+
assert.Equal(t, "tls.caBundlePath is required", err.Error())
210+
}
211+
174212
func TestParseKfpMLflowRuntimeConfig_CABundlePath_Preserved(t *testing.T) {
175213
cfg := commonmlflow.MLflowRuntimeConfig{
176214
Endpoint: "http://localhost",

backend/src/v2/common/plugins/mlflow/factory_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package mlflow
33
import (
44
"testing"
55

6+
commonplugins "github.com/kubeflow/pipelines/backend/src/common/plugins"
67
commonmlflow "github.com/kubeflow/pipelines/backend/src/common/plugins/mlflow"
78
"github.com/kubeflow/pipelines/backend/src/v2/common/plugins"
89
"github.com/spf13/viper"
@@ -43,6 +44,9 @@ func TestMlflowHandlerFactory_Create_Success(t *testing.T) {
4344
ExperimentID: "exp-1",
4445
AuthType: "kubernetes",
4546
Timeout: "10s",
47+
TLS: &commonplugins.TLSConfig{
48+
CABundlePath: testCABundlePath,
49+
},
4650
})
4751
t.Cleanup(func() { viper.Set(commonmlflow.EnvMLflowConfig, "") })
4852

@@ -65,6 +69,24 @@ func TestMlflowHandlerFactory_Create_MissingConfig(t *testing.T) {
6569
assert.Contains(t, err.Error(), "KFP_MLFLOW_CONFIG env var not set")
6670
}
6771

72+
func TestMlflowHandlerFactory_Create_MissingCABundlePath(t *testing.T) {
73+
setRuntimeCfg(commonmlflow.MLflowRuntimeConfig{
74+
Endpoint: "http://localhost",
75+
ParentRunID: "parent-run-1",
76+
ExperimentID: "exp-1",
77+
AuthType: "kubernetes",
78+
Timeout: "10s",
79+
})
80+
t.Cleanup(func() { viper.Set(commonmlflow.EnvMLflowConfig, "") })
81+
82+
factory := &mlflowHandlerFactory{}
83+
handler, err := factory.Create()
84+
85+
require.Error(t, err)
86+
assert.Nil(t, handler)
87+
assert.Contains(t, err.Error(), "tls.caBundlePath is required")
88+
}
89+
6890
func TestMLflowHandlerFactory_Create_MissingConfigField(t *testing.T) {
6991
setRuntimeCfg(commonmlflow.MLflowRuntimeConfig{
7092
Endpoint: "http://localhost",

0 commit comments

Comments
 (0)