4735 lines
176 KiB
Python
4735 lines
176 KiB
Python
"""
|
|
Usage
|
|
-----
|
|
|
|
.. code-block:: bash
|
|
|
|
mlflow server --app-name basic-auth
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import functools
|
|
import hmac
|
|
import importlib
|
|
import json
|
|
import logging
|
|
import re
|
|
import secrets
|
|
import threading
|
|
from dataclasses import asdict, dataclass
|
|
from http import HTTPStatus
|
|
from typing import Any, Awaitable, Callable
|
|
|
|
import sqlalchemy
|
|
from cachetools import TTLCache
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
from flask import (
|
|
Flask,
|
|
Request,
|
|
Response,
|
|
flash,
|
|
g,
|
|
jsonify,
|
|
make_response,
|
|
render_template_string,
|
|
request,
|
|
)
|
|
from starlette.requests import Request as StarletteRequest
|
|
from werkzeug.datastructures import Authorization
|
|
|
|
from mlflow import MlflowException
|
|
from mlflow.entities import Experiment
|
|
from mlflow.entities.logged_model import LoggedModel
|
|
from mlflow.entities.model_registry import RegisteredModel
|
|
from mlflow.environment_variables import (
|
|
_MLFLOW_INTERNAL_GATEWAY_AUTH_TOKEN,
|
|
_MLFLOW_SGI_NAME,
|
|
MLFLOW_ENABLE_WORKSPACES,
|
|
MLFLOW_FLASK_SERVER_SECRET_KEY,
|
|
MLFLOW_RBAC_SEED_DEFAULT_ROLES,
|
|
MLFLOW_SERVER_ENABLE_GRAPHQL_AUTH,
|
|
)
|
|
from mlflow.prompt.constants import IS_PROMPT_TAG_KEY
|
|
from mlflow.protos.databricks_pb2 import (
|
|
BAD_REQUEST,
|
|
INTERNAL_ERROR,
|
|
INVALID_PARAMETER_VALUE,
|
|
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,
|
|
DeleteModelVersion,
|
|
DeleteModelVersionTag,
|
|
DeleteRegisteredModel,
|
|
DeleteRegisteredModelAlias,
|
|
DeleteRegisteredModelTag,
|
|
GetLatestVersions,
|
|
GetModelVersion,
|
|
GetModelVersionByAlias,
|
|
GetModelVersionDownloadUri,
|
|
GetRegisteredModel,
|
|
RenameRegisteredModel,
|
|
SearchModelVersions,
|
|
SearchRegisteredModels,
|
|
SetModelVersionTag,
|
|
SetRegisteredModelAlias,
|
|
SetRegisteredModelTag,
|
|
TransitionModelVersionStage,
|
|
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,
|
|
BatchGetTraces,
|
|
CalculateTraceFilterCorrelation,
|
|
CancelPromptOptimizationJob,
|
|
CreateAssessment,
|
|
CreateExperiment,
|
|
CreateGatewayBudgetPolicy,
|
|
CreateGatewayEndpoint,
|
|
CreateGatewayEndpointBinding,
|
|
CreateGatewayModelDefinition,
|
|
CreateGatewaySecret,
|
|
CreateLoggedModel,
|
|
CreatePromptOptimizationJob,
|
|
CreateRun,
|
|
CreateWorkspace,
|
|
DeleteAssessment,
|
|
DeleteExperiment,
|
|
DeleteExperimentTag,
|
|
DeleteGatewayBudgetPolicy,
|
|
DeleteGatewayEndpoint,
|
|
DeleteGatewayEndpointBinding,
|
|
DeleteGatewayEndpointTag,
|
|
DeleteGatewayModelDefinition,
|
|
DeleteGatewaySecret,
|
|
DeleteLoggedModel,
|
|
DeleteLoggedModelTag,
|
|
DeletePromptOptimizationJob,
|
|
DeleteRun,
|
|
DeleteScorer,
|
|
DeleteTag,
|
|
DeleteTraces,
|
|
DeleteTracesV3,
|
|
DeleteTraceTag,
|
|
DeleteTraceTagV3,
|
|
DeleteWorkspace,
|
|
DetachModelFromGatewayEndpoint,
|
|
EndTrace,
|
|
FinalizeLoggedModel,
|
|
GetAssessmentRequest,
|
|
GetExperiment,
|
|
GetExperimentByName,
|
|
GetGatewayEndpoint,
|
|
GetGatewayModelDefinition,
|
|
GetGatewaySecretInfo,
|
|
GetLoggedModel,
|
|
GetMetricHistory,
|
|
GetPromptOptimizationJob,
|
|
GetRun,
|
|
GetScorer,
|
|
GetTrace,
|
|
GetTraceInfo,
|
|
GetTraceInfoV3,
|
|
GetWorkspace,
|
|
LinkPromptsToTrace,
|
|
LinkTracesToRun,
|
|
ListArtifacts,
|
|
ListGatewayEndpointBindings,
|
|
ListLoggedModelArtifacts,
|
|
ListScorers,
|
|
ListScorerVersions,
|
|
ListWorkspaces,
|
|
LogBatch,
|
|
LogInputs,
|
|
LogLoggedModelParamsRequest,
|
|
LogMetric,
|
|
LogModel,
|
|
LogOutputs,
|
|
LogParam,
|
|
QueryTraceMetrics,
|
|
RegisterScorer,
|
|
RestoreExperiment,
|
|
RestoreRun,
|
|
SearchExperiments,
|
|
SearchLoggedModels,
|
|
SearchPromptOptimizationJobs,
|
|
SearchTraces,
|
|
SearchTracesV3,
|
|
SetExperimentTag,
|
|
SetGatewayEndpointTag,
|
|
SetLoggedModelTags,
|
|
SetTag,
|
|
SetTraceTag,
|
|
SetTraceTagV3,
|
|
StartTrace,
|
|
StartTraceV3,
|
|
UpdateAssessment,
|
|
UpdateExperiment,
|
|
UpdateGatewayBudgetPolicy,
|
|
UpdateGatewayEndpoint,
|
|
UpdateGatewayModelDefinition,
|
|
UpdateGatewaySecret,
|
|
UpdateRun,
|
|
UpdateWorkspace,
|
|
)
|
|
from mlflow.protos.service_pb2 import (
|
|
GetGatewayBudgetPolicy as GetGatewayBudgetPolicy,
|
|
)
|
|
from mlflow.protos.service_pb2 import (
|
|
ListGatewayBudgetPolicies as ListGatewayBudgetPolicies,
|
|
)
|
|
from mlflow.protos.service_pb2 import (
|
|
ListGatewayEndpoints as ListGatewayEndpoints,
|
|
)
|
|
from mlflow.protos.service_pb2 import (
|
|
ListGatewayModelDefinitions as ListGatewayModelDefinitions,
|
|
)
|
|
from mlflow.protos.service_pb2 import (
|
|
ListGatewaySecretInfos as ListGatewaySecretInfos,
|
|
)
|
|
from mlflow.protos.webhooks_pb2 import (
|
|
CreateWebhook,
|
|
DeleteWebhook,
|
|
GetWebhook,
|
|
ListWebhooks,
|
|
TestWebhook,
|
|
UpdateWebhook,
|
|
WebhookService,
|
|
)
|
|
from mlflow.server import app
|
|
from mlflow.server.asgi_utils import get_routed_asgi_path
|
|
from mlflow.server.auth.config import DEFAULT_AUTHORIZATION_FUNCTION, read_auth_config
|
|
from mlflow.server.auth.entities import GetUserPermissionResult, User
|
|
from mlflow.server.auth.logo import MLFLOW_LOGO
|
|
from mlflow.server.auth.permissions import (
|
|
MANAGE,
|
|
NO_PERMISSIONS,
|
|
RESOURCE_TYPE_EXPERIMENT,
|
|
RESOURCE_TYPE_GATEWAY_ENDPOINT,
|
|
RESOURCE_TYPE_GATEWAY_MODEL_DEFINITION,
|
|
RESOURCE_TYPE_GATEWAY_SECRET,
|
|
RESOURCE_TYPE_REGISTERED_MODEL,
|
|
RESOURCE_TYPE_SCORER,
|
|
RESOURCE_TYPE_WORKSPACE,
|
|
USE,
|
|
Permission,
|
|
_validate_resource_type,
|
|
get_permission,
|
|
)
|
|
from mlflow.server.auth.permissions import (
|
|
max_permission as max_permission,
|
|
)
|
|
from mlflow.server.auth.routes import (
|
|
ADD_ROLE_PERMISSION,
|
|
AJAX_ADD_ROLE_PERMISSION,
|
|
AJAX_ASSIGN_ROLE,
|
|
AJAX_CREATE_ROLE,
|
|
AJAX_CREATE_USER,
|
|
AJAX_DELETE_ROLE,
|
|
AJAX_DELETE_USER,
|
|
AJAX_GET_CURRENT_USER,
|
|
AJAX_GET_ROLE,
|
|
AJAX_GET_USER,
|
|
AJAX_GET_USER_PERMISSION,
|
|
AJAX_GRANT_USER_PERMISSION,
|
|
AJAX_LIST_CURRENT_USER_PERMISSIONS,
|
|
AJAX_LIST_ROLE_PERMISSIONS,
|
|
AJAX_LIST_ROLE_USERS,
|
|
AJAX_LIST_ROLES,
|
|
AJAX_LIST_USER_PERMISSIONS,
|
|
AJAX_LIST_USER_ROLES,
|
|
AJAX_LIST_USERS,
|
|
AJAX_REMOVE_ROLE_PERMISSION,
|
|
AJAX_REVOKE_USER_PERMISSION,
|
|
AJAX_UNASSIGN_ROLE,
|
|
AJAX_UPDATE_ROLE,
|
|
AJAX_UPDATE_ROLE_PERMISSION,
|
|
AJAX_UPDATE_USER_ADMIN,
|
|
AJAX_UPDATE_USER_PASSWORD,
|
|
ASSIGN_ROLE,
|
|
CREATE_PROMPTLAB_RUN,
|
|
CREATE_ROLE,
|
|
CREATE_USER,
|
|
CREATE_USER_UI,
|
|
DELETE_ROLE,
|
|
DELETE_USER,
|
|
GATEWAY_PROVIDER_CONFIG,
|
|
GATEWAY_PROXY,
|
|
GATEWAY_SECRETS_CONFIG,
|
|
GATEWAY_SUPPORTED_MODELS,
|
|
GATEWAY_SUPPORTED_PROVIDERS,
|
|
GET_ARTIFACT,
|
|
GET_CURRENT_USER,
|
|
GET_METRIC_HISTORY_BULK,
|
|
GET_METRIC_HISTORY_BULK_INTERVAL,
|
|
GET_MODEL_VERSION_ARTIFACT,
|
|
GET_ROLE,
|
|
GET_TRACE_ARTIFACT,
|
|
GET_TRACE_ARTIFACT_V3,
|
|
GET_USER,
|
|
GET_USER_PERMISSION,
|
|
GRANT_USER_PERMISSION,
|
|
HOME,
|
|
INVOKE_SCORER,
|
|
LIST_CURRENT_USER_PERMISSIONS,
|
|
LIST_ROLE_PERMISSIONS,
|
|
LIST_ROLE_USERS,
|
|
LIST_ROLES,
|
|
LIST_USER_PERMISSIONS,
|
|
LIST_USER_ROLES,
|
|
LIST_USERS,
|
|
REMOVE_ROLE_PERMISSION,
|
|
REVOKE_USER_PERMISSION,
|
|
SEARCH_DATASETS,
|
|
SIGNUP,
|
|
UNASSIGN_ROLE,
|
|
UPDATE_ROLE,
|
|
UPDATE_ROLE_PERMISSION,
|
|
UPDATE_USER_ADMIN,
|
|
UPDATE_USER_PASSWORD,
|
|
UPLOAD_ARTIFACT,
|
|
)
|
|
from mlflow.server.auth.sqlalchemy_store import SqlAlchemyStore
|
|
from mlflow.server.fastapi_app import create_fastapi_app
|
|
from mlflow.server.handlers import (
|
|
_add_static_prefix,
|
|
_get_ajax_path,
|
|
_get_model_registry_store,
|
|
_get_request_message,
|
|
_get_tracking_store,
|
|
catch_mlflow_exception,
|
|
get_endpoints,
|
|
get_service_endpoints,
|
|
)
|
|
from mlflow.server.handlers import (
|
|
_disable_if_workspaces_disabled as _disable_if_workspaces_disabled,
|
|
)
|
|
from mlflow.server.jobs import get_job
|
|
from mlflow.server.workspace_helpers import (
|
|
WORKSPACE_HEADER_NAME,
|
|
_get_workspace_store,
|
|
resolve_workspace_for_request_if_enabled,
|
|
)
|
|
from mlflow.store.entities import PagedList
|
|
from mlflow.store.workspace.utils import get_default_workspace_optional
|
|
from mlflow.utils import workspace_context
|
|
from mlflow.utils.proto_json_utils import message_to_json, parse_dict
|
|
from mlflow.utils.rest_utils import _REST_API_PATH_PREFIX
|
|
from mlflow.utils.search_utils import SearchUtils
|
|
from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME
|
|
|
|
try:
|
|
from flask_wtf.csrf import CSRFProtect
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"The MLflow basic auth app requires the Flask-WTF package to perform CSRF "
|
|
"validation. Please run `pip install mlflow[auth]` to install it."
|
|
) from e
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
auth_config = read_auth_config()
|
|
store = SqlAlchemyStore()
|
|
|
|
# Cache for resource_id -> workspace_name mapping. The relationship between a resource
|
|
# (experiment, registered model) and its workspace is immutable.
|
|
_RESOURCE_WORKSPACE_CACHE: TTLCache[str, str | None] = TTLCache(
|
|
maxsize=auth_config.workspace_cache_max_size,
|
|
ttl=auth_config.workspace_cache_ttl_seconds,
|
|
)
|
|
|
|
# Cache for successful basic-auth credential checks. Keys derive from the password via
|
|
# HMAC-SHA256 using a per-process secret, so the plaintext password is not stored in the
|
|
# cache key *and* the digest is not usable for offline dictionary attack if process memory
|
|
# is ever compromised (an attacker without the HMAC key cannot recompute the digest).
|
|
# Skipping the PBKDF2 hash comparison inside ``store.authenticate_user`` on cache hits is
|
|
# the dominant cost saving — a single check_password_hash call costs tens of milliseconds
|
|
# by design.
|
|
_USER_AUTH_CACHE: TTLCache[tuple[str, bytes], User] | None = (
|
|
TTLCache(
|
|
maxsize=auth_config.auth_cache_max_size,
|
|
ttl=auth_config.auth_cache_ttl_seconds,
|
|
)
|
|
if auth_config.auth_cache_ttl_seconds > 0
|
|
else None
|
|
)
|
|
# cachetools.TTLCache is not thread-safe — Flask handlers run under gunicorn's thread
|
|
# pool, so every touch of the cache needs to hold this lock.
|
|
_USER_AUTH_CACHE_LOCK = threading.Lock()
|
|
# Random per-process key for the HMAC that turns the password into a cache-key digest.
|
|
# Regenerated at every server start; the cache is ephemeral anyway (process-local, TTL'd),
|
|
# so invalidating the key on restart costs nothing beyond a one-time re-auth for each
|
|
# active credential.
|
|
_USER_AUTH_CACHE_HMAC_KEY = secrets.token_bytes(32)
|
|
|
|
|
|
def _auth_cache_key(username: str, password: str) -> tuple[str, bytes]:
|
|
digest = hmac.new(_USER_AUTH_CACHE_HMAC_KEY, password.encode("utf-8"), "sha256").digest()
|
|
return (username, digest)
|
|
|
|
|
|
from mlflow.gateway.constants import MLFLOW_GATEWAY_AUTH_HEADER
|
|
|
|
|
|
def _authenticate_cached(username: str, password: str) -> User | None:
|
|
"""Run basic-auth verification with the credential cache in front of it.
|
|
|
|
Used by both the Flask (``authenticate_request_basic_auth``) and FastAPI
|
|
(``_authenticate_fastapi_request``) auth paths so neither pays the PBKDF2
|
|
cost twice for the same credential within ``auth_cache_ttl_seconds``.
|
|
|
|
Returns the ``User`` on success, or ``None`` when the credential is invalid
|
|
or the user has been deleted between ``authenticate_user`` and ``get_user``.
|
|
"""
|
|
if _USER_AUTH_CACHE is None:
|
|
if not store.authenticate_user(username, password):
|
|
return None
|
|
try:
|
|
return store.get_user(username)
|
|
except MlflowException:
|
|
return None
|
|
|
|
key = _auth_cache_key(username, password)
|
|
with _USER_AUTH_CACHE_LOCK:
|
|
cached = _USER_AUTH_CACHE.get(key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
# Keep the PBKDF2 comparison outside the lock so concurrent verifications for
|
|
# *different* credentials still run in parallel.
|
|
if not store.authenticate_user(username, password):
|
|
return None
|
|
try:
|
|
user = store.get_user(username)
|
|
except MlflowException:
|
|
# User was deleted between authenticate_user and get_user — treat as auth
|
|
# failure and don't cache anything.
|
|
return None
|
|
with _USER_AUTH_CACHE_LOCK:
|
|
_USER_AUTH_CACHE[key] = user
|
|
return user
|
|
|
|
|
|
def _invalidate_user_auth_cache(username: str) -> None:
|
|
"""Drop every cached credential for ``username``.
|
|
|
|
Called from user-mutation routes (password change, admin flag change, deletion)
|
|
so those changes take effect immediately instead of after ``auth_cache_ttl_seconds``.
|
|
"""
|
|
if _USER_AUTH_CACHE is None:
|
|
return
|
|
with _USER_AUTH_CACHE_LOCK:
|
|
for key in [k for k in _USER_AUTH_CACHE if k[0] == username]:
|
|
_USER_AUTH_CACHE.pop(key, None)
|
|
|
|
|
|
_UNPROTECTED_PATH_PREFIXES = ("/static", "/favicon.ico", "/health")
|
|
|
|
|
|
def is_unprotected_route(path: str) -> bool:
|
|
# When ``_MLFLOW_STATIC_PREFIX`` is set, the health/static routes are
|
|
# actually served from e.g. ``/mlflow/health``, not ``/health``. Match
|
|
# both the unprefixed and the prefixed forms so health checks don't end
|
|
# up requiring auth on prefixed deployments.
|
|
prefixed = tuple(_add_static_prefix(p) for p in _UNPROTECTED_PATH_PREFIXES)
|
|
return path.startswith(_UNPROTECTED_PATH_PREFIXES) or path.startswith(prefixed)
|
|
|
|
|
|
def make_basic_auth_response() -> Response:
|
|
res = make_response(
|
|
"You are not authenticated. Please see "
|
|
"https://www.mlflow.org/docs/latest/auth/index.html#authenticating-to-mlflow "
|
|
"on how to authenticate."
|
|
)
|
|
res.status_code = 401
|
|
res.headers["WWW-Authenticate"] = 'Basic realm="mlflow"'
|
|
return res
|
|
|
|
|
|
def make_forbidden_response() -> Response:
|
|
res = make_response("Permission denied")
|
|
res.status_code = 403
|
|
return res
|
|
|
|
|
|
def _get_request_param(param: str) -> str:
|
|
if request.method == "GET":
|
|
args = request.args
|
|
elif request.method in ("POST", "PATCH"):
|
|
# Coerce null/empty/non-dict JSON bodies to {} so callers get a 400, not
|
|
# a 500 from the dict-merge below.
|
|
body = request.get_json(silent=True)
|
|
args = body if isinstance(body, dict) else {}
|
|
elif request.method == "DELETE":
|
|
if request.is_json:
|
|
body = request.get_json(silent=True)
|
|
args = body if isinstance(body, dict) else {}
|
|
else:
|
|
args = request.args
|
|
else:
|
|
raise MlflowException(
|
|
f"Unsupported HTTP method '{request.method}'",
|
|
BAD_REQUEST,
|
|
)
|
|
|
|
args = args | (request.view_args or {})
|
|
if param not in args:
|
|
# Special handling for run_id
|
|
if param == "run_id":
|
|
return _get_request_param("run_uuid")
|
|
raise MlflowException(
|
|
f"Missing value for required parameter '{param}'. "
|
|
"See the API docs for more information about request parameters.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
return args[param]
|
|
|
|
|
|
def _get_int_request_param(param: str) -> int:
|
|
"""
|
|
Extract an integer request parameter or raise ``INVALID_PARAMETER_VALUE``.
|
|
|
|
Wraps ``_get_request_param`` so non-numeric input produces a 400 instead of bubbling
|
|
up a ``ValueError`` and surfacing as a 500.
|
|
"""
|
|
return _coerce_int_param(param, _get_request_param(param))
|
|
|
|
|
|
def _coerce_int_param(param: str, raw: object) -> int:
|
|
"""
|
|
Convert an already-extracted parameter value to ``int`` or raise
|
|
``INVALID_PARAMETER_VALUE`` on non-numeric input. Used by call sites that pick
|
|
the parameter themselves (e.g. branching on which of several optional keys is
|
|
present) instead of going through ``_get_request_param``.
|
|
"""
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError):
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Parameter '{param}' must be an integer. Got: {raw!r}"
|
|
)
|
|
|
|
|
|
def _user_inherits_default_workspace_grant(workspace_name: str) -> bool:
|
|
"""
|
|
True if the request workspace is the configured default workspace *and* the
|
|
auth server is opted into auto-granting ``default_permission`` there
|
|
(``auth_config.grant_default_workspace_access``). Used as a fallback for the
|
|
resource-permission resolver and create-gate so deployments that relied on
|
|
the pre-simplification implicit "default workspace is open" behavior keep
|
|
working when configured.
|
|
"""
|
|
if not auth_config.grant_default_workspace_access:
|
|
return False
|
|
default_workspace, _ = get_default_workspace_optional(_get_workspace_store())
|
|
return default_workspace is not None and workspace_name == default_workspace.name
|
|
|
|
|
|
def _get_role_permission_or_default(
|
|
role_permission_func: Callable[[], Permission | None],
|
|
) -> Permission:
|
|
"""Fold the role-derived permission against ``default_permission`` as a floor.
|
|
|
|
``NO_PERMISSIONS`` is preserved rather than max'd against ``default_permission``
|
|
— it's the resolver's "user has no presence in this workspace" signal (no role
|
|
matches in the resource's workspace and it isn't an autograted default workspace).
|
|
That's the only place the workspace boundary lives in this chain; lifting it via
|
|
the floor would silently leak ``default_permission`` (e.g. READ) into every
|
|
workspace the user has no role in. ``None`` (workspaces disabled, no grant) still
|
|
falls through to ``default_permission`` as the safety net.
|
|
"""
|
|
perm = role_permission_func()
|
|
default = get_permission(auth_config.default_permission)
|
|
if perm is None:
|
|
# Workspaces disabled, no grant matched.
|
|
return default
|
|
if perm.name == NO_PERMISSIONS.name:
|
|
# Workspace-boundary deny — see docstring.
|
|
return perm
|
|
return get_permission(max_permission(perm.name, default.name))
|
|
|
|
|
|
def _user_can_create_in_workspace() -> bool:
|
|
"""
|
|
True if the current request can create new resources in the request's
|
|
workspace. Always allows when workspaces are disabled. Otherwise requires
|
|
a workspace-wide grant whose level has ``can_use`` (i.e. USE or MANAGE under
|
|
the simplified two-tier workspace model). Resource-specific grants don't
|
|
confer create rights — only workspace-wide grants do.
|
|
|
|
Querying with ``resource_type='workspace'`` restricts the resolver to the
|
|
unified workspace-wide grant slot (``rp.resource_type='workspace'`` with
|
|
``rp.resource_pattern='*'``); resource-specific grants on concrete types
|
|
don't satisfy this lookup.
|
|
|
|
Also honors ``auth_config.grant_default_workspace_access``: when enabled and
|
|
the request workspace is the default workspace, an ungranted user inherits
|
|
``default_permission`` and can create iff that permission carries ``can_use``.
|
|
"""
|
|
if not MLFLOW_ENABLE_WORKSPACES.get():
|
|
return True
|
|
|
|
workspace_name = workspace_context.get_request_workspace()
|
|
if workspace_name is None:
|
|
return False
|
|
|
|
user = store.get_user(authenticate_request().username)
|
|
perm = store.get_role_permission_for_resource(user.id, "workspace", "*", workspace_name)
|
|
if perm is not None and perm.can_use:
|
|
return True
|
|
if perm is None and _user_inherits_default_workspace_grant(workspace_name):
|
|
return get_permission(auth_config.default_permission).can_use
|
|
return False
|
|
|
|
|
|
def _get_resource_workspace(
|
|
resource_id: str,
|
|
fetcher: Callable[[str], Any],
|
|
resource_label: str,
|
|
silent: bool = False,
|
|
) -> str | None:
|
|
"""
|
|
Get the workspace name for a resource, using a cache to avoid repeated lookups.
|
|
|
|
The resource->workspace relationship is immutable, so caching is safe.
|
|
|
|
Args:
|
|
silent: When True, suppress the lookup-failure warning. Set by
|
|
non-authorization callers (e.g. listing endpoints) where a
|
|
``None`` return is an expected outcome for deleted resources
|
|
rather than a security-relevant error.
|
|
"""
|
|
# Use a cache key that includes the resource_label to avoid collisions between
|
|
# experiments and registered models that might have the same ID/name.
|
|
workspace_scope = (
|
|
workspace_context.get_request_workspace() if MLFLOW_ENABLE_WORKSPACES.get() else None
|
|
)
|
|
cache_key = (
|
|
f"{resource_label}:{workspace_scope}:{resource_id}" if workspace_scope is not None else None
|
|
)
|
|
|
|
if cache_key is not None and cache_key in _RESOURCE_WORKSPACE_CACHE:
|
|
return _RESOURCE_WORKSPACE_CACHE[cache_key]
|
|
|
|
try:
|
|
resource = fetcher(resource_id)
|
|
workspace_name = getattr(resource, "workspace", None)
|
|
except MlflowException as e:
|
|
if not silent:
|
|
_logger.warning(
|
|
"Failed to determine workspace for %s '%s': %s. Denying access for security.",
|
|
resource_label,
|
|
resource_id,
|
|
e,
|
|
)
|
|
workspace_name = None
|
|
|
|
if cache_key is None:
|
|
cache_key = (
|
|
f"{resource_label}:{workspace_name}:{resource_id}"
|
|
if workspace_name is not None
|
|
else f"{resource_label}:{resource_id}"
|
|
)
|
|
|
|
_RESOURCE_WORKSPACE_CACHE[cache_key] = workspace_name
|
|
return workspace_name
|
|
|
|
|
|
def _get_permission_from_experiment_id() -> Permission:
|
|
experiment_id = _get_request_param("experiment_id")
|
|
username = authenticate_request().username
|
|
return _get_experiment_permission(experiment_id, username)
|
|
|
|
|
|
def _role_permission_for(
|
|
username: str,
|
|
resource_type: str,
|
|
resource_key: str,
|
|
workspace_lookup_id: str,
|
|
workspace_fetcher: Callable[[str], Any],
|
|
workspace_label: str,
|
|
) -> Callable[[], Permission | None]:
|
|
"""
|
|
Build a callable that resolves a user's role-based permission on a specific resource,
|
|
for use as ``role_permission_func`` in ``_get_role_permission_or_default``.
|
|
|
|
``resource_key`` is the lookup key for ``role_permissions`` (may differ from the
|
|
workspace-resolution id for composite resources, e.g. scorers use
|
|
``SqlAlchemyStore._scorer_pattern(experiment_id, scorer_name)`` as the role key
|
|
but resolve the workspace via the parent experiment).
|
|
"""
|
|
|
|
def _role_perm() -> Permission | None:
|
|
user = store.get_user(username)
|
|
workspace_name = _get_resource_workspace(
|
|
workspace_lookup_id, workspace_fetcher, workspace_label
|
|
)
|
|
if workspace_name is None:
|
|
# Workspace lookup failed — when workspaces are enabled, deny by returning
|
|
# NO_PERMISSIONS (security: don't let resource_not_found silently become a
|
|
# default-permission grant). When disabled, fall through to the default.
|
|
return NO_PERMISSIONS if MLFLOW_ENABLE_WORKSPACES.get() else None
|
|
perm = store.get_role_permission_for_resource(
|
|
user.id, resource_type, resource_key, workspace_name
|
|
)
|
|
if perm is not None:
|
|
return perm
|
|
# No grant in the resolved workspace. With workspaces disabled, fall through
|
|
# to the configured default. With workspaces enabled, deny — *unless* the
|
|
# operator opted into ``grant_default_workspace_access`` and this is the
|
|
# default workspace, in which case the user inherits ``default_permission``
|
|
# so deployments that relied on the implicit auto-grant pre-simplification
|
|
# don't lose resource-level access.
|
|
if not MLFLOW_ENABLE_WORKSPACES.get():
|
|
return None
|
|
if _user_inherits_default_workspace_grant(workspace_name):
|
|
return get_permission(auth_config.default_permission)
|
|
return NO_PERMISSIONS
|
|
|
|
return _role_perm
|
|
|
|
|
|
def _role_permission_for_known_workspace(
|
|
username: str,
|
|
resource_type: str,
|
|
resource_key: str,
|
|
workspace_name: str | None,
|
|
) -> Callable[[], Permission | None]:
|
|
"""Like ``_role_permission_for`` but with workspace already resolved.
|
|
|
|
Avoids the ``workspace_fetcher`` DB round-trip when the caller already
|
|
holds the resource object (e.g. ``_get_permission_from_registered_model_or_prompt_name``).
|
|
"""
|
|
|
|
def _role_perm() -> Permission | None:
|
|
if workspace_name is None:
|
|
return NO_PERMISSIONS if MLFLOW_ENABLE_WORKSPACES.get() else None
|
|
user = store.get_user(username)
|
|
perm = store.get_role_permission_for_resource(
|
|
user.id, resource_type, resource_key, workspace_name
|
|
)
|
|
if perm is not None:
|
|
return perm
|
|
if not MLFLOW_ENABLE_WORKSPACES.get():
|
|
return None
|
|
if _user_inherits_default_workspace_grant(workspace_name):
|
|
return get_permission(auth_config.default_permission)
|
|
return NO_PERMISSIONS
|
|
|
|
return _role_perm
|
|
|
|
|
|
def _get_experiment_permission(experiment_id: str, username: str) -> Permission:
|
|
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",
|
|
),
|
|
)
|
|
|
|
|
|
# Proxied artifact paths are ``<experiment_id>/<run_id>/artifacts/...``. When
|
|
# workspaces are enabled, non-default workspaces prefix the path with
|
|
# ``workspaces/<workspace>/``. Accept the optional prefix so the experiment id is
|
|
# resolved in both layouts; otherwise the prefixed form fails to match and the
|
|
# artifact-proxy validator falls back to the coarser workspace-tier grant.
|
|
_EXPERIMENT_ID_PATTERN = re.compile(r"^(?:workspaces/[^/]+/)?(\d+)/")
|
|
|
|
|
|
def _get_experiment_id_from_view_args():
|
|
# For download/upload/delete artifact endpoints, artifact_path is a URL path parameter.
|
|
# For the list-artifacts endpoint, the path is a query parameter named "path".
|
|
if artifact_path := (request.view_args.get("artifact_path") or request.args.get("path")):
|
|
if m := _EXPERIMENT_ID_PATTERN.match(artifact_path):
|
|
return m.group(1)
|
|
return None
|
|
|
|
|
|
def _get_permission_from_experiment_id_artifact_proxy() -> Permission:
|
|
username = authenticate_request().username
|
|
|
|
if experiment_id := _get_experiment_id_from_view_args():
|
|
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",
|
|
),
|
|
)
|
|
|
|
if MLFLOW_ENABLE_WORKSPACES.get():
|
|
if workspace_name := workspace_context.get_request_workspace():
|
|
user = store.get_user(username)
|
|
perm = store.get_role_permission_for_resource(user.id, "workspace", "*", workspace_name)
|
|
if perm is not None:
|
|
return perm
|
|
# Honor the default-workspace auto-grant when configured.
|
|
if _user_inherits_default_workspace_grant(workspace_name):
|
|
return get_permission(auth_config.default_permission)
|
|
return NO_PERMISSIONS
|
|
|
|
return get_permission(auth_config.default_permission)
|
|
|
|
|
|
def _get_permission_from_experiment_name() -> Permission:
|
|
experiment_name = _get_request_param("experiment_name")
|
|
store_exp = _get_tracking_store().get_experiment_by_name(experiment_name)
|
|
if store_exp is None:
|
|
raise MlflowException(
|
|
f"Could not find experiment with name {experiment_name}",
|
|
error_code=RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
username = authenticate_request().username
|
|
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="experiment",
|
|
resource_key=store_exp.experiment_id,
|
|
workspace_lookup_id=store_exp.experiment_id,
|
|
workspace_fetcher=_get_tracking_store().get_experiment,
|
|
workspace_label="experiment",
|
|
),
|
|
)
|
|
|
|
|
|
def _get_permission_from_run_id() -> Permission:
|
|
# run permissions inherit from parent resource (experiment)
|
|
# so we just get the experiment permission
|
|
run_id = _get_request_param("run_id")
|
|
run = _get_tracking_store().get_run(run_id)
|
|
experiment_id = run.info.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",
|
|
),
|
|
)
|
|
|
|
|
|
def _get_permission_from_model_id() -> Permission:
|
|
# logged model permissions inherit from parent resource (experiment)
|
|
model_id = _get_request_param("model_id")
|
|
model = _get_tracking_store().get_logged_model(model_id)
|
|
experiment_id = model.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",
|
|
),
|
|
)
|
|
|
|
|
|
def _get_permission_from_prompt_optimization_job_id() -> Permission:
|
|
# prompt optimization job permissions inherit from parent resource (experiment)
|
|
job_id = _get_request_param("job_id")
|
|
job_entity = get_job(job_id)
|
|
params = json.loads(job_entity.params)
|
|
experiment_id = params.get("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",
|
|
),
|
|
)
|
|
|
|
|
|
def _get_permission_from_registered_model_name() -> Permission:
|
|
name = _get_request_param("name")
|
|
username = authenticate_request().username
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="registered_model",
|
|
resource_key=name,
|
|
workspace_lookup_id=name,
|
|
workspace_fetcher=_get_model_registry_store().get_registered_model,
|
|
workspace_label="registered model",
|
|
),
|
|
)
|
|
|
|
|
|
def _get_permission_from_prompt_name() -> Permission:
|
|
# Grant lookup is namespaced under ``"prompt"`` so a registered_model grant
|
|
# on the same name does not satisfy a prompt request, and vice versa.
|
|
# Workspace resolution reuses the registry's ``get_registered_model``
|
|
# (returns both shapes).
|
|
name = _get_request_param("name")
|
|
username = authenticate_request().username
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="prompt",
|
|
resource_key=name,
|
|
workspace_lookup_id=name,
|
|
workspace_fetcher=_get_model_registry_store().get_registered_model,
|
|
workspace_label="prompt",
|
|
),
|
|
)
|
|
|
|
|
|
def _get_permission_from_registered_model_or_prompt_name() -> Permission:
|
|
"""Resolve permission for a shared model-registry route in a single DB round-trip.
|
|
|
|
Fetches the ``RegisteredModel`` once, classifies it as prompt or model via
|
|
``._is_prompt()``, and resolves the workspace from the same object — avoiding
|
|
the separate classify fetch that ``_request_targets_prompt`` would add.
|
|
"""
|
|
name = _get_request_param("name")
|
|
username = authenticate_request().username
|
|
workspace_name = None
|
|
resource_type = "registered_model"
|
|
try:
|
|
rm = _get_model_registry_store().get_registered_model(name)
|
|
resource_type = "prompt" if rm._is_prompt() else "registered_model"
|
|
workspace_name = getattr(rm, "workspace", None)
|
|
except MlflowException as e:
|
|
if e.error_code != ErrorCode.Name(RESOURCE_DOES_NOT_EXIST):
|
|
raise
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for_known_workspace(username, resource_type, name, workspace_name)
|
|
)
|
|
|
|
|
|
def _get_permission_from_scorer_name() -> Permission:
|
|
experiment_id = _get_request_param("experiment_id")
|
|
name = _get_request_param("name")
|
|
username = authenticate_request().username
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="scorer",
|
|
resource_key=store._scorer_pattern(experiment_id, name),
|
|
workspace_lookup_id=experiment_id,
|
|
workspace_fetcher=_get_tracking_store().get_experiment,
|
|
workspace_label="experiment",
|
|
),
|
|
)
|
|
|
|
|
|
def _get_permission_from_scorer_permission_request() -> Permission:
|
|
experiment_id = _get_request_param("experiment_id")
|
|
scorer_name = _get_request_param("scorer_name")
|
|
username = authenticate_request().username
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="scorer",
|
|
resource_key=store._scorer_pattern(experiment_id, scorer_name),
|
|
workspace_lookup_id=experiment_id,
|
|
workspace_fetcher=_get_tracking_store().get_experiment,
|
|
workspace_label="experiment",
|
|
),
|
|
)
|
|
|
|
|
|
def _get_permission_from_gateway_secret_id() -> Permission:
|
|
secret_id = _get_request_param("secret_id")
|
|
username = authenticate_request().username
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="gateway_secret",
|
|
resource_key=secret_id,
|
|
workspace_lookup_id=secret_id,
|
|
workspace_fetcher=lambda sid: _get_tracking_store().get_secret_info(secret_id=sid),
|
|
workspace_label="gateway secret",
|
|
),
|
|
)
|
|
|
|
|
|
def _get_permission_from_gateway_endpoint_id() -> Permission:
|
|
endpoint_id = _get_request_param("endpoint_id")
|
|
username = authenticate_request().username
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="gateway_endpoint",
|
|
resource_key=endpoint_id,
|
|
workspace_lookup_id=endpoint_id,
|
|
workspace_fetcher=lambda eid: _get_tracking_store().get_gateway_endpoint(
|
|
endpoint_id=eid
|
|
),
|
|
workspace_label="gateway endpoint",
|
|
),
|
|
)
|
|
|
|
|
|
def _get_permission_from_gateway_model_definition_id() -> Permission:
|
|
model_definition_id = _get_request_param("model_definition_id")
|
|
username = authenticate_request().username
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="gateway_model_definition",
|
|
resource_key=model_definition_id,
|
|
workspace_lookup_id=model_definition_id,
|
|
workspace_fetcher=lambda mdid: _get_tracking_store().get_gateway_model_definition(
|
|
model_definition_id=mdid
|
|
),
|
|
workspace_label="gateway model definition",
|
|
),
|
|
)
|
|
|
|
|
|
def validate_can_read_experiment():
|
|
return _get_permission_from_experiment_id().can_read
|
|
|
|
|
|
def validate_can_read_scorer_list():
|
|
# ``ListScorers`` accepts an optional ``experiment_id``. When set, gate
|
|
# on the experiment read permission as usual; when empty, the request is
|
|
# a cross-experiment listing and ``AFTER_REQUEST_PATH_HANDLERS`` does the
|
|
# per-row RBAC filtering, so the route itself is open to any authenticated
|
|
# caller.
|
|
args = request.args if request.method == "GET" else (request.get_json(silent=True) or {})
|
|
if not args.get("experiment_id"):
|
|
return True
|
|
return _get_permission_from_experiment_id().can_read
|
|
|
|
|
|
def validate_can_read_experiment_by_name():
|
|
return _get_permission_from_experiment_name().can_read
|
|
|
|
|
|
def validate_can_update_experiment():
|
|
return _get_permission_from_experiment_id().can_update
|
|
|
|
|
|
def validate_can_delete_experiment():
|
|
return _get_permission_from_experiment_id().can_delete
|
|
|
|
|
|
def validate_can_manage_experiment():
|
|
return _get_permission_from_experiment_id().can_manage
|
|
|
|
|
|
def validate_can_read_experiment_artifact_proxy():
|
|
return _get_permission_from_experiment_id_artifact_proxy().can_read
|
|
|
|
|
|
def validate_can_update_experiment_artifact_proxy():
|
|
return _get_permission_from_experiment_id_artifact_proxy().can_update
|
|
|
|
|
|
def validate_can_delete_experiment_artifact_proxy():
|
|
return _get_permission_from_experiment_id_artifact_proxy().can_manage
|
|
|
|
|
|
# Runs
|
|
def validate_can_read_run():
|
|
return _get_permission_from_run_id().can_read
|
|
|
|
|
|
def validate_can_update_run():
|
|
return _get_permission_from_run_id().can_update
|
|
|
|
|
|
def validate_can_delete_run():
|
|
return _get_permission_from_run_id().can_delete
|
|
|
|
|
|
def validate_can_manage_run():
|
|
return _get_permission_from_run_id().can_manage
|
|
|
|
|
|
# Prompt optimization jobs
|
|
def validate_can_read_prompt_optimization_job():
|
|
return _get_permission_from_prompt_optimization_job_id().can_read
|
|
|
|
|
|
def validate_can_update_prompt_optimization_job():
|
|
return _get_permission_from_prompt_optimization_job_id().can_update
|
|
|
|
|
|
def validate_can_delete_prompt_optimization_job():
|
|
return _get_permission_from_prompt_optimization_job_id().can_delete
|
|
|
|
|
|
# Logged models
|
|
def validate_can_read_logged_model():
|
|
return _get_permission_from_model_id().can_read
|
|
|
|
|
|
def validate_can_update_logged_model():
|
|
return _get_permission_from_model_id().can_update
|
|
|
|
|
|
def validate_can_delete_logged_model():
|
|
return _get_permission_from_model_id().can_delete
|
|
|
|
|
|
def validate_can_manage_logged_model():
|
|
return _get_permission_from_model_id().can_manage
|
|
|
|
|
|
# Registered models
|
|
def validate_can_read_registered_model():
|
|
return _get_permission_from_registered_model_name().can_read
|
|
|
|
|
|
def validate_can_update_registered_model():
|
|
return _get_permission_from_registered_model_name().can_update
|
|
|
|
|
|
def validate_can_delete_registered_model():
|
|
return _get_permission_from_registered_model_name().can_delete
|
|
|
|
|
|
def validate_can_manage_registered_model():
|
|
return _get_permission_from_registered_model_name().can_manage
|
|
|
|
|
|
# Prompts
|
|
def validate_can_read_prompt():
|
|
return _get_permission_from_prompt_name().can_read
|
|
|
|
|
|
def validate_can_update_prompt():
|
|
return _get_permission_from_prompt_name().can_update
|
|
|
|
|
|
def validate_can_delete_prompt():
|
|
return _get_permission_from_prompt_name().can_delete
|
|
|
|
|
|
def validate_can_manage_prompt():
|
|
return _get_permission_from_prompt_name().can_manage
|
|
|
|
|
|
def _request_targets_prompt() -> bool:
|
|
"""Classify a shared registered-model request as targeting a prompt.
|
|
|
|
Reads the ``mlflow.prompt.is_prompt`` tag from the **persisted** entity, not
|
|
the request body — trusting the body would let a caller with
|
|
``(prompt, foo, MANAGE)`` spoof the tag on a non-CREATE registered-model
|
|
route and escalate. Missing names and ``RESOURCE_DOES_NOT_EXIST`` fall
|
|
through to the registered-model path; other errors propagate so a broken
|
|
registry doesn't silently flip the auth namespace.
|
|
"""
|
|
name = _request_params().get("name")
|
|
if not name:
|
|
return False
|
|
try:
|
|
rm = _get_model_registry_store().get_registered_model(name)
|
|
except MlflowException as e:
|
|
if e.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST):
|
|
return False
|
|
raise
|
|
return rm._is_prompt()
|
|
|
|
|
|
def _validate_can_read_registered_model_or_prompt():
|
|
return _get_permission_from_registered_model_or_prompt_name().can_read
|
|
|
|
|
|
def _validate_can_update_registered_model_or_prompt():
|
|
return _get_permission_from_registered_model_or_prompt_name().can_update
|
|
|
|
|
|
def _validate_can_delete_registered_model_or_prompt():
|
|
return _get_permission_from_registered_model_or_prompt_name().can_delete
|
|
|
|
|
|
def _validate_can_manage_registered_model_or_prompt():
|
|
return _get_permission_from_registered_model_or_prompt_name().can_manage
|
|
|
|
|
|
def validate_can_create_model_version():
|
|
# A model version anchors its `source` inside the artifact directory of the run/model
|
|
# named by `run_id`/`model_id`. Downstream artifact reads are gated on the model version's
|
|
# registered model, so without a read check here a caller could point `source` at another
|
|
# user's run/model and read those artifacts through their own registered model. Require read
|
|
# on the source run/model to keep create-time access consistent with artifact-read gating.
|
|
if not _validate_can_update_registered_model_or_prompt():
|
|
return False
|
|
body = request.get_json(force=True, silent=True)
|
|
body = body if isinstance(body, dict) else {}
|
|
# Presence of run_id/model_id means the version is anchored to that source, so require
|
|
# READ on it. Guard on presence (not truthiness): an explicitly-supplied empty id is
|
|
# denied here rather than being allowed to slip past the guard as if it were absent.
|
|
if "run_id" in body and not (body["run_id"] and _get_permission_from_run_id().can_read):
|
|
return False
|
|
if "model_id" in body and not (body["model_id"] and _get_permission_from_model_id().can_read):
|
|
return False
|
|
return True
|
|
|
|
|
|
def validate_can_create_experiment() -> bool:
|
|
return _user_can_create_in_workspace()
|
|
|
|
|
|
def validate_can_create_registered_model() -> bool:
|
|
return _user_can_create_in_workspace()
|
|
|
|
|
|
def validate_can_view_workspace() -> bool:
|
|
if not MLFLOW_ENABLE_WORKSPACES.get():
|
|
return True
|
|
|
|
username = authenticate_request().username
|
|
|
|
workspace_name = request.view_args.get("workspace_name") if request.view_args else None
|
|
if workspace_name is None:
|
|
return False
|
|
|
|
if username is None:
|
|
return False
|
|
|
|
if auth_config.grant_default_workspace_access:
|
|
default_workspace, _ = get_default_workspace_optional(_get_workspace_store())
|
|
if default_workspace and workspace_name == default_workspace.name:
|
|
return True
|
|
|
|
names = set(store.list_accessible_workspace_names(username))
|
|
|
|
return workspace_name in names
|
|
|
|
|
|
# Scorers
|
|
def validate_can_read_scorer():
|
|
return _get_permission_from_scorer_name().can_read
|
|
|
|
|
|
def validate_can_update_scorer():
|
|
return _get_permission_from_scorer_name().can_update
|
|
|
|
|
|
def validate_can_delete_scorer():
|
|
return _get_permission_from_scorer_name().can_delete
|
|
|
|
|
|
def validate_can_manage_scorer():
|
|
return _get_permission_from_scorer_name().can_manage
|
|
|
|
|
|
def validate_can_manage_scorer_permission():
|
|
return _get_permission_from_scorer_permission_request().can_manage
|
|
|
|
|
|
def sender_is_admin():
|
|
"""Validate if the sender is admin"""
|
|
username = authenticate_request().username
|
|
return store.get_user(username).is_admin
|
|
|
|
|
|
def _is_workspace_admin(user_id: int, workspace: str) -> bool:
|
|
return store.is_workspace_admin(user_id, workspace)
|
|
|
|
|
|
def _request_params() -> dict[str, object]:
|
|
"""Return the request's params dict (body for POST/PATCH/DELETE, args for GET)."""
|
|
if request.method == "GET":
|
|
return dict(request.args)
|
|
if request.method in ("POST", "PATCH"):
|
|
return dict(request.get_json(silent=True) or {})
|
|
if request.method == "DELETE":
|
|
if request.is_json:
|
|
return dict(request.get_json(silent=True) or {})
|
|
return dict(request.args)
|
|
return {}
|
|
|
|
|
|
def _get_role_workspace_from_request() -> str | None:
|
|
"""
|
|
Resolve the workspace the request is targeting for role-authorization purposes.
|
|
|
|
Requests identify a role either directly (``role_id``), indirectly via a role
|
|
permission (``role_permission_id``), or by supplying ``workspace`` on create.
|
|
Returns ``None`` if the referenced role/role_permission does not exist — callers
|
|
(validators) should treat that as unauthorized rather than leaking existence via
|
|
a 404.
|
|
"""
|
|
params = _request_params()
|
|
try:
|
|
if "role_id" in params:
|
|
return store.get_role(_coerce_int_param("role_id", params["role_id"])).workspace
|
|
if "role_permission_id" in params:
|
|
rp = store.get_role_permission(
|
|
_coerce_int_param("role_permission_id", params["role_permission_id"])
|
|
)
|
|
return store.get_role(rp.role_id).workspace
|
|
except MlflowException as e:
|
|
if e.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST):
|
|
return None
|
|
raise
|
|
if "workspace" in params:
|
|
workspace = params["workspace"]
|
|
if not isinstance(workspace, str) or not workspace.strip():
|
|
raise MlflowException.invalid_parameter_value(
|
|
"Parameter 'workspace' must be a non-empty string."
|
|
)
|
|
return workspace
|
|
raise MlflowException.invalid_parameter_value(
|
|
"Request must include one of: role_id, role_permission_id, workspace."
|
|
)
|
|
|
|
|
|
def validate_can_manage_roles():
|
|
username = authenticate_request().username
|
|
user = store.get_user(username)
|
|
if user.is_admin:
|
|
return True
|
|
workspace = _get_role_workspace_from_request()
|
|
if workspace is None:
|
|
return False
|
|
return _is_workspace_admin(user.id, workspace)
|
|
|
|
|
|
def validate_can_view_roles():
|
|
username = authenticate_request().username
|
|
user = store.get_user(username)
|
|
if user.is_admin:
|
|
return True
|
|
workspace = _get_role_workspace_from_request()
|
|
if workspace is None:
|
|
return False
|
|
return store.user_has_any_role_in_workspace(user.id, workspace)
|
|
|
|
|
|
def validate_can_list_roles():
|
|
"""
|
|
Authorization for the ``/mlflow/roles/list`` endpoint. The endpoint accepts a
|
|
repeated ``workspace`` query param: zero workspaces lists across the system
|
|
(admin-only), one or more scopes the listing to those workspaces. Non-admins
|
|
must hold a role in *every* workspace they request; only super admins can list
|
|
unscoped.
|
|
"""
|
|
username = authenticate_request().username
|
|
user = store.get_user(username)
|
|
if user.is_admin:
|
|
return True
|
|
requested = {
|
|
w.strip() for w in request.args.getlist("workspace") if isinstance(w, str) and w.strip()
|
|
}
|
|
if not requested:
|
|
return False
|
|
# Single batch query — avoids N round-trips when multiple workspaces are
|
|
# requested and dedups duplicates implicitly via the set.
|
|
return requested <= store.list_user_present_workspaces(user.id)
|
|
|
|
|
|
def validate_can_view_user_roles():
|
|
username = authenticate_request().username
|
|
user = store.get_user(username)
|
|
if user.is_admin:
|
|
return True
|
|
target_username = _get_request_param("username")
|
|
if username == target_username:
|
|
return True
|
|
# WP admins can view user roles for users in their workspaces.
|
|
# If the target user does not exist, the handler will raise RESOURCE_DOES_NOT_EXIST;
|
|
# treat this as "not authorized" here rather than letting the validator raise.
|
|
if not store.has_user(target_username):
|
|
return False
|
|
target_user = store.get_user(target_username)
|
|
return store.is_workspace_admin_of_any_of_users_workspaces(user.id, target_user.id)
|
|
|
|
|
|
# Unified per-user permission convenience APIs. Replace the deprecated
|
|
# per-resource endpoints with a uniform ``(resource_type, resource_id)`` shape;
|
|
# preserve per-resource MANAGE delegation (gated on resource-level MANAGE).
|
|
|
|
|
|
def _scorer_lookup_keys(resource_id: str) -> tuple[str, str]:
|
|
"""Split ``<experiment_id>/<url_quote(scorer_name)>`` into (experiment_id, full_pattern)."""
|
|
experiment_id, sep, _ = resource_id.partition("/")
|
|
if not sep:
|
|
raise MlflowException(
|
|
"Invalid scorer resource_id. Expected '<experiment_id>/<scorer_name>'.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
return experiment_id, resource_id
|
|
|
|
|
|
def _reject_workspace_resource_type(resource_type: str) -> None:
|
|
# Workspace-tier grants have their own surface (set_workspace_permission /
|
|
# delete_workspace_permission); rejecting here gives every code path the
|
|
# same 400 message regardless of which gate fires first.
|
|
if resource_type == RESOURCE_TYPE_WORKSPACE:
|
|
raise MlflowException(
|
|
"resource_type 'workspace' is not supported by the per-user permission "
|
|
"convenience APIs. Use set_workspace_permission / "
|
|
"delete_workspace_permission for workspace-wide grants.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
|
|
# Maps each resource_type to ``(workspace_label, workspace_fetcher_factory)``.
|
|
# Factory is invoked lazily so tests can patch the underlying store.
|
|
_RESOURCE_WORKSPACE_FETCHER: dict[str, tuple[str, Callable[[], Callable[[str], Any]]]] = {
|
|
RESOURCE_TYPE_EXPERIMENT: ("experiment", lambda: _get_tracking_store().get_experiment),
|
|
RESOURCE_TYPE_REGISTERED_MODEL: (
|
|
"registered model",
|
|
lambda: _get_model_registry_store().get_registered_model,
|
|
),
|
|
RESOURCE_TYPE_GATEWAY_SECRET: (
|
|
"gateway secret",
|
|
lambda: lambda sid: _get_tracking_store().get_secret_info(secret_id=sid),
|
|
),
|
|
RESOURCE_TYPE_GATEWAY_ENDPOINT: (
|
|
"gateway endpoint",
|
|
lambda: lambda eid: _get_tracking_store().get_gateway_endpoint(endpoint_id=eid),
|
|
),
|
|
RESOURCE_TYPE_GATEWAY_MODEL_DEFINITION: (
|
|
"gateway model definition",
|
|
lambda: (
|
|
lambda mdid: _get_tracking_store().get_gateway_model_definition(
|
|
model_definition_id=mdid
|
|
)
|
|
),
|
|
),
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ResourceDispatch:
|
|
"""Lookup keys for resolving ``(resource_type, resource_id)`` against the
|
|
role-permission store + the workspace fetcher.
|
|
|
|
Scorers are the only resource_type whose role-lookup key differs from the
|
|
workspace-lookup id (key = ``<exp_id>/<scorer_name>``, lookup = ``exp_id``).
|
|
"""
|
|
|
|
resource_key: str
|
|
workspace_lookup_id: str
|
|
workspace_fetcher: Callable[[str], Any]
|
|
workspace_label: str
|
|
|
|
|
|
def _resource_dispatch_keys(resource_type: str, resource_id: str) -> _ResourceDispatch | None:
|
|
"""Return the dispatch keys for ``(resource_type, resource_id)``, or ``None``
|
|
if the type isn't supported by the per-user convenience APIs.
|
|
"""
|
|
if resource_type == RESOURCE_TYPE_SCORER:
|
|
experiment_id, scorer_pattern = _scorer_lookup_keys(resource_id)
|
|
return _ResourceDispatch(
|
|
resource_key=scorer_pattern,
|
|
workspace_lookup_id=experiment_id,
|
|
workspace_fetcher=_get_tracking_store().get_experiment,
|
|
workspace_label="experiment",
|
|
)
|
|
spec = _RESOURCE_WORKSPACE_FETCHER.get(resource_type)
|
|
if spec is None:
|
|
return None
|
|
label, fetcher_factory = spec
|
|
return _ResourceDispatch(
|
|
resource_key=resource_id,
|
|
workspace_lookup_id=resource_id,
|
|
workspace_fetcher=fetcher_factory(),
|
|
workspace_label=label,
|
|
)
|
|
|
|
|
|
def _resolve_user_permission_for_resource(
|
|
username: str, resource_type: str, resource_id: str
|
|
) -> Permission:
|
|
"""Resolve effective permission via the same code path as the runtime check
|
|
(``_get_permission_from_*`` family) so ``get_user_permission`` can't drift
|
|
from real authorization decisions.
|
|
"""
|
|
_reject_workspace_resource_type(resource_type)
|
|
_validate_resource_type(resource_type)
|
|
dispatch = _resource_dispatch_keys(resource_type, resource_id)
|
|
if dispatch is None:
|
|
raise MlflowException(
|
|
f"resource_type '{resource_type}' is not supported by the per-user "
|
|
"permission convenience APIs.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type=resource_type,
|
|
resource_key=dispatch.resource_key,
|
|
workspace_lookup_id=dispatch.workspace_lookup_id,
|
|
workspace_fetcher=dispatch.workspace_fetcher,
|
|
workspace_label=dispatch.workspace_label,
|
|
),
|
|
)
|
|
|
|
|
|
def validate_can_manage_resource() -> bool:
|
|
"""Dispatcher validator for ``grant_user_permission`` / ``revoke_user_permission``.
|
|
Gates on the same per-resource MANAGE check the legacy ``validate_can_manage_*``
|
|
validators used.
|
|
"""
|
|
resource_type = _get_request_param("resource_type")
|
|
resource_id = _get_request_param("resource_id")
|
|
requester = authenticate_request().username
|
|
return _resolve_user_permission_for_resource(requester, resource_type, resource_id).can_manage
|
|
|
|
|
|
def _workspace_for_resource(resource_type: str, resource_id: str) -> str | None:
|
|
"""Resolve the workspace name owning ``(resource_type, resource_id)``, or None
|
|
if the resource can't be located. Fail-closed: any lookup failure returns
|
|
None so authorization gates deny rather than leak across workspaces.
|
|
"""
|
|
try:
|
|
dispatch = _resource_dispatch_keys(resource_type, resource_id)
|
|
except MlflowException:
|
|
# Malformed scorer id — deny rather than 500.
|
|
return None
|
|
if dispatch is None:
|
|
return None
|
|
return _get_resource_workspace(
|
|
dispatch.workspace_lookup_id,
|
|
dispatch.workspace_fetcher,
|
|
dispatch.workspace_label,
|
|
silent=True,
|
|
)
|
|
|
|
|
|
def validate_can_get_user_permission() -> bool:
|
|
"""Gate ``get_user_permission``: admin / self / workspace MANAGE in the
|
|
resource's workspace. Scoping to the resource workspace closes the
|
|
cross-workspace probe (workspace-A admin asking about a workspace-B resource).
|
|
"""
|
|
# Reject workspace upfront so admin and non-admin paths produce the same
|
|
# 400 message rather than a 400 for admin and a 403 for non-admin.
|
|
resource_type = _get_request_param("resource_type")
|
|
_reject_workspace_resource_type(resource_type)
|
|
requester = authenticate_request().username
|
|
requester_user = store.get_user(requester)
|
|
if requester_user.is_admin:
|
|
return True
|
|
target_username = _get_request_param("username")
|
|
if requester == target_username:
|
|
return True
|
|
if not store.has_user(target_username):
|
|
return False
|
|
resource_id = _get_request_param("resource_id")
|
|
workspace_name = _workspace_for_resource(resource_type, resource_id)
|
|
if workspace_name is None:
|
|
return False
|
|
return store.is_workspace_admin(requester_user.id, workspace_name)
|
|
|
|
|
|
def _entity_is_prompt(entity) -> bool:
|
|
"""True if a ``RegisteredModel`` / ``ModelVersion`` entity is prompt-flagged.
|
|
|
|
Unifies the two response-filtering paths in ``filter_search_*`` — initial
|
|
response rows arrive as protos (via ``parse_dict``), refetched rows arrive
|
|
as ORM entities (``PagedList[RegisteredModel]`` / ``[ModelVersion]``).
|
|
Both ORM entities expose ``_is_prompt()``; protos don't, so we fall back
|
|
to scanning the repeated ``.tags`` field for the prompt marker.
|
|
"""
|
|
if hasattr(entity, "_is_prompt"):
|
|
return entity._is_prompt()
|
|
return any(t.key == IS_PROMPT_TAG_KEY and t.value.lower() == "true" for t in entity.tags)
|
|
|
|
|
|
def _rm_or_prompt_read_predicate(username: str) -> Callable[[Any], bool]:
|
|
"""Build a ``p(entity) -> bool`` for filtering shared registered-model /
|
|
model-version search responses. Classifies each row by its
|
|
``mlflow.prompt.is_prompt`` tag and consults the matching grant namespace.
|
|
"""
|
|
can_read_rm = _role_based_read_predicate(username, "registered_model")
|
|
can_read_prompt = _role_based_read_predicate(username, "prompt")
|
|
|
|
def can_read(entity) -> bool:
|
|
return (can_read_prompt if _entity_is_prompt(entity) else can_read_rm)(entity.name)
|
|
|
|
return can_read
|
|
|
|
|
|
def _role_based_read_predicate(username: str, resource_type: str) -> Callable[[str], bool]:
|
|
"""
|
|
Build a ``p(resource_id) -> bool`` predicate from ``username``'s role
|
|
grants in the active workspace. Max-style: any positive grant (specific or
|
|
wildcard) wins; ``NO_PERMISSIONS`` rows are ignored. Falls back to
|
|
``default_permission.can_read`` when workspaces are disabled, otherwise to
|
|
deny.
|
|
"""
|
|
workspace_name = (
|
|
workspace_context.get_request_workspace()
|
|
if MLFLOW_ENABLE_WORKSPACES.get()
|
|
else DEFAULT_WORKSPACE_NAME
|
|
)
|
|
if workspace_name is None:
|
|
return lambda _resource_id: False
|
|
|
|
user = store.get_user(username)
|
|
readable: set[str] = set()
|
|
wildcard_can_read = False
|
|
for resource_pattern, permission in store.list_role_grants_for_user_in_workspace(
|
|
user.id, workspace_name, resource_type
|
|
):
|
|
if not get_permission(permission).can_read:
|
|
continue
|
|
if resource_pattern == "*":
|
|
wildcard_can_read = True
|
|
else:
|
|
readable.add(resource_pattern)
|
|
|
|
default_can_read = get_permission(auth_config.default_permission).can_read
|
|
fallback = default_can_read if not MLFLOW_ENABLE_WORKSPACES.get() else False
|
|
|
|
def predicate(resource_id: str) -> bool:
|
|
return resource_id in readable or wildcard_can_read or fallback
|
|
|
|
return predicate
|
|
|
|
|
|
def filter_experiment_ids(experiment_ids: list[str]) -> list[str]:
|
|
"""
|
|
Filter experiment IDs to only include those the user has read access to.
|
|
|
|
Called from ``search_runs_impl`` before the tracking store query. When workspaces
|
|
are enabled, the tracking store subsequently filters to the active workspace, so we
|
|
only consult role grants in that workspace here — experiments outside it would be
|
|
rejected anyway.
|
|
|
|
Args:
|
|
experiment_ids: List of experiment IDs to filter
|
|
|
|
Returns:
|
|
Filtered list of experiment IDs the user can read
|
|
"""
|
|
if not auth_config:
|
|
return experiment_ids
|
|
|
|
try:
|
|
if sender_is_admin():
|
|
return experiment_ids
|
|
predicate = _role_based_read_predicate(authenticate_request().username, "experiment")
|
|
return [exp_id for exp_id in experiment_ids if predicate(exp_id)]
|
|
except (RuntimeError, AttributeError):
|
|
# Auth system not fully initialized, skip filtering
|
|
return experiment_ids
|
|
|
|
|
|
def username_is_sender():
|
|
"""Validate if the request username is the sender"""
|
|
username = _get_request_param("username")
|
|
sender = authenticate_request().username
|
|
return username == sender
|
|
|
|
|
|
def validate_can_read_user():
|
|
return username_is_sender()
|
|
|
|
|
|
def validate_can_list_users():
|
|
# Any workspace member may list users (the reviewer-assignment UI needs the
|
|
# roster); listing grants no access on its own. Workspace-scoped so the roster
|
|
# isn't leaked across workspaces.
|
|
return _user_can_create_in_workspace()
|
|
|
|
|
|
def validate_can_create_user():
|
|
# Workspace admins may need to seed an account before assigning it a role
|
|
# in a workspace they manage; creating a user grants no access on its own.
|
|
# Deletion stays super-admin-only (see ``validate_can_delete_user``).
|
|
# (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))
|
|
|
|
|
|
def validate_can_update_user_password():
|
|
return username_is_sender()
|
|
|
|
|
|
def validate_can_update_user_admin():
|
|
# only admins can update, but admins won't reach this validator
|
|
return False
|
|
|
|
|
|
def validate_can_delete_user():
|
|
# only admins can delete, but admins won't reach this validator
|
|
return False
|
|
|
|
|
|
def validate_can_read_gateway_secret():
|
|
return _get_permission_from_gateway_secret_id().can_read
|
|
|
|
|
|
def validate_can_update_gateway_secret():
|
|
return _get_permission_from_gateway_secret_id().can_update
|
|
|
|
|
|
def validate_can_delete_gateway_secret():
|
|
return _get_permission_from_gateway_secret_id().can_delete
|
|
|
|
|
|
def validate_can_manage_gateway_secret():
|
|
return _get_permission_from_gateway_secret_id().can_manage
|
|
|
|
|
|
def validate_can_read_gateway_endpoint():
|
|
return _get_permission_from_gateway_endpoint_id().can_read
|
|
|
|
|
|
def validate_can_delete_gateway_endpoint():
|
|
return _get_permission_from_gateway_endpoint_id().can_delete
|
|
|
|
|
|
def validate_can_manage_gateway_endpoint():
|
|
return _get_permission_from_gateway_endpoint_id().can_manage
|
|
|
|
|
|
def validate_can_read_gateway_model_definition():
|
|
return _get_permission_from_gateway_model_definition_id().can_read
|
|
|
|
|
|
def validate_can_delete_gateway_model_definition():
|
|
return _get_permission_from_gateway_model_definition_id().can_delete
|
|
|
|
|
|
def validate_can_manage_gateway_model_definition():
|
|
return _get_permission_from_gateway_model_definition_id().can_manage
|
|
|
|
|
|
def validate_can_create_gateway_model_definition():
|
|
"""
|
|
Validate that the user can create a gateway model definition.
|
|
This requires USE permission on the referenced secret.
|
|
"""
|
|
body = request.json or {}
|
|
secret_id = body.get("secret_id")
|
|
if not secret_id:
|
|
# If no secret is provided, allow creation (will fail in handler)
|
|
return True
|
|
|
|
username = authenticate_request().username
|
|
permission = _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="gateway_secret",
|
|
resource_key=secret_id,
|
|
workspace_lookup_id=secret_id,
|
|
workspace_fetcher=lambda sid: _get_tracking_store().get_secret_info(secret_id=sid),
|
|
workspace_label="gateway secret",
|
|
),
|
|
)
|
|
return permission.can_use
|
|
|
|
|
|
def validate_can_update_gateway_model_definition():
|
|
"""
|
|
Validate that the user can update a gateway model definition.
|
|
This requires UPDATE permission on the model definition AND
|
|
USE permission on any new secret being referenced.
|
|
"""
|
|
# First check update permission on the model definition
|
|
if not _get_permission_from_gateway_model_definition_id().can_update:
|
|
return False
|
|
|
|
# If updating the secret, check USE permission on the new secret
|
|
body = request.json or {}
|
|
secret_id = body.get("secret_id")
|
|
if not secret_id:
|
|
# No secret being changed, just return True
|
|
return True
|
|
|
|
username = authenticate_request().username
|
|
permission = _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="gateway_secret",
|
|
resource_key=secret_id,
|
|
workspace_lookup_id=secret_id,
|
|
workspace_fetcher=lambda sid: _get_tracking_store().get_secret_info(secret_id=sid),
|
|
workspace_label="gateway secret",
|
|
),
|
|
)
|
|
return permission.can_use
|
|
|
|
|
|
def _validate_can_use_model_definitions(model_configs: list[dict[str, Any]]) -> bool:
|
|
"""
|
|
Helper to validate USE permission on all model definitions in model_configs.
|
|
Returns True if all model definitions have USE permission, False otherwise.
|
|
"""
|
|
if not model_configs:
|
|
return True
|
|
|
|
model_def_ids = [
|
|
config.get("model_definition_id")
|
|
for config in model_configs
|
|
if config.get("model_definition_id")
|
|
]
|
|
|
|
if not model_def_ids:
|
|
return True
|
|
|
|
username = authenticate_request().username
|
|
for model_def_id in model_def_ids:
|
|
permission = _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="gateway_model_definition",
|
|
resource_key=model_def_id,
|
|
workspace_lookup_id=model_def_id,
|
|
workspace_fetcher=lambda mdid: _get_tracking_store().get_gateway_model_definition(
|
|
model_definition_id=mdid
|
|
),
|
|
workspace_label="gateway model definition",
|
|
),
|
|
)
|
|
if not permission.can_use:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _validate_can_use_model_definitions_for_create(model_configs: list[dict[str, Any]]) -> bool:
|
|
"""
|
|
Create-only helper that enforces workspace USE permission when no model definitions
|
|
are provided, otherwise validates USE permission on referenced model definitions.
|
|
"""
|
|
if not model_configs or not any(config.get("model_definition_id") for config in model_configs):
|
|
if not MLFLOW_ENABLE_WORKSPACES.get():
|
|
return True
|
|
workspace_name = workspace_context.get_request_workspace()
|
|
if workspace_name is None:
|
|
return False
|
|
username = authenticate_request().username
|
|
user = store.get_user(username)
|
|
workspace_perm = store.get_role_permission_for_resource(
|
|
user.id, "workspace", "*", workspace_name
|
|
)
|
|
if workspace_perm is not None and workspace_perm.can_use:
|
|
return True
|
|
# Honor ``grant_default_workspace_access``: an ungranted user in the
|
|
# default workspace inherits ``default_permission`` and can create iff
|
|
# that permission carries ``can_use``.
|
|
if workspace_perm is None and _user_inherits_default_workspace_grant(workspace_name):
|
|
return get_permission(auth_config.default_permission).can_use
|
|
return False
|
|
|
|
return _validate_can_use_model_definitions(model_configs)
|
|
|
|
|
|
def validate_can_create_gateway_endpoint():
|
|
"""
|
|
Validate that the user can create a gateway endpoint.
|
|
This requires USE permission on all referenced model definitions.
|
|
"""
|
|
body = request.json or {}
|
|
model_configs = body.get("model_configs", [])
|
|
return _validate_can_use_model_definitions_for_create(model_configs)
|
|
|
|
|
|
def validate_can_update_gateway_endpoint():
|
|
"""
|
|
Validate that the user can update a gateway endpoint.
|
|
This requires UPDATE permission on the endpoint AND
|
|
USE permission on any new model definitions being referenced.
|
|
"""
|
|
if not _get_permission_from_gateway_endpoint_id().can_update:
|
|
return False
|
|
|
|
body = request.json or {}
|
|
model_configs = body.get("model_configs", [])
|
|
return _validate_can_use_model_definitions(model_configs)
|
|
|
|
|
|
def _get_permission_from_run_id_or_uuid() -> Permission:
|
|
"""
|
|
Get permission for Flask routes that use either run_id or run_uuid parameter.
|
|
"""
|
|
run_id = request.args.get("run_id") or request.args.get("run_uuid")
|
|
if not run_id:
|
|
raise MlflowException(
|
|
"Request must specify run_id or run_uuid parameter",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
run = _get_tracking_store().get_run(run_id)
|
|
experiment_id = run.info.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",
|
|
),
|
|
)
|
|
|
|
|
|
def validate_can_read_run_artifact():
|
|
"""Checks READ permission on run artifacts."""
|
|
return _get_permission_from_run_id_or_uuid().can_read
|
|
|
|
|
|
def validate_can_update_run_artifact():
|
|
"""Checks UPDATE permission on run artifacts."""
|
|
return _get_permission_from_run_id_or_uuid().can_update
|
|
|
|
|
|
def _get_permission_from_model_version() -> Permission:
|
|
"""
|
|
Get permission for model version artifacts.
|
|
Model versions inherit permissions from their registered model.
|
|
"""
|
|
name = request.args.get("name")
|
|
if not name:
|
|
raise MlflowException(
|
|
"Request must specify name parameter",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
username = authenticate_request().username
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="registered_model",
|
|
resource_key=name,
|
|
workspace_lookup_id=name,
|
|
workspace_fetcher=_get_model_registry_store().get_registered_model,
|
|
workspace_label="registered model",
|
|
),
|
|
)
|
|
|
|
|
|
def validate_can_read_model_version_artifact():
|
|
"""Checks READ permission on model version artifacts."""
|
|
return _get_permission_from_model_version().can_read
|
|
|
|
|
|
def _get_permission_from_trace_request_id() -> Permission:
|
|
request_id = request.args.get("request_id")
|
|
if not request_id:
|
|
raise MlflowException(
|
|
"Request must specify request_id parameter",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
trace = _get_tracking_store().get_trace_info(request_id)
|
|
return _get_experiment_permission(trace.experiment_id, authenticate_request().username)
|
|
|
|
|
|
def validate_can_read_trace_artifact():
|
|
"""Checks READ permission on trace artifacts."""
|
|
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)
|
|
|
|
|
|
def validate_can_read_trace_by_request_id():
|
|
return _get_permission_from_trace(
|
|
_get_request_param("request_id"), authenticate_request().username
|
|
).can_read
|
|
|
|
|
|
def validate_can_read_trace_by_trace_id():
|
|
return _get_permission_from_trace(
|
|
_get_request_param("trace_id"), authenticate_request().username
|
|
).can_read
|
|
|
|
|
|
def validate_can_search_traces():
|
|
experiment_ids = request.args.to_dict(flat=False).get("experiment_ids", [])
|
|
username = authenticate_request().username
|
|
return bool(experiment_ids) and all(
|
|
_get_experiment_permission(eid, username).can_read for eid in experiment_ids
|
|
)
|
|
|
|
|
|
def validate_can_search_traces_v3():
|
|
locations = (request.json or {}).get("locations", [])
|
|
# Only mlflow_experiment locations carry an experiment_id we can permission-check;
|
|
# inference_table and other future location types don't map to a local experiment so
|
|
# they are intentionally excluded and requests containing only those locations are
|
|
# denied (fail-closed) via the bool(experiment_ids) guard below.
|
|
experiment_ids = [
|
|
eid
|
|
for loc in locations
|
|
if isinstance(loc, dict)
|
|
if isinstance(ml_exp := loc.get("mlflow_experiment"), dict)
|
|
if (eid := ml_exp.get("experiment_id"))
|
|
]
|
|
username = authenticate_request().username
|
|
return bool(experiment_ids) and all(
|
|
_get_experiment_permission(eid, username).can_read for eid in experiment_ids
|
|
)
|
|
|
|
|
|
def validate_can_batch_get_traces():
|
|
if request.method == "GET":
|
|
trace_ids = request.args.to_dict(flat=False).get("trace_ids", [])
|
|
else:
|
|
trace_ids = (request.json or {}).get("trace_ids", [])
|
|
username = authenticate_request().username
|
|
tracking_store = _get_tracking_store()
|
|
try:
|
|
experiment_ids = {tracking_store.get_trace_info(tid).experiment_id for tid in trace_ids}
|
|
except MlflowException as e:
|
|
if e.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST):
|
|
return False
|
|
raise
|
|
return bool(experiment_ids) and all(
|
|
_get_experiment_permission(eid, username).can_read for eid in experiment_ids
|
|
)
|
|
|
|
|
|
def validate_can_delete_traces():
|
|
return _get_experiment_permission(
|
|
_get_request_param("experiment_id"), authenticate_request().username
|
|
).can_delete
|
|
|
|
|
|
def validate_can_update_trace_by_trace_id():
|
|
return _get_permission_from_trace(
|
|
_get_request_param("trace_id"), authenticate_request().username
|
|
).can_update
|
|
|
|
|
|
def validate_can_update_trace_by_request_id():
|
|
return _get_permission_from_trace(
|
|
_get_request_param("request_id"), authenticate_request().username
|
|
).can_update
|
|
|
|
|
|
def validate_can_read_traces_by_experiment_ids():
|
|
experiment_ids = (request.json or {}).get("experiment_ids", [])
|
|
username = authenticate_request().username
|
|
return bool(experiment_ids) and all(
|
|
_get_experiment_permission(eid, username).can_read for eid in experiment_ids
|
|
)
|
|
|
|
|
|
def validate_can_start_trace_v3():
|
|
body = request.json or {}
|
|
match body:
|
|
case {
|
|
"trace": {
|
|
"trace_info": {"trace_location": {"mlflow_experiment": {"experiment_id": str(eid)}}}
|
|
}
|
|
} if eid:
|
|
return _get_experiment_permission(eid, authenticate_request().username).can_update
|
|
case _:
|
|
return False
|
|
|
|
|
|
def validate_can_link_traces_to_run():
|
|
tracking_store = _get_tracking_store()
|
|
username = authenticate_request().username
|
|
run_id = _get_request_param("run_id")
|
|
try:
|
|
run = tracking_store.get_run(run_id)
|
|
except MlflowException as e:
|
|
if e.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST):
|
|
return False
|
|
raise
|
|
if not _get_experiment_permission(run.info.experiment_id, username).can_update:
|
|
return False
|
|
trace_ids = (request.json or {}).get("trace_ids", [])
|
|
try:
|
|
trace_experiment_ids = {
|
|
tracking_store.get_trace_info(tid).experiment_id for tid in trace_ids
|
|
}
|
|
except MlflowException as e:
|
|
if e.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST):
|
|
return False
|
|
raise
|
|
return bool(trace_experiment_ids) and all(
|
|
_get_experiment_permission(eid, username).can_read for eid in trace_experiment_ids
|
|
)
|
|
|
|
|
|
def validate_can_read_metric_history_bulk(run_ids=None):
|
|
"""Checks READ permission on all requested runs.
|
|
|
|
Args:
|
|
run_ids: Optional list of run IDs to validate. If not provided,
|
|
extracts 'run_id' from request args (for GetMetricHistoryBulk endpoint).
|
|
"""
|
|
if run_ids is None:
|
|
run_ids = request.args.to_dict(flat=False).get("run_id", [])
|
|
if not run_ids:
|
|
raise MlflowException(
|
|
"GetMetricHistoryBulk request must specify at least one run_id.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
username = authenticate_request().username
|
|
tracking_store = _get_tracking_store()
|
|
|
|
for run_id in run_ids:
|
|
run = tracking_store.get_run(run_id)
|
|
experiment_id = run.info.experiment_id
|
|
permission = _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",
|
|
),
|
|
)
|
|
if not permission.can_read:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def validate_can_read_metric_history_bulk_interval():
|
|
"""Checks READ permission on all requested runs for the bulk interval endpoint."""
|
|
run_ids = request.args.to_dict(flat=False).get("run_ids", [])
|
|
if not run_ids:
|
|
raise MlflowException(
|
|
"GetMetricHistoryBulkInterval request must specify at least one run_id.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
return validate_can_read_metric_history_bulk(run_ids)
|
|
|
|
|
|
def validate_can_search_datasets():
|
|
"""Checks READ permission on all requested experiments."""
|
|
if request.method == "POST":
|
|
data = request.json
|
|
experiment_ids = data.get("experiment_ids", [])
|
|
else:
|
|
experiment_ids = request.args.getlist("experiment_ids")
|
|
|
|
if not experiment_ids:
|
|
raise MlflowException(
|
|
"SearchDatasets request must specify at least one experiment_id.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
username = authenticate_request().username
|
|
|
|
# Check permission for each experiment
|
|
for experiment_id in experiment_ids:
|
|
permission = _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",
|
|
),
|
|
)
|
|
if not permission.can_read:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def validate_can_create_promptlab_run():
|
|
"""Checks UPDATE permission on the experiment."""
|
|
data = request.json
|
|
experiment_id = data.get("experiment_id")
|
|
if not experiment_id:
|
|
raise MlflowException(
|
|
"CreatePromptlabRun request must specify experiment_id.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
|
|
username = authenticate_request().username
|
|
permission = _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 permission.can_update
|
|
|
|
|
|
def validate_gateway_proxy():
|
|
"""
|
|
Allows gateway proxy requests without permission checks.
|
|
This endpoint proxies to external services that handle their own authorization.
|
|
"""
|
|
return True
|
|
|
|
|
|
# Review queues & label schemas
|
|
#
|
|
# Permissions inherit from the parent experiment (like runs / logged models).
|
|
# Creating a queue requires EDIT (the creator owns it); updating or deleting one
|
|
# requires MANAGE or EDIT-with-ownership (reassigning a queue's owner is
|
|
# MANAGE-only); managing label schemas (create / update / delete) requires
|
|
# 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:
|
|
# Assigned users are normalized at write time, so normalize only the incoming name.
|
|
target = (username or "").strip().lower()
|
|
return target in queue.users
|
|
|
|
|
|
def _is_review_queue_owner(queue, username: str) -> bool:
|
|
# ``created_by`` is stored case-preserved; compare case-insensitively.
|
|
owner = (queue.created_by or "").strip().lower()
|
|
return bool(owner) and owner == (username or "").strip().lower()
|
|
|
|
|
|
def _can_own_or_manage_review_queue(queue, username: str) -> bool:
|
|
"""Owner-level access to a queue: experiment MANAGE, or experiment EDIT and
|
|
you own the queue (``created_by``). Ownership amplifies EDIT — it is never a
|
|
substitute for it.
|
|
"""
|
|
perm = _get_experiment_permission(queue.experiment_id, username)
|
|
if perm.can_manage:
|
|
return True
|
|
return perm.can_update and _is_review_queue_owner(queue, username)
|
|
|
|
|
|
def _can_delete_or_prune_review_queue(queue, username: str) -> bool:
|
|
"""Whether the user may delete the queue or remove its items (un-assign work).
|
|
|
|
A manager may act on any queue; an EDIT owner only on their own CUSTOM queue (a
|
|
USER queue's lifecycle is a manager's responsibility, never its assignee's).
|
|
"""
|
|
from mlflow.genai.review_queues import ReviewQueueType
|
|
|
|
perm = _get_experiment_permission(queue.experiment_id, username)
|
|
if perm.can_manage:
|
|
return True
|
|
return (
|
|
perm.can_update
|
|
and _is_review_queue_owner(queue, username)
|
|
and queue.queue_type == ReviewQueueType.CUSTOM
|
|
)
|
|
|
|
|
|
def _parse_update_review_queue_request() -> UpdateReviewQueue:
|
|
"""Parse the ``UpdateReviewQueue`` request body once for the gate to inspect.
|
|
|
|
Field presence is read from the parsed proto, not raw JSON keys, so protobuf
|
|
JSON's camelCase aliases (e.g. ``newOwner``) are detected the same way the
|
|
handler reads them; a raw-key scan would miss the camelCase form.
|
|
"""
|
|
body = request.get_json(silent=True)
|
|
message = UpdateReviewQueue()
|
|
parse_dict(body if isinstance(body, dict) else {}, message)
|
|
return message
|
|
|
|
|
|
def _registered_username_match(name: object) -> str | None:
|
|
"""The registered user whose name equals ``name`` (case-insensitive), or None.
|
|
|
|
User queues are named after their user (lowercased by ``normalize_user``), so a
|
|
custom queue or a rename that takes a username shadows that user's personal
|
|
queue. The match is intentionally case-insensitive — broader than the store's
|
|
case-sensitive name uniqueness — so a look-alike like ``"Alice"`` is rejected
|
|
too, not just the exact ``"alice"`` that would hard-collide and lock the user out.
|
|
"""
|
|
if not isinstance(name, str) or not name.strip():
|
|
return None
|
|
target = name.strip().lower()
|
|
return next(
|
|
(u.username for u in store.list_users() if u.username.strip().lower() == target), None
|
|
)
|
|
|
|
|
|
def _reject_create_review_queue_shadowing_user():
|
|
"""Reject creating a CUSTOM queue whose name is a registered username."""
|
|
from mlflow.genai.review_queues import ReviewQueueType
|
|
|
|
body = request.get_json(silent=True)
|
|
message = CreateReviewQueue()
|
|
parse_dict(body if isinstance(body, dict) else {}, message)
|
|
# Only CUSTOM queues choose an arbitrary name; a USER queue *is* its username.
|
|
# Compare the raw proto enum (an unset queue_type is 0/UNSPECIFIED, which
|
|
# `from_proto` would reject) so a non-CUSTOM request just skips the check.
|
|
if message.queue_type != ReviewQueueType.CUSTOM.to_proto():
|
|
return
|
|
if (matched := _registered_username_match(message.name)) is not None:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"'{matched}' is a registered user. A custom review queue cannot use a "
|
|
"username as its name; assign that user to a queue instead.",
|
|
)
|
|
|
|
|
|
def _reject_rename_review_queue_shadowing_user(queue, message):
|
|
"""Reject renaming a CUSTOM queue onto a registered username.
|
|
|
|
User queues can't be renamed at all (the store rejects that), so this only
|
|
concerns a CUSTOM queue being renamed onto a username.
|
|
"""
|
|
from mlflow.genai.review_queues import ReviewQueueType
|
|
|
|
if queue.queue_type != ReviewQueueType.CUSTOM or not message.HasField("name"):
|
|
return
|
|
if (matched := _registered_username_match(message.name)) is not None:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"'{matched}' is a registered user. A custom review queue cannot be "
|
|
"renamed to a username; assign that user to a queue instead.",
|
|
)
|
|
|
|
|
|
def validate_can_create_review_queue():
|
|
# Creating (and thereby owning) a queue requires experiment EDIT.
|
|
permission = _get_permission_from_experiment_id().can_update
|
|
# A custom queue may not take a registered username, which would shadow that
|
|
# user's personal queue. Only enforced once the caller is authorized.
|
|
if permission:
|
|
_reject_create_review_queue_shadowing_user()
|
|
return permission
|
|
|
|
|
|
def validate_can_update_review_queue():
|
|
# Editing a queue's shape (name / users / schemas) is allowed to a manager or
|
|
# the owning EDIT user. Reassigning the owner (``new_owner``) is MANAGE-only —
|
|
# an owner cannot transfer their own queue.
|
|
username = authenticate_request().username
|
|
queue = _get_tracking_store().get_review_queue(_get_request_param("queue_id"))
|
|
message = _parse_update_review_queue_request()
|
|
if message.HasField("new_owner"):
|
|
permission = _get_experiment_permission(queue.experiment_id, username).can_manage
|
|
else:
|
|
permission = _can_own_or_manage_review_queue(queue, username)
|
|
# A rename can't take a registered username either (same shadowing concern).
|
|
if permission:
|
|
_reject_rename_review_queue_shadowing_user(queue, message)
|
|
return permission
|
|
|
|
|
|
def enforce_review_queue_name_not_username():
|
|
"""Reject a review-queue create/rename that shadows a username, for admins.
|
|
|
|
A custom queue (or a rename) named after a registered user shadows that user's
|
|
personal queue, so this is a data-integrity rule that applies to *every* caller,
|
|
not a permission. Non-admins hit it inside the validators above, after the
|
|
permission gate. Admins bypass validators entirely, so ``_before_request`` calls
|
|
this for them. A no-op for every endpoint other than create/update review queue.
|
|
"""
|
|
# Resolve via the dispatcher (not a raw path lookup) so this stays correct if
|
|
# these routes ever gain a path parameter, matching how non-admins are routed.
|
|
validator = _find_validator(request)
|
|
if validator is validate_can_create_review_queue:
|
|
_reject_create_review_queue_shadowing_user()
|
|
elif validator is validate_can_update_review_queue:
|
|
queue = _get_tracking_store().get_review_queue(_get_request_param("queue_id"))
|
|
_reject_rename_review_queue_shadowing_user(queue, _parse_update_review_queue_request())
|
|
|
|
|
|
def validate_can_remove_items_from_review_queue():
|
|
username = authenticate_request().username
|
|
queue = _get_tracking_store().get_review_queue(_get_request_param("queue_id"))
|
|
return _can_delete_or_prune_review_queue(queue, username)
|
|
|
|
|
|
def validate_can_delete_review_queue():
|
|
username = authenticate_request().username
|
|
queue = _get_tracking_store().get_review_queue(_get_request_param("queue_id"))
|
|
return _can_delete_or_prune_review_queue(queue, username)
|
|
|
|
|
|
def validate_can_add_items_to_review_queue():
|
|
# Adding items (flag-for-review) is open to any EDITor, unlike removing them.
|
|
return _get_permission_from_review_queue_id().can_update
|
|
|
|
|
|
def validate_can_get_or_create_user_queue():
|
|
return _get_permission_from_experiment_id().can_update
|
|
|
|
|
|
def validate_can_view_review_queue():
|
|
# Detail-tier read: experiment READ plus MANAGE, owner, or membership. Mirrors
|
|
# the row predicate in ``filter_list_review_queues``.
|
|
username = authenticate_request().username
|
|
queue = _get_tracking_store().get_review_queue(_get_request_param("queue_id"))
|
|
perm = _get_experiment_permission(queue.experiment_id, username)
|
|
if not perm.can_read:
|
|
return False
|
|
if perm.can_manage or _review_queue_has_member(queue, username):
|
|
return True
|
|
return perm.can_update and _is_review_queue_owner(queue, username)
|
|
|
|
|
|
def validate_can_view_review_queue_by_name():
|
|
experiment_id = _get_request_param("experiment_id")
|
|
username = authenticate_request().username
|
|
perm = _get_experiment_permission(experiment_id, username)
|
|
if not perm.can_read:
|
|
return False
|
|
if perm.can_manage:
|
|
return True
|
|
queue = _get_tracking_store().get_review_queue_by_name(
|
|
experiment_id, name=_get_request_param("name")
|
|
)
|
|
return _review_queue_has_member(queue, username) or (
|
|
perm.can_update and _is_review_queue_owner(queue, username)
|
|
)
|
|
|
|
|
|
def validate_can_review_queue_item():
|
|
# Submitting / reopening review work: experiment EDIT plus membership in the
|
|
# queue's assigned-user pool (even a manager must assign themselves first).
|
|
# Fetch the queue once and resolve the experiment permission from it.
|
|
username = authenticate_request().username
|
|
queue = _get_tracking_store().get_review_queue(_get_request_param("queue_id"))
|
|
perm = _get_experiment_permission(queue.experiment_id, username)
|
|
return perm.can_update and _review_queue_has_member(queue, username)
|
|
|
|
|
|
def validate_can_create_label_schema():
|
|
return _get_permission_from_experiment_id().can_manage
|
|
|
|
|
|
def validate_can_read_label_schema():
|
|
return _get_permission_from_label_schema_id().can_read
|
|
|
|
|
|
def validate_can_manage_label_schema():
|
|
return _get_permission_from_label_schema_id().can_manage
|
|
|
|
|
|
def filter_list_review_queues(resp: Response) -> None:
|
|
"""Narrow a ``ListReviewQueues`` response to queues the caller may see.
|
|
|
|
A server admin or any user with experiment EDIT (or MANAGE) sees every
|
|
queue (the list tier is intentionally broad — clicking into a queue is
|
|
separately gated by ``validate_can_view_review_queue``). A READ-only user
|
|
sees only queues they are assigned to (their personal queue plus any custom
|
|
queue whose assigned-user pool contains them).
|
|
"""
|
|
if sender_is_admin():
|
|
return
|
|
|
|
response_message = ListReviewQueues.Response()
|
|
parse_dict(resp.json, response_message)
|
|
|
|
username = authenticate_request().username
|
|
# One shared experiment, so resolve the grant once: EDIT/MANAGE see all rows,
|
|
# READ-only users see only queues they're assigned to.
|
|
experiment_id = _get_request_param("experiment_id")
|
|
perm = _get_experiment_permission(experiment_id, username)
|
|
if perm.can_update:
|
|
return
|
|
|
|
visible = [q for q in response_message.review_queues if _review_queue_has_member(q, username)]
|
|
response_message.ClearField("review_queues")
|
|
response_message.review_queues.extend(visible)
|
|
resp.data = message_to_json(response_message)
|
|
|
|
|
|
BEFORE_REQUEST_HANDLERS = {
|
|
# Routes for experiments
|
|
CreateExperiment: validate_can_create_experiment,
|
|
GetExperiment: validate_can_read_experiment,
|
|
GetExperimentByName: validate_can_read_experiment_by_name,
|
|
DeleteExperiment: validate_can_delete_experiment,
|
|
RestoreExperiment: validate_can_delete_experiment,
|
|
UpdateExperiment: validate_can_update_experiment,
|
|
SetExperimentTag: validate_can_update_experiment,
|
|
DeleteExperimentTag: validate_can_update_experiment,
|
|
# Routes for runs
|
|
CreateRun: validate_can_update_experiment,
|
|
GetRun: validate_can_read_run,
|
|
DeleteRun: validate_can_delete_run,
|
|
RestoreRun: validate_can_delete_run,
|
|
UpdateRun: validate_can_update_run,
|
|
LogMetric: validate_can_update_run,
|
|
LogBatch: validate_can_update_run,
|
|
LogInputs: validate_can_update_run,
|
|
LogModel: validate_can_update_run,
|
|
LogOutputs: validate_can_update_run,
|
|
SetTag: validate_can_update_run,
|
|
DeleteTag: validate_can_update_run,
|
|
LogParam: validate_can_update_run,
|
|
GetMetricHistory: validate_can_read_run,
|
|
ListArtifacts: validate_can_read_run,
|
|
# Routes for model registry (shared with prompts — dispatch via
|
|
# `_get_permission_from_registered_model_or_prompt_name`).
|
|
CreateRegisteredModel: validate_can_create_registered_model,
|
|
GetRegisteredModel: _validate_can_read_registered_model_or_prompt,
|
|
DeleteRegisteredModel: _validate_can_delete_registered_model_or_prompt,
|
|
UpdateRegisteredModel: _validate_can_update_registered_model_or_prompt,
|
|
RenameRegisteredModel: _validate_can_update_registered_model_or_prompt,
|
|
GetLatestVersions: _validate_can_read_registered_model_or_prompt,
|
|
CreateModelVersion: validate_can_create_model_version,
|
|
GetModelVersion: _validate_can_read_registered_model_or_prompt,
|
|
DeleteModelVersion: _validate_can_delete_registered_model_or_prompt,
|
|
UpdateModelVersion: _validate_can_update_registered_model_or_prompt,
|
|
TransitionModelVersionStage: _validate_can_update_registered_model_or_prompt,
|
|
GetModelVersionDownloadUri: _validate_can_read_registered_model_or_prompt,
|
|
SetRegisteredModelTag: _validate_can_update_registered_model_or_prompt,
|
|
DeleteRegisteredModelTag: _validate_can_update_registered_model_or_prompt,
|
|
SetModelVersionTag: _validate_can_update_registered_model_or_prompt,
|
|
DeleteModelVersionTag: _validate_can_delete_registered_model_or_prompt,
|
|
SetRegisteredModelAlias: _validate_can_update_registered_model_or_prompt,
|
|
DeleteRegisteredModelAlias: _validate_can_delete_registered_model_or_prompt,
|
|
GetModelVersionByAlias: _validate_can_read_registered_model_or_prompt,
|
|
# Routes for scorers
|
|
RegisterScorer: validate_can_update_experiment,
|
|
ListScorers: validate_can_read_scorer_list,
|
|
GetScorer: validate_can_read_scorer,
|
|
DeleteScorer: validate_can_delete_scorer,
|
|
ListScorerVersions: validate_can_read_scorer,
|
|
# Routes for gateway secrets
|
|
GetGatewaySecretInfo: validate_can_read_gateway_secret,
|
|
UpdateGatewaySecret: validate_can_update_gateway_secret,
|
|
DeleteGatewaySecret: validate_can_delete_gateway_secret,
|
|
# Routes for gateway endpoints
|
|
CreateGatewayEndpoint: validate_can_create_gateway_endpoint,
|
|
GetGatewayEndpoint: validate_can_read_gateway_endpoint,
|
|
UpdateGatewayEndpoint: validate_can_update_gateway_endpoint,
|
|
DeleteGatewayEndpoint: validate_can_delete_gateway_endpoint,
|
|
# Routes for gateway model definitions
|
|
CreateGatewayModelDefinition: validate_can_create_gateway_model_definition,
|
|
GetGatewayModelDefinition: validate_can_read_gateway_model_definition,
|
|
UpdateGatewayModelDefinition: validate_can_update_gateway_model_definition,
|
|
DeleteGatewayModelDefinition: validate_can_delete_gateway_model_definition,
|
|
# Routes for gateway budget policies
|
|
CreateGatewayBudgetPolicy: sender_is_admin,
|
|
UpdateGatewayBudgetPolicy: sender_is_admin,
|
|
DeleteGatewayBudgetPolicy: sender_is_admin,
|
|
# Routes for gateway endpoint-model mappings
|
|
AttachModelToGatewayEndpoint: validate_can_update_gateway_endpoint,
|
|
DetachModelFromGatewayEndpoint: validate_can_update_gateway_endpoint,
|
|
# Routes for gateway endpoint bindings
|
|
CreateGatewayEndpointBinding: validate_can_update_gateway_endpoint,
|
|
DeleteGatewayEndpointBinding: validate_can_update_gateway_endpoint,
|
|
ListGatewayEndpointBindings: validate_can_read_gateway_endpoint,
|
|
# Routes for gateway endpoint tags
|
|
SetGatewayEndpointTag: validate_can_update_gateway_endpoint,
|
|
DeleteGatewayEndpointTag: validate_can_update_gateway_endpoint,
|
|
# Routes for prompt optimization jobs
|
|
CreatePromptOptimizationJob: validate_can_update_experiment,
|
|
GetPromptOptimizationJob: validate_can_read_prompt_optimization_job,
|
|
SearchPromptOptimizationJobs: validate_can_read_experiment,
|
|
CancelPromptOptimizationJob: validate_can_update_prompt_optimization_job,
|
|
DeletePromptOptimizationJob: validate_can_delete_prompt_optimization_job,
|
|
# Routes for traces
|
|
StartTrace: validate_can_update_experiment,
|
|
StartTraceV3: validate_can_start_trace_v3,
|
|
EndTrace: validate_can_update_trace_by_request_id,
|
|
GetTraceInfo: validate_can_read_trace_by_request_id,
|
|
GetTraceInfoV3: validate_can_read_trace_by_trace_id,
|
|
GetTrace: validate_can_read_trace_by_trace_id,
|
|
SearchTraces: validate_can_search_traces,
|
|
SearchTracesV3: validate_can_search_traces_v3,
|
|
BatchGetTraces: validate_can_batch_get_traces,
|
|
BatchGetTraceInfos: validate_can_batch_get_traces,
|
|
DeleteTraces: validate_can_delete_traces,
|
|
DeleteTracesV3: validate_can_delete_traces,
|
|
SetTraceTag: validate_can_update_trace_by_request_id,
|
|
SetTraceTagV3: validate_can_update_trace_by_trace_id,
|
|
DeleteTraceTag: validate_can_update_trace_by_request_id,
|
|
DeleteTraceTagV3: validate_can_update_trace_by_trace_id,
|
|
LinkTracesToRun: validate_can_link_traces_to_run,
|
|
LinkPromptsToTrace: validate_can_update_trace_by_trace_id,
|
|
CalculateTraceFilterCorrelation: validate_can_read_traces_by_experiment_ids,
|
|
QueryTraceMetrics: validate_can_read_traces_by_experiment_ids,
|
|
CreateAssessment: validate_can_update_trace_by_trace_id,
|
|
GetAssessmentRequest: validate_can_read_trace_by_trace_id,
|
|
UpdateAssessment: validate_can_update_trace_by_trace_id,
|
|
DeleteAssessment: validate_can_update_trace_by_trace_id,
|
|
# Routes for review queues
|
|
CreateReviewQueue: validate_can_create_review_queue,
|
|
GetReviewQueue: validate_can_view_review_queue,
|
|
GetReviewQueueByName: validate_can_view_review_queue_by_name,
|
|
GetOrCreateUserQueue: validate_can_get_or_create_user_queue,
|
|
ListReviewQueues: validate_can_read_experiment,
|
|
UpdateReviewQueue: validate_can_update_review_queue,
|
|
DeleteReviewQueue: validate_can_delete_review_queue,
|
|
AddItemsToReviewQueue: validate_can_add_items_to_review_queue,
|
|
RemoveItemsFromReviewQueue: validate_can_remove_items_from_review_queue,
|
|
ListReviewQueueItems: validate_can_view_review_queue,
|
|
SetReviewQueueItemStatus: validate_can_review_queue_item,
|
|
# Routes for label schemas (review questions)
|
|
CreateLabelSchema: validate_can_create_label_schema,
|
|
GetLabelSchema: validate_can_read_label_schema,
|
|
GetLabelSchemaByName: validate_can_read_experiment,
|
|
ListLabelSchemas: validate_can_read_experiment,
|
|
UpdateLabelSchema: validate_can_manage_label_schema,
|
|
DeleteLabelSchema: validate_can_manage_label_schema,
|
|
# Workspace routes
|
|
ListWorkspaces: None,
|
|
CreateWorkspace: sender_is_admin,
|
|
GetWorkspace: validate_can_view_workspace,
|
|
UpdateWorkspace: sender_is_admin,
|
|
DeleteWorkspace: sender_is_admin,
|
|
}
|
|
|
|
|
|
def get_before_request_handler(request_class):
|
|
return BEFORE_REQUEST_HANDLERS.get(request_class)
|
|
|
|
|
|
@functools.lru_cache(maxsize=None)
|
|
def _re_compile_path(path: str) -> re.Pattern:
|
|
"""
|
|
Convert a path with angle brackets to a regex pattern. For example,
|
|
"/api/2.0/experiments/<experiment_id>" becomes "/api/2.0/experiments/([^/]+)".
|
|
"""
|
|
return re.compile(re.sub(r"<([^>]+)>", r"([^/]+)", path))
|
|
|
|
|
|
BEFORE_REQUEST_VALIDATORS = {
|
|
(http_path, method): handler
|
|
for http_path, handler, methods in get_endpoints(get_before_request_handler)
|
|
for method in methods
|
|
if "/scorers/online-config" not in http_path
|
|
# ``get_endpoints`` hardcodes the view function as the handler for explicitly
|
|
# defined endpoints (e.g. ``/mlflow/issues/invoke``), ignoring the selector we
|
|
# pass. Keep only genuine auth validators so a view function like
|
|
# ``_invoke_issue_detection_handler`` isn't mistakenly invoked as a before-request
|
|
# validator (which would run the endpoint's side effects — creating runs and
|
|
# submitting jobs — a second time, before the real handler runs).
|
|
and handler in BEFORE_REQUEST_HANDLERS.values()
|
|
}
|
|
|
|
# Auth-related routes
|
|
BEFORE_REQUEST_VALIDATORS.update({
|
|
(SIGNUP, "GET"): validate_can_create_user,
|
|
(GET_USER, "GET"): validate_can_read_user,
|
|
(AJAX_GET_USER, "GET"): validate_can_read_user,
|
|
# /current returns only the authenticated user's own identity — any
|
|
# authenticated user may read it.
|
|
(GET_CURRENT_USER, "GET"): lambda: True,
|
|
(AJAX_GET_CURRENT_USER, "GET"): lambda: True,
|
|
# Same goes for /current/permissions.
|
|
(LIST_CURRENT_USER_PERMISSIONS, "GET"): lambda: True,
|
|
(AJAX_LIST_CURRENT_USER_PERMISSIONS, "GET"): lambda: True,
|
|
(LIST_USERS, "GET"): validate_can_list_users,
|
|
(AJAX_LIST_USERS, "GET"): validate_can_list_users,
|
|
(CREATE_USER, "POST"): validate_can_create_user,
|
|
(AJAX_CREATE_USER, "POST"): validate_can_create_user,
|
|
(UPDATE_USER_PASSWORD, "PATCH"): validate_can_update_user_password,
|
|
(AJAX_UPDATE_USER_PASSWORD, "PATCH"): validate_can_update_user_password,
|
|
(UPDATE_USER_ADMIN, "PATCH"): validate_can_update_user_admin,
|
|
(AJAX_UPDATE_USER_ADMIN, "PATCH"): validate_can_update_user_admin,
|
|
(DELETE_USER, "DELETE"): validate_can_delete_user,
|
|
(AJAX_DELETE_USER, "DELETE"): validate_can_delete_user,
|
|
})
|
|
|
|
# Role management routes (RBAC)
|
|
BEFORE_REQUEST_VALIDATORS.update({
|
|
(CREATE_ROLE, "POST"): validate_can_manage_roles,
|
|
(AJAX_CREATE_ROLE, "POST"): validate_can_manage_roles,
|
|
(GET_ROLE, "GET"): validate_can_view_roles,
|
|
(AJAX_GET_ROLE, "GET"): validate_can_view_roles,
|
|
(LIST_ROLES, "GET"): validate_can_list_roles,
|
|
(AJAX_LIST_ROLES, "GET"): validate_can_list_roles,
|
|
(UPDATE_ROLE, "PATCH"): validate_can_manage_roles,
|
|
(AJAX_UPDATE_ROLE, "PATCH"): validate_can_manage_roles,
|
|
(DELETE_ROLE, "DELETE"): validate_can_manage_roles,
|
|
(AJAX_DELETE_ROLE, "DELETE"): validate_can_manage_roles,
|
|
(ADD_ROLE_PERMISSION, "POST"): validate_can_manage_roles,
|
|
(AJAX_ADD_ROLE_PERMISSION, "POST"): validate_can_manage_roles,
|
|
(REMOVE_ROLE_PERMISSION, "DELETE"): validate_can_manage_roles,
|
|
(AJAX_REMOVE_ROLE_PERMISSION, "DELETE"): validate_can_manage_roles,
|
|
(LIST_ROLE_PERMISSIONS, "GET"): validate_can_view_roles,
|
|
(AJAX_LIST_ROLE_PERMISSIONS, "GET"): validate_can_view_roles,
|
|
(UPDATE_ROLE_PERMISSION, "PATCH"): validate_can_manage_roles,
|
|
(AJAX_UPDATE_ROLE_PERMISSION, "PATCH"): validate_can_manage_roles,
|
|
(ASSIGN_ROLE, "POST"): validate_can_manage_roles,
|
|
(AJAX_ASSIGN_ROLE, "POST"): validate_can_manage_roles,
|
|
(UNASSIGN_ROLE, "DELETE"): validate_can_manage_roles,
|
|
(AJAX_UNASSIGN_ROLE, "DELETE"): validate_can_manage_roles,
|
|
(LIST_USER_ROLES, "GET"): validate_can_view_user_roles,
|
|
(AJAX_LIST_USER_ROLES, "GET"): validate_can_view_user_roles,
|
|
# Same authorization shape as ``LIST_USER_ROLES``: super admins
|
|
# bypass; self can view their own grants; workspace admins can
|
|
# view grants for users in workspaces they administer.
|
|
(LIST_USER_PERMISSIONS, "GET"): validate_can_view_user_roles,
|
|
(AJAX_LIST_USER_PERMISSIONS, "GET"): validate_can_view_user_roles,
|
|
(LIST_ROLE_USERS, "GET"): validate_can_manage_roles,
|
|
(AJAX_LIST_ROLE_USERS, "GET"): validate_can_manage_roles,
|
|
# Unified per-user grant convenience APIs. ``grant`` / ``revoke`` gate on
|
|
# per-resource MANAGE on the target resource (preserving the per-resource
|
|
# MANAGE delegation the legacy endpoints offered, permanently). ``check``
|
|
# uses the same self/admin/workspace-admin shape as ``LIST_USER_ROLES``.
|
|
(GRANT_USER_PERMISSION, "POST"): validate_can_manage_resource,
|
|
(AJAX_GRANT_USER_PERMISSION, "POST"): validate_can_manage_resource,
|
|
(REVOKE_USER_PERMISSION, "POST"): validate_can_manage_resource,
|
|
(AJAX_REVOKE_USER_PERMISSION, "POST"): validate_can_manage_resource,
|
|
(GET_USER_PERMISSION, "GET"): validate_can_get_user_permission,
|
|
(AJAX_GET_USER_PERMISSION, "GET"): validate_can_get_user_permission,
|
|
})
|
|
|
|
# Flask routes (no proto mapping)
|
|
BEFORE_REQUEST_VALIDATORS.update({
|
|
(GET_ARTIFACT, "GET"): validate_can_read_run_artifact,
|
|
(UPLOAD_ARTIFACT, "POST"): validate_can_update_run_artifact,
|
|
(GET_MODEL_VERSION_ARTIFACT, "GET"): validate_can_read_model_version_artifact,
|
|
(GET_TRACE_ARTIFACT, "GET"): validate_can_read_trace_artifact,
|
|
(GET_TRACE_ARTIFACT_V3, "GET"): validate_can_read_trace_artifact,
|
|
(GET_METRIC_HISTORY_BULK, "GET"): validate_can_read_metric_history_bulk,
|
|
(GET_METRIC_HISTORY_BULK_INTERVAL, "GET"): validate_can_read_metric_history_bulk_interval,
|
|
(SEARCH_DATASETS, "POST"): validate_can_search_datasets,
|
|
(CREATE_PROMPTLAB_RUN, "POST"): validate_can_create_promptlab_run,
|
|
(GATEWAY_PROXY, "GET"): validate_gateway_proxy,
|
|
(GATEWAY_PROXY, "POST"): validate_gateway_proxy,
|
|
(INVOKE_SCORER, "POST"): validate_gateway_proxy,
|
|
})
|
|
|
|
# Trace endpoints with path parameters (e.g. /mlflow/traces/<request_id>/tags) require
|
|
# regex matching — the BEFORE_REQUEST_VALIDATORS exact-match lookup won't find them when
|
|
# the real request path contains an actual trace/request ID instead of the template name.
|
|
TRACE_PARAMETERIZED_BEFORE_REQUEST_VALIDATORS = {
|
|
(_re_compile_path(path), method): handler
|
|
for (path, method), handler in BEFORE_REQUEST_VALIDATORS.items()
|
|
if "<" in path and "/mlflow/traces/" in path
|
|
}
|
|
|
|
LOGGED_MODEL_BEFORE_REQUEST_HANDLERS = {
|
|
CreateLoggedModel: validate_can_update_experiment,
|
|
GetLoggedModel: validate_can_read_logged_model,
|
|
DeleteLoggedModel: validate_can_delete_logged_model,
|
|
FinalizeLoggedModel: validate_can_update_logged_model,
|
|
DeleteLoggedModelTag: validate_can_delete_logged_model,
|
|
SetLoggedModelTags: validate_can_update_logged_model,
|
|
ListLoggedModelArtifacts: validate_can_read_logged_model,
|
|
LogLoggedModelParamsRequest: validate_can_update_logged_model,
|
|
}
|
|
|
|
|
|
def get_logged_model_before_request_handler(request_class):
|
|
return LOGGED_MODEL_BEFORE_REQUEST_HANDLERS.get(request_class)
|
|
|
|
|
|
LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS = {
|
|
# Paths for logged models contains path parameters (e.g. /mlflow/logged-models/<model_id>)
|
|
(_re_compile_path(http_path), method): handler
|
|
for http_path, handler, methods in get_endpoints(get_logged_model_before_request_handler)
|
|
for method in methods
|
|
}
|
|
# The AJAX artifact download endpoint is a plain Flask route with a path parameter, so it
|
|
# can't go in routes.py/BEFORE_REQUEST_VALIDATORS (exact match) and must be added here.
|
|
LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS[
|
|
(
|
|
_re_compile_path(_get_ajax_path("/mlflow/logged-models/<model_id>/artifacts/files")),
|
|
"GET",
|
|
)
|
|
] = validate_can_read_logged_model
|
|
|
|
WEBHOOK_BEFORE_REQUEST_HANDLERS = {
|
|
CreateWebhook: sender_is_admin,
|
|
GetWebhook: sender_is_admin,
|
|
ListWebhooks: sender_is_admin,
|
|
UpdateWebhook: sender_is_admin,
|
|
DeleteWebhook: sender_is_admin,
|
|
TestWebhook: sender_is_admin,
|
|
}
|
|
|
|
|
|
def get_webhook_before_request_handler(request_class):
|
|
return WEBHOOK_BEFORE_REQUEST_HANDLERS.get(request_class)
|
|
|
|
|
|
WEBHOOK_BEFORE_REQUEST_VALIDATORS = {
|
|
# Paths for webhooks contain path parameters (e.g. /mlflow/webhooks/<webhook_id>)
|
|
(_re_compile_path(http_path), method): handler
|
|
for http_path, handler, methods in get_service_endpoints(
|
|
WebhookService, get_webhook_before_request_handler
|
|
)
|
|
for method in methods
|
|
}
|
|
|
|
_AJAX_API_PATH_PREFIX = "/ajax-api/2.0"
|
|
|
|
|
|
_AJAX_API_PATH_PREFIX = "/ajax-api/2.0"
|
|
|
|
|
|
def _is_proxy_artifact_path(path: str) -> bool:
|
|
# MlflowArtifactsService endpoints are registered at both /api/2.0/... and /ajax-api/2.0/...
|
|
# paths (see handlers._get_paths), so we need to check both prefixes for auth validation.
|
|
prefixes = [
|
|
f"{_REST_API_PATH_PREFIX}/mlflow-artifacts/artifacts",
|
|
f"{_AJAX_API_PATH_PREFIX}/mlflow-artifacts/artifacts",
|
|
f"{_REST_API_PATH_PREFIX}/mlflow-artifacts/mpu/",
|
|
f"{_AJAX_API_PATH_PREFIX}/mlflow-artifacts/mpu/",
|
|
]
|
|
return any(path.startswith(prefix) for prefix in prefixes)
|
|
|
|
|
|
def _get_proxy_artifact_validator(
|
|
method: str, view_args: dict[str, Any] | None
|
|
) -> Callable[[], bool] | None:
|
|
if view_args is None:
|
|
return validate_can_read_experiment_artifact_proxy # List
|
|
|
|
return {
|
|
"GET": validate_can_read_experiment_artifact_proxy, # Download
|
|
"PUT": validate_can_update_experiment_artifact_proxy, # Upload
|
|
"DELETE": validate_can_delete_experiment_artifact_proxy, # Delete
|
|
"POST": validate_can_update_experiment_artifact_proxy, # Multipart upload
|
|
}.get(method)
|
|
|
|
|
|
def authenticate_request() -> Authorization | Response:
|
|
"""Use configured authorization function to get request authorization."""
|
|
auth_func = get_auth_func(auth_config.authorization_function)
|
|
return auth_func()
|
|
|
|
|
|
@functools.lru_cache(maxsize=None)
|
|
def get_auth_func(authorization_function: str) -> Callable[[], Authorization | Response]:
|
|
"""
|
|
Import and return the specified authorization function.
|
|
|
|
Args:
|
|
authorization_function: A string of the form "module.submodule:auth_func"
|
|
"""
|
|
mod_name, fn_name = authorization_function.split(":", 1)
|
|
module = importlib.import_module(mod_name)
|
|
return getattr(module, fn_name)
|
|
|
|
|
|
def authenticate_request_basic_auth() -> Authorization | Response:
|
|
"""Authenticate the request using basic auth."""
|
|
if request.authorization is None:
|
|
return make_basic_auth_response()
|
|
|
|
username = request.authorization.username
|
|
password = request.authorization.password
|
|
# When the cache is disabled, don't pay the extra get_user round-trip that
|
|
# _authenticate_cached does for the sake of cache-population — the Flask
|
|
# path only cares about the yes/no auth decision.
|
|
if _USER_AUTH_CACHE is None:
|
|
if store.authenticate_user(username, password):
|
|
return request.authorization
|
|
elif _authenticate_cached(username, password):
|
|
return request.authorization
|
|
# let user attempt login again
|
|
return make_basic_auth_response()
|
|
|
|
|
|
def _find_validator(req: Request) -> Callable[[], bool] | None:
|
|
"""
|
|
Finds the validator matching the request path and method.
|
|
"""
|
|
if "/mlflow/logged-models" in req.path:
|
|
# logged model routes are not registered in the app
|
|
# so we need to check them manually
|
|
return next(
|
|
(
|
|
v
|
|
for (pat, method), v in LOGGED_MODEL_BEFORE_REQUEST_VALIDATORS.items()
|
|
if pat.fullmatch(req.path) and method == req.method
|
|
),
|
|
None,
|
|
)
|
|
|
|
if "/mlflow/webhooks" in req.path:
|
|
# Webhook routes contain path parameters (e.g., /mlflow/webhooks/<webhook_id>)
|
|
# so we need regex matching
|
|
return next(
|
|
(
|
|
v
|
|
for (pat, method), v in WEBHOOK_BEFORE_REQUEST_VALIDATORS.items()
|
|
if pat.fullmatch(req.path) and method == req.method
|
|
),
|
|
None,
|
|
)
|
|
|
|
if validator := BEFORE_REQUEST_VALIDATORS.get((req.path, req.method)):
|
|
return validator
|
|
|
|
# Trace routes with path parameters (e.g. /mlflow/traces/<request_id>/tags).
|
|
# Unknown paths under this prefix are denied (fail-closed) rather than skipped.
|
|
if "/mlflow/traces/" in req.path:
|
|
validator = next(
|
|
(
|
|
v
|
|
for (pat, method), v in TRACE_PARAMETERIZED_BEFORE_REQUEST_VALIDATORS.items()
|
|
if pat.fullmatch(req.path) and method == req.method
|
|
),
|
|
None,
|
|
)
|
|
return validator if validator is not None else lambda: False
|
|
|
|
return None
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def _before_request():
|
|
if is_unprotected_route(request.path):
|
|
return
|
|
|
|
authorization = authenticate_request()
|
|
if isinstance(authorization, Response):
|
|
return authorization
|
|
elif not isinstance(authorization, Authorization):
|
|
raise MlflowException(
|
|
f"Unsupported result type from {auth_config.authorization_function}: "
|
|
f"'{type(authorization).__name__}'",
|
|
INTERNAL_ERROR,
|
|
)
|
|
|
|
# Expose the authenticated user to handlers (e.g. to stamp a review-queue owner).
|
|
g.mlflow_authenticated_user = authorization.username
|
|
|
|
# admins don't need to be authorized, but data-integrity rules still apply to
|
|
# them. A custom review queue (or rename) may not shadow a username; admins skip
|
|
# the validators, so the guard runs here for them (non-admins hit it post-gate
|
|
# inside the validators).
|
|
if sender_is_admin():
|
|
enforce_review_queue_name_not_username()
|
|
return
|
|
|
|
# authorization
|
|
if validator := _find_validator(request):
|
|
if not validator():
|
|
return make_forbidden_response()
|
|
elif _is_proxy_artifact_path(request.path):
|
|
if validator := _get_proxy_artifact_validator(request.method, request.view_args):
|
|
if not validator():
|
|
return make_forbidden_response()
|
|
|
|
|
|
def set_can_manage_experiment_permission(resp: Response):
|
|
response_message = CreateExperiment.Response()
|
|
parse_dict(resp.json, response_message)
|
|
experiment_id = response_message.experiment_id
|
|
username = authenticate_request().username
|
|
store.grant_user_permission(username, "experiment", experiment_id, MANAGE.name)
|
|
|
|
|
|
def set_can_manage_registered_model_permission(resp: Response):
|
|
# ``CreateRegisteredModel`` is shared with prompt creation; the response
|
|
# carries the persisted ``mlflow.prompt.is_prompt`` tag, so we can classify
|
|
# the entity authoritatively here and grant MANAGE in the matching
|
|
# namespace. Granting unconditionally on ``registered_model`` would leave
|
|
# the prompt creator without ``(prompt, name, MANAGE)`` and lock them out
|
|
# of the entity they just created via the prompt-side validators.
|
|
response_message = CreateRegisteredModel.Response()
|
|
parse_dict(resp.json, response_message)
|
|
name = response_message.registered_model.name
|
|
resource_type = (
|
|
"prompt" if _entity_is_prompt(response_message.registered_model) else "registered_model"
|
|
)
|
|
username = authenticate_request().username
|
|
store.grant_user_permission(username, resource_type, name, MANAGE.name)
|
|
|
|
|
|
def delete_can_manage_registered_model_permission(resp: Response):
|
|
"""
|
|
Sweep registered-model and prompt grants when the entity is deleted.
|
|
|
|
The model registry's primary key is the entity name (unlike experiments
|
|
which use a UUID), so a future entity with the same name would otherwise
|
|
inherit stale grants. ``DeleteRegisteredModel`` is shared between
|
|
registered models and prompts on the REST surface; the entity is already
|
|
gone by the time this after-request hook runs, so we cannot classify it
|
|
now. Names are unique within the registry, so exactly one of the two
|
|
sweeps applies and the other is a no-op.
|
|
"""
|
|
# ``silent=True`` returns ``None`` on missing / unparsable bodies; the
|
|
# ``or {}`` guard prevents a ``TypeError`` from leaking out as a 500.
|
|
data = request.get_json(force=True, silent=True) or {}
|
|
name = data.get("name")
|
|
if not name:
|
|
raise MlflowException(
|
|
"Missing value for required parameter 'name'.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
store.delete_grants_for_resource("registered_model", name, workspace_scoped=True)
|
|
store.delete_grants_for_resource("prompt", name, workspace_scoped=True)
|
|
|
|
|
|
# ---- Role management handlers (RBAC) ----
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def create_role():
|
|
name = _get_request_param("name")
|
|
workspace = _get_request_param("workspace")
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise MlflowException.invalid_parameter_value("Role name cannot be empty.")
|
|
if not isinstance(workspace, str) or not workspace.strip():
|
|
raise MlflowException.invalid_parameter_value("Workspace cannot be empty.")
|
|
body = request.get_json(silent=True) or {}
|
|
description = body.get("description")
|
|
if description is not None and not isinstance(description, str):
|
|
raise MlflowException.invalid_parameter_value("Role description must be a string or null.")
|
|
role = store.create_role(name, workspace, description)
|
|
return jsonify({"role": role.to_json()})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def get_role():
|
|
role_id = _get_int_request_param("role_id")
|
|
role = store.get_role(role_id)
|
|
return jsonify({"role": role.to_json()})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def list_roles():
|
|
# Repeated ``workspace`` scopes the listing. When omitted, fall back to cross-
|
|
# workspace listing (admin-only — enforced by validate_can_list_roles).
|
|
workspaces = request.args.getlist("workspace")
|
|
for w in workspaces:
|
|
if not isinstance(w, str) or not w.strip():
|
|
raise MlflowException.invalid_parameter_value(
|
|
"Parameter 'workspace' must be a non-empty string when provided."
|
|
)
|
|
roles = store.list_roles(workspaces) if workspaces else store.list_roles()
|
|
return jsonify({"roles": [r.to_json() for r in roles]})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def update_role():
|
|
role_id = _get_int_request_param("role_id")
|
|
body = request.get_json(silent=True) or {}
|
|
name = body.get("name")
|
|
description = body.get("description")
|
|
if name is None and description is None:
|
|
raise MlflowException.invalid_parameter_value(
|
|
"At least one of 'name' or 'description' must be provided to update a role."
|
|
)
|
|
if name is not None and (not isinstance(name, str) or not name.strip()):
|
|
raise MlflowException.invalid_parameter_value("Role name cannot be empty.")
|
|
if description is not None and not isinstance(description, str):
|
|
raise MlflowException.invalid_parameter_value("Role description must be a string.")
|
|
role = store.update_role(role_id, name=name, description=description)
|
|
return jsonify({"role": role.to_json()})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def delete_role():
|
|
role_id = _get_int_request_param("role_id")
|
|
store.delete_role(role_id)
|
|
return make_response({})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def add_role_permission():
|
|
role_id = _get_int_request_param("role_id")
|
|
resource_type = _get_request_param("resource_type")
|
|
resource_pattern = _get_request_param("resource_pattern")
|
|
permission = _get_request_param("permission")
|
|
rp = store.add_role_permission(role_id, resource_type, resource_pattern, permission)
|
|
return jsonify({"role_permission": rp.to_json()})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def remove_role_permission():
|
|
role_permission_id = _get_int_request_param("role_permission_id")
|
|
store.remove_role_permission(role_permission_id)
|
|
return make_response({})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def list_role_permissions():
|
|
role_id = _get_int_request_param("role_id")
|
|
perms = store.list_role_permissions(role_id)
|
|
return jsonify({"role_permissions": [p.to_json() for p in perms]})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def update_role_permission():
|
|
role_permission_id = _get_int_request_param("role_permission_id")
|
|
permission = _get_request_param("permission")
|
|
rp = store.update_role_permission(role_permission_id, permission)
|
|
return jsonify({"role_permission": rp.to_json()})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def assign_role():
|
|
username = _get_request_param("username")
|
|
role_id = _get_int_request_param("role_id")
|
|
user = store.get_user(username)
|
|
assignment = store.assign_role_to_user(user.id, role_id)
|
|
return jsonify({"assignment": assignment.to_json()})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def unassign_role():
|
|
username = _get_request_param("username")
|
|
role_id = _get_int_request_param("role_id")
|
|
user = store.get_user(username)
|
|
store.unassign_role_from_user(user.id, role_id)
|
|
return make_response({})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def list_user_roles():
|
|
username = _get_request_param("username")
|
|
user = store.get_user(username)
|
|
roles = store.list_user_roles(user.id)
|
|
|
|
# Filter the response to match the caller's authorization scope so we don't leak
|
|
# role/workspace membership outside what the caller can see:
|
|
# - Self or super admin: see all of the target's roles.
|
|
# - Workspace admin: see only roles in workspaces where the caller is a WP admin.
|
|
# Fetch the requester's admin workspaces once rather than querying per role.
|
|
requester = authenticate_request().username
|
|
requester_user = store.get_user(requester)
|
|
if not (requester_user.is_admin or requester == username):
|
|
admin_workspaces = store.list_workspace_admin_workspaces(requester_user.id)
|
|
roles = [r for r in roles if r.workspace in admin_workspaces]
|
|
|
|
return jsonify({"roles": [r.to_json() for r in roles]})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def list_role_users():
|
|
role_id = _get_int_request_param("role_id")
|
|
assignments = store.list_role_users(role_id)
|
|
return jsonify({"assignments": [a.to_json() for a in assignments]})
|
|
|
|
|
|
def filter_list_workspaces(resp: Response) -> None:
|
|
if sender_is_admin():
|
|
return
|
|
|
|
username = authenticate_request().username
|
|
response_message = ListWorkspaces.Response()
|
|
parse_dict(resp.json, response_message)
|
|
|
|
allowed: set[str] = set()
|
|
if username is not None:
|
|
allowed = set(store.list_accessible_workspace_names(username))
|
|
if auth_config.grant_default_workspace_access:
|
|
default_workspace, _ = get_default_workspace_optional(_get_workspace_store())
|
|
if default_workspace:
|
|
allowed.add(default_workspace.name)
|
|
|
|
filtered = [ws for ws in response_message.workspaces if ws.name in allowed]
|
|
response_message.ClearField("workspaces")
|
|
response_message.workspaces.extend(filtered)
|
|
|
|
resp.data = message_to_json(response_message)
|
|
|
|
|
|
# Default roles seeded into every new workspace when
|
|
# ``MLFLOW_RBAC_SEED_DEFAULT_ROLES`` is on. ``CreateWorkspace`` is gated to
|
|
# ``sender_is_admin``, so the creator is always a super-admin whose ``is_admin``
|
|
# flag already bypasses RBAC checks — we therefore don't assign the creator to
|
|
# any of these roles. The two roles exist as ready-made scaffolding for the
|
|
# admin to hand out to other users.
|
|
#
|
|
# Workspace permissions in the simplified model collapse to two tiers, both
|
|
# stored in the unified ``resource_type='workspace'`` slot:
|
|
# - ``admin`` (MANAGE on ``('workspace', '*')``): full authority, including
|
|
# role/user management within the workspace.
|
|
# - ``user`` (USE on ``('workspace', '*')``): read every resource in the workspace
|
|
# plus the ability to create new experiments / registered models. The
|
|
# creator-as-owner mechanism then grants the creator MANAGE on what they
|
|
# create — so users manage their own resources without gaining write or
|
|
# delete on resources owned by others.
|
|
_WORKSPACE_GRANT = ("workspace", "*")
|
|
_DEFAULT_WORKSPACE_ROLES = (
|
|
(
|
|
"admin",
|
|
_WORKSPACE_GRANT,
|
|
MANAGE.name,
|
|
"Full MANAGE authority over the workspace.",
|
|
),
|
|
(
|
|
"user",
|
|
_WORKSPACE_GRANT,
|
|
USE.name,
|
|
(
|
|
"Read every resource in the workspace and create new experiments "
|
|
"and registered models; the creator-as-owner mechanism grants "
|
|
"MANAGE on what you create, with no write or delete access to "
|
|
"resources owned by other users."
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def _seed_default_workspace_roles(resp: Response) -> None:
|
|
"""After a successful ``CreateWorkspace``, seed default RBAC roles into the new
|
|
workspace. Partial failures are logged rather than raised — the workspace
|
|
creation has already succeeded at this point.
|
|
|
|
No creator-assignment logic: the ``before_request`` handler gates
|
|
``CreateWorkspace`` to super-admins (via ``sender_is_admin``), and super-admins
|
|
already bypass RBAC checks via their ``is_admin`` flag. The seeded roles are
|
|
therefore a convenience for the admin to hand out to other users, not
|
|
something the creator needs assigned to themselves.
|
|
"""
|
|
if not MLFLOW_RBAC_SEED_DEFAULT_ROLES.get():
|
|
return
|
|
|
|
response_message = CreateWorkspace.Response()
|
|
parse_dict(resp.json, response_message)
|
|
workspace_name = response_message.workspace.name
|
|
|
|
for role_name, (
|
|
resource_type,
|
|
resource_pattern,
|
|
), permission, description in _DEFAULT_WORKSPACE_ROLES:
|
|
try:
|
|
role = store.create_role(
|
|
name=role_name, workspace=workspace_name, description=description
|
|
)
|
|
except MlflowException as e:
|
|
_logger.error(
|
|
"Failed to create default role '%s' for workspace '%s': %s",
|
|
role_name,
|
|
workspace_name,
|
|
e,
|
|
)
|
|
continue
|
|
try:
|
|
store.add_role_permission(role.id, resource_type, resource_pattern, permission)
|
|
except MlflowException as e:
|
|
_logger.error(
|
|
"Failed to add permission to default role '%s' for workspace '%s': %s. "
|
|
"Rolling back the orphan role.",
|
|
role_name,
|
|
workspace_name,
|
|
e,
|
|
)
|
|
# Remove the orphan role so the workspace doesn't end up with a named
|
|
# role that grants nothing. Best-effort: log on failure.
|
|
try:
|
|
store.delete_role(role.id)
|
|
except MlflowException as delete_err:
|
|
_logger.error(
|
|
"Failed to roll back orphan role '%s' (id=%s) for workspace '%s': %s",
|
|
role_name,
|
|
role.id,
|
|
workspace_name,
|
|
delete_err,
|
|
)
|
|
|
|
|
|
def _cleanup_workspace_permissions(resp: Response) -> None:
|
|
# This handler runs only on successful DELETE responses. Cleanup failures are logged
|
|
# instead of raised because the workspace deletion has already succeeded at this point.
|
|
workspace_name = request.view_args.get("workspace_name") if request.view_args else None
|
|
if not workspace_name:
|
|
return
|
|
|
|
try:
|
|
store.delete_workspace_permissions_for_workspace(workspace_name)
|
|
except MlflowException as e:
|
|
_logger.error(
|
|
"Failed to delete workspace permissions for workspace '%s': %s",
|
|
workspace_name,
|
|
e,
|
|
)
|
|
|
|
try:
|
|
store.delete_roles_for_workspace(workspace_name)
|
|
except MlflowException as e:
|
|
_logger.error(
|
|
"Failed to delete roles for workspace '%s': %s",
|
|
workspace_name,
|
|
e,
|
|
)
|
|
|
|
|
|
def filter_search_experiments(resp: Response):
|
|
if sender_is_admin():
|
|
return
|
|
|
|
response_message = SearchExperiments.Response()
|
|
parse_dict(resp.json, response_message)
|
|
|
|
username = authenticate_request().username
|
|
can_read = _role_based_read_predicate(username, "experiment")
|
|
# filter out unreadable
|
|
for e in list(response_message.experiments):
|
|
if not can_read(e.experiment_id):
|
|
response_message.experiments.remove(e)
|
|
|
|
# re-fetch to fill max results
|
|
request_message = _get_request_message(SearchExperiments())
|
|
while (
|
|
len(response_message.experiments) < request_message.max_results
|
|
and response_message.next_page_token != ""
|
|
):
|
|
refetched: PagedList[Experiment] = _get_tracking_store().search_experiments(
|
|
view_type=request_message.view_type,
|
|
max_results=request_message.max_results,
|
|
order_by=request_message.order_by,
|
|
filter_string=request_message.filter,
|
|
page_token=response_message.next_page_token,
|
|
)
|
|
refetched = refetched[: request_message.max_results - len(response_message.experiments)]
|
|
if len(refetched) == 0:
|
|
response_message.next_page_token = ""
|
|
break
|
|
|
|
refetched_readable_proto = [e.to_proto() for e in refetched if can_read(e.experiment_id)]
|
|
response_message.experiments.extend(refetched_readable_proto)
|
|
|
|
# recalculate next page token
|
|
start_offset = SearchUtils.parse_start_offset_from_page_token(
|
|
response_message.next_page_token
|
|
)
|
|
final_offset = start_offset + len(refetched)
|
|
response_message.next_page_token = SearchUtils.create_page_token(final_offset)
|
|
|
|
resp.data = message_to_json(response_message)
|
|
|
|
|
|
def filter_search_logged_models(resp: Response) -> None:
|
|
"""
|
|
Filter out unreadable logged models from the search results.
|
|
"""
|
|
from mlflow.utils.search_utils import SearchLoggedModelsPaginationToken as Token
|
|
|
|
if sender_is_admin():
|
|
return
|
|
|
|
response_proto = SearchLoggedModels.Response()
|
|
parse_dict(resp.json, response_proto)
|
|
|
|
username = authenticate_request().username
|
|
can_read = _role_based_read_predicate(username, "experiment")
|
|
# Remove unreadable models
|
|
for m in list(response_proto.models):
|
|
if not can_read(m.info.experiment_id):
|
|
response_proto.models.remove(m)
|
|
|
|
request_proto = _get_request_message(SearchLoggedModels())
|
|
max_results = request_proto.max_results
|
|
# These parameters won't change in the loop
|
|
params = {
|
|
"experiment_ids": list(request_proto.experiment_ids),
|
|
"filter_string": request_proto.filter or None,
|
|
"order_by": (
|
|
[
|
|
{
|
|
"field_name": ob.field_name,
|
|
"ascending": ob.ascending,
|
|
"dataset_name": ob.dataset_name,
|
|
"dataset_digest": ob.dataset_digest,
|
|
}
|
|
for ob in request_proto.order_by
|
|
]
|
|
if request_proto.order_by
|
|
else None
|
|
),
|
|
}
|
|
next_page_token = response_proto.next_page_token or None
|
|
tracking_store = _get_tracking_store()
|
|
while len(response_proto.models) < max_results and next_page_token is not None:
|
|
batch: PagedList[LoggedModel] = tracking_store.search_logged_models(
|
|
max_results=max_results, page_token=next_page_token, **params
|
|
)
|
|
is_last_page = batch.token is None
|
|
offset = Token.decode(next_page_token).offset if next_page_token else 0
|
|
last_index = len(batch) - 1
|
|
for index, model in enumerate(batch):
|
|
if not can_read(model.experiment_id):
|
|
continue
|
|
response_proto.models.append(model.to_proto())
|
|
if len(response_proto.models) >= max_results:
|
|
next_page_token = (
|
|
None
|
|
if is_last_page and index == last_index
|
|
else Token(offset=offset + index + 1, **params).encode()
|
|
)
|
|
break
|
|
else:
|
|
# If we reach here, it means we have not reached the max results.
|
|
next_page_token = (
|
|
None if is_last_page else Token(offset=offset + max_results, **params).encode()
|
|
)
|
|
|
|
if next_page_token:
|
|
response_proto.next_page_token = next_page_token
|
|
resp.data = message_to_json(response_proto)
|
|
|
|
|
|
def filter_search_registered_models(resp: Response):
|
|
if sender_is_admin():
|
|
return
|
|
|
|
response_message = SearchRegisteredModels.Response()
|
|
parse_dict(resp.json, response_message)
|
|
|
|
username = authenticate_request().username
|
|
# The registered-model REST surface is shared with prompts; classify each
|
|
# row by its ``mlflow.prompt.is_prompt`` tag and check the correct grant
|
|
# namespace. Without this, a user holding only a ``(prompt, foo, READ)``
|
|
# grant would have prompt ``foo`` silently filtered out of the response.
|
|
can_read = _rm_or_prompt_read_predicate(username)
|
|
|
|
# filter out unreadable
|
|
for rm in list(response_message.registered_models):
|
|
if not can_read(rm):
|
|
response_message.registered_models.remove(rm)
|
|
|
|
# re-fetch to fill max results
|
|
request_message = _get_request_message(SearchRegisteredModels())
|
|
while (
|
|
len(response_message.registered_models) < request_message.max_results
|
|
and response_message.next_page_token != ""
|
|
):
|
|
refetched: PagedList[RegisteredModel] = (
|
|
_get_model_registry_store().search_registered_models(
|
|
filter_string=request_message.filter,
|
|
max_results=request_message.max_results,
|
|
order_by=request_message.order_by,
|
|
page_token=response_message.next_page_token,
|
|
)
|
|
)
|
|
refetched = refetched[
|
|
: request_message.max_results - len(response_message.registered_models)
|
|
]
|
|
if len(refetched) == 0:
|
|
response_message.next_page_token = ""
|
|
break
|
|
|
|
# ``can_read`` accepts both protos and ORM entities; reuse it here so
|
|
# refetched ORM rows go through the same classification as the initial
|
|
# JSON-parsed proto rows above.
|
|
refetched_readable_proto = [rm.to_proto() for rm in refetched if can_read(rm)]
|
|
response_message.registered_models.extend(refetched_readable_proto)
|
|
|
|
# recalculate next page token
|
|
start_offset = SearchUtils.parse_start_offset_from_page_token(
|
|
response_message.next_page_token
|
|
)
|
|
final_offset = start_offset + len(refetched)
|
|
response_message.next_page_token = SearchUtils.create_page_token(final_offset)
|
|
|
|
resp.data = message_to_json(response_message)
|
|
|
|
|
|
def filter_search_model_versions(resp: Response):
|
|
if sender_is_admin():
|
|
return
|
|
|
|
response_message = SearchModelVersions.Response()
|
|
parse_dict(resp.json, response_message)
|
|
|
|
username = authenticate_request().username
|
|
# Prompt versions and model versions share the same REST surface; classify
|
|
# each row by its ``mlflow.prompt.is_prompt`` tag so a prompt-version
|
|
# carrying a ``(prompt, name, READ)`` grant isn't dropped on the floor.
|
|
can_read = _rm_or_prompt_read_predicate(username)
|
|
|
|
# filter out model versions whose parent model is unreadable
|
|
for mv in list(response_message.model_versions):
|
|
if not can_read(mv):
|
|
response_message.model_versions.remove(mv)
|
|
|
|
resp.data = message_to_json(response_message)
|
|
|
|
|
|
def rename_registered_model_permission(resp: Response):
|
|
"""
|
|
Propagate a registered-model rename to RBAC grants.
|
|
|
|
``RenameRegisteredModel`` is shared between registered models and prompts;
|
|
sweep both namespaces so a prompt rename doesn't orphan its
|
|
``(prompt, old_name, ...)`` grants. Names are unique within the registry,
|
|
so exactly one of the two renames applies and the other is a no-op.
|
|
"""
|
|
# ``silent=True`` returns ``None`` on missing / unparsable bodies; ``or
|
|
# {}`` plus the explicit value checks below prevent ``None`` from
|
|
# propagating to ``resource_pattern`` and silently rewriting rows.
|
|
data = request.get_json(force=True, silent=True) or {}
|
|
old_name = data.get("name")
|
|
new_name = data.get("new_name")
|
|
if not old_name or not new_name:
|
|
raise MlflowException(
|
|
"Missing value for required parameter 'name' or 'new_name'.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
store.rename_grants_for_resource("registered_model", old_name, new_name, workspace_scoped=True)
|
|
store.rename_grants_for_resource("prompt", old_name, new_name, workspace_scoped=True)
|
|
|
|
|
|
def set_can_manage_scorer_permission(resp: Response):
|
|
response_message = RegisterScorer.Response()
|
|
parse_dict(resp.json, response_message)
|
|
experiment_id = response_message.experiment_id
|
|
name = response_message.name
|
|
username = authenticate_request().username
|
|
# ``grant_user_permission`` is upsert, so re-registration is a no-op
|
|
# rather than an error — no try/except needed.
|
|
pattern = store._scorer_pattern(experiment_id, name)
|
|
store.grant_user_permission(username, "scorer", pattern, MANAGE.name)
|
|
|
|
|
|
def delete_scorer_permissions_cascade(resp: Response):
|
|
data = request.get_json(force=True, silent=True)
|
|
experiment_id = data.get("experiment_id")
|
|
name = data.get("name")
|
|
if experiment_id and name:
|
|
pattern = store._scorer_pattern(experiment_id, name)
|
|
store.delete_grants_for_resource("scorer", pattern)
|
|
|
|
|
|
def set_can_manage_gateway_secret_permission(resp: Response):
|
|
response_message = CreateGatewaySecret.Response()
|
|
parse_dict(resp.json, response_message)
|
|
secret_id = response_message.secret.secret_id
|
|
username = authenticate_request().username
|
|
store.grant_user_permission(username, "gateway_secret", secret_id, MANAGE.name)
|
|
|
|
|
|
def delete_gateway_secret_permissions_cascade(resp: Response):
|
|
data = request.get_json(force=True, silent=True)
|
|
if secret_id := data.get("secret_id"):
|
|
store.delete_grants_for_resource("gateway_secret", secret_id)
|
|
|
|
|
|
def set_can_manage_gateway_endpoint_permission(resp: Response):
|
|
response_message = CreateGatewayEndpoint.Response()
|
|
parse_dict(resp.json, response_message)
|
|
endpoint_id = response_message.endpoint.endpoint_id
|
|
username = authenticate_request().username
|
|
store.grant_user_permission(username, "gateway_endpoint", endpoint_id, MANAGE.name)
|
|
|
|
|
|
def delete_gateway_endpoint_permissions_cascade(resp: Response):
|
|
data = request.get_json(force=True, silent=True)
|
|
if endpoint_id := data.get("endpoint_id"):
|
|
store.delete_grants_for_resource("gateway_endpoint", endpoint_id)
|
|
|
|
|
|
def set_can_manage_gateway_model_definition_permission(resp: Response):
|
|
response_message = CreateGatewayModelDefinition.Response()
|
|
parse_dict(resp.json, response_message)
|
|
model_definition_id = response_message.model_definition.model_definition_id
|
|
username = authenticate_request().username
|
|
store.grant_user_permission(
|
|
username, "gateway_model_definition", model_definition_id, MANAGE.name
|
|
)
|
|
|
|
|
|
def delete_gateway_model_definition_permissions_cascade(resp: Response):
|
|
data = request.get_json(force=True, silent=True)
|
|
if model_definition_id := data.get("model_definition_id"):
|
|
store.delete_grants_for_resource("gateway_model_definition", model_definition_id)
|
|
|
|
|
|
def filter_list_scorers(resp: Response) -> None:
|
|
"""Filter cross-experiment ``ListScorers`` responses to rows the caller can read.
|
|
|
|
Single-experiment requests are already gated by ``validate_can_read_scorer_list``
|
|
(which delegates to ``validate_can_read_experiment``); cross-experiment requests
|
|
(empty ``experiment_id``) skip that gate so the response can carry scorers from
|
|
multiple experiments. This filter applies the experiment + scorer read
|
|
predicates per row so the picker doesn't leak names the caller has no grant on.
|
|
"""
|
|
if sender_is_admin():
|
|
return
|
|
|
|
response_message = ListScorers.Response()
|
|
parse_dict(resp.json, response_message)
|
|
|
|
username = authenticate_request().username
|
|
can_read_experiment = _role_based_read_predicate(username, "experiment")
|
|
can_read_scorer = _role_based_read_predicate(username, "scorer")
|
|
for scorer in list(response_message.scorers):
|
|
exp_id = str(scorer.experiment_id)
|
|
if not can_read_experiment(exp_id):
|
|
response_message.scorers.remove(scorer)
|
|
continue
|
|
if not can_read_scorer(store._scorer_pattern(exp_id, scorer.scorer_name)):
|
|
response_message.scorers.remove(scorer)
|
|
resp.data = message_to_json(response_message)
|
|
|
|
|
|
AFTER_REQUEST_PATH_HANDLERS = {
|
|
CreateExperiment: set_can_manage_experiment_permission,
|
|
CreateRegisteredModel: set_can_manage_registered_model_permission,
|
|
DeleteRegisteredModel: delete_can_manage_registered_model_permission,
|
|
SearchExperiments: filter_search_experiments,
|
|
SearchLoggedModels: filter_search_logged_models,
|
|
SearchModelVersions: filter_search_model_versions,
|
|
SearchRegisteredModels: filter_search_registered_models,
|
|
RenameRegisteredModel: rename_registered_model_permission,
|
|
RegisterScorer: set_can_manage_scorer_permission,
|
|
ListScorers: filter_list_scorers,
|
|
DeleteScorer: delete_scorer_permissions_cascade,
|
|
ListReviewQueues: filter_list_review_queues,
|
|
CreateGatewaySecret: set_can_manage_gateway_secret_permission,
|
|
DeleteGatewaySecret: delete_gateway_secret_permissions_cascade,
|
|
CreateGatewayEndpoint: set_can_manage_gateway_endpoint_permission,
|
|
DeleteGatewayEndpoint: delete_gateway_endpoint_permissions_cascade,
|
|
CreateGatewayModelDefinition: set_can_manage_gateway_model_definition_permission,
|
|
DeleteGatewayModelDefinition: delete_gateway_model_definition_permissions_cascade,
|
|
ListWorkspaces: filter_list_workspaces,
|
|
CreateWorkspace: _seed_default_workspace_roles,
|
|
DeleteWorkspace: _cleanup_workspace_permissions,
|
|
}
|
|
|
|
|
|
def get_after_request_handler(request_class):
|
|
return AFTER_REQUEST_PATH_HANDLERS.get(request_class)
|
|
|
|
|
|
_AJAX_GATEWAY_PATHS = frozenset([
|
|
GATEWAY_SUPPORTED_PROVIDERS,
|
|
GATEWAY_SUPPORTED_MODELS,
|
|
GATEWAY_PROVIDER_CONFIG,
|
|
GATEWAY_SECRETS_CONFIG,
|
|
INVOKE_SCORER,
|
|
])
|
|
|
|
AFTER_REQUEST_HANDLERS = {
|
|
(http_path, method): handler
|
|
for http_path, handler, methods in get_endpoints(get_after_request_handler)
|
|
for method in methods
|
|
if handler is not None
|
|
and "/graphql" not in http_path
|
|
and "/scorers/online-config" not in http_path
|
|
and "/mlflow/server-info" not in http_path
|
|
and http_path not in _AJAX_GATEWAY_PATHS
|
|
}
|
|
|
|
# Precompile workspace parameterized paths for after-request handlers.
|
|
WORKSPACE_PARAMETERIZED_AFTER_REQUEST_HANDLERS = {
|
|
(_re_compile_path(path), method): handler
|
|
for (path, method), handler in AFTER_REQUEST_HANDLERS.items()
|
|
if "<" in path and "/workspaces/" in path
|
|
}
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def _after_request(resp: Response):
|
|
if 400 <= resp.status_code < 600:
|
|
return resp
|
|
|
|
handler = AFTER_REQUEST_HANDLERS.get((request.path, request.method))
|
|
if handler is None and "/workspaces/" in request.path:
|
|
# Fallback to regex matching for workspace paths.
|
|
for (path, method), candidate in WORKSPACE_PARAMETERIZED_AFTER_REQUEST_HANDLERS.items():
|
|
if method != request.method:
|
|
continue
|
|
if path.fullmatch(request.path):
|
|
handler = candidate
|
|
break
|
|
|
|
if handler is not None:
|
|
handler(resp)
|
|
return resp
|
|
|
|
|
|
def create_admin_user(username, password):
|
|
if not store.has_user(username):
|
|
try:
|
|
store.create_user(username, password, is_admin=True)
|
|
_logger.info(
|
|
f"Created admin user '{username}'. "
|
|
"It is recommended that you set a new password as soon as possible "
|
|
f"on {UPDATE_USER_PASSWORD}."
|
|
)
|
|
except MlflowException as e:
|
|
if isinstance(e.__cause__, sqlalchemy.exc.IntegrityError):
|
|
# When multiple workers are starting up at the same time, it's possible
|
|
# that they try to create the admin user at the same time and one of them
|
|
# will succeed while the others will fail with an IntegrityError.
|
|
return
|
|
raise
|
|
|
|
|
|
# Must match the admin_password shipped in mlflow/server/auth/basic_auth.ini.
|
|
_DEFAULT_ADMIN_PASSWORD = "password1234"
|
|
|
|
|
|
def _warn_if_default_admin_password(password):
|
|
if password == _DEFAULT_ADMIN_PASSWORD:
|
|
_logger.warning(
|
|
"The MLflow basic auth admin account is using the default password shipped "
|
|
"in basic_auth.ini. Change it before exposing this server beyond localhost. "
|
|
"To override, set the MLFLOW_AUTH_CONFIG_PATH environment variable to point "
|
|
"to a custom basic_auth.ini with a non-default admin_password, or update the "
|
|
f"password via {UPDATE_USER_PASSWORD} after startup."
|
|
)
|
|
|
|
|
|
def alert(href: str):
|
|
return render_template_string(
|
|
r"""
|
|
<script type = "text/javascript">
|
|
{% with messages = get_flashed_messages() %}
|
|
{% if messages %}
|
|
{% for message in messages %}
|
|
alert("{{ message }}");
|
|
{% endfor %}
|
|
{% endif %}
|
|
{% endwith %}
|
|
window.location.href = "{{ href }}";
|
|
</script>
|
|
""",
|
|
href=href,
|
|
)
|
|
|
|
|
|
def signup():
|
|
return render_template_string(
|
|
r"""
|
|
<style>
|
|
form {
|
|
background-color: #F5F5F5;
|
|
border: 1px solid #CCCCCC;
|
|
border-radius: 4px;
|
|
padding: 20px;
|
|
max-width: 400px;
|
|
margin: 0 auto;
|
|
font-family: Arial, sans-serif;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
input[type=text], input[type=password] {
|
|
width: 100%;
|
|
padding: 10px;
|
|
margin-bottom: 10px;
|
|
border: 1px solid #CCCCCC;
|
|
border-radius: 4px;
|
|
box-sizing: border-box;
|
|
}
|
|
input[type=submit] {
|
|
background-color: rgb(34, 114, 180);
|
|
color: #FFFFFF;
|
|
border: none;
|
|
border-radius: 4px;
|
|
padding: 10px 20px;
|
|
cursor: pointer;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
input[type=submit]:hover {
|
|
background-color: rgb(14, 83, 139);
|
|
}
|
|
|
|
.logo-container {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.logo {
|
|
max-width: 150px;
|
|
margin-right: 10px;
|
|
}
|
|
</style>
|
|
|
|
<form action="{{ users_route }}" method="post">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
<div class="logo-container">
|
|
{% autoescape false %}
|
|
{{ mlflow_logo }}
|
|
{% endautoescape %}
|
|
</div>
|
|
<label for="username">Username:</label>
|
|
<br>
|
|
<input type="text" id="username" name="username" minlength="4">
|
|
<br>
|
|
<label for="password">Password:</label>
|
|
<br>
|
|
<input type="password" id="password" name="password" minlength="12">
|
|
<br>
|
|
<br>
|
|
<input type="submit" value="Sign up">
|
|
</form>
|
|
""",
|
|
mlflow_logo=MLFLOW_LOGO,
|
|
users_route=CREATE_USER_UI,
|
|
)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def create_user_ui(csrf):
|
|
csrf.protect()
|
|
content_type = request.headers.get("Content-Type")
|
|
if content_type == "application/x-www-form-urlencoded":
|
|
username = request.form["username"]
|
|
password = request.form["password"]
|
|
|
|
if not username or not password:
|
|
message = "Username and password cannot be empty."
|
|
return make_response(message, 400)
|
|
|
|
if store.has_user(username):
|
|
flash(f"Username has already been taken: {username}")
|
|
return alert(href=SIGNUP)
|
|
|
|
store.create_user(username, password)
|
|
flash(f"Successfully signed up user: {username}")
|
|
return alert(href=HOME)
|
|
else:
|
|
message = "Invalid content type. Must be application/x-www-form-urlencoded"
|
|
return make_response(message, 400)
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def create_user():
|
|
if not request.is_json:
|
|
return make_response("Invalid content type. Must be application/json", 400)
|
|
|
|
username = _get_request_param("username")
|
|
password = _get_request_param("password")
|
|
|
|
if not username or not password:
|
|
return make_response("Username and password cannot be empty.", 400)
|
|
|
|
user = store.create_user(username, password)
|
|
return jsonify({"user": user.to_json()})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def get_user():
|
|
username = _get_request_param("username")
|
|
user = store.get_user(username)
|
|
return jsonify({"user": user.to_json()})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def list_users():
|
|
# Eager-load each user's roles in one batch so the admin Users tab doesn't
|
|
# have to fan out per-user requests. Per-user roles are scoped to what the
|
|
# caller is allowed to see, mirroring ``list_user_roles``:
|
|
# - super admin / self → every role
|
|
# - workspace admin → roles in workspaces they administer
|
|
users_with_roles = store.list_users_with_roles()
|
|
requester = authenticate_request().username
|
|
requester_user = store.get_user(requester)
|
|
admin_workspaces: set[str] | None = (
|
|
None
|
|
if requester_user.is_admin
|
|
else store.list_workspace_admin_workspaces(requester_user.id)
|
|
)
|
|
response_users = []
|
|
for u, roles in users_with_roles:
|
|
if admin_workspaces is None or u.username == requester:
|
|
visible_roles = roles
|
|
else:
|
|
visible_roles = [r for r in roles if r.workspace in admin_workspaces]
|
|
response_users.append({
|
|
"id": u.id,
|
|
"username": u.username,
|
|
"is_admin": u.is_admin,
|
|
"roles": [r.to_json() for r in visible_roles],
|
|
})
|
|
return jsonify({"users": response_users})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def get_current_user():
|
|
# HTTP Basic Auth doesn't set any identifying cookie, so the frontend has
|
|
# no way to know *which* user the browser authenticated as. This endpoint
|
|
# returns minimal identity info (no password hash, no permission arrays)
|
|
# for the currently authenticated user.
|
|
#
|
|
# ``is_basic_auth`` lets the frontend gate Basic-Auth-only affordances
|
|
# (the logout XHR trick and the change-password modal) on deployments
|
|
# that swap in a custom ``authorization_function``.
|
|
username = authenticate_request().username
|
|
user = store.get_user(username)
|
|
is_basic_auth = auth_config.authorization_function == DEFAULT_AUTHORIZATION_FUNCTION
|
|
return jsonify({
|
|
"user": {"id": user.id, "username": user.username, "is_admin": user.is_admin},
|
|
"is_basic_auth": is_basic_auth,
|
|
})
|
|
|
|
|
|
@dataclass
|
|
class _UserRolePermissionRow:
|
|
"""One row of ``GET /users/permissions/list``: a single grant on one of the
|
|
user's roles, enriched with role identity so the frontend can split the
|
|
"Direct permissions" view (rows whose ``role_name`` matches the synthetic
|
|
``__user_<id>__`` pattern) from the "Role permissions" view.
|
|
"""
|
|
|
|
role_id: int
|
|
role_name: str
|
|
workspace: str
|
|
resource_type: str
|
|
resource_pattern: str
|
|
permission: str
|
|
|
|
|
|
def _list_user_role_permissions(username: str) -> tuple[bool, list[_UserRolePermissionRow]]:
|
|
"""Flatten every role-derived permission grant the user holds.
|
|
|
|
Returns ``(is_admin, rows)`` where ``rows`` covers all roles the user is
|
|
assigned to (including the synthetic ``__user_<id>__`` role used by the
|
|
``grant`` / ``revoke`` convenience APIs). The ``workspace`` of each row is
|
|
the role's workspace; because resource-level grants are written into roles
|
|
scoped to the resource's workspace, that's equivalent to the resource's
|
|
workspace.
|
|
"""
|
|
user = store.get_user(username)
|
|
rows = [
|
|
_UserRolePermissionRow(
|
|
role_id=role.id,
|
|
role_name=role.name,
|
|
workspace=role.workspace,
|
|
resource_type=rp.resource_type,
|
|
resource_pattern=rp.resource_pattern,
|
|
permission=rp.permission,
|
|
)
|
|
for role in store.list_user_roles(user.id)
|
|
for rp in role.permissions
|
|
]
|
|
return user.is_admin, rows
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def list_current_user_permissions():
|
|
# Sender == target. Returns every permission grant across every role the
|
|
# user holds, plus ``is_admin`` at the top level so the frontend can show
|
|
# admin status without a second call to ``/users/get``.
|
|
username = authenticate_request().username
|
|
is_admin, rows = _list_user_role_permissions(username)
|
|
return jsonify({"is_admin": is_admin, "permissions": [asdict(r) for r in rows]})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def list_user_permissions():
|
|
# Admin / self / workspace-admin-of-target view of one user's permissions
|
|
# across every role they hold. Workspace admins see only rows in workspaces
|
|
# they administer; super admins and self see everything. Mirrors
|
|
# ``list_user_roles``'s response-scoping.
|
|
username = _get_request_param("username")
|
|
is_admin, rows = _list_user_role_permissions(username)
|
|
|
|
requester = authenticate_request().username
|
|
requester_user = store.get_user(requester)
|
|
if not (requester_user.is_admin or requester == username):
|
|
admin_workspaces = store.list_workspace_admin_workspaces(requester_user.id)
|
|
rows = [r for r in rows if r.workspace in admin_workspaces]
|
|
|
|
return jsonify({"is_admin": is_admin, "permissions": [asdict(r) for r in rows]})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def update_user_password():
|
|
username = _get_request_param("username")
|
|
password = _get_request_param("password")
|
|
# Self-service flows re-assert current_password so a hijacked browser
|
|
# session can't silently rotate it. Admin paths skip this check.
|
|
sender = authenticate_request()
|
|
sender_username = getattr(sender, "username", None)
|
|
if sender_username == username:
|
|
body = request.get_json(silent=True) or {}
|
|
current_password = body.get("current_password")
|
|
if not current_password:
|
|
raise MlflowException(
|
|
"Current password is required when changing your own password.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
if not store.authenticate_user(username, current_password):
|
|
raise MlflowException(
|
|
"Current password does not match.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
# Self-service only: equality avoids re-running bcrypt, and admin
|
|
# paths skip it so they don't leak a same-vs-different oracle.
|
|
if password == current_password:
|
|
raise MlflowException(
|
|
"New password must differ from the current password.",
|
|
INVALID_PARAMETER_VALUE,
|
|
)
|
|
store.update_user(username, password=password)
|
|
_invalidate_user_auth_cache(username)
|
|
return make_response({})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def update_user_admin():
|
|
username = _get_request_param("username")
|
|
is_admin = _get_request_param("is_admin")
|
|
store.update_user(username, is_admin=is_admin)
|
|
_invalidate_user_auth_cache(username)
|
|
return make_response({})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def delete_user():
|
|
username = _get_request_param("username")
|
|
# Admins cannot delete their own account — like blocking ``root`` from
|
|
# ``userdel root`` on Unix. Without this guard the request still succeeds
|
|
# but leaves the caller in a broken state (browser still has Basic Auth
|
|
# creds for a now-missing user, so every subsequent request 401s).
|
|
#
|
|
# ``authenticate_request()`` can return a ``Response`` (401 challenge)
|
|
# rather than an ``Authorization`` object, so guard the ``.username``
|
|
# access with ``getattr`` — same pattern ``update_user_password`` uses.
|
|
sender = authenticate_request()
|
|
sender_username = getattr(sender, "username", None)
|
|
if username == sender_username:
|
|
raise MlflowException(
|
|
"Users cannot delete their own account. Ask another admin to delete this user instead.",
|
|
BAD_REQUEST,
|
|
)
|
|
store.delete_user(username)
|
|
_invalidate_user_auth_cache(username)
|
|
return make_response({})
|
|
|
|
|
|
# =============================================================================
|
|
# Unified per-user permission convenience handlers
|
|
# =============================================================================
|
|
|
|
|
|
# Workspace rejection + resource_type / permission validation live on the store
|
|
# layer (sqlalchemy_store.grant/revoke_user_resource_permission); the handlers
|
|
# defer so both paths share one source of truth.
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def grant_user_permission():
|
|
username = _get_request_param("username")
|
|
resource_type = _get_request_param("resource_type")
|
|
resource_id = _get_request_param("resource_id")
|
|
permission = _get_request_param("permission")
|
|
store.get_user(username)
|
|
store.grant_user_resource_permission(username, resource_type, resource_id, permission)
|
|
return make_response({})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def revoke_user_permission():
|
|
username = _get_request_param("username")
|
|
resource_type = _get_request_param("resource_type")
|
|
resource_id = _get_request_param("resource_id")
|
|
store.get_user(username)
|
|
store.revoke_user_resource_permission(username, resource_type, resource_id)
|
|
return make_response({})
|
|
|
|
|
|
@catch_mlflow_exception
|
|
def get_user_permission():
|
|
username = _get_request_param("username")
|
|
resource_type = _get_request_param("resource_type")
|
|
resource_id = _get_request_param("resource_id")
|
|
# Unknown *users* and unsupported resource_types raise 4xx; unknown *resources*
|
|
# (e.g. nonexistent experiment_id) intentionally return ``allowed=False`` —
|
|
# matches the deny-by-default semantics of the runtime authorization check.
|
|
store.get_user(username)
|
|
permission = _resolve_user_permission_for_resource(username, resource_type, resource_id)
|
|
# ``allowed`` mirrors ``can_use`` (regular access tier). READ alone is not
|
|
# sufficient — callers needing a different cut inspect ``permission`` directly.
|
|
return make_response(
|
|
GetUserPermissionResult(allowed=permission.can_use, permission=permission.name).to_json()
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# GraphQL Authorization
|
|
# =============================================================================
|
|
|
|
_auth_initialized = False
|
|
|
|
|
|
def is_auth_enabled() -> bool:
|
|
return _auth_initialized
|
|
|
|
|
|
def _graphql_get_permission_for_experiment(experiment_id: str, username: str) -> Permission:
|
|
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",
|
|
),
|
|
)
|
|
|
|
|
|
def _graphql_get_permission_for_run(run_id: str, username: str) -> Permission:
|
|
run = _get_tracking_store().get_run(run_id)
|
|
experiment_id = run.info.experiment_id
|
|
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",
|
|
),
|
|
)
|
|
|
|
|
|
def _graphql_get_permission_for_model(model_name: str, username: str) -> Permission:
|
|
return _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="registered_model",
|
|
resource_key=model_name,
|
|
workspace_lookup_id=model_name,
|
|
workspace_fetcher=_get_model_registry_store().get_registered_model,
|
|
workspace_label="registered model",
|
|
),
|
|
)
|
|
|
|
|
|
def _graphql_can_read_experiment(experiment_id: str, username: str) -> bool:
|
|
return _graphql_get_permission_for_experiment(experiment_id, username).can_read
|
|
|
|
|
|
def _graphql_can_read_run(run_id: str, username: str) -> bool:
|
|
return _graphql_get_permission_for_run(run_id, username).can_read
|
|
|
|
|
|
def _graphql_can_read_model(model_name: str, username: str) -> bool:
|
|
return _graphql_get_permission_for_model(model_name, username).can_read
|
|
|
|
|
|
class GraphQLAuthorizationMiddleware:
|
|
"""
|
|
Graphene middleware that enforces per-object authorization for GraphQL queries.
|
|
|
|
This middleware checks user permissions before resolving protected fields.
|
|
It integrates with MLflow's basic-auth permission system.
|
|
"""
|
|
|
|
PROTECTED_FIELDS = {
|
|
"mlflowGetExperiment",
|
|
"mlflowGetRun",
|
|
"mlflowListArtifacts",
|
|
"mlflowGetMetricHistoryBulkInterval",
|
|
"mlflowSearchRuns",
|
|
"mlflowSearchDatasets",
|
|
"mlflowSearchModelVersions",
|
|
}
|
|
|
|
def resolve(self, next, root, info, **args):
|
|
"""
|
|
Middleware resolve function called for every field resolution.
|
|
|
|
Args:
|
|
next: The next resolver in the chain
|
|
root: The root value object
|
|
info: GraphQL resolve info containing field name and context
|
|
args: Field arguments as keyword arguments
|
|
|
|
Returns:
|
|
The resolved value or an error response
|
|
"""
|
|
field_name = info.field_name
|
|
|
|
if field_name not in self.PROTECTED_FIELDS:
|
|
return next(root, info, **args)
|
|
|
|
try:
|
|
authorization = authenticate_request()
|
|
if isinstance(authorization, Response):
|
|
return None
|
|
username = authorization.username
|
|
|
|
if store.get_user(username).is_admin:
|
|
return next(root, info, **args)
|
|
except Exception:
|
|
_logger.warning("GraphQL authorization failed: auth system error", exc_info=True)
|
|
return None
|
|
|
|
try:
|
|
if not self._check_authorization(field_name, args, username):
|
|
_logger.debug(f"GraphQL authorization denied for {field_name} by user {username}")
|
|
return None
|
|
except MlflowException:
|
|
return None
|
|
except Exception:
|
|
_logger.warning(f"GraphQL authorization error for {field_name}", exc_info=True)
|
|
return None
|
|
|
|
result = next(root, info, **args)
|
|
return self._post_resolve(field_name, result, username) if result is not None else None
|
|
|
|
def _check_authorization(self, field_name: str, args: dict[str, Any], username: str) -> bool:
|
|
"""
|
|
Check if the user is authorized to access the requested field.
|
|
|
|
Args:
|
|
field_name: The GraphQL field being resolved
|
|
args: The field arguments
|
|
username: The authenticated username
|
|
|
|
Returns:
|
|
True if authorized, False otherwise
|
|
"""
|
|
input_obj = args.get("input")
|
|
if input_obj is None:
|
|
# No input means no specific resource to check
|
|
return True
|
|
|
|
if field_name == "mlflowGetExperiment":
|
|
if experiment_id := getattr(input_obj, "experiment_id", None):
|
|
return _graphql_can_read_experiment(experiment_id, username)
|
|
|
|
elif field_name in ("mlflowGetRun", "mlflowListArtifacts"):
|
|
if run_id := (
|
|
getattr(input_obj, "run_id", None) or getattr(input_obj, "run_uuid", None)
|
|
):
|
|
return _graphql_can_read_run(run_id, username)
|
|
|
|
elif field_name == "mlflowGetMetricHistoryBulkInterval":
|
|
run_ids = getattr(input_obj, "run_ids", None) or []
|
|
for run_id in run_ids:
|
|
if not _graphql_can_read_run(run_id, username):
|
|
return False
|
|
|
|
elif field_name in ("mlflowSearchRuns", "mlflowSearchDatasets"):
|
|
if experiment_ids := (getattr(input_obj, "experiment_ids", None) or []):
|
|
readable_ids = [
|
|
exp_id
|
|
for exp_id in experiment_ids
|
|
if _graphql_can_read_experiment(exp_id, username)
|
|
]
|
|
if not readable_ids:
|
|
return False
|
|
input_obj.experiment_ids = readable_ids
|
|
|
|
return True
|
|
|
|
def _post_resolve(self, field_name: str, result, username: str):
|
|
"""Apply post-resolution filtering on GraphQL results."""
|
|
if field_name == "mlflowSearchModelVersions":
|
|
return self._filter_model_versions_result(result, username)
|
|
return result
|
|
|
|
def _filter_model_versions_result(self, result, username: str):
|
|
"""Filter model versions the user doesn't have read access to."""
|
|
can_read = _role_based_read_predicate(username, "registered_model")
|
|
if hasattr(result, "model_versions") and result.model_versions is not None:
|
|
filtered = [mv for mv in result.model_versions if can_read(mv.name)]
|
|
del result.model_versions[:]
|
|
result.model_versions.extend(filtered)
|
|
return result
|
|
|
|
|
|
def get_graphql_authorization_middleware():
|
|
"""
|
|
Get the GraphQL authorization middleware instance if auth is enabled.
|
|
|
|
Returns:
|
|
A list containing the middleware instance if auth is enabled,
|
|
empty list otherwise. Suitable for passing to schema.execute(middleware=...).
|
|
"""
|
|
if not MLFLOW_SERVER_ENABLE_GRAPHQL_AUTH.get():
|
|
return []
|
|
if not is_auth_enabled():
|
|
return []
|
|
return [GraphQLAuthorizationMiddleware()]
|
|
|
|
|
|
# Routes that need request body to extract endpoint name for validation
|
|
_ROUTES_NEEDING_BODY = frozenset((
|
|
"/gateway/mlflow/v1/chat/completions",
|
|
"/gateway/openai/v1/chat/completions",
|
|
"/gateway/openai/v1/embeddings",
|
|
"/gateway/openai/v1/responses",
|
|
"/gateway/anthropic/v1/messages",
|
|
))
|
|
|
|
|
|
def _authenticate_fastapi_request(request: StarletteRequest) -> User | None:
|
|
"""
|
|
Authenticate request using Basic Auth.
|
|
|
|
External clients send real username/password credentials. Server-spawned job
|
|
subprocesses (e.g., online scoring) send the internal gateway token as the
|
|
password; when it matches, the user is trusted without calling
|
|
``store.authenticate_user()``.
|
|
|
|
Args:
|
|
request: The Starlette/FastAPI Request object.
|
|
|
|
Returns:
|
|
User object if authentication succeeds, None otherwise.
|
|
"""
|
|
request_path = get_routed_asgi_path(request)
|
|
|
|
# On /gateway/ routes, a coding agent's own provider key occupies the standard
|
|
# Authorization header (forwarded upstream), so MLflow credentials ride in a dedicated
|
|
# header. Prefer it there; fall back to Authorization for backward compat.
|
|
auth = None
|
|
if request_path.startswith("/gateway/"):
|
|
auth = request.headers.get(MLFLOW_GATEWAY_AUTH_HEADER)
|
|
# Treat a missing OR empty header as absent so an empty X-MLflow-Authorization
|
|
# does not shadow a valid Authorization header.
|
|
if not auth:
|
|
auth = request.headers.get("Authorization")
|
|
|
|
# Neither header present — unauthenticated.
|
|
if not auth:
|
|
return None
|
|
try:
|
|
scheme, credentials = auth.split()
|
|
if scheme.lower() != "basic":
|
|
return None
|
|
decoded = base64.b64decode(credentials).decode("ascii")
|
|
username, _, password = decoded.partition(":")
|
|
|
|
# Check if this is a trusted internal request from a job subprocess.
|
|
# The server generates a random token at startup and passes it to workers
|
|
# via _MLFLOW_INTERNAL_GATEWAY_AUTH_TOKEN. When the password matches that
|
|
# token, we trust the username without calling store.authenticate_user().
|
|
# Restrict to /gateway/ routes only so the token cannot be used as a
|
|
# master password on other endpoints (e.g. /v1/traces, /ajax-api/).
|
|
internal_token = _MLFLOW_INTERNAL_GATEWAY_AUTH_TOKEN.get()
|
|
if (
|
|
internal_token
|
|
and request_path.startswith("/gateway/")
|
|
and secrets.compare_digest(password, internal_token)
|
|
):
|
|
return store.get_user(username)
|
|
|
|
return _authenticate_cached(username, password)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _extract_gateway_endpoint_name(path: str, body: dict[str, Any] | None) -> str | None:
|
|
"""Extract endpoint name from gateway routes."""
|
|
# Pattern 1: /gateway/{endpoint_name}/mlflow/invocations
|
|
if match := re.match(r"^/gateway/([^/]+)/mlflow/invocations$", path):
|
|
return match.group(1)
|
|
|
|
# Pattern 2-6: Passthrough routes (endpoint in request body as "model")
|
|
if path in _ROUTES_NEEDING_BODY:
|
|
if body:
|
|
return body.get("model")
|
|
return None
|
|
|
|
# Pattern 7-8: Gemini routes (endpoint in URL path)
|
|
if match := re.match(r"^/gateway/gemini/v1beta/models/([^/:]+):generateContent$", path):
|
|
return match.group(1)
|
|
if match := re.match(r"^/gateway/gemini/v1beta/models/([^/:]+):streamGenerateContent$", path):
|
|
return match.group(1)
|
|
|
|
# Pattern 9: Raw proxy route (/gateway/proxy/{endpoint_name}/{path:path})
|
|
if match := re.match(r"^/gateway/proxy/([^/]+)/", path):
|
|
return match.group(1)
|
|
|
|
return None
|
|
|
|
|
|
def _validate_gateway_use_permission(endpoint_name: str, username: str) -> bool:
|
|
"""Check if the user has USE permission on the gateway endpoint."""
|
|
# TODO: we need to query endpoint ID by name from the database.
|
|
# Revisit the mutability of the endpoint name if it causes latency issues.
|
|
try:
|
|
tracking_store = _get_tracking_store()
|
|
endpoint = tracking_store.get_gateway_endpoint(name=endpoint_name)
|
|
endpoint_id = endpoint.endpoint_id
|
|
|
|
permission = _get_role_permission_or_default(
|
|
_role_permission_for(
|
|
username=username,
|
|
resource_type="gateway_endpoint",
|
|
resource_key=endpoint_id,
|
|
workspace_lookup_id=endpoint_id,
|
|
workspace_fetcher=lambda eid: _get_tracking_store().get_gateway_endpoint(
|
|
endpoint_id=eid
|
|
),
|
|
workspace_label="gateway endpoint",
|
|
),
|
|
)
|
|
return permission.can_use
|
|
except MlflowException:
|
|
return False
|
|
|
|
|
|
def _get_gateway_validator(path: str) -> Callable[[str, StarletteRequest], Awaitable[bool]] | None:
|
|
"""
|
|
Get a validator function for gateway routes.
|
|
|
|
Args:
|
|
path: The request path.
|
|
|
|
Returns:
|
|
An async validator function that takes (username, request) and returns
|
|
True if authorized, or None if no validation is needed for this route.
|
|
"""
|
|
|
|
async def validator(username: str, request: StarletteRequest) -> bool:
|
|
body = None
|
|
if path in _ROUTES_NEEDING_BODY:
|
|
try:
|
|
body = await request.json()
|
|
# Cache parsed body in request.state so route handlers can reuse it
|
|
# (request body can only be read once in Starlette/FastAPI)
|
|
request.state.cached_body = body
|
|
except Exception as e:
|
|
raise MlflowException(f"Invalid JSON payload: {e}", error_code=BAD_REQUEST)
|
|
|
|
endpoint_name = _extract_gateway_endpoint_name(path, body)
|
|
if endpoint_name is None:
|
|
raise MlflowException("No endpoint name found", error_code=BAD_REQUEST)
|
|
|
|
return _validate_gateway_use_permission(endpoint_name, username)
|
|
|
|
return validator
|
|
|
|
|
|
def _get_require_authentication_validator() -> Callable[[str, StarletteRequest], Awaitable[bool]]:
|
|
"""
|
|
Get a validator that requires authentication but grants access to any authenticated user.
|
|
|
|
Returns:
|
|
An async validator function that always returns True.
|
|
"""
|
|
|
|
async def validator(username: str, request: StarletteRequest) -> bool:
|
|
return True
|
|
|
|
return validator
|
|
|
|
|
|
def _get_otel_validator(
|
|
path: str,
|
|
) -> Callable[[str, StarletteRequest], Awaitable[bool]]:
|
|
"""
|
|
Get a validator for OpenTelemetry trace ingestion routes.
|
|
"""
|
|
|
|
async def validator(username: str, request: StarletteRequest) -> bool:
|
|
experiment_id = request.headers.get("x-mlflow-experiment-id")
|
|
if not experiment_id:
|
|
raise MlflowException(
|
|
"Missing required header: X-Mlflow-Experiment-Id", error_code=BAD_REQUEST
|
|
)
|
|
return _get_experiment_permission(experiment_id, username).can_update
|
|
|
|
return validator
|
|
|
|
|
|
def _find_fastapi_validator(path: str) -> Callable[[str, StarletteRequest], Awaitable[bool]] | None:
|
|
"""
|
|
Find the validator for a FastAPI route that bypasses Flask.
|
|
|
|
This mirrors the _find_validator pattern used in Flask's _before_request,
|
|
returning a validator function for routes that need permission checks.
|
|
|
|
Args:
|
|
path: The request path.
|
|
|
|
Returns:
|
|
An async validator function that takes (username, request) and returns
|
|
True if authorized, or None if the route is handled by Flask (WSGI).
|
|
"""
|
|
if path.startswith("/gateway/"):
|
|
return _get_gateway_validator(path)
|
|
|
|
if path.startswith("/v1/traces"):
|
|
return _get_otel_validator(path)
|
|
|
|
if path.startswith("/ajax-api/3.0/jobs"):
|
|
return _get_require_authentication_validator()
|
|
|
|
if path.startswith("/ajax-api/3.0/mlflow/assistant"):
|
|
return _get_require_authentication_validator()
|
|
|
|
return None
|
|
|
|
|
|
def add_fastapi_permission_middleware(app: FastAPI) -> None:
|
|
"""
|
|
Add permission middleware to FastAPI app for routes not handled by Flask.
|
|
|
|
This middleware mirrors the high-level logic of ``_before_request`` for routes that are
|
|
served directly by FastAPI (e.g., ``/gateway/`` routes) and thus bypass Flask's
|
|
``before_request`` hooks. It follows the same authorization flow:
|
|
|
|
1. Skip unprotected routes
|
|
2. Find the appropriate validator for the route
|
|
3. Reject if custom authorization_function is configured (not supported for FastAPI routes)
|
|
4. Authenticate the request
|
|
5. Allow admins full access
|
|
6. Run the validator
|
|
|
|
Args:
|
|
app: The FastAPI application instance.
|
|
"""
|
|
|
|
@app.middleware("http")
|
|
async def fastapi_permission_middleware(request, call_next):
|
|
path = get_routed_asgi_path(request)
|
|
|
|
# Skip unprotected routes
|
|
if is_unprotected_route(path):
|
|
return await call_next(request)
|
|
|
|
# Find validator for this route
|
|
validator = _find_fastapi_validator(path)
|
|
if validator is None:
|
|
return await call_next(request)
|
|
|
|
# Check for custom authorization_function (only affects routes with validators)
|
|
if auth_config.authorization_function != DEFAULT_AUTHORIZATION_FUNCTION:
|
|
return PlainTextResponse(
|
|
f"Custom authorization_function '{auth_config.authorization_function}' is not "
|
|
f"supported for FastAPI routes (e.g., /gateway/ endpoints). Only the default "
|
|
f"Basic Auth function is supported. Please use "
|
|
f"'{DEFAULT_AUTHORIZATION_FUNCTION}' or disable the AI Gateway feature.",
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
# Authenticate user
|
|
user = _authenticate_fastapi_request(request)
|
|
if user is None:
|
|
return PlainTextResponse(
|
|
"You are not authenticated. Please see "
|
|
"https://www.mlflow.org/docs/latest/auth/index.html#authenticating-to-mlflow "
|
|
"on how to authenticate.",
|
|
status_code=HTTPStatus.UNAUTHORIZED,
|
|
headers={"WWW-Authenticate": 'Basic realm="mlflow"'},
|
|
)
|
|
|
|
# Store user info in request state for downstream handlers (e.g., gateway tracing)
|
|
request.state.username = user.username
|
|
request.state.user_id = user.id
|
|
|
|
# Admins have full access
|
|
if user.is_admin:
|
|
return await call_next(request)
|
|
|
|
# The workspace-context middleware registered in ``create_fastapi_app`` runs
|
|
# *inside* this middleware (Starlette runs the most recently added middleware
|
|
# first), so the request workspace is not resolved yet when validators execute.
|
|
# Workspace-scoped lookups inside validators (e.g. resolving a gateway endpoint
|
|
# by name for the USE check) would fail and deny every non-admin request when
|
|
# workspaces are enabled. Resolve and set the workspace for the validator run,
|
|
# mirroring ``workspace_context_middleware``.
|
|
try:
|
|
workspace = resolve_workspace_for_request_if_enabled(
|
|
path, request.headers.get(WORKSPACE_HEADER_NAME)
|
|
)
|
|
except MlflowException as e:
|
|
return JSONResponse(
|
|
status_code=e.get_http_status_code(),
|
|
content=json.loads(e.serialize_as_json()),
|
|
)
|
|
workspace_context.set_server_request_workspace(workspace.name if workspace else None)
|
|
|
|
# Run the validator
|
|
try:
|
|
if not await validator(user.username, request):
|
|
return PlainTextResponse(
|
|
"Permission denied",
|
|
status_code=HTTPStatus.FORBIDDEN,
|
|
)
|
|
except MlflowException as e:
|
|
return PlainTextResponse(
|
|
e.message,
|
|
status_code=e.get_http_status_code(),
|
|
)
|
|
finally:
|
|
workspace_context.clear_server_request_workspace()
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
# Role management routes (RBAC). Each route is exposed at both the REST path (Python
|
|
# client) and the AJAX path (MLflow frontend). Registration loop lives inside create_app.
|
|
_RBAC_ROUTES: list[tuple[Callable[[], Any], str, str, str]] = [
|
|
(create_role, "POST", CREATE_ROLE, AJAX_CREATE_ROLE),
|
|
(get_role, "GET", GET_ROLE, AJAX_GET_ROLE),
|
|
(list_roles, "GET", LIST_ROLES, AJAX_LIST_ROLES),
|
|
(update_role, "PATCH", UPDATE_ROLE, AJAX_UPDATE_ROLE),
|
|
(delete_role, "DELETE", DELETE_ROLE, AJAX_DELETE_ROLE),
|
|
(add_role_permission, "POST", ADD_ROLE_PERMISSION, AJAX_ADD_ROLE_PERMISSION),
|
|
(remove_role_permission, "DELETE", REMOVE_ROLE_PERMISSION, AJAX_REMOVE_ROLE_PERMISSION),
|
|
(list_role_permissions, "GET", LIST_ROLE_PERMISSIONS, AJAX_LIST_ROLE_PERMISSIONS),
|
|
(update_role_permission, "PATCH", UPDATE_ROLE_PERMISSION, AJAX_UPDATE_ROLE_PERMISSION),
|
|
(assign_role, "POST", ASSIGN_ROLE, AJAX_ASSIGN_ROLE),
|
|
(unassign_role, "DELETE", UNASSIGN_ROLE, AJAX_UNASSIGN_ROLE),
|
|
(list_user_roles, "GET", LIST_USER_ROLES, AJAX_LIST_USER_ROLES),
|
|
(list_role_users, "GET", LIST_ROLE_USERS, AJAX_LIST_ROLE_USERS),
|
|
# Unified per-user permission convenience APIs under /mlflow/users/permissions/*.
|
|
# ``grant`` / ``revoke`` mutate state (POST); ``check`` resolves the user's
|
|
# effective permission without touching the DB (GET).
|
|
(grant_user_permission, "POST", GRANT_USER_PERMISSION, AJAX_GRANT_USER_PERMISSION),
|
|
(revoke_user_permission, "POST", REVOKE_USER_PERMISSION, AJAX_REVOKE_USER_PERMISSION),
|
|
(get_user_permission, "GET", GET_USER_PERMISSION, AJAX_GET_USER_PERMISSION),
|
|
]
|
|
|
|
|
|
def create_app(app: Flask = app):
|
|
"""
|
|
A factory to enable authentication and authorization for the MLflow server.
|
|
|
|
Args:
|
|
app: The Flask app to enable authentication and authorization for.
|
|
|
|
Returns:
|
|
The app with authentication and authorization enabled.
|
|
"""
|
|
global _auth_initialized
|
|
|
|
_logger.warning(
|
|
"This feature is still experimental and may change in a future release without warning"
|
|
)
|
|
|
|
# a secret key is required for flashing, and also for
|
|
# CSRF protection. it's important that this is a static key,
|
|
# otherwise CSRF validation won't work across workers.
|
|
secret_key = MLFLOW_FLASK_SERVER_SECRET_KEY.get()
|
|
if not secret_key:
|
|
raise MlflowException(
|
|
"A static secret key needs to be set for CSRF protection. Please set the "
|
|
"`MLFLOW_FLASK_SERVER_SECRET_KEY` environment variable before starting the "
|
|
"server. For example:\n\n"
|
|
"export MLFLOW_FLASK_SERVER_SECRET_KEY='my-secret-key'\n\n"
|
|
"If you are using multiple servers, please ensure this key is consistent between "
|
|
"them, in order to prevent validation issues."
|
|
)
|
|
app.secret_key = secret_key
|
|
|
|
# we only need to protect the CREATE_USER_UI route, since that's
|
|
# the only browser-accessible route. the rest are client / REST
|
|
# APIs that do not have access to the CSRF token for validation
|
|
app.config["WTF_CSRF_CHECK_DEFAULT"] = False
|
|
csrf = CSRFProtect()
|
|
csrf.init_app(app)
|
|
|
|
store.init_db(
|
|
auth_config.database_uri,
|
|
read_db_uri=auth_config.read_database_uri,
|
|
)
|
|
create_admin_user(auth_config.admin_username, auth_config.admin_password)
|
|
_warn_if_default_admin_password(auth_config.admin_password)
|
|
|
|
_auth_initialized = True
|
|
|
|
app.add_url_rule(
|
|
rule=SIGNUP,
|
|
view_func=signup,
|
|
methods=["GET"],
|
|
)
|
|
app.add_url_rule(
|
|
rule=CREATE_USER_UI,
|
|
view_func=lambda: create_user_ui(csrf),
|
|
methods=["POST"],
|
|
)
|
|
for rule in [CREATE_USER, AJAX_CREATE_USER]:
|
|
app.add_url_rule(
|
|
rule=rule,
|
|
view_func=create_user,
|
|
methods=["POST"],
|
|
)
|
|
for rule in [GET_USER, AJAX_GET_USER]:
|
|
app.add_url_rule(
|
|
rule=rule,
|
|
view_func=get_user,
|
|
methods=["GET"],
|
|
)
|
|
for rule in [LIST_USERS, AJAX_LIST_USERS]:
|
|
app.add_url_rule(
|
|
rule=rule,
|
|
view_func=list_users,
|
|
methods=["GET"],
|
|
)
|
|
for rule in [GET_CURRENT_USER, AJAX_GET_CURRENT_USER]:
|
|
app.add_url_rule(
|
|
rule=rule,
|
|
view_func=get_current_user,
|
|
methods=["GET"],
|
|
)
|
|
for rule in [LIST_CURRENT_USER_PERMISSIONS, AJAX_LIST_CURRENT_USER_PERMISSIONS]:
|
|
app.add_url_rule(
|
|
rule=rule,
|
|
view_func=list_current_user_permissions,
|
|
methods=["GET"],
|
|
)
|
|
for rule in [LIST_USER_PERMISSIONS, AJAX_LIST_USER_PERMISSIONS]:
|
|
app.add_url_rule(
|
|
rule=rule,
|
|
view_func=list_user_permissions,
|
|
methods=["GET"],
|
|
)
|
|
for rule in [UPDATE_USER_PASSWORD, AJAX_UPDATE_USER_PASSWORD]:
|
|
app.add_url_rule(
|
|
rule=rule,
|
|
view_func=update_user_password,
|
|
methods=["PATCH"],
|
|
)
|
|
for rule in [UPDATE_USER_ADMIN, AJAX_UPDATE_USER_ADMIN]:
|
|
app.add_url_rule(
|
|
rule=rule,
|
|
view_func=update_user_admin,
|
|
methods=["PATCH"],
|
|
)
|
|
for rule in [DELETE_USER, AJAX_DELETE_USER]:
|
|
app.add_url_rule(
|
|
rule=rule,
|
|
view_func=delete_user,
|
|
methods=["DELETE"],
|
|
)
|
|
# Role management routes (RBAC) — see _RBAC_ROUTES at module scope.
|
|
for view_func, method, rest_path, ajax_path in _RBAC_ROUTES:
|
|
for path in (rest_path, ajax_path):
|
|
app.add_url_rule(rule=path, view_func=view_func, methods=[method])
|
|
|
|
app.before_request(_before_request)
|
|
app.after_request(_after_request)
|
|
|
|
if _MLFLOW_SGI_NAME.get() == "uvicorn":
|
|
fastapi_app = create_fastapi_app(app)
|
|
add_fastapi_permission_middleware(fastapi_app)
|
|
return fastapi_app
|
|
else:
|
|
return app
|