Root cause
Injection & unsafe execution
Wire OSS-native label-schema REST surface + SDK
AI-assisted change: Claude
How AI contributed: New attack surfacePublished Jun 28, 2026 in
mlflow/mlflowAll 7 findings in this repository
The AI-written change added a full label-schema REST surface to the tracking server, registering handlers for creating, reading, listing, updating, upserting and deleting experiment labels without adding the request classes to the basic-auth validator table. When MLflow runs with --app-name basic-auth, the before-request hook authenticates the caller and then finds no validator for those routes, so the authorization branch is skipped. Any authenticated non-admin user can read and modify labels on experiments they cannot otherwise access.
The handlers added in mlflow/server/handlers.py were registered in HANDLERS without matching entries in BEFORE_REQUEST_HANDLERS, so the auth module's _find_validator returns None for every label-schema path. The authorization step is skipped instead of failing closed, and the tracking store executes create, read, list, update and delete without any experiment permission check.
Root cause
Wire OSS-native label-schema REST surface + SDK
AI-assisted change: Claude
Fix
Owner-aware authorization for review queues (experiment ACL + per-queue owner) (#23844)
Fix by Kris Concepcion · no AI marker found
Case facts
@@ -105,6 +105,15 @@ UpdateIssue, ) from mlflow.protos.jobs_pb2 import JobStatus+from mlflow.protos.label_schemas_pb2 import (+ CreateLabelSchema,+ DeleteLabelSchema,+ GetLabelSchema,+ GetLabelSchemaByName,+ ListLabelSchemas,+ UpdateLabelSchema,+ UpsertLabelSchema,+) from mlflow.protos.mlflow_artifacts_pb2 import ( AbortMultipartUpload, CompleteMultipartUpload,@@ -4444,6 +4453,165 @@ def _search_issues(): return _wrap_response(response_message) +# =============================================================================+# Label Schema Handlers (OSS-native CRUD; see mlflow/genai/label_schemas/)+# =============================================================================+++def _label_schema_type_from_proto(type_proto):+ """Coerce a `LabelSchemaType` proto enum value into the Python entity enum.++ The handler explicitly rejects the zero-value UNSPECIFIED because proto2+ `validate_required` on an optional enum only verifies HasField, not value+ validity.+ """+ from mlflow.genai.label_schemas.label_schemas import LabelSchemaType++ return LabelSchemaType.from_proto(type_proto)+++def _label_schema_input_from_proto(input_proto):+ """Unwrap a `LabelSchemaInput` oneof, raising if no variant is set."""+ from mlflow.genai.label_schemas.label_schemas import _input_from_proto++ return _input_from_proto(input_proto)+++@catch_mlflow_exception+@_disable_if_artifacts_only+def _create_label_schema():+ request_message = _get_request_message(+ CreateLabelSchema(),+ schema={+ "experiment_id": [_assert_required, _assert_string],+ "name": [_assert_required, _assert_string],+ "title": [_assert_required, _assert_string],+ },+ )+ schema_type = _label_schema_type_from_proto(request_message.type)+ input_obj = _label_schema_input_from_proto(request_message.input)+ created = _get_tracking_store().create_label_schema(+ experiment_id=request_message.experiment_id,+ name=request_message.name,+ type=schema_type,+ title=request_message.title,+ input=input_obj,+ instruction=request_message.instruction+ if request_message.HasField("instruction")+ else None,+ enable_comment=request_message.enable_comment,+ )+ return _wrap_response(CreateLabelSchema.Response(label_schema=created.to_proto()))+++@catch_mlflow_exception+@_disable_if_artifacts_only+def _get_label_schema():+ request_message = _get_request_message(+ GetLabelSchema(),+ schema={"schema_id": [_assert_required, _assert_string]},+ )+ schema = _get_tracking_store().get_label_schema(request_message.schema_id)+ return _wrap_response(GetLabelSchema.Response(label_schema=schema.to_proto()))+++@catch_mlflow_exception+@_disable_if_artifacts_only+def _get_label_schema_by_name():+ request_message = _get_request_message(+ GetLabelSchemaByName(),+ schema={+ "experiment_id": [_assert_required, _assert_string],+ "name": [_assert_required, _assert_string],@@ -58,6 +58,15 @@ SearchIssues, UpdateIssue, )+from mlflow.protos.label_schemas_pb2 import (+ CreateLabelSchema,+ DeleteLabelSchema,+ GetLabelSchema,+ GetLabelSchemaByName,+ ListLabelSchemas,+ UpdateLabelSchema,+ UpsertLabelSchema,+) from mlflow.protos.service_pb2 import ( AddDatasetToExperiments, BatchGetTraceInfos,@@ -149,6 +158,7 @@ from mlflow.utils.rest_utils import ( _REST_API_PATH_PREFIX, _V3_ISSUES_REST_API_PATH_PREFIX,+ _V3_LABEL_SCHEMAS_REST_API_PATH_PREFIX, _V3_REST_API_PATH_PREFIX, _V3_TRACE_REST_API_PATH_PREFIX, MlflowHostCreds,@@ -970,6 +980,152 @@ def search_issues( issues = [Issue.from_proto(issue_proto) for issue_proto in response_proto.issues] return PagedList(issues, response_proto.next_page_token or None) + # ----- Label schemas (OSS-native CRUD) -----++ def create_label_schema(+ self,+ experiment_id,+ *,+ name,+ type,+ title,+ input,+ instruction=None,+ enable_comment=False,+ ):+ from mlflow.genai.label_schemas.label_schemas import (+ LabelSchema,+ LabelSchemaType,+ _input_to_proto,+ )++ type_proto = LabelSchemaType(str(type)).to_proto()+ req = CreateLabelSchema(+ experiment_id=str(experiment_id),+ name=name,+ type=type_proto,+ title=title,+ input=_input_to_proto(input),+ enable_comment=enable_comment,+ )+ if instruction is not None:+ req.instruction = instruction+ response_proto = self._call_endpoint(+ CreateLabelSchema,+ message_to_json(req),+ endpoint=f"{_V3_LABEL_SCHEMAS_REST_API_PATH_PREFIX}/create",+ )+ return LabelSchema.from_proto(response_proto.label_schema)++ def get_label_schema(self, schema_id):+ from mlflow.genai.label_schemas.label_schemas import LabelSchema++ req = GetLabelSchema(schema_id=schema_id)+ response_proto = self._call_endpoint(+ GetLabelSchema,+ message_to_json(req),+ endpoint=f"{_V3_LABEL_SCHEMAS_REST_API_PATH_PREFIX}/get",+ )+ return LabelSchema.from_proto(response_proto.label_schema)++ def get_label_schema_by_name(self, experiment_id, name):+ from mlflow.genai.label_schemas.label_schemas import LabelSchema++ req = GetLabelSchemaByName(experiment_id=str(experiment_id), name=name)+ response_proto = self._call_endpoint(+ GetLabelSchemaByName,+ message_to_json(req),+ endpoint=f"{_V3_LABEL_SCHEMAS_REST_API_PATH_PREFIX}/get-by-name",+ )+ return LabelSchema.from_proto(response_proto.label_schema)++ def list_label_schemas(self, experiment_id, max_results=100, page_token=None):+ from mlflow.genai.label_schemas.label_schemas import LabelSchema+@@ -879,3 +879,80 @@ def _get_issue(self, issue_id: str) -> Issue: The Issue entity. """ return self.store.get_issue(issue_id)++ # ----- Label schemas (OSS-native CRUD) -----++ def _create_label_schema(+ self,+ experiment_id,+ *,+ name,+ type,+ title,+ input,+ instruction=None,+ enable_comment=False,+ ):+ return self.store.create_label_schema(+ experiment_id=experiment_id,+ name=name,+ type=type,+ title=title,+ input=input,+ instruction=instruction,+ enable_comment=enable_comment,+ )++ def _get_label_schema(self, schema_id):+ return self.store.get_label_schema(schema_id)++ def _get_label_schema_by_name(self, experiment_id, name):+ return self.store.get_label_schema_by_name(experiment_id, name)++ def _list_label_schemas(self, experiment_id, max_results=100, page_token=None):+ return self.store.list_label_schemas(+ experiment_id, max_results=max_results, page_token=page_token+ )++ def _update_label_schema(+ self,+ schema_id,+ *,+ name=None,+ title=None,+ instruction=None,+ enable_comment=None,+ input=None,+ ):+ return self.store.update_label_schema(+ schema_id,+ name=name,+ title=title,+ instruction=instruction,+ enable_comment=enable_comment,+ input=input,+ )++ def _upsert_label_schema(+ self,+ experiment_id,+ *,+ name,+ type,+ title,+ input,+ instruction=None,+ enable_comment=None,+ ):+ return self.store.upsert_label_schema(+ experiment_id=experiment_id,+ name=name,+ type=type,+ title=title,+ input=input,+ instruction=instruction,+ enable_comment=enable_comment,+ )++ def _delete_label_schema(self, schema_id):+ return self.store.delete_label_schema(schema_id)@@ -31,6 +31,7 @@ Request, Response, flash,+ g, jsonify, make_response, render_template_string,@@ -59,6 +60,14 @@ RESOURCE_DOES_NOT_EXIST, ErrorCode, )+from mlflow.protos.label_schemas_pb2 import (+ CreateLabelSchema,+ DeleteLabelSchema,+ GetLabelSchema,+ GetLabelSchemaByName,+ ListLabelSchemas,+ UpdateLabelSchema,+) from mlflow.protos.model_registry_pb2 import ( CreateModelVersion, CreateRegisteredModel,@@ -82,6 +91,19 @@ UpdateModelVersion, UpdateRegisteredModel, )+from mlflow.protos.review_queues_pb2 import (+ AddItemsToReviewQueue,+ CreateReviewQueue,+ DeleteReviewQueue,+ GetOrCreateUserQueue,+ GetReviewQueue,+ GetReviewQueueByName,+ ListReviewQueueItems,+ ListReviewQueues,+ RemoveItemsFromReviewQueue,+ SetReviewQueueItemStatus,+ UpdateReviewQueue,+) from mlflow.protos.service_pb2 import ( AttachModelToGatewayEndpoint, BatchGetTraceInfos,@@ -1605,11 +1627,15 @@ def validate_can_read_user(): def validate_can_list_users():- # Workspace admins are allowed: the payload has no per-workspace data, and- # they need all usernames to assign outsiders to roles in workspaces they manage.- # (Super admins short-circuit in ``_before_request`` and never reach this validator.)- user = store.get_user(authenticate_request().username)- return bool(store.list_workspace_admin_workspaces(user.id))+ # Any workspace member may list users: the review-queue assignment UI needs the+ # roster so a (non-admin) experiment manager can pick reviewers to assign.+ # Listing grants no access on its own — assigning a user to a queue still+ # requires experiment MANAGE. Scope it to the request workspace so the roster+ # isn't leaked across workspaces: ``_user_can_create_in_workspace`` requires a+ # workspace-wide grant when workspaces are enabled, and allows any authenticated+ # user when they're disabled (single-tenant). (Super admins short-circuit in+ # ``_before_request`` and never reach this validator.)+ return _user_can_create_in_workspace() def validate_can_create_user():@@ -2146,6 +2172,194 @@ def validate_gateway_proxy(): return True +# Review queues & label schemas+#+# Permissions inherit from the parent experiment (like runs / logged models).+# Managing a queue or schema (create / update / delete) requires experiment+# MANAGE; routing work into a queue requires EDIT; reviewing through a queue+# (set / reopen status) requires EDIT plus membership in the queue's assigned-user+# pool; reads require experiment READ, with per-queue visibility narrowed by+# ``filter_list_review_queues``.+def _get_permission_from_review_queue_id() -> Permission:+ queue = _get_tracking_store().get_review_queue(_get_request_param("queue_id"))+ username = authenticate_request().username+ return _get_experiment_permission(queue.experiment_id, username)+++def _get_permission_from_label_schema_id() -> Permission:+ schema = _get_tracking_store().get_label_schema(_get_request_param("schema_id"))+ username = authenticate_request().username+ return _get_experiment_permission(schema.experiment_id, username)+++def _review_queue_has_member(queue, username: str) -> bool:+ # Membership in the queue's assigned-user pool (a USER queue's pool is its@@ -14,15 +14,16 @@ import { ManageQuestionsModal } from './ManageQuestionsModal'; import { QueueSettingsModal } from './QueueSettingsModal'; import { ReviewQueueList } from './ReviewQueueList'; import { ReviewQueueSidebar } from './ReviewQueueSidebar';-import { useCanManageReviews } from './hooks/useCanManageReviews';+import { useCanEditReviews, useCanManageReviews } from './hooks/useCanManageReviews'; import { useDeleteReviewQueueMutation } from './hooks/useDeleteReviewQueueMutation'; import { useGetOrCreateUserQueueMutation } from './hooks/useGetOrCreateUserQueueMutation'; import { useListReviewQueueItemsQuery } from './hooks/useListReviewQueueItemsQuery'; import { useListReviewQueuesQuery } from './hooks/useListReviewQueuesQuery';+import { useUpdateReviewQueueMutation } from './hooks/useUpdateReviewQueueMutation'; import { useRemoveItemsFromReviewQueueMutation } from './hooks/useRemoveItemsFromReviewQueueMutation'; import { DEFAULT_REVIEWER, displayUser, useIsReviewerResolved, useReviewer } from './hooks/useReviewer'; import { useSetReviewQueueItemStatusMutation } from './hooks/useSetReviewQueueItemStatusMutation';-import { canManageQueue } from './queuePermissions';+import { canDeleteQueue, canManageQueue, canRemoveQueueItems, sameUser } from './queuePermissions'; import type { ReviewQueueItem, ReviewStatus } from './types'; /**@@ -31,9 +32,9 @@ import type { ReviewQueueItem, ReviewStatus } from './types'; * Clicking a trace opens the full-page focused question-answering view (the * queue list collapses), with a "Back" control to return to the list. *- * The left list is grouped on authenticated servers (queues others assigned to- * me vs. queues I created); a no-auth server shows one list. See- * `ReviewQueueSidebar`.+ * The left list is a flat, sortable list of the reviewer's visible queues+ * (name / owner / to-do count); on an auth server an editor sees every queue,+ * with ones they can't open greyed out. See `ReviewQueueSidebar`. */ const ExperimentReviewQueuePage = () => { const { theme } = useDesignSystemTheme();@@ -44,9 +45,11 @@ const ExperimentReviewQueuePage = () => { // identity is settled (an in-flight /users/current load reads as `default`). const reviewerResolved = useIsReviewerResolved(); const authAvailable = useIsAuthAvailable();- // Gate management controls (create / edit / delete queue, edit questions) on- // EDIT+; reviewing stays available to everyone assigned. See useCanManageReviews.+ // Question management (create / edit / delete label schemas) and owner+ // reassignment require MANAGE; creating + owner-managing queues and reviewing+ // require EDIT. Owner-level per-queue access combines `canEdit` with ownership. const canManage = useCanManageReviews(experimentId ?? '');+ const canEdit = useCanEditReviews(experimentId ?? ''); const [selectedQueueIdState, setSelectedQueueIdState] = useState<string>(); // The trace open in focused review (null = show the queue's trace table).@@ -61,13 +64,15 @@ const ExperimentReviewQueuePage = () => { const { reviewQueues, isLoading: queuesLoading } = useListReviewQueuesQuery({ experimentId: experimentId ?? '',- // Scope to the current reviewer (the reserved `default` user on a no-auth server).- user: reviewer,+ // Don't scope by reviewer: a manager must see every queue, and the server's+ // visibility filter (`filter_list_review_queues`) narrows the list to assigned+ // queues for non-managers (admins / no-auth see all). }); const { labelSchemas } = useListLabelSchemasQuery({ experimentId: experimentId ?? '' }); const { setReviewQueueItemStatusAsync, isSettingStatus } = useSetReviewQueueItemStatusMutation(); const { removeItemsFromReviewQueue, isRemovingItems } = useRemoveItemsFromReviewQueueMutation(); const { deleteReviewQueue } = useDeleteReviewQueueMutation();+ const { updateReviewQueueAsync, isUpdatingQueue } = useUpdateReviewQueueMutation(); const { getOrCreateUserQueueAsync } = useGetOrCreateUserQueueMutation(); // No-auth catch-all: the reviewer's reserved `default` user queue. Ensure it@@ -86,8 +91,8 @@ const ExperimentReviewQueuePage = () => { }, [authAvailable, experimentId]); // No queue is selected until the reviewer picks one (the right panel prompts- // them to). Auto-selecting the first queue would land on a no-work queue and- // force the "No work to do" group open.+ // them to). Auto-selecting the first queue could land on a queue with no work+ // left, or one the reviewer can't open. const selectedQueueId = selectedQueueIdState; const selectedQueue = useMemo( () => reviewQueues.find((q) => q.queue_id === selectedQueueId) ?? null,@@ -101,12 +106,40 @@ const ExperimentReviewQueuePage = () => { () => (confirmDeleteQueueId ? (reviewQueues.find((q) => q.queue_id === confirmDeleteQueueId) ?? null) : null), [reviewQueues, confirmDeleteQueueId], );- // Whether the reviewer may manage the selected queue — removing traces and- // the right-pane gear (manage settings / delete) share one permission: a- // CUSTOM queue they created, or any on a no-auth server (never USER queues).- const canManageSelectedQueue = selectedQueue- ? canManageQueue(selectedQueue, reviewer, authAvailable, canManage)+ // Whether the reviewer may manage the selected queue — a CUSTOM queue they can+ // manage (MANAGE) or own (EDIT + owner). Removing traces and the right-pane+ // gear (manage settings / delete) share this one permission.+ const canManageSelectedQueue = selectedQueue ? canManageQueue(selectedQueue, reviewer, canManage, canEdit) : false;+ // Delete is broader than manage: a manager may delete a personal USER queue+ // (which has no editable settings), so it gets its own gate.@@ -16,7 +16,7 @@ import requests from cachetools import TTLCache-from flask import Request, Response, current_app, jsonify, request, send_file+from flask import Request, Response, current_app, g, jsonify, request, send_file from google.protobuf import descriptor from google.protobuf.json_format import ParseError from werkzeug.http import quote_header_value@@ -4649,6 +4649,14 @@ def _review_queue_max_results_validator(x): ) +def _get_request_username():+ """The authenticated request user, stamped on ``flask.g`` by the auth+ plugin's before-request hook. ``None`` when no auth plugin is active (a+ no-auth server), where queue ownership is meaningless.+ """+ return getattr(g, "mlflow_authenticated_user", None)++ @catch_mlflow_exception @_disable_if_artifacts_only def _create_review_queue():@@ -4669,8 +4677,13 @@ def _create_review_queue(): "users": list(request_message.users), "schema_ids": list(request_message.schema_ids), }- if request_message.HasField("created_by"):- kwargs["created_by"] = request_message.created_by+ # `created_by` is the queue owner and must be trustworthy — never honor the+ # client's value. On an auth server it is the authenticated user (stamped on+ # `flask.g` by the auth plugin); on a no-auth server it stays unset (owner is+ # meaningless there).+ username = _get_request_username()+ if username is not None:+ kwargs["created_by"] = username created = _get_tracking_store().create_review_queue(**kwargs) return _wrap_response(CreateReviewQueue.Response(review_queue=created.to_proto())) @@ -4685,13 +4698,11 @@ def _get_or_create_user_queue(): "user": [_assert_required, _assert_string], }, )- kwargs: dict[str, object] = {- "experiment_id": request_message.experiment_id,- "user": request_message.user,- }- if request_message.HasField("created_by"):- kwargs["created_by"] = request_message.created_by- queue = _get_tracking_store().get_or_create_user_queue(**kwargs)+ # A user queue is owned by its user (set in the store); the client-supplied+ # `created_by` is ignored.+ queue = _get_tracking_store().get_or_create_user_queue(+ request_message.experiment_id, user=request_message.user+ ) return _wrap_response(GetOrCreateUserQueue.Response(review_queue=queue.to_proto())) AI-assisted change 8fdbe171f2c02a38dcb80f8b5917591dcd176577f7b5ba288c51e4e568156c49 · Fix a24c951ea4de754f23a89c423c451c637b28046b8bbff112c9e468c5e9db982c