Root cause
Authentication & access control
Implement `BatchGetTraceInfos` server handler and `RestStore` client (#21666)
How AI contributed: New attack surfacePublished Jul 2, 2026 in
mlflow/mlflowAll 7 findings in this repository
In MLflow versions prior to 3.13.0, when running with authentication enabled, the trace API endpoints lack proper authorization validators. This allows any authenticated user to bypass experiment-level authorization controls on all trace operations, including reading, deleting, and modifying traces on experiments they do not have permission to access. The issue arises from the _before_request handler, which does not register authorization validators for trace endpoints, resulting in requests proceeding without validation. This vulnerability can expose sensitive data, destroy audit logs, and allow unauthorized modifications.
Trace endpoints are registered in HANDLERS in mlflow/server/auth/__init__.py without matching _before_request authorization validators, so _find_validator returns None and any authenticated non-admin can read, search, delete or modify traces on experiments they lack permission for. Claude-coauthored handlers then widened that unvalidated surface by adding BatchGetTraceInfos, and the fix adds validators such as validate_can_batch_get_traces.
Root cause
Implement `BatchGetTraceInfos` server handler and `RestStore` client (#21666)
Fix
Fix trace API authorization vulnerability (#23014)
AI-assisted fix: Claude Code · Tomu Hirata
Case facts
--- a/mlflow/server/handlers.py+++ b/mlflow/server/handlers.py@@ -141,6 +141,7 @@ from mlflow.protos.prompt_optimization_pb2 import ( from mlflow.protos.service_pb2 import ( AddDatasetToExperiments, AttachModelToGatewayEndpoint,+ BatchGetTraceInfos, BatchGetTraces, CalculateTraceFilterCorrelation, CancelPromptOptimizationJob,@@ -3565,6 +3566,19 @@ def _batch_get_traces() -> Response: return _wrap_response(response_message) +@catch_mlflow_exception+@_disable_if_artifacts_only+def _batch_get_trace_infos() -> Response:+ request_message = _get_request_message(+ BatchGetTraceInfos(),+ schema={"trace_ids": [_assert_array, _assert_required, _assert_item_type_string]},+ )+ trace_infos = _get_tracking_store().batch_get_trace_infos(request_message.trace_ids)+ response_message = BatchGetTraceInfos.Response()+ response_message.trace_infos.extend([ti.to_proto() for ti in trace_infos])+ return _wrap_response(response_message)++ @catch_mlflow_exception @_disable_if_artifacts_only def _get_trace() -> Response:@@ -6514,6 +6528,7 @@ HANDLERS = { LinkTracesToRun: _link_traces_to_run, LinkPromptsToTrace: _link_prompts_to_trace, BatchGetTraces: _batch_get_traces,+ BatchGetTraceInfos: _batch_get_trace_infos, GetTrace: _get_trace, QueryTraceMetrics: _query_trace_metrics, # Assessment APIs--- a/mlflow/store/tracking/databricks_rest_store.py+++ b/mlflow/store/tracking/databricks_rest_store.py@@ -314,6 +314,11 @@ class DatabricksTracingRestStore(RestStore): ) return [trace_from_proto(proto, location) for proto in response_proto.traces] + def batch_get_trace_infos(+ self, trace_ids: list[str], location: str | None = None+ ) -> list[TraceInfo]:+ raise MlflowNotImplementedException()+ def get_trace_info(self, trace_id: str) -> TraceInfo: """ Get the trace info matching the `trace_id`.--- a/mlflow/store/tracking/rest_store.py+++ b/mlflow/store/tracking/rest_store.py@@ -60,6 +60,7 @@ from mlflow.protos.issues_pb2 import ( ) from mlflow.protos.service_pb2 import ( AddDatasetToExperiments,+ BatchGetTraceInfos, BatchGetTraces, CalculateTraceFilterCorrelation, CreateAssessment,@@ -537,6 +538,17 @@ class RestStore(WorkspaceRestStoreMixin, RestGatewayStoreMixin, AbstractStore): ) return [Trace.from_proto(proto) for proto in response_proto.traces] + def batch_get_trace_infos(+ self, trace_ids: list[str], location: str | None = None+ ) -> list[TraceInfo]:+ req_body = message_to_json(BatchGetTraceInfos(trace_ids=trace_ids))+ response_proto = self._call_endpoint(+ BatchGetTraceInfos,+ req_body,+ endpoint=f"{_V3_TRACE_REST_API_PATH_PREFIX}/batchGetInfos",+ )+ return [TraceInfo.from_proto(proto) for proto in response_proto.trace_infos]+ def search_traces( self, experiment_ids: list[str] | None = None,--- a/mlflow/server/auth/__init__.py+++ b/mlflow/server/auth/__init__.py@@ -84,7 +84,11 @@ from mlflow.protos.model_registry_pb2 import ( ) from mlflow.protos.service_pb2 import ( AttachModelToGatewayEndpoint,+ BatchGetTraceInfos,+ BatchGetTraces,+ CalculateTraceFilterCorrelation, CancelPromptOptimizationJob,+ CreateAssessment, CreateExperiment, CreateGatewayBudgetPolicy, CreateGatewayEndpoint,@@ -95,6 +99,7 @@ from mlflow.protos.service_pb2 import ( CreatePromptOptimizationJob, CreateRun, CreateWorkspace,+ DeleteAssessment, DeleteExperiment, DeleteExperimentTag, DeleteGatewayBudgetPolicy,@@ -109,9 +114,15 @@ from mlflow.protos.service_pb2 import ( DeleteRun, DeleteScorer, DeleteTag,+ DeleteTraces,+ DeleteTracesV3,+ DeleteTraceTag,+ DeleteTraceTagV3, DeleteWorkspace, DetachModelFromGatewayEndpoint,+ EndTrace, FinalizeLoggedModel,+ GetAssessmentRequest, GetExperiment, GetExperimentByName, GetGatewayEndpoint,@@ -122,7 +133,12 @@ from mlflow.protos.service_pb2 import ( GetPromptOptimizationJob, GetRun, GetScorer,+ GetTrace,+ GetTraceInfo,+ GetTraceInfoV3, GetWorkspace,+ LinkPromptsToTrace,+ LinkTracesToRun, ListArtifacts, ListGatewayEndpointBindings, ListLoggedModelArtifacts,@@ -134,16 +150,24 @@ from mlflow.protos.service_pb2 import ( LogMetric, LogModel, LogParam,+ QueryTraceMetrics, RegisterScorer, RestoreExperiment, RestoreRun, SearchExperiments, SearchLoggedModels, SearchPromptOptimizationJobs,+ SearchTraces,+ SearchTracesV3, SetExperimentTag, SetGatewayEndpointTag, SetLoggedModelTags, SetTag,+ SetTraceTag,+ SetTraceTagV3,+ StartTrace,+ StartTraceV3,+ UpdateAssessment, UpdateExperiment, UpdateGatewayBudgetPolicy, UpdateGatewayEndpoint,@@ -1743,30 +1767,14 @@ def validate_can_read_model_version_artifact(): def _get_permission_from_trace_request_id() -> Permission:- """- Get permission for trace artifacts.- Traces inherit permissions from their parent run/experiment.- """ request_id = request.args.get("request_id") if not request_id: raise MlflowException( "Request must specify request_id parameter", INVALID_PARAMETER_VALUE, )- # Get the trace to find its experiment trace = _get_tracking_store().get_trace_info(request_id)- experiment_id = trace.experiment_id- username = authenticate_request().username- return _get_role_permission_or_default(- _role_permission_for(- username=username,- resource_type="experiment",- resource_key=experiment_id,- workspace_lookup_id=experiment_id,- workspace_fetcher=_get_tracking_store().get_experiment,- workspace_label="experiment",- ),- )+ return _get_experiment_permission(trace.experiment_id, authenticate_request().username) def validate_can_read_trace_artifact():@@ -1774,6 +1782,138 @@ def validate_can_read_trace_artifact(): return _get_permission_from_trace_request_id().can_read +def _get_permission_from_trace(trace_id: str, username: str) -> Permission:+ try:+ trace = _get_tracking_store().get_trace_info(trace_id)+ except MlflowException as e:+ if e.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST):+ return NO_PERMISSIONS+ raise+ return _get_experiment_permission(trace.experiment_id, username)--- a/tests/server/auth/test_auth.py+++ b/tests/server/auth/test_auth.py@@ -3435,3 +3435,319 @@ def test_invalidate_user_auth_cache_drops_only_matching_username( _authenticate_fastapi_request(_make_request("/x", f"Basic {alice_alt}")) _authenticate_fastapi_request(_make_request("/x", f"Basic {bob}")) assert mock_auth_store.authenticate_user.call_count == 5+++def _create_trace(tracking_uri: str, experiment_id: str, auth: tuple[str, str]) -> str:+ """Create a trace and return its request_id."""+ resp = requests.post(+ url=tracking_uri + "/api/2.0/mlflow/traces",+ json={+ "experiment_id": experiment_id,+ "timestamp_ms": int(time.time() * 1000),+ "execution_time_ms": 10,+ "status": "OK",+ "request_metadata": [],+ "tags": [],+ },+ auth=auth,+ )+ resp.raise_for_status()+ return resp.json()["trace_info"]["request_id"]+++def _grant_experiment_permission(+ tracking_uri: str, experiment_id: str, username: str, permission: str, auth: tuple[str, str]+) -> None:+ _send_rest_tracking_post_request(+ tracking_uri,+ "/api/2.0/mlflow/experiments/permissions/create",+ json_payload={+ "experiment_id": experiment_id,+ "username": username,+ "permission": permission,+ },+ auth=auth,+ )++[email protected](+ "client",+ [{"MLFLOW_AUTH_CONFIG_PATH": "tests/server/auth/fixtures/no_permission_auth.ini"}],+ indirect=True,+)+def test_trace_search_permission(client, monkeypatch):+ user1, password1 = create_user(client.tracking_uri)+ user2, password2 = create_user(client.tracking_uri)++ with User(user1, password1, monkeypatch):+ experiment_id = client.create_experiment("trace_search_test")++ # user2 has no grant; default_permission=NO_PERMISSIONS denies access++ # user1 can search traces+ resp = requests.get(+ url=client.tracking_uri + "/api/2.0/mlflow/traces",+ params={"experiment_ids": [experiment_id]},+ auth=(user1, password1),+ )+ assert resp.status_code == 200++ # user2 is denied+ resp = requests.get(+ url=client.tracking_uri + "/api/2.0/mlflow/traces",+ params={"experiment_ids": [experiment_id]},+ auth=(user2, password2),+ )+ assert resp.status_code == 403++ # Grant READ; user2 can now search+ _grant_experiment_permission(+ client.tracking_uri, experiment_id, user2, "READ", (user1, password1)+ )+ resp = requests.get(+ url=client.tracking_uri + "/api/2.0/mlflow/traces",+ params={"experiment_ids": [experiment_id]},+ auth=(user2, password2),+ )+ assert resp.status_code == 200+++def test_trace_delete_permission(client, monkeypatch):+ user1, password1 = create_user(client.tracking_uri)+ user2, password2 = create_user(client.tracking_uri)++ with User(user1, password1, monkeypatch):+ experiment_id = client.create_experiment("trace_delete_test")++ _grant_experiment_permission(+ client.tracking_uri, experiment_id, user2, "READ", (user1, password1)+ )++ def delete_traces(auth):+ return requests.post(+ url=client.tracking_uri + "/api/2.0/mlflow/traces/delete-traces",+ json={"experiment_id": experiment_id, "max_timestamp_millis": 9999999999999},+ auth=auth,+ )++ # user2 with READ is denied+ assert delete_traces((user2, password2)).status_code == 403++ # user1 (MANAGE) can delete+ assert delete_traces((user1, password1)).status_code == 200++ # Upgrade user2 to MANAGE; now allowed+ requests.patch(+ url=client.tracking_uri + "/api/2.0/mlflow/experiments/permissions/update",+ json={"experiment_id": experiment_id, "username": user2, "permission": "MANAGE"},+ auth=(user1, password1),+ ).raise_for_status()+ assert delete_traces((user2, password2)).status_code == 200+++def test_trace_tag_permission(client, monkeypatch):+ user1, password1 = create_user(client.tracking_uri)+ user2, password2 = create_user(client.tracking_uri)+AI-assisted change 655d1bfa2c810ecbdd7c10e211fe28017c7fa8c530b35720410d9dd59c59f65f · Fix 3e054f9c90573a23937eb383c8efd81af1e749c7971502a633f1c8efd5742252