Files
2026-07-13 13:22:34 +08:00

3930 lines
158 KiB
Python

import json
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock
import pytest
from flask import Response, request
from mlflow.environment_variables import MLFLOW_ENABLE_WORKSPACES
from mlflow.exceptions import MlflowException
from mlflow.prompt.constants import IS_PROMPT_TAG_KEY
from mlflow.protos.databricks_pb2 import RESOURCE_DOES_NOT_EXIST
from mlflow.server import auth as auth_module
from mlflow.server.auth.permissions import EDIT, MANAGE, NO_PERMISSIONS, READ, USE
from mlflow.server.auth.routes import (
CREATE_PROMPTLAB_RUN,
GET_ARTIFACT,
GET_METRIC_HISTORY_BULK,
GET_METRIC_HISTORY_BULK_INTERVAL,
GET_MODEL_VERSION_ARTIFACT,
GET_TRACE_ARTIFACT,
GET_TRACE_ARTIFACT_V3,
SEARCH_DATASETS,
UPLOAD_ARTIFACT,
)
from mlflow.server.auth.sqlalchemy_store import SqlAlchemyStore
from mlflow.utils import workspace_context
from tests.helper_functions import random_str
def test_cleanup_workspace_permissions_handler(monkeypatch):
mock_delete_workspace_perms = Mock()
mock_delete_roles = Mock()
monkeypatch.setattr(
auth_module.store,
"delete_workspace_permissions_for_workspace",
mock_delete_workspace_perms,
raising=True,
)
monkeypatch.setattr(
auth_module.store,
"delete_roles_for_workspace",
mock_delete_roles,
raising=True,
)
workspace_name = f"team-{random_str(10)}"
with auth_module.app.test_request_context(
f"/api/3.0/mlflow/workspaces/{workspace_name}", method="DELETE"
):
request.view_args = {"workspace_name": workspace_name}
response = Response(status=204)
auth_module._after_request(response)
mock_delete_workspace_perms.assert_called_once_with(workspace_name)
mock_delete_roles.assert_called_once_with(workspace_name)
def _create_workspace_response(workspace_name: str) -> Response:
payload = {"workspace": {"name": workspace_name}}
return Response(json.dumps(payload), status=201, content_type="application/json")
def test_seed_default_workspace_roles_happy_path(monkeypatch):
monkeypatch.setenv("MLFLOW_RBAC_SEED_DEFAULT_ROLES", "true")
workspace_name = f"team-{random_str(10)}"
created_roles: list[dict[str, object]] = []
added_perms: list[dict[str, object]] = []
def fake_create_role(name, workspace, description=None):
role_id = len(created_roles) + 1
created_roles.append({
"id": role_id,
"name": name,
"workspace": workspace,
"description": description,
})
return SimpleNamespace(id=role_id, name=name, workspace=workspace)
def fake_add_role_permission(role_id, resource_type, resource_pattern, permission):
added_perms.append({
"role_id": role_id,
"resource_type": resource_type,
"resource_pattern": resource_pattern,
"permission": permission,
})
return SimpleNamespace(id=role_id)
monkeypatch.setattr(auth_module.store, "create_role", fake_create_role, raising=True)
monkeypatch.setattr(
auth_module.store, "add_role_permission", fake_add_role_permission, raising=True
)
with auth_module.app.test_request_context("/api/3.0/mlflow/workspaces", method="POST"):
auth_module._seed_default_workspace_roles(_create_workspace_response(workspace_name))
names = [r["name"] for r in created_roles]
assert names == ["admin", "user"]
assert all(r["workspace"] == workspace_name for r in created_roles)
# The simplified two-tier model lives in a single ``resource_type='workspace'``
# slot: ``admin`` carries MANAGE (admin grant), ``user`` carries USE (regular
# member). The permission tier distinguishes the two without needing a separate
# ``resource_type`` discriminant.
assert [(p["resource_type"], p["permission"]) for p in added_perms] == [
("workspace", MANAGE.name),
("workspace", USE.name),
]
assert all(p["resource_pattern"] == "*" for p in added_perms)
def test_seed_default_workspace_roles_disabled_skips_seeding(monkeypatch):
# With seeding off, no roles are created. ``CreateWorkspace`` is gated to
# super-admins so the creator already bypasses RBAC — there is nothing to
# fall back to.
monkeypatch.setenv("MLFLOW_RBAC_SEED_DEFAULT_ROLES", "false")
workspace_name = f"team-{random_str(10)}"
mock_create_role = Mock()
mock_add_role_permission = Mock()
mock_assign_role_to_user = Mock()
mock_set_workspace_permission = Mock()
monkeypatch.setattr(auth_module.store, "create_role", mock_create_role, raising=True)
monkeypatch.setattr(
auth_module.store, "add_role_permission", mock_add_role_permission, raising=True
)
monkeypatch.setattr(
auth_module.store, "assign_role_to_user", mock_assign_role_to_user, raising=True
)
monkeypatch.setattr(
auth_module.store,
"set_workspace_permission",
mock_set_workspace_permission,
raising=True,
)
with auth_module.app.test_request_context("/api/3.0/mlflow/workspaces", method="POST"):
auth_module._seed_default_workspace_roles(_create_workspace_response(workspace_name))
mock_create_role.assert_not_called()
mock_add_role_permission.assert_not_called()
mock_assign_role_to_user.assert_not_called()
mock_set_workspace_permission.assert_not_called()
def test_seed_default_workspace_roles_admin_creation_fails_still_seeds_others(monkeypatch):
# Best-effort seeding: a failure on one role doesn't block the rest.
monkeypatch.setenv("MLFLOW_RBAC_SEED_DEFAULT_ROLES", "true")
workspace_name = f"team-{random_str(10)}"
def fake_create_role(name, workspace, description=None):
if name == "admin":
raise MlflowException("simulated admin role failure")
return SimpleNamespace(id=10, name=name, workspace=workspace)
mock_add_role_permission = Mock()
monkeypatch.setattr(auth_module.store, "create_role", fake_create_role, raising=True)
monkeypatch.setattr(
auth_module.store, "add_role_permission", mock_add_role_permission, raising=True
)
with auth_module.app.test_request_context("/api/3.0/mlflow/workspaces", method="POST"):
auth_module._seed_default_workspace_roles(_create_workspace_response(workspace_name))
# ``user`` still got created (best-effort seeding).
assert mock_add_role_permission.call_count == 1
def test_seed_default_workspace_roles_permission_add_fails_rolls_back_role(monkeypatch):
# create_role succeeds but add_role_permission raises — the orphan role must be
# deleted so the workspace doesn't end up with a named role that grants nothing.
monkeypatch.setenv("MLFLOW_RBAC_SEED_DEFAULT_ROLES", "true")
workspace_name = f"team-{random_str(10)}"
def fake_create_role(name, workspace, description=None):
return SimpleNamespace(
id={"admin": 1, "user": 2}[name],
name=name,
workspace=workspace,
)
def fake_add_role_permission(role_id, resource_type, resource_pattern, permission):
if role_id == 1: # admin
raise MlflowException("simulated add_role_permission failure")
return SimpleNamespace(id=role_id)
mock_delete_role = Mock()
monkeypatch.setattr(auth_module.store, "create_role", fake_create_role, raising=True)
monkeypatch.setattr(
auth_module.store, "add_role_permission", fake_add_role_permission, raising=True
)
monkeypatch.setattr(auth_module.store, "delete_role", mock_delete_role, raising=True)
with auth_module.app.test_request_context("/api/3.0/mlflow/workspaces", method="POST"):
auth_module._seed_default_workspace_roles(_create_workspace_response(workspace_name))
# Orphan admin role (id=1) was rolled back.
mock_delete_role.assert_called_once_with(1)
class _TrackingStore:
def __init__(
self,
experiment_workspaces: dict[str, str],
run_experiments: dict[str, str],
trace_experiments: dict[str, str],
experiment_names: dict[str, str] | None = None,
logged_model_experiments: dict[str, str] | None = None,
gateway_secret_workspaces: dict[str, str] | None = None,
gateway_endpoint_workspaces: dict[str, str] | None = None,
gateway_model_def_workspaces: dict[str, str] | None = None,
engine=None,
ManagedSessionMaker=None,
):
self._experiment_workspaces = experiment_workspaces
self._run_experiments = run_experiments
self._trace_experiments = trace_experiments
self._experiment_names = experiment_names or {}
self._logged_model_experiments = logged_model_experiments or {}
self._gateway_secret_workspaces = gateway_secret_workspaces or {}
self._gateway_endpoint_workspaces = gateway_endpoint_workspaces or {}
self._gateway_model_def_workspaces = gateway_model_def_workspaces or {}
self.engine = engine
self.ManagedSessionMaker = ManagedSessionMaker
def get_experiment(self, experiment_id: str):
if experiment_id not in self._experiment_workspaces:
raise MlflowException(
f"Experiment {experiment_id!r} not found", RESOURCE_DOES_NOT_EXIST
)
return SimpleNamespace(workspace=self._experiment_workspaces[experiment_id])
def get_experiment_by_name(self, experiment_name: str):
experiment_id = self._experiment_names.get(experiment_name)
if experiment_id is None:
return None
return SimpleNamespace(
experiment_id=experiment_id,
workspace=self._experiment_workspaces[experiment_id],
)
def get_run(self, run_id: str):
return SimpleNamespace(info=SimpleNamespace(experiment_id=self._run_experiments[run_id]))
def get_trace_info(self, request_id: str):
return SimpleNamespace(experiment_id=self._trace_experiments[request_id])
def get_logged_model(self, model_id: str):
experiment_id = self._logged_model_experiments[model_id]
return SimpleNamespace(experiment_id=experiment_id)
def get_secret_info(self, secret_id: str | None = None, secret_name: str | None = None):
if secret_id:
if secret_id not in self._gateway_secret_workspaces:
raise MlflowException(
f"GatewaySecret not found ({secret_id})",
error_code=RESOURCE_DOES_NOT_EXIST,
)
# Add workspace attribute so _get_resource_workspace can extract it
return SimpleNamespace(
secret_id=secret_id, workspace=self._gateway_secret_workspaces[secret_id]
)
raise ValueError("Must provide secret_id or secret_name")
def get_gateway_endpoint(self, endpoint_id: str | None = None, name: str | None = None):
# For test simplicity we treat ``name`` as a synonym for ``endpoint_id``
# (our fixture data uses the same string for both). This mirrors how the
# real store resolves a name → id lookup before returning the endpoint.
if lookup_id := (endpoint_id or name):
if lookup_id not in self._gateway_endpoint_workspaces:
raise MlflowException(
f"GatewayEndpoint not found ({lookup_id})",
error_code=RESOURCE_DOES_NOT_EXIST,
)
# Add workspace attribute so _get_resource_workspace can extract it
return SimpleNamespace(
endpoint_id=lookup_id, workspace=self._gateway_endpoint_workspaces[lookup_id]
)
raise ValueError("Must provide endpoint_id or name")
def get_gateway_model_definition(
self, model_definition_id: str | None = None, name: str | None = None
):
if model_definition_id:
if model_definition_id not in self._gateway_model_def_workspaces:
raise MlflowException(
f"GatewayModelDefinition not found ({model_definition_id})",
error_code=RESOURCE_DOES_NOT_EXIST,
)
# Add workspace attribute so _get_resource_workspace can extract it
return SimpleNamespace(
model_definition_id=model_definition_id,
workspace=self._gateway_model_def_workspaces[model_definition_id],
)
raise ValueError("Must provide model_definition_id or name")
def _create_mock_session(self):
"""Create a mock session that can query gateway SQL models."""
mock_session = MagicMock()
def _filter_by_secret_id(secret_id):
if secret_id in self._gateway_secret_workspaces:
mock_result = MagicMock()
mock_result.first.return_value = SimpleNamespace(
workspace=self._gateway_secret_workspaces[secret_id]
)
return mock_result
mock_result = MagicMock()
mock_result.first.return_value = None
return mock_result
def _filter_by_endpoint_id(endpoint_id):
if endpoint_id in self._gateway_endpoint_workspaces:
mock_result = MagicMock()
mock_result.first.return_value = SimpleNamespace(
workspace=self._gateway_endpoint_workspaces[endpoint_id]
)
return mock_result
mock_result = MagicMock()
mock_result.first.return_value = None
return mock_result
def _filter_by_model_def_id(model_definition_id):
if model_definition_id in self._gateway_model_def_workspaces:
mock_result = MagicMock()
mock_result.first.return_value = SimpleNamespace(
workspace=self._gateway_model_def_workspaces[model_definition_id]
)
return mock_result
mock_result = MagicMock()
mock_result.first.return_value = None
return mock_result
def _query(model_class):
mock_query_result = MagicMock()
# Mock the filter method to return different results based on the filter
def _mock_filter(*args, **kwargs):
if "secret_id" in kwargs:
return _filter_by_secret_id(kwargs["secret_id"])
elif "endpoint_id" in kwargs:
return _filter_by_endpoint_id(kwargs["endpoint_id"])
elif "model_definition_id" in kwargs:
return _filter_by_model_def_id(kwargs["model_definition_id"])
return mock_query_result
mock_query_result.filter = _mock_filter
return mock_query_result
mock_session.query = _query
return mock_session
def _create_mock_session_maker(self):
"""Create a mock ManagedSessionMaker context manager."""
@contextmanager
def _mock_session_maker():
yield self._create_mock_session()
return _mock_session_maker
class _RegistryStore:
def __init__(self, model_workspaces: dict[str, str], prompts: set[str] | None = None):
self._model_workspaces = model_workspaces
self._prompts = prompts or set()
def get_registered_model(self, name: str):
if name not in self._model_workspaces:
raise MlflowException(
f"Registered Model with name={name!r} not found",
error_code=RESOURCE_DOES_NOT_EXIST,
)
is_prompt = name in self._prompts
return SimpleNamespace(
workspace=self._model_workspaces[name],
_is_prompt=lambda: is_prompt,
)
@pytest.fixture
def workspace_permission_setup(tmp_path, monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(default_permission=NO_PERMISSIONS.name),
)
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
username = "alice"
auth_store.create_user(username, "supersecurepassword", is_admin=False)
tracking_store = _TrackingStore(
experiment_workspaces={"exp-1": "team-a", "exp-2": "team-a", "1": "team-a"},
run_experiments={"run-1": "exp-1", "run-2": "exp-2"},
trace_experiments={"trace-1": "exp-1"},
experiment_names={"Primary Experiment": "exp-1"},
logged_model_experiments={"model-1": "exp-1"},
gateway_secret_workspaces={"secret-1": "team-a", "secret-2": "team-a"},
gateway_endpoint_workspaces={"endpoint-1": "team-a", "endpoint-2": "team-a"},
gateway_model_def_workspaces={"model-def-1": "team-a", "model-def-2": "team-a"},
engine=MagicMock(), # Mock engine for SQL model queries
)
# Set ManagedSessionMaker after creating the store
tracking_store.ManagedSessionMaker = tracking_store._create_mock_session_maker()
monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store)
registry_store = _RegistryStore({"model-xyz": "team-a"})
monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store)
monkeypatch.setattr(
auth_module,
"authenticate_request",
lambda: SimpleNamespace(username=username),
)
auth_store.set_workspace_permission("team-a", username, MANAGE.name)
with workspace_context.WorkspaceContext("team-a"):
yield {"store": auth_store, "username": username}
auth_store.engine.dispose()
def _set_workspace_permission(store: SqlAlchemyStore, username: str, permission: str):
"""Replace the user's workspace grant on ``team-a`` with ``permission``.
The ``workspace_permission_setup`` fixture pre-grants MANAGE so each test
starts from a known authority; this helper rewrites that grant. ``permission
== NO_PERMISSIONS`` is treated as "clear the grant" since the simplified
model rejects NO_PERMISSIONS as a workspace-grant value — absence of a
grant combined with ``default_permission=NO_PERMISSIONS`` produces the same
deny semantics the explicit row used to provide.
"""
if permission == NO_PERMISSIONS.name:
try:
store.delete_workspace_permission("team-a", username)
except MlflowException:
pass
return
store.set_workspace_permission("team-a", username, permission)
def test_filter_list_workspaces_includes_default_when_autogrant(monkeypatch):
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
auth = SimpleNamespace(username="alice")
monkeypatch.setattr(auth_module, "authenticate_request", lambda: auth)
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(
grant_default_workspace_access=True,
default_permission=READ.name,
),
raising=False,
)
default_workspace = "team-default"
monkeypatch.setattr(auth_module, "_get_workspace_store", lambda: None, raising=False)
monkeypatch.setattr(
auth_module,
"get_default_workspace_optional",
lambda *args, **kwargs: (SimpleNamespace(name=default_workspace), True),
raising=False,
)
class DummyStore:
def list_accessible_workspace_names(self, username):
return []
monkeypatch.setattr(auth_module, "store", DummyStore(), raising=False)
response = Response(
json.dumps({
"workspaces": [
{"name": default_workspace},
{"name": "other-workspace"},
]
}),
mimetype="application/json",
)
auth_module.filter_list_workspaces(response)
payload = json.loads(response.get_data(as_text=True))
assert payload["workspaces"] == [{"name": default_workspace}]
def test_filter_list_workspaces_filters_to_allowed(monkeypatch):
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
auth = SimpleNamespace(username="alice")
monkeypatch.setattr(auth_module, "authenticate_request", lambda: auth)
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(
grant_default_workspace_access=False,
),
raising=False,
)
class DummyStore:
def list_accessible_workspace_names(self, username):
return ["team-a"]
monkeypatch.setattr(auth_module, "store", DummyStore(), raising=False)
response = Response(
json.dumps({"workspaces": [{"name": "team-a"}, {"name": "team-b"}]}),
mimetype="application/json",
)
auth_module.filter_list_workspaces(response)
payload = json.loads(response.get_data(as_text=True))
assert [ws["name"] for ws in payload["workspaces"]] == ["team-a"]
def test_list_workspaces_hides_workspace_with_only_synthetic_resource_grant(tmp_path, monkeypatch):
# Pins the docs scenario: a user with only a per-resource grant (e.g.
# ``(experiment, exp-456, EDIT)``) inside team-a does NOT see team-a in
# ``mlflow.list_workspaces()`` — workspace visibility requires a
# workspace-scoped role assignment, not a synthetic per-resource grant.
#
# The grant lives on Carol's synthetic ``__user_<id>__`` role with a
# single ``(experiment, exp-456, EDIT)`` row — no ``(workspace, *)``
# grant. ``list_accessible_workspace_names`` should treat this as "no
# workspace-level access" and filter team-a out.
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(grant_default_workspace_access=False),
raising=False,
)
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
auth_store.create_user("carol", "supersecurepassword", is_admin=False)
# Direct per-resource grant via ``grant_user_permission`` — writes to
# Carol's synthetic ``__user_<id>__`` role in team-a. This is exactly the
# surface the admin UI's "Direct permissions" section sits on top of.
with workspace_context.WorkspaceContext("team-a"):
auth_store.grant_user_permission("carol", "experiment", "exp-456", "EDIT")
monkeypatch.setattr(
auth_module, "authenticate_request", lambda: SimpleNamespace(username="carol")
)
response = Response(
json.dumps({"workspaces": [{"name": "team-a"}, {"name": "team-b"}]}),
mimetype="application/json",
)
auth_module.filter_list_workspaces(response)
payload = json.loads(response.get_data(as_text=True))
# protobuf-to-JSON omits empty repeated fields, so an empty list of
# workspaces serializes as ``{}``. Both forms mean "Carol sees no
# workspaces", which is what the docs claim.
assert [ws["name"] for ws in payload.get("workspaces", [])] == [], (
"A direct-permission-only user must not see team-a in list_workspaces; "
"only workspace-scoped role assignments confer visibility."
)
auth_store.engine.dispose()
def test_list_workspaces_filters_to_role_assigned_workspaces(tmp_path, monkeypatch):
# End-to-end guard for the list_accessible_workspace_names fix: alice has NO
# legacy workspace_permissions rows — her only workspace membership is via a
# role assignment in ws-alpha. The ListWorkspaces filter must treat that role
# assignment as workspace visibility and surface ws-alpha but not ws-beta.
# Before the fix, the legacy-only query returned an empty set and alice saw
# no workspaces in the UI.
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(grant_default_workspace_access=False),
raising=False,
)
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
alice = auth_store.create_user("alice", "supersecurepassword", is_admin=False)
role = auth_store.create_role(name="viewer", workspace="ws-alpha")
auth_store.add_role_permission(role.id, "experiment", "*", READ.name)
auth_store.assign_role_to_user(alice.id, role.id)
monkeypatch.setattr(
auth_module, "authenticate_request", lambda: SimpleNamespace(username="alice")
)
response = Response(
json.dumps({"workspaces": [{"name": "ws-alpha"}, {"name": "ws-beta"}]}),
mimetype="application/json",
)
auth_module.filter_list_workspaces(response)
payload = json.loads(response.get_data(as_text=True))
assert [ws["name"] for ws in payload["workspaces"]] == ["ws-alpha"]
auth_store.engine.dispose()
def test_validate_can_view_workspace_allows_default_autogrant(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
auth = SimpleNamespace(username="alice")
monkeypatch.setattr(auth_module, "authenticate_request", lambda: auth)
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(
grant_default_workspace_access=True,
default_permission=READ.name,
),
raising=False,
)
default_workspace = "team-default"
monkeypatch.setattr(auth_module, "_get_workspace_store", lambda: None, raising=False)
monkeypatch.setattr(
auth_module,
"get_default_workspace_optional",
lambda *args, **kwargs: (SimpleNamespace(name=default_workspace), True),
raising=False,
)
class DummyStore:
def list_accessible_workspace_names(self, username):
return []
monkeypatch.setattr(auth_module, "store", DummyStore(), raising=False)
with auth_module.app.test_request_context(
f"/api/3.0/mlflow/workspaces/{default_workspace}", method="GET"
):
request.view_args = {"workspace_name": default_workspace}
assert auth_module.validate_can_view_workspace()
with auth_module.app.test_request_context(
"/api/3.0/mlflow/workspaces/other-team", method="GET"
):
request.view_args = {"workspace_name": "other-team"}
assert not auth_module.validate_can_view_workspace()
def test_experiment_validators_allow_manage_permission(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, MANAGE.name)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/experiments/get", method="GET", query_string={"experiment_id": "exp-1"}
):
assert auth_module.validate_can_read_experiment()
assert auth_module.validate_can_update_experiment()
assert auth_module.validate_can_delete_experiment()
assert auth_module.validate_can_manage_experiment()
with auth_module.app.test_request_context(
"/api/2.0/mlflow/experiments/get-by-name",
method="GET",
query_string={"experiment_name": "Primary Experiment"},
):
assert auth_module.validate_can_read_experiment_by_name()
with workspace_context.WorkspaceContext("team-a"):
assert auth_module.validate_can_create_experiment()
def test_experiment_validators_allow_role_based_workspace_manage(workspace_permission_setup):
# Grant MANAGE on the workspace via a role (with no legacy
# ``workspace_permissions`` row). Pre-fix this returned 403 — the
# workspace-level permission check only consulted the legacy table.
# ``_workspace_permission`` now max-merges role-based grants.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
# Drop the legacy grant the fixture installs so we exercise the role path
# in isolation.
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="ws-admin", workspace="team-a")
store.add_role_permission(role.id, "workspace", "*", MANAGE.name)
user = store.get_user(username)
store.assign_role_to_user(user.id, role.id)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/experiments/get", method="GET", query_string={"experiment_id": "exp-1"}
):
assert auth_module.validate_can_read_experiment()
assert auth_module.validate_can_update_experiment()
assert auth_module.validate_can_delete_experiment()
assert auth_module.validate_can_manage_experiment()
with workspace_context.WorkspaceContext("team-a"):
assert auth_module.validate_can_create_experiment()
def test_experiment_validators_workspace_use_allows_create_but_blocks_reads_on_others(
workspace_permission_setup,
):
# Workspace USE confers create + workspace access, not read on others'
# resources — that needs an explicit per-resource grant.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, USE.name)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/experiments/get", method="GET", query_string={"experiment_id": "exp-1"}
):
assert not auth_module.validate_can_read_experiment()
assert not auth_module.validate_can_update_experiment()
assert not auth_module.validate_can_delete_experiment()
assert not auth_module.validate_can_manage_experiment()
with workspace_context.WorkspaceContext("team-a"):
assert auth_module.validate_can_create_experiment()
def test_workspace_permission_max_merges_legacy_and_role(workspace_permission_setup):
# Operators mid-migration may have BOTH a legacy grant and a role grant.
# The effective permission must be the higher of the two — neither side
# should silently downgrade the other.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
# Legacy USE + role MANAGE → effective MANAGE.
_set_workspace_permission(store, username, USE.name)
role = store.create_role(name="ws-admin", workspace="team-a")
store.add_role_permission(role.id, "workspace", "*", MANAGE.name)
user = store.get_user(username)
store.assign_role_to_user(user.id, role.id)
with workspace_context.WorkspaceContext("team-a"):
assert auth_module.validate_can_create_experiment()
def test_use_workspace_permission_allows_create_but_blocks_reads_and_writes_on_others(
workspace_permission_setup,
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, USE.name)
with workspace_context.WorkspaceContext("team-a"):
assert auth_module.validate_can_create_experiment()
assert auth_module.validate_can_create_registered_model()
with auth_module.app.test_request_context(
"/api/2.0/mlflow/experiments/get", method="GET", query_string={"experiment_id": "exp-1"}
):
assert not auth_module.validate_can_read_experiment()
assert not auth_module.validate_can_update_experiment()
assert not auth_module.validate_can_delete_experiment()
assert not auth_module.validate_can_manage_experiment()
with auth_module.app.test_request_context(
"/api/2.0/mlflow/experiments/get-by-name",
method="GET",
query_string={"experiment_name": "Primary Experiment"},
):
assert not auth_module.validate_can_read_experiment_by_name()
with (
workspace_context.WorkspaceContext("team-a"),
auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/get",
method="GET",
query_string={"name": "model-xyz"},
),
):
assert not auth_module.validate_can_read_registered_model()
assert not auth_module.validate_can_update_registered_model()
assert not auth_module.validate_can_delete_registered_model()
assert not auth_module.validate_can_manage_registered_model()
def test_no_permissions_blocks_create(workspace_permission_setup):
# Without any access to the workspace, create is denied.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with workspace_context.WorkspaceContext("team-a"):
assert not auth_module.validate_can_create_experiment()
assert not auth_module.validate_can_create_registered_model()
def test_role_grant_workspace_use_allows_create(workspace_permission_setup, monkeypatch):
# Workspace-wide USE grant ('workspace', '*', USE) confers create rights.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
user_id = store.get_user(username).id
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
# Drop the legacy grant the fixture installs so we exercise the role-only
# path in isolation.
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="ws-contributor", workspace="team-a")
store.add_role_permission(role.id, "workspace", "*", USE.name)
store.assign_role_to_user(user_id, role.id)
with workspace_context.WorkspaceContext("team-a"):
assert auth_module.validate_can_create_experiment()
assert auth_module.validate_can_create_registered_model()
def test_role_grant_resource_type_use_does_not_allow_create(
workspace_permission_setup, monkeypatch
):
# Resource-specific USE on ``experiment`` doesn't confer workspace-wide
# create rights — only workspace-wide grants do.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
user_id = store.get_user(username).id
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="exp-user", workspace="team-a")
store.add_role_permission(role.id, "experiment", "*", USE.name)
store.assign_role_to_user(user_id, role.id)
with workspace_context.WorkspaceContext("team-a"):
assert not auth_module.validate_can_create_experiment()
assert not auth_module.validate_can_create_registered_model()
def test_experiment_artifact_proxy_validators_respect_permissions(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, MANAGE.name)
with auth_module.app.test_request_context(
"/ajax-api/2.0/mlflow-artifacts/artifacts/1/path",
method="GET",
):
request.view_args = {"artifact_path": "1/path"}
assert auth_module.validate_can_read_experiment_artifact_proxy()
assert auth_module.validate_can_update_experiment_artifact_proxy()
assert auth_module.validate_can_delete_experiment_artifact_proxy()
_set_workspace_permission(store, username, USE.name)
with auth_module.app.test_request_context(
"/ajax-api/2.0/mlflow-artifacts/artifacts/1/path",
method="GET",
):
request.view_args = {"artifact_path": "1/path"}
assert not auth_module.validate_can_read_experiment_artifact_proxy()
assert not auth_module.validate_can_update_experiment_artifact_proxy()
assert not auth_module.validate_can_delete_experiment_artifact_proxy()
def test_experiment_artifact_proxy_without_experiment_id_uses_workspace_permissions(
workspace_permission_setup,
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, USE.name)
with auth_module.app.test_request_context(
"/ajax-api/2.0/mlflow-artifacts/artifacts/uploads/path",
method="GET",
):
request.view_args = {"artifact_path": "uploads/path"}
assert auth_module.validate_can_read_experiment_artifact_proxy()
assert not auth_module.validate_can_update_experiment_artifact_proxy()
def test_experiment_artifact_proxy_without_experiment_id_denied_without_workspace_permission(
workspace_permission_setup,
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
"/ajax-api/2.0/mlflow-artifacts/artifacts/uploads/path",
method="GET",
):
request.view_args = {"artifact_path": "uploads/path"}
assert not auth_module.validate_can_read_experiment_artifact_proxy()
def test_experiment_artifact_proxy_resolves_experiment_id_under_workspace_prefix(
workspace_permission_setup,
):
# In a non-default workspace the proxied artifact path is prefixed with
# ``workspaces/<ws>/``. The experiment id must still be resolved from the path so
# per-experiment grants apply, instead of falling back to the workspace-tier grant
# (which, for a USE member, has no can_update and would wrongly reject writes).
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
# "DataScientist" shape: workspace USE (member, no can_update at the workspace tier)
# plus an explicit experiment-level EDIT grant.
_set_workspace_permission(store, username, USE.name)
store.create_experiment_permission("1", username, EDIT.name)
prefixed_path = "workspaces/team-a/1/run-1/artifacts/plots/x.png"
with auth_module.app.test_request_context(
f"/ajax-api/2.0/mlflow-artifacts/artifacts/{prefixed_path}",
method="GET",
):
request.view_args = {"artifact_path": prefixed_path}
# EDIT on the experiment resolves through the workspace prefix -> reads and
# writes are allowed even though the workspace-tier grant is only USE.
assert auth_module.validate_can_read_experiment_artifact_proxy()
assert auth_module.validate_can_update_experiment_artifact_proxy()
# EDIT does not confer delete.
assert not auth_module.validate_can_delete_experiment_artifact_proxy()
def test_filter_experiment_ids_respects_workspace_permissions(
workspace_permission_setup, monkeypatch
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
experiment_ids = ["exp-1", "exp-2"]
assert auth_module.filter_experiment_ids(experiment_ids) == experiment_ids
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
assert auth_module.filter_experiment_ids(experiment_ids) == []
def test_filter_experiment_ids_role_wildcard_grant(workspace_permission_setup, monkeypatch):
# Role granting experiment(*) in the active workspace should include all experiments.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
user_id = store.get_user(username).id
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
# Start from NO_PERMISSIONS: workspace fallback would exclude everything.
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="exp-reader", workspace="team-a")
store.add_role_permission(role.id, "experiment", "*", "READ")
store.assign_role_to_user(user_id, role.id)
token = workspace_context.set_server_request_workspace("team-a")
try:
assert auth_module.filter_experiment_ids(["exp-1", "exp-2"]) == ["exp-1", "exp-2"]
finally:
workspace_context._WORKSPACE.reset(token)
def test_filter_experiment_ids_role_specific_grant(workspace_permission_setup, monkeypatch):
# Role granting a specific experiment id should include that id only (plus direct grants).
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
user_id = store.get_user(username).id
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="exp-1-reader", workspace="team-a")
store.add_role_permission(role.id, "experiment", "exp-1", "READ")
store.assign_role_to_user(user_id, role.id)
token = workspace_context.set_server_request_workspace("team-a")
try:
# Only exp-1 (via role); exp-2 is filtered out.
assert auth_module.filter_experiment_ids(["exp-1", "exp-2"]) == ["exp-1"]
finally:
workspace_context._WORKSPACE.reset(token)
def test_filter_experiment_ids_workspace_scope_role(workspace_permission_setup, monkeypatch):
# Role with ('workspace', '*', USE) should grant read access to all experiments.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
user_id = store.get_user(username).id
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="ws-user", workspace="team-a")
store.add_role_permission(role.id, "workspace", "*", "USE")
store.assign_role_to_user(user_id, role.id)
token = workspace_context.set_server_request_workspace("team-a")
try:
assert auth_module.filter_experiment_ids(["exp-1", "exp-2"]) == ["exp-1", "exp-2"]
finally:
workspace_context._WORKSPACE.reset(token)
def test_run_validators_allow_manage_permission(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, MANAGE.name)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/runs/get", method="GET", query_string={"run_id": "run-1"}
):
assert auth_module.validate_can_read_run()
assert auth_module.validate_can_update_run()
assert auth_module.validate_can_delete_run()
assert auth_module.validate_can_manage_run()
def test_run_validators_workspace_use_blocks_reads_and_writes(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, USE.name)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/runs/get", method="GET", query_string={"run_id": "run-1"}
):
assert not auth_module.validate_can_read_run()
assert not auth_module.validate_can_update_run()
assert not auth_module.validate_can_delete_run()
assert not auth_module.validate_can_manage_run()
def test_create_model_version_source_read_blocks_cross_workspace(
workspace_permission_setup, monkeypatch
):
# Target model lives in team-a (user has MANAGE); source run/model live in team-b
# (user has no access). The source-read check must block anchoring the model version
# at a run/model the caller cannot read, even though they can update the target model.
tracking_store = _TrackingStore(
experiment_workspaces={"exp-a": "team-a", "exp-b": "team-b"},
run_experiments={"run-a": "exp-a", "run-b": "exp-b"},
trace_experiments={},
logged_model_experiments={"model-a": "exp-a", "model-b": "exp-b"},
)
monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/model-versions/create",
method="POST",
json={"name": "model-xyz", "source": "s3://bucket/x", "run_id": "run-b"},
):
assert not auth_module.validate_can_create_model_version()
with auth_module.app.test_request_context(
"/api/2.0/mlflow/model-versions/create",
method="POST",
json={"name": "model-xyz", "source": "s3://bucket/x", "model_id": "model-b"},
):
assert not auth_module.validate_can_create_model_version()
# Same-workspace source (team-a) is allowed.
with auth_module.app.test_request_context(
"/api/2.0/mlflow/model-versions/create",
method="POST",
json={"name": "model-xyz", "source": "s3://bucket/x", "run_id": "run-a"},
):
assert auth_module.validate_can_create_model_version()
# A truthy non-dict JSON body must be coerced to {} rather than raising
# AttributeError on `.get(...)`. Bypass the target-model check (which reads
# `name` from the body) to isolate the source-read coercion.
monkeypatch.setattr(
auth_module, "_validate_can_update_registered_model_or_prompt", lambda: True
)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/model-versions/create",
method="POST",
json=[1],
):
assert auth_module.validate_can_create_model_version()
def test_logged_model_validators_respect_permissions(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, MANAGE.name)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/logged-models/get",
method="GET",
query_string={"model_id": "model-1"},
):
assert auth_module.validate_can_read_logged_model()
assert auth_module.validate_can_update_logged_model()
assert auth_module.validate_can_delete_logged_model()
assert auth_module.validate_can_manage_logged_model()
_set_workspace_permission(store, username, USE.name)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/logged-models/get",
method="GET",
query_string={"model_id": "model-1"},
):
assert not auth_module.validate_can_read_logged_model()
assert not auth_module.validate_can_update_logged_model()
assert not auth_module.validate_can_delete_logged_model()
assert not auth_module.validate_can_manage_logged_model()
def test_scorer_validators_use_workspace_permissions(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, MANAGE.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/scorers/get",
method="GET",
query_string={"experiment_id": "exp-1", "name": "score-1"},
):
assert auth_module.validate_can_read_scorer()
assert auth_module.validate_can_update_scorer()
assert auth_module.validate_can_delete_scorer()
assert auth_module.validate_can_manage_scorer()
with auth_module.app.test_request_context(
"/api/3.0/mlflow/scorers/permissions/create",
method="POST",
json={
"experiment_id": "exp-1",
"scorer_name": "score-1",
"username": "bob",
"permission": "READ",
},
):
assert auth_module.validate_can_manage_scorer_permission()
def test_scorer_validators_workspace_use_blocks_reads_and_writes(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, USE.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/scorers/get",
method="GET",
query_string={"experiment_id": "exp-1", "name": "score-1"},
):
assert not auth_module.validate_can_read_scorer()
assert not auth_module.validate_can_update_scorer()
assert not auth_module.validate_can_delete_scorer()
assert not auth_module.validate_can_manage_scorer()
with auth_module.app.test_request_context(
"/api/3.0/mlflow/scorers/permissions/create",
method="POST",
json={
"experiment_id": "exp-1",
"scorer_name": "score-1",
"username": "bob",
"permission": "READ",
},
):
assert not auth_module.validate_can_manage_scorer_permission()
def test_registered_model_validators_require_manage_for_writes(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
with workspace_context.WorkspaceContext("team-a"):
_set_workspace_permission(store, username, MANAGE.name)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/get",
method="GET",
query_string={"name": "model-xyz"},
):
assert auth_module.validate_can_read_registered_model()
assert auth_module.validate_can_update_registered_model()
assert auth_module.validate_can_delete_registered_model()
assert auth_module.validate_can_manage_registered_model()
user = store.get_user(auth_module.authenticate_request().username)
assert store.is_workspace_admin(user.id, "team-a")
assert workspace_context.get_request_workspace() == "team-a"
assert auth_module.validate_can_create_registered_model()
_set_workspace_permission(store, username, USE.name)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/get",
method="GET",
query_string={"name": "model-xyz"},
):
assert not auth_module.validate_can_read_registered_model()
assert not auth_module.validate_can_update_registered_model()
assert not auth_module.validate_can_delete_registered_model()
assert not auth_module.validate_can_manage_registered_model()
# USE still confers create rights via creator-as-owner.
assert auth_module.validate_can_create_registered_model()
def test_prompt_validators_require_manage_for_writes(workspace_permission_setup, monkeypatch):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
registry_store = _RegistryStore({"prompt-xyz": "team-a"}, prompts={"prompt-xyz"})
monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store)
with workspace_context.WorkspaceContext("team-a"):
_set_workspace_permission(store, username, MANAGE.name)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/get",
method="GET",
query_string={"name": "prompt-xyz"},
):
assert auth_module.validate_can_read_prompt()
assert auth_module.validate_can_update_prompt()
assert auth_module.validate_can_delete_prompt()
assert auth_module.validate_can_manage_prompt()
_set_workspace_permission(store, username, USE.name)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/get",
method="GET",
query_string={"name": "prompt-xyz"},
):
assert not auth_module.validate_can_read_prompt()
assert not auth_module.validate_can_update_prompt()
assert not auth_module.validate_can_delete_prompt()
assert not auth_module.validate_can_manage_prompt()
def test_prompt_dispatch_routes_request_by_is_prompt_tag(workspace_permission_setup, monkeypatch):
# Shared registered-model route resolves to the prompt resource_type only when
# the entity is a prompt; the dispatching wrappers pick via
# `_get_permission_from_registered_model_or_prompt_name()` (single fetch + `._is_prompt()`).
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
registry_store = _RegistryStore(
{"prompt-xyz": "team-a", "model-xyz": "team-a"},
prompts={"prompt-xyz"},
)
monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store)
# Grant prompt READ only — NOT registered_model READ.
role = store.create_role(name="prompt-reader", workspace="team-a")
store.add_role_permission(role.id, "prompt", "prompt-xyz", "READ")
user = store.get_user(username)
store.assign_role_to_user(user.id, role.id)
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with workspace_context.WorkspaceContext("team-a"):
# The prompt name maps to the prompt validator and succeeds.
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/get",
method="GET",
query_string={"name": "prompt-xyz"},
):
assert auth_module._validate_can_read_registered_model_or_prompt()
# A non-prompt name maps to the registered_model validator and FAILS:
# pins cross-resource isolation (prompt grant ≠ registered_model grant).
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/get",
method="GET",
query_string={"name": "model-xyz"},
):
assert not auth_module._validate_can_read_registered_model_or_prompt()
def test_registered_model_grant_does_not_satisfy_prompt_request(
workspace_permission_setup, monkeypatch
):
"""The inverse of the dispatch test: a `(registered_model, foo, READ)`
grant must NOT satisfy a request for prompt `foo` after the resource_type
split.
"""
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
registry_store = _RegistryStore({"foo": "team-a"}, prompts={"foo"})
monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store)
role = store.create_role(name="rm-reader", workspace="team-a")
store.add_role_permission(role.id, "registered_model", "foo", "READ")
user = store.get_user(username)
store.assign_role_to_user(user.id, role.id)
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with workspace_context.WorkspaceContext("team-a"):
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/get",
method="GET",
query_string={"name": "foo"},
):
# registered_model grant must not leak into the prompt namespace.
assert not auth_module.validate_can_read_prompt()
def test_request_targets_prompt_is_registry_driven_not_body_driven(
workspace_permission_setup, monkeypatch
):
# Spoofing regression: classification reads the persisted tag, not the body.
# Otherwise `(prompt, foo, MANAGE)` could flip the namespace on a non-CREATE
# registered-model route via a spoofed body tag.
# ``foo`` exists as a regular registered model, not a prompt.
registry_store = _RegistryStore({"foo": "team-a"}, prompts=set())
monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store)
with workspace_context.WorkspaceContext("team-a"):
# Spoofed body tag on a non-CREATE route must NOT classify ``foo`` as a prompt.
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/delete",
method="DELETE",
json={
"name": "foo",
"tags": [{"key": "mlflow.prompt.is_prompt", "value": "true"}],
},
):
assert not auth_module._request_targets_prompt()
# Without the spoofed tag, the persisted-state lookup still says
# registered_model — body tags have no impact on classification.
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/delete",
method="DELETE",
json={"name": "foo"},
):
assert not auth_module._request_targets_prompt()
def test_request_targets_prompt_persisted_prompt_classifies_true(
workspace_permission_setup, monkeypatch
):
# Mirror of the spoofing regression: persisted `_is_prompt() == True` routes
# to the prompt validator regardless of the body.
registry_store = _RegistryStore({"foo": "team-a"}, prompts={"foo"})
monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store)
with workspace_context.WorkspaceContext("team-a"):
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/get",
method="GET",
query_string={"name": "foo"},
):
assert auth_module._request_targets_prompt()
def test_request_targets_prompt_unknown_entity_falls_back_to_registered_model(
workspace_permission_setup, monkeypatch
):
# Non-existent entity → False (registered-model path surfaces the 404).
# Body-tag spoof can't force the prompt namespace because the body is ignored.
registry_store = _RegistryStore({}) # empty — every lookup raises 404
monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store)
with workspace_context.WorkspaceContext("team-a"):
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/create",
method="POST",
json={
"name": "new-prompt",
"tags": [{"key": "mlflow.prompt.is_prompt", "value": "true"}],
},
):
assert not auth_module._request_targets_prompt()
def test_request_targets_prompt_propagates_unexpected_errors(
workspace_permission_setup, monkeypatch
):
# Non-`RESOURCE_DOES_NOT_EXIST` errors must propagate; silencing them would
# quietly route every request down the registered-model path.
class _BrokenRegistryStore:
def get_registered_model(self, name):
raise RuntimeError("registry store backend is down")
monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: _BrokenRegistryStore())
with workspace_context.WorkspaceContext("team-a"):
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/get",
method="GET",
query_string={"name": "foo"},
):
with pytest.raises(RuntimeError, match="registry store backend is down"):
auth_module._request_targets_prompt()
def test_filter_search_registered_models_uses_prompt_grant_for_prompt_rows(
workspace_permission_setup, monkeypatch
):
# A user holding only ``(prompt, foo, READ)`` previously had prompt ``foo``
# silently filtered out of ``SearchRegisteredModels`` results because the
# filter checked the ``registered_model`` namespace exclusively. With the
# per-row classify, the prompt grant satisfies the prompt row.
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="prompt-reader", workspace="team-a")
store.add_role_permission(role.id, "prompt", "foo", READ.name)
store.assign_role_to_user(store.get_user(username).id, role.id)
payload = json.dumps({
"registered_models": [
{"name": "foo", "tags": [{"key": IS_PROMPT_TAG_KEY, "value": "true"}]},
{"name": "bar", "tags": []},
],
"next_page_token": "",
})
flask_resp = Response(payload, mimetype="application/json")
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/search",
method="GET",
query_string={"max_results": "100"},
):
auth_module.filter_search_registered_models(flask_resp)
out = json.loads(flask_resp.get_data(as_text=True))
names = [rm["name"] for rm in out.get("registered_models", [])]
# Prompt ``foo`` is kept (grant satisfies prompt namespace); ``bar`` is filtered out.
assert names == ["foo"]
def test_filter_search_registered_models_does_not_satisfy_prompt_with_rm_grant(
workspace_permission_setup, monkeypatch
):
# Inverse direction: a ``(registered_model, foo, READ)`` grant must NOT
# leak through and make a prompt row readable. Pins cross-namespace
# isolation on the response-filtering path.
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="rm-reader", workspace="team-a")
store.add_role_permission(role.id, "registered_model", "foo", READ.name)
store.assign_role_to_user(store.get_user(username).id, role.id)
payload = json.dumps({
"registered_models": [
{"name": "foo", "tags": [{"key": IS_PROMPT_TAG_KEY, "value": "true"}]},
],
"next_page_token": "",
})
flask_resp = Response(payload, mimetype="application/json")
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/search",
method="GET",
query_string={"max_results": "100"},
):
auth_module.filter_search_registered_models(flask_resp)
out = json.loads(flask_resp.get_data(as_text=True))
assert out.get("registered_models", []) == []
def test_filter_search_model_versions_uses_prompt_grant_for_prompt_versions(
workspace_permission_setup, monkeypatch
):
# Same gap on ``SearchModelVersions``: prompt versions carry the
# ``mlflow.prompt.is_prompt`` tag and must be checked against ``prompt``
# grants, not ``registered_model`` grants.
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="prompt-reader", workspace="team-a")
store.add_role_permission(role.id, "prompt", "foo", READ.name)
store.assign_role_to_user(store.get_user(username).id, role.id)
payload = json.dumps({
"model_versions": [
{"name": "foo", "tags": [{"key": IS_PROMPT_TAG_KEY, "value": "true"}]},
{"name": "bar", "tags": []},
],
})
flask_resp = Response(payload, mimetype="application/json")
with auth_module.app.test_request_context(
"/api/2.0/mlflow/model-versions/search", method="GET"
):
auth_module.filter_search_model_versions(flask_resp)
out = json.loads(flask_resp.get_data(as_text=True))
names = [mv["name"] for mv in out.get("model_versions", [])]
assert names == ["foo"]
def test_rename_registered_model_permission_sweeps_prompt_namespace(
workspace_permission_setup,
):
# Renaming a prompt must propagate to ``(prompt, old_name, ...)`` grants.
# Without sweeping both namespaces, the rename leaves those grants orphaned
# under the old name and creates nothing under the new one.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
store.grant_user_permission(username, "prompt", "foo", READ.name)
# Confirm the seed grant landed where we expect.
user_id = store.get_user(username).id
before = {
(rp.resource_type, rp.resource_pattern)
for role in store.list_user_roles(user_id)
for rp in role.permissions
}
assert ("prompt", "foo") in before
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/rename",
method="POST",
json={"name": "foo", "new_name": "bar"},
):
auth_module.rename_registered_model_permission(Response(status=200))
after = {
(rp.resource_type, rp.resource_pattern)
for role in store.list_user_roles(user_id)
for rp in role.permissions
}
assert ("prompt", "foo") not in after
assert ("prompt", "bar") in after
def test_delete_can_manage_registered_model_permission_sweeps_prompt_namespace(
workspace_permission_setup,
):
# Deleting a prompt must sweep its ``(prompt, name, ...)`` grants.
# Without the prompt-side delete, those rows would leak permanently.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
store.grant_user_permission(username, "prompt", "foo", READ.name)
user_id = store.get_user(username).id
before = {
(rp.resource_type, rp.resource_pattern)
for role in store.list_user_roles(user_id)
for rp in role.permissions
}
assert ("prompt", "foo") in before
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/delete",
method="DELETE",
json={"name": "foo"},
):
auth_module.delete_can_manage_registered_model_permission(Response(status=200))
after = {
(rp.resource_type, rp.resource_pattern)
for role in store.list_user_roles(user_id)
for rp in role.permissions
}
assert ("prompt", "foo") not in after
def test_set_can_manage_registered_model_permission_grants_prompt_for_prompt_entity(
workspace_permission_setup,
):
# ``CreateRegisteredModel`` is shared with prompt creation. When the
# created entity is a prompt, the creator-default MANAGE grant must land
# in the ``prompt`` namespace — otherwise the prompt-side validators
# immediately lock the creator out of their own prompt.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
user_id = store.get_user(username).id
flask_resp = Response(
json.dumps({
"registered_model": {
"name": "my-prompt",
"tags": [{"key": IS_PROMPT_TAG_KEY, "value": "true"}],
}
}),
mimetype="application/json",
)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/create",
method="POST",
json={"name": "my-prompt"},
):
auth_module.set_can_manage_registered_model_permission(flask_resp)
grants = {
(rp.resource_type, rp.resource_pattern, rp.permission)
for role in store.list_user_roles(user_id)
for rp in role.permissions
}
assert ("prompt", "my-prompt", MANAGE.name) in grants
assert ("registered_model", "my-prompt", MANAGE.name) not in grants
def test_set_can_manage_registered_model_permission_grants_registered_model_for_plain_entity(
workspace_permission_setup,
):
# Inverse: a non-prompt registered model still grants in the
# ``registered_model`` namespace — pins that the new classification path
# didn't accidentally flip the default for normal models.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
user_id = store.get_user(username).id
flask_resp = Response(
json.dumps({"registered_model": {"name": "my-model", "tags": []}}),
mimetype="application/json",
)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/create",
method="POST",
json={"name": "my-model"},
):
auth_module.set_can_manage_registered_model_permission(flask_resp)
grants = {
(rp.resource_type, rp.resource_pattern, rp.permission)
for role in store.list_user_roles(user_id)
for rp in role.permissions
}
assert ("registered_model", "my-model", MANAGE.name) in grants
assert ("prompt", "my-model", MANAGE.name) not in grants
def test_filter_search_registered_models_classifies_refetched_rows(
workspace_permission_setup, monkeypatch
):
# The initial filter pass works on protos; if it doesn't fill
# ``max_results``, the loop refetches more rows as ORM
# ``RegisteredModel`` entities. Those ORM rows have ``.tags`` that hide
# the ``mlflow.prompt.is_prompt`` key, so naive ``_proto_is_prompt`` on
# them would misclassify every prompt as a registered_model. Pins that
# ``_entity_is_prompt`` dispatches to ``_is_prompt()`` on the ORM side.
from mlflow.entities.model_registry import RegisteredModel
from mlflow.entities.model_registry.registered_model_tag import RegisteredModelTag
from mlflow.store.entities import PagedList
from mlflow.utils.search_utils import SearchUtils
monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False)
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="prompt-reader", workspace="team-a")
store.add_role_permission(role.id, "prompt", "refetched-prompt", READ.name)
store.assign_role_to_user(store.get_user(username).id, role.id)
# Initial response is empty + has a next_page_token so the refetch loop
# runs. Then a fake registry returns one prompt + one registered_model.
refetched_rows = [
RegisteredModel(
name="refetched-prompt",
tags=[RegisteredModelTag(key=IS_PROMPT_TAG_KEY, value="true")],
),
RegisteredModel(name="refetched-model", tags=[]),
]
# First refetch returns the seed rows; subsequent calls return an empty
# page so the loop terminates instead of spinning on the same fake page.
calls = {"count": 0}
def fake_search(**_kwargs):
calls["count"] += 1
return PagedList(refetched_rows if calls["count"] == 1 else [], token=None)
fake_registry = SimpleNamespace(search_registered_models=fake_search)
monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: fake_registry)
# ``SearchUtils.parse_start_offset_from_page_token`` requires a real
# base64-encoded JSON token; use the project's helper so the loop's
# offset-bookkeeping doesn't reject our seed.
seed_token = SearchUtils.create_page_token(1).decode("utf-8")
flask_resp = Response(
json.dumps({"registered_models": [], "next_page_token": seed_token}),
mimetype="application/json",
)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/search",
method="GET",
query_string={"max_results": "10"},
):
auth_module.filter_search_registered_models(flask_resp)
out = json.loads(flask_resp.get_data(as_text=True))
names = [rm["name"] for rm in out.get("registered_models", [])]
# The refetched prompt row is kept (prompt grant satisfies it); the
# plain registered_model row is filtered out.
assert names == ["refetched-prompt"]
def test_delete_can_manage_registered_model_permission_rejects_missing_name(
workspace_permission_setup,
):
# ``request.get_json(silent=True)`` returns ``None`` on missing /
# unparsable bodies; the guard must surface a clean 400 instead of a
# ``TypeError`` -> 500.
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/delete",
method="DELETE",
data="", # empty body
content_type="application/json",
):
with pytest.raises(MlflowException, match="Missing value for required parameter 'name'"):
auth_module.delete_can_manage_registered_model_permission(Response(status=200))
def test_rename_registered_model_permission_rejects_missing_fields(
workspace_permission_setup,
):
# Missing ``name`` / ``new_name`` must raise INVALID_PARAMETER_VALUE
# rather than silently forwarding ``None`` to
# ``rename_grants_for_resource`` where it would corrupt grants.
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/rename",
method="POST",
json={"name": "foo"}, # no new_name
):
with pytest.raises(MlflowException, match="Missing value for required parameter"):
auth_module.rename_registered_model_permission(Response(status=200))
def test_validate_can_view_workspace_requires_access(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
with auth_module.app.test_request_context(
"/api/3.0/mlflow/workspaces/team-a",
method="GET",
):
request.view_args = {"workspace_name": "team-a"}
assert auth_module.validate_can_view_workspace()
store.delete_workspace_permission("team-a", username)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/workspaces/team-a",
method="GET",
):
request.view_args = {"workspace_name": "team-a"}
assert not auth_module.validate_can_view_workspace()
def test_run_artifact_validators_use_workspace_permissions(workspace_permission_setup):
with auth_module.app.test_request_context(
GET_ARTIFACT,
method="GET",
query_string={"run_id": "run-1"},
):
assert auth_module.validate_can_read_run_artifact()
with auth_module.app.test_request_context(
UPLOAD_ARTIFACT,
method="POST",
query_string={"run_id": "run-1"},
):
assert auth_module.validate_can_update_run_artifact()
def test_model_version_artifact_validator_uses_workspace_permissions(workspace_permission_setup):
with auth_module.app.test_request_context(
GET_MODEL_VERSION_ARTIFACT,
method="GET",
query_string={"name": "model-xyz"},
):
assert auth_module.validate_can_read_model_version_artifact()
def test_metric_history_bulk_validator_uses_workspace_permissions(workspace_permission_setup):
with auth_module.app.test_request_context(
GET_METRIC_HISTORY_BULK,
method="GET",
query_string=[("run_id", "run-1"), ("run_id", "run-2")],
):
assert auth_module.validate_can_read_metric_history_bulk()
def test_metric_history_bulk_interval_validator_uses_workspace_permissions(
workspace_permission_setup,
):
with auth_module.app.test_request_context(
GET_METRIC_HISTORY_BULK_INTERVAL,
method="GET",
query_string=[
("run_ids", "run-1"),
("run_ids", "run-2"),
("metric_key", "loss"),
],
):
assert auth_module.validate_can_read_metric_history_bulk_interval()
def test_search_datasets_validator_uses_workspace_permissions(workspace_permission_setup):
with auth_module.app.test_request_context(
SEARCH_DATASETS,
method="POST",
json={"experiment_ids": ["exp-1", "exp-2"]},
):
assert auth_module.validate_can_search_datasets()
def test_create_promptlab_run_validator_uses_workspace_permissions(workspace_permission_setup):
with auth_module.app.test_request_context(
CREATE_PROMPTLAB_RUN,
method="POST",
json={"experiment_id": "exp-2"},
):
assert auth_module.validate_can_create_promptlab_run()
@pytest.mark.parametrize("path", [GET_TRACE_ARTIFACT, GET_TRACE_ARTIFACT_V3])
def test_trace_artifact_validator_uses_workspace_permissions(workspace_permission_setup, path):
with auth_module.app.test_request_context(
path,
method="GET",
query_string={"request_id": "trace-1"},
):
assert auth_module.validate_can_read_trace_artifact()
def test_experiment_artifact_proxy_without_workspaces_falls_back_to_default(monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "false")
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(default_permission=READ.name),
raising=False,
)
monkeypatch.setattr(
auth_module,
"authenticate_request",
lambda: SimpleNamespace(username="carol"),
)
with auth_module.app.test_request_context(
"/ajax-api/2.0/mlflow-artifacts/artifacts/uploads/path",
method="GET",
):
request.view_args = {"artifact_path": "uploads/path"}
assert auth_module.validate_can_read_experiment_artifact_proxy()
def test_run_artifact_validators_denied_without_workspace_permission(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
GET_ARTIFACT,
method="GET",
query_string={"run_id": "run-1"},
):
assert not auth_module.validate_can_read_run_artifact()
with auth_module.app.test_request_context(
UPLOAD_ARTIFACT,
method="POST",
query_string={"run_id": "run-1"},
):
assert not auth_module.validate_can_update_run_artifact()
def test_model_version_artifact_validator_denied_without_workspace_permission(
workspace_permission_setup,
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
GET_MODEL_VERSION_ARTIFACT,
method="GET",
query_string={"name": "model-xyz"},
):
assert not auth_module.validate_can_read_model_version_artifact()
def test_metric_history_bulk_validator_denied_without_workspace_permission(
workspace_permission_setup,
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
GET_METRIC_HISTORY_BULK,
method="GET",
query_string=[("run_id", "run-1"), ("run_id", "run-2")],
):
assert not auth_module.validate_can_read_metric_history_bulk()
def test_metric_history_bulk_interval_validator_denied_without_workspace_permission(
workspace_permission_setup,
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
GET_METRIC_HISTORY_BULK_INTERVAL,
method="GET",
query_string=[
("run_ids", "run-1"),
("run_ids", "run-2"),
("metric_key", "loss"),
],
):
assert not auth_module.validate_can_read_metric_history_bulk_interval()
def test_search_datasets_validator_denied_without_workspace_permission(
workspace_permission_setup,
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
SEARCH_DATASETS,
method="POST",
json={"experiment_ids": ["exp-1", "exp-2"]},
):
assert not auth_module.validate_can_search_datasets()
def test_create_promptlab_run_validator_denied_without_workspace_permission(
workspace_permission_setup,
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
CREATE_PROMPTLAB_RUN,
method="POST",
json={"experiment_id": "exp-2"},
):
assert not auth_module.validate_can_create_promptlab_run()
@pytest.mark.parametrize("path", [GET_TRACE_ARTIFACT, GET_TRACE_ARTIFACT_V3])
def test_trace_artifact_validator_denied_without_workspace_permission(
workspace_permission_setup, path
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
path,
method="GET",
query_string={"request_id": "trace-1"},
):
assert not auth_module.validate_can_read_trace_artifact()
def test_cross_workspace_access_denied(workspace_permission_setup, monkeypatch):
tracking_store = _TrackingStore(
experiment_workspaces={"exp-other-ws": "team-b"},
run_experiments={"run-other-ws": "exp-other-ws"},
trace_experiments={},
)
monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/experiments/get",
method="GET",
query_string={"experiment_id": "exp-other-ws"},
):
assert not auth_module.validate_can_read_experiment()
assert not auth_module.validate_can_update_experiment()
assert not auth_module.validate_can_delete_experiment()
with auth_module.app.test_request_context(
"/api/2.0/mlflow/runs/get",
method="GET",
query_string={"run_id": "run-other-ws"},
):
assert not auth_module.validate_can_read_run()
assert not auth_module.validate_can_update_run()
def test_cross_workspace_registered_model_access_denied(workspace_permission_setup, monkeypatch):
registry_store = _RegistryStore({"model-other-ws": "team-b"})
monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/registered-models/get",
method="GET",
query_string={"name": "model-other-ws"},
):
assert not auth_module.validate_can_read_registered_model()
assert not auth_module.validate_can_update_registered_model()
assert not auth_module.validate_can_delete_registered_model()
def test_explicit_experiment_permission_overrides_workspace(
workspace_permission_setup,
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
store.create_experiment_permission("exp-1", username, READ.name)
with auth_module.app.test_request_context(
"/api/2.0/mlflow/experiments/get",
method="GET",
query_string={"experiment_id": "exp-1"},
):
assert auth_module.validate_can_read_experiment()
assert not auth_module.validate_can_update_experiment()
with auth_module.app.test_request_context(
"/api/2.0/mlflow/experiments/get",
method="GET",
query_string={"experiment_id": "exp-2"},
):
assert not auth_module.validate_can_read_experiment()
def test_cross_workspace_gateway_secret_access_denied(workspace_permission_setup, monkeypatch):
tracking_store = _TrackingStore(
experiment_workspaces={"exp-1": "team-a"},
run_experiments={},
trace_experiments={},
gateway_secret_workspaces={"secret-other-ws": "team-b"},
engine=MagicMock(),
)
tracking_store.ManagedSessionMaker = tracking_store._create_mock_session_maker()
monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/secrets/get",
method="GET",
query_string={"secret_id": "secret-other-ws"},
):
assert not auth_module.validate_can_read_gateway_secret()
assert not auth_module.validate_can_update_gateway_secret()
assert not auth_module.validate_can_delete_gateway_secret()
def test_cross_workspace_gateway_endpoint_access_denied(workspace_permission_setup, monkeypatch):
tracking_store = _TrackingStore(
experiment_workspaces={"exp-1": "team-a"},
run_experiments={},
trace_experiments={},
gateway_endpoint_workspaces={"endpoint-other-ws": "team-b"},
engine=MagicMock(),
)
tracking_store.ManagedSessionMaker = tracking_store._create_mock_session_maker()
monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/endpoints/get",
method="GET",
query_string={"endpoint_id": "endpoint-other-ws"},
):
assert not auth_module.validate_can_read_gateway_endpoint()
assert not auth_module.validate_can_update_gateway_endpoint()
assert not auth_module.validate_can_delete_gateway_endpoint()
def test_cross_workspace_gateway_model_definition_access_denied(
workspace_permission_setup, monkeypatch
):
tracking_store = _TrackingStore(
experiment_workspaces={"exp-1": "team-a"},
run_experiments={},
trace_experiments={},
gateway_model_def_workspaces={"model-def-other-ws": "team-b"},
engine=MagicMock(),
)
tracking_store.ManagedSessionMaker = tracking_store._create_mock_session_maker()
monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/model-definitions/get",
method="GET",
query_string={"model_definition_id": "model-def-other-ws"},
):
assert not auth_module.validate_can_read_gateway_model_definition()
assert not auth_module.validate_can_update_gateway_model_definition()
assert not auth_module.validate_can_delete_gateway_model_definition()
def test_workspace_permission_required_for_gateway_creation(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
# Remove workspace permission
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/endpoints/create",
method="POST",
json={"name": "test-endpoint", "model_configs": []},
):
assert not auth_module.validate_can_create_gateway_endpoint()
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/model-definitions/create",
method="POST",
json={
"name": "test-model",
"secret_id": "secret-1",
"provider": "openai",
"model_name": "gpt-4",
},
):
assert not auth_module.validate_can_create_gateway_model_definition()
# Restore workspace permission
store.set_workspace_permission("team-a", username, MANAGE.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/endpoints/create",
method="POST",
json={"name": "test-endpoint", "model_configs": []},
):
assert auth_module.validate_can_create_gateway_endpoint()
def test_prompt_optimization_job_validators_use_workspace_permissions(
workspace_permission_setup, monkeypatch
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
# Mock get_job to return a job associated with exp-1 (in team-a)
mock_job = SimpleNamespace(params='{"experiment_id": "exp-1"}')
monkeypatch.setattr(auth_module, "get_job", lambda job_id: mock_job)
_set_workspace_permission(store, username, MANAGE.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/prompt-optimization/jobs/get",
method="GET",
query_string={"job_id": "job-1"},
):
assert auth_module.validate_can_read_prompt_optimization_job()
assert auth_module.validate_can_update_prompt_optimization_job()
assert auth_module.validate_can_delete_prompt_optimization_job()
def test_prompt_optimization_job_validators_workspace_use_blocks_reads_and_writes(
workspace_permission_setup, monkeypatch
):
# Job auth gates on parent experiment permission; workspace USE no longer folds.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
mock_job = SimpleNamespace(params='{"experiment_id": "exp-1"}')
monkeypatch.setattr(auth_module, "get_job", lambda job_id: mock_job)
_set_workspace_permission(store, username, USE.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/prompt-optimization/jobs/get",
method="GET",
query_string={"job_id": "job-1"},
):
assert not auth_module.validate_can_read_prompt_optimization_job()
assert not auth_module.validate_can_update_prompt_optimization_job()
assert not auth_module.validate_can_delete_prompt_optimization_job()
def test_prompt_optimization_job_validators_denied_without_workspace_permission(
workspace_permission_setup, monkeypatch
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
# Mock get_job to return a job associated with exp-1 (in team-a)
mock_job = SimpleNamespace(params='{"experiment_id": "exp-1"}')
monkeypatch.setattr(auth_module, "get_job", lambda job_id: mock_job)
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/prompt-optimization/jobs/get",
method="GET",
query_string={"job_id": "job-1"},
):
assert not auth_module.validate_can_read_prompt_optimization_job()
assert not auth_module.validate_can_update_prompt_optimization_job()
assert not auth_module.validate_can_delete_prompt_optimization_job()
def test_graphql_permission_functions_use_workspace_permissions(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, MANAGE.name)
# Test experiment permission
assert auth_module._graphql_can_read_experiment("exp-1", username)
# Test run permission (inherits from experiment)
assert auth_module._graphql_can_read_run("run-1", username)
# Test registered model permission
assert auth_module._graphql_can_read_model("model-xyz", username)
def test_graphql_permission_functions_denied_without_workspace_permission(
workspace_permission_setup,
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
# Test experiment permission denied
assert not auth_module._graphql_can_read_experiment("exp-1", username)
# Test run permission denied (inherits from experiment)
assert not auth_module._graphql_can_read_run("run-1", username)
# Test registered model permission denied
assert not auth_module._graphql_can_read_model("model-xyz", username)
def test_cross_workspace_graphql_access_denied(workspace_permission_setup, monkeypatch):
# User has MANAGE in team-a but tries to access resources in team-b
tracking_store = _TrackingStore(
experiment_workspaces={"exp-other-ws": "team-b"},
run_experiments={"run-other-ws": "exp-other-ws"},
trace_experiments={},
)
monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store)
registry_store = _RegistryStore({"model-other-ws": "team-b"})
monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store)
username = workspace_permission_setup["username"]
# Should be denied access to resources in team-b
assert not auth_module._graphql_can_read_experiment("exp-other-ws", username)
assert not auth_module._graphql_can_read_run("run-other-ws", username)
assert not auth_module._graphql_can_read_model("model-other-ws", username)
# =============================================================================
# Role-based permission coverage for gateway resources
# =============================================================================
#
# The fixture grants workspace MANAGE by default. These tests first strip that
# grant (set to NO_PERMISSIONS) so the only path to a positive permission is
# the role assignment being exercised. That isolates the role-based resolver
# from the legacy workspace_permissions fallback.
def _assign_role_with_permission(
store: SqlAlchemyStore, username: str, workspace: str, resource_type: str, permission: str
) -> None:
"""Create a role in ``workspace`` with a wildcard grant of ``permission`` on
``resource_type``, and assign ``username`` to it.
Using ``random_str`` keeps the role names unique so multiple calls within a
single test don't collide on the (workspace, name) unique constraint.
"""
role = store.create_role(name=random_str(), workspace=workspace)
store.add_role_permission(role.id, resource_type, "*", permission)
user = store.get_user(username)
store.assign_role_to_user(user.id, role.id)
# ---- Gateway endpoint: role-based permission levels ----
@pytest.mark.parametrize(
("granted", "expected_read", "expected_delete", "expected_manage"),
[
("READ", True, False, False),
("USE", True, False, False),
("EDIT", True, False, False),
("MANAGE", True, True, True),
],
)
def test_role_grant_on_gateway_endpoint_gates_validator_capabilities(
workspace_permission_setup, granted, expected_read, expected_delete, expected_manage
):
"""A role grant at permission level ``granted`` exposes exactly the
capabilities that level implies on the endpoint validators — no more, no
less. Catches regressions where a validator starts accepting a weaker
permission than it should (or refuses a stronger one).
"""
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
# Strip the default workspace MANAGE so the only positive grant is the role.
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
_assign_role_with_permission(store, username, "team-a", "gateway_endpoint", granted)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/endpoints/get",
method="GET",
query_string={"endpoint_id": "endpoint-1"},
):
assert auth_module.validate_can_read_gateway_endpoint() is expected_read
assert auth_module.validate_can_delete_gateway_endpoint() is expected_delete
assert auth_module.validate_can_manage_gateway_endpoint() is expected_manage
def test_role_grant_read_on_gateway_endpoint_does_not_permit_use(
workspace_permission_setup,
):
"""Regression guard specific to the bug class the user called out:
a user with only READ on a gateway endpoint should not be able to *invoke*
it. USE is a stricter capability than READ and has its own validator.
"""
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
_assign_role_with_permission(store, username, "team-a", "gateway_endpoint", "READ")
# _validate_gateway_use_permission looks up the endpoint by name, resolves
# the endpoint id, then checks ``can_use`` via the permission resolver.
with auth_module.app.test_request_context("/"):
assert auth_module._validate_gateway_use_permission("endpoint-1", username) is False
def test_role_grant_use_on_gateway_endpoint_permits_use(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
_assign_role_with_permission(store, username, "team-a", "gateway_endpoint", "USE")
with auth_module.app.test_request_context("/"):
assert auth_module._validate_gateway_use_permission("endpoint-1", username) is True
@pytest.mark.parametrize(
("granted", "expected_can_use"),
[
("READ", False), # READ does not imply USE.
("USE", True),
("EDIT", True), # EDIT implies USE.
("MANAGE", True), # MANAGE implies USE.
],
)
def test_role_grant_permission_level_determines_use_capability(
workspace_permission_setup, granted, expected_can_use
):
"""Parametrized matrix for the USE capability specifically. READ should NOT
let the user invoke; every stronger permission should.
"""
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
_assign_role_with_permission(store, username, "team-a", "gateway_endpoint", granted)
with auth_module.app.test_request_context("/"):
assert (
auth_module._validate_gateway_use_permission("endpoint-1", username) is expected_can_use
)
# ---- Workspace-wide role grants on gateway resources ----
@pytest.mark.parametrize(
("granted", "expected_read", "expected_manage"),
[
# USE doesn't fold into resource lookups; MANAGE does.
("USE", False, False),
("MANAGE", True, True),
],
)
def test_role_workspace_wide_grant_folds_for_manage_only_on_gateway_endpoints(
workspace_permission_setup, granted, expected_read, expected_manage
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
_assign_role_with_permission(store, username, "team-a", "workspace", granted)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/endpoints/get",
method="GET",
query_string={"endpoint_id": "endpoint-1"},
):
assert auth_module.validate_can_read_gateway_endpoint() is expected_read
assert auth_module.validate_can_delete_gateway_endpoint() is expected_manage
assert auth_module.validate_can_manage_gateway_endpoint() is expected_manage
@pytest.mark.parametrize(
("granted", "expected_use"),
[
# USE doesn't fold into resource lookups; MANAGE does.
("USE", False),
("MANAGE", True),
],
)
def test_role_workspace_wide_grant_invocation_tier_dependent(
workspace_permission_setup, granted, expected_use
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
_assign_role_with_permission(store, username, "team-a", "workspace", granted)
with auth_module.app.test_request_context("/"):
assert auth_module._validate_gateway_use_permission("endpoint-1", username) is expected_use
# ---- Gateway secret and model definition parity ----
@pytest.mark.parametrize(
("granted", "expected_read", "expected_delete"),
[
("READ", True, False),
("EDIT", True, False),
("MANAGE", True, True),
],
)
def test_role_grant_on_gateway_secret_gates_validator(
workspace_permission_setup, granted, expected_read, expected_delete
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
_assign_role_with_permission(store, username, "team-a", "gateway_secret", granted)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/secrets/get",
method="GET",
query_string={"secret_id": "secret-1"},
):
assert auth_module.validate_can_read_gateway_secret() is expected_read
assert auth_module.validate_can_delete_gateway_secret() is expected_delete
@pytest.mark.parametrize(
("granted", "expected_read", "expected_delete"),
[
("READ", True, False),
("EDIT", True, False),
("MANAGE", True, True),
],
)
def test_role_grant_on_gateway_model_definition_gates_validator(
workspace_permission_setup, granted, expected_read, expected_delete
):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
_assign_role_with_permission(store, username, "team-a", "gateway_model_definition", granted)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/model-definitions/get",
method="GET",
query_string={"model_definition_id": "model-def-1"},
):
assert auth_module.validate_can_read_gateway_model_definition() is expected_read
assert auth_module.validate_can_delete_gateway_model_definition() is expected_delete
# ---- Cross-workspace isolation for role-based gateway grants ----
def test_role_in_other_workspace_does_not_grant_gateway_endpoint_access(
workspace_permission_setup,
):
"""A role in team-b with MANAGE on gateway_endpoints must not grant any
access when resolving an endpoint that belongs to team-a. The resolver
scopes role permissions to the role's workspace.
"""
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
# Role with MANAGE in team-b — should NOT apply to team-a endpoints.
_assign_role_with_permission(store, username, "team-b", "gateway_endpoint", "MANAGE")
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/endpoints/get",
method="GET",
query_string={"endpoint_id": "endpoint-1"}, # endpoint-1 is in team-a.
):
assert auth_module.validate_can_read_gateway_endpoint() is False
assert auth_module.validate_can_manage_gateway_endpoint() is False
def test_role_in_other_workspace_does_not_grant_gateway_use(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
_assign_role_with_permission(store, username, "team-b", "gateway_endpoint", "USE")
with auth_module.app.test_request_context("/"):
# endpoint-1 is in team-a; role grant is in team-b.
assert auth_module._validate_gateway_use_permission("endpoint-1", username) is False
# ---- Multi-role union: best grant wins ----
def test_role_union_best_permission_wins_for_gateway_endpoint(workspace_permission_setup):
"""Two roles: one grants READ, the other grants MANAGE. Validator should
reflect the max (MANAGE).
"""
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
_assign_role_with_permission(store, username, "team-a", "gateway_endpoint", "READ")
_assign_role_with_permission(store, username, "team-a", "gateway_endpoint", "MANAGE")
with auth_module.app.test_request_context(
"/api/3.0/mlflow/gateway/endpoints/get",
method="GET",
query_string={"endpoint_id": "endpoint-1"},
):
assert auth_module.validate_can_manage_gateway_endpoint() is True
# =============================================================================
# Authorization for role management endpoints (Batch 5)
# =============================================================================
#
# Four validators guard the role endpoints:
# - validate_can_manage_roles: create/update/delete role, add/remove/update
# role_permission, assign/unassign role. Super admin OR workspace admin
# in the resolved workspace.
# - validate_can_view_roles: get_role, list_role_permissions. Super admin
# OR any role assignment in the resolved workspace.
# - validate_can_list_roles: list_roles. Super admin unconditionally; for
# non-admins the request must scope to a workspace where the caller holds
# at least one role.
# - validate_can_view_user_roles: list_user_roles. Super admin, the target
# themselves, or a workspace admin over any workspace the target is in.
#
# _get_role_workspace_from_request resolves the workspace from role_id,
# role_permission_id, or a literal ``workspace`` param. These tests exercise
# all three shapes and every actor x endpoint combination.
@pytest.fixture
def role_auth_setup(tmp_path, monkeypatch):
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(default_permission=NO_PERMISSIONS.name),
)
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
auth_store.create_user("super_admin", "supersecurepassword", is_admin=True)
for name in ("ws_admin_foo", "ws_admin_bar", "ws_member_foo", "outsider"):
auth_store.create_user(name, "supersecurepassword", is_admin=False)
admin_role_foo = auth_store.create_role(name="admin-foo", workspace="foo")
auth_store.add_role_permission(admin_role_foo.id, "workspace", "*", MANAGE.name)
auth_store.assign_role_to_user(auth_store.get_user("ws_admin_foo").id, admin_role_foo.id)
admin_role_bar = auth_store.create_role(name="admin-bar", workspace="bar")
auth_store.add_role_permission(admin_role_bar.id, "workspace", "*", MANAGE.name)
auth_store.assign_role_to_user(auth_store.get_user("ws_admin_bar").id, admin_role_bar.id)
member_role_foo = auth_store.create_role(name="member-foo", workspace="foo")
auth_store.add_role_permission(member_role_foo.id, "experiment", "*", READ.name)
auth_store.assign_role_to_user(auth_store.get_user("ws_member_foo").id, member_role_foo.id)
role_foo = auth_store.create_role(name="target-foo", workspace="foo")
role_bar = auth_store.create_role(name="target-bar", workspace="bar")
rp_foo = auth_store.add_role_permission(role_foo.id, "experiment", "*", READ.name)
rp_bar = auth_store.add_role_permission(role_bar.id, "experiment", "*", READ.name)
def login_as(username: str) -> None:
monkeypatch.setattr(
auth_module,
"authenticate_request",
lambda: SimpleNamespace(username=username),
)
yield {
"store": auth_store,
"login_as": login_as,
"role_foo_id": role_foo.id,
"role_bar_id": role_bar.id,
"role_permission_foo_id": rp_foo.id,
"role_permission_bar_id": rp_bar.id,
}
auth_store.engine.dispose()
def _request_context_for_shape(shape, role_auth_setup, workspace):
match shape:
case "role_id":
role_id = (
role_auth_setup["role_foo_id"]
if workspace == "foo"
else role_auth_setup["role_bar_id"]
)
return auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/get",
method="GET",
query_string={"role_id": str(role_id)},
)
case "role_permission_id":
rp_id = (
role_auth_setup["role_permission_foo_id"]
if workspace == "foo"
else role_auth_setup["role_permission_bar_id"]
)
return auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/permissions/update",
method="PATCH",
json={"role_permission_id": rp_id, "permission": READ.name},
)
case "workspace":
return auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/create",
method="POST",
json={"name": "new-role", "workspace": workspace},
)
case _:
raise ValueError(f"Unknown shape: {shape}")
# Authorization matrices are exercised with a single request shape (role_id);
# shape-resolution itself is covered independently below so we don't multiply
# every actor-case by three shape-cases.
@pytest.mark.parametrize(
("actor", "workspace", "expected"),
[
# Super admin short-circuits regardless of workspace — one case suffices.
("super_admin", "foo", True),
# Outsider has no role anywhere — one case suffices.
("outsider", "foo", False),
# Workspace admins manage only their own workspace.
("ws_admin_foo", "foo", True),
("ws_admin_foo", "bar", False),
("ws_admin_bar", "foo", False),
("ws_admin_bar", "bar", True),
# Plain role membership is not enough to manage — needs workspace MANAGE.
("ws_member_foo", "foo", False),
("ws_member_foo", "bar", False),
],
)
def test_validate_can_manage_roles_authorization(role_auth_setup, actor, workspace, expected):
role_auth_setup["login_as"](actor)
with _request_context_for_shape("role_id", role_auth_setup, workspace):
assert auth_module.validate_can_manage_roles() is expected
@pytest.mark.parametrize("workspace", ["foo", "bar"])
@pytest.mark.parametrize("shape", ["role_id", "role_permission_id", "workspace"])
def test_manage_roles_resolves_workspace_from_each_shape(role_auth_setup, shape, workspace):
# Sanity check that _get_role_workspace_from_request correctly dispatches
# on every request shape. Use ws_admin_foo — their answer differs by
# workspace, so an incorrectly resolved (or swapped) workspace flips the
# result and the test fails.
role_auth_setup["login_as"]("ws_admin_foo")
expected = workspace == "foo"
with _request_context_for_shape(shape, role_auth_setup, workspace):
assert auth_module.validate_can_manage_roles() is expected
@pytest.mark.parametrize(
("actor", "workspace", "expected"),
[
("super_admin", "foo", True),
("outsider", "foo", False),
("ws_admin_foo", "foo", True),
("ws_admin_foo", "bar", False),
("ws_admin_bar", "foo", False),
("ws_admin_bar", "bar", True),
# Unlike manage, a plain workspace member can view roles.
("ws_member_foo", "foo", True),
("ws_member_foo", "bar", False),
],
)
def test_validate_can_view_roles_authorization(role_auth_setup, actor, workspace, expected):
role_auth_setup["login_as"](actor)
with _request_context_for_shape("role_id", role_auth_setup, workspace):
assert auth_module.validate_can_view_roles() is expected
@pytest.mark.parametrize(
("actor", "expected"),
[
("super_admin", True),
# Any non-admin is denied regardless of their workspace memberships —
# one representative non-admin is enough.
("ws_admin_foo", False),
],
)
def test_validate_can_list_roles_unscoped_is_super_admin_only(role_auth_setup, actor, expected):
# No workspace param: only super admins may list every role in the system.
role_auth_setup["login_as"](actor)
with auth_module.app.test_request_context("/api/3.0/mlflow/roles/list", method="GET"):
assert auth_module.validate_can_list_roles() is expected
@pytest.mark.parametrize(
("actor", "workspace", "expected"),
[
("super_admin", "foo", True),
("outsider", "foo", False),
("ws_admin_foo", "foo", True),
("ws_admin_foo", "bar", False),
("ws_admin_bar", "foo", False),
("ws_admin_bar", "bar", True),
("ws_member_foo", "foo", True),
("ws_member_foo", "bar", False),
],
)
def test_validate_can_list_roles_workspace_scoped(role_auth_setup, actor, workspace, expected):
role_auth_setup["login_as"](actor)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/list", method="GET", query_string={"workspace": workspace}
):
assert auth_module.validate_can_list_roles() is expected
def test_validate_can_list_roles_blank_workspace_denied_for_non_admin(role_auth_setup):
# Blank workspace param hits a *different* branch from the missing-param
# case: validate_can_list_roles checks ``workspace.strip()`` and denies
# rather than raising, unlike _get_role_workspace_from_request which would
# raise on blank workspace. Kept as a guard for that specific branch.
role_auth_setup["login_as"]("ws_admin_foo")
with auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/list",
method="GET",
query_string={"workspace": " "},
):
assert auth_module.validate_can_list_roles() is False
@pytest.mark.parametrize(
("actor", "workspaces", "expected"),
[
# Super admin lists across any combination unconditionally.
("super_admin", ["foo", "bar"], True),
# Workspace admin must hold a role in *every* requested workspace.
("ws_admin_foo", ["foo"], True),
("ws_admin_foo", ["foo", "bar"], False), # not present in bar
("ws_admin_foo", ["foo", "foo"], True), # duplicate is fine
# Member of foo can list foo, but not foo + bar.
("ws_member_foo", ["foo"], True),
("ws_member_foo", ["foo", "bar"], False),
# Outsider can list nothing.
("outsider", ["foo"], False),
("outsider", ["foo", "bar"], False),
],
)
def test_validate_can_list_roles_multi_workspace(role_auth_setup, actor, workspaces, expected):
role_auth_setup["login_as"](actor)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/list",
method="GET",
query_string=[("workspace", w) for w in workspaces],
):
assert auth_module.validate_can_list_roles() is expected
# Listing users is scoped to workspace membership (the review-queue assignment UI
# needs the roster; assigning still requires experiment MANAGE), so the roster
# isn't leaked across workspaces. Super admin is omitted: ``_before_request``
# short-circuits via ``sender_is_admin`` before the validator is reached.
@pytest.mark.parametrize(
("actor", "workspace", "expected"),
[
# Workspace-wide grant carrying can_use (USE/MANAGE) → may list the roster.
("ws_admin_foo", "foo", True),
# Isolation: an admin of another workspace can't list users in this one.
("ws_admin_foo", "bar", False),
("ws_admin_bar", "foo", False),
# A plain experiment-level grant is not a workspace-wide grant → denied.
("ws_member_foo", "foo", False),
# No grant anywhere.
("outsider", "foo", False),
],
)
def test_validate_can_list_users_workspace_scoped(role_auth_setup, actor, workspace, expected):
role_auth_setup["login_as"](actor)
token = workspace_context.set_server_request_workspace(workspace)
try:
with auth_module.app.test_request_context("/api/2.0/mlflow/users/list", method="GET"):
assert auth_module.validate_can_list_users() is expected
finally:
workspace_context._WORKSPACE.reset(token)
def test_validate_can_list_users_allows_any_user_without_workspaces(role_auth_setup, monkeypatch):
# With workspaces disabled there is no isolation boundary, so any authenticated
# user may list the roster (single-tenant). ``role_auth_setup`` enables
# workspaces; override it back off for this case.
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "false")
role_auth_setup["login_as"]("outsider")
with auth_module.app.test_request_context("/api/2.0/mlflow/users/list", method="GET"):
assert auth_module.validate_can_list_users() is True
def test_list_users_handler_eager_loads_scoped_roles(role_auth_setup):
# Workspace admin: bulk response includes per-user roles, scoped to
# workspaces the requester administers (plus self, unscoped).
role_auth_setup["login_as"]("ws_admin_foo")
with auth_module.app.test_request_context("/api/2.0/mlflow/users/list", method="GET"):
response = auth_module.list_users()
by_username = {u["username"]: u for u in response.get_json()["users"]}
# Self: own admin role visible.
assert {(r["workspace"], r["name"]) for r in by_username["ws_admin_foo"]["roles"]} == {
("foo", "admin-foo")
}
# Cross-user in foo: filtered to foo only (member-foo lives in foo).
assert {(r["workspace"], r["name"]) for r in by_username["ws_member_foo"]["roles"]} == {
("foo", "member-foo")
}
# Cross-user outside requester's admin set: roles hidden.
assert by_username["ws_admin_bar"]["roles"] == []
assert by_username["outsider"]["roles"] == []
def test_list_users_handler_super_admin_sees_every_role(role_auth_setup):
role_auth_setup["login_as"]("super_admin")
with auth_module.app.test_request_context("/api/2.0/mlflow/users/list", method="GET"):
response = auth_module.list_users()
by_username = {u["username"]: u for u in response.get_json()["users"]}
assert {(r["workspace"], r["name"]) for r in by_username["ws_admin_foo"]["roles"]} == {
("foo", "admin-foo")
}
assert {(r["workspace"], r["name"]) for r in by_username["ws_admin_bar"]["roles"]} == {
("bar", "admin-bar")
}
assert {(r["workspace"], r["name"]) for r in by_username["ws_member_foo"]["roles"]} == {
("foo", "member-foo")
}
@pytest.mark.parametrize(
("actor", "expected"),
[
("ws_admin_foo", True),
("ws_admin_bar", True),
("ws_member_foo", False),
("outsider", False),
],
)
def test_validate_can_create_user(role_auth_setup, actor, expected):
role_auth_setup["login_as"](actor)
with auth_module.app.test_request_context("/api/2.0/mlflow/users/create", method="POST"):
assert auth_module.validate_can_create_user() is expected
def test_validate_can_delete_user_stays_super_admin_only(role_auth_setup):
# Regression: the create-user widening must not have leaked into delete.
for actor in ("ws_admin_foo", "outsider"):
role_auth_setup["login_as"](actor)
with auth_module.app.test_request_context("/api/2.0/mlflow/users/delete", method="DELETE"):
assert auth_module.validate_can_delete_user() is False
def test_validate_can_view_user_roles_self_always_allowed(role_auth_setup):
# A user can always read their own role list, even one with no roles.
# Using ``outsider`` (zero roles) exercises the self-short-circuit without
# any membership helping.
role_auth_setup["login_as"]("outsider")
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/roles/list",
method="GET",
query_string={"username": "outsider"},
):
assert auth_module.validate_can_view_user_roles() is True
@pytest.mark.parametrize(
("requester", "target", "expected"),
[
("super_admin", "ws_member_foo", True),
("ws_admin_foo", "ws_member_foo", True),
("ws_admin_bar", "ws_member_foo", False),
("ws_member_foo", "ws_admin_foo", False),
("outsider", "ws_member_foo", False),
],
)
def test_validate_can_view_user_roles_cross_user(role_auth_setup, requester, target, expected):
role_auth_setup["login_as"](requester)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/roles/list",
method="GET",
query_string={"username": target},
):
assert auth_module.validate_can_view_user_roles() is expected
def test_validate_can_view_user_roles_nonexistent_target_denied_for_non_admin(
role_auth_setup,
):
# Non-existent target: return False rather than leaking existence via the
# RESOURCE_DOES_NOT_EXIST the handler would raise downstream.
role_auth_setup["login_as"]("ws_admin_foo")
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/roles/list",
method="GET",
query_string={"username": "ghost"},
):
assert auth_module.validate_can_view_user_roles() is False
def test_validate_can_view_user_roles_nonexistent_target_allowed_for_super_admin(
role_auth_setup,
):
# Super admin short-circuits before the target lookup — they're authorized
# regardless of whether the target exists (the handler then 404s cleanly).
role_auth_setup["login_as"]("super_admin")
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/roles/list",
method="GET",
query_string={"username": "ghost"},
):
assert auth_module.validate_can_view_user_roles() is True
@pytest.mark.parametrize("shape", ["role_id", "role_permission_id"])
def test_validate_can_manage_roles_nonexistent_resource_denied(role_auth_setup, shape):
# A non-admin pointing at a role/role_permission that doesn't exist fails
# closed: _get_role_workspace_from_request returns None and the validator
# treats that as unauthorized rather than leaking existence.
role_auth_setup["login_as"]("ws_admin_foo")
bogus_id = 999_999
if shape == "role_id":
ctx = auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/get",
method="GET",
query_string={"role_id": str(bogus_id)},
)
else:
ctx = auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/permissions/update",
method="PATCH",
json={"role_permission_id": bogus_id, "permission": READ.name},
)
with ctx:
assert auth_module.validate_can_manage_roles() is False
def test_validate_can_manage_roles_nonexistent_role_id_bypassed_by_super_admin(
role_auth_setup,
):
# Super admins skip the workspace resolution entirely — an unresolvable
# role_id still produces True at the validator layer.
role_auth_setup["login_as"]("super_admin")
with auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/get",
method="GET",
query_string={"role_id": "999999"},
):
assert auth_module.validate_can_manage_roles() is True
def test_validate_can_manage_roles_missing_workspace_params_raises(role_auth_setup):
# No role_id / role_permission_id / workspace in the request body: the
# resolver raises INVALID_PARAMETER_VALUE — callers that hit this path have
# a client bug, and we surface it instead of silently denying.
role_auth_setup["login_as"]("ws_admin_foo")
with auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/create", method="POST", json={}
):
with pytest.raises(MlflowException, match="must include one of"):
auth_module.validate_can_manage_roles()
def test_validate_can_manage_roles_blank_workspace_raises(role_auth_setup):
role_auth_setup["login_as"]("ws_admin_foo")
with auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/create",
method="POST",
json={"name": "new-role", "workspace": " "},
):
with pytest.raises(MlflowException, match="non-empty string"):
auth_module.validate_can_manage_roles()
def test_validate_can_manage_roles_propagates_param_coercion_errors(role_auth_setup):
# Integration check: a non-integer role_id in the request surfaces the
# coercion error through the validator chain rather than silently denying.
role_auth_setup["login_as"]("ws_admin_foo")
with auth_module.app.test_request_context(
"/api/3.0/mlflow/roles/get",
method="GET",
query_string={"role_id": "not-an-int"},
):
with pytest.raises(MlflowException, match="must be an integer"):
auth_module.validate_can_manage_roles()
def test_role_permission_resolver_honors_default_workspace_autogrant(monkeypatch):
"""Resource-level resolution must fall back to ``default_permission`` for an
ungranted user in the configured default workspace when
``grant_default_workspace_access=true``. Without this, deployments that
relied on the implicit auto-grant pre-simplification suddenly see
``NO_PERMISSIONS`` for resources in the default workspace.
"""
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(
default_permission=READ.name,
grant_default_workspace_access=True,
),
raising=False,
)
default_workspace = "team-default"
monkeypatch.setattr(auth_module, "_get_workspace_store", lambda: None, raising=False)
monkeypatch.setattr(
auth_module,
"get_default_workspace_optional",
lambda *args, **kwargs: (SimpleNamespace(name=default_workspace), True),
raising=False,
)
class DummyStore:
def get_user(self, username):
return SimpleNamespace(id=42, username=username)
def get_role_permission_for_resource(self, *args, **kwargs):
return None
monkeypatch.setattr(auth_module, "store", DummyStore(), raising=False)
monkeypatch.setattr(
auth_module,
"_get_resource_workspace",
lambda *args, **kwargs: default_workspace,
)
role_perm = auth_module._role_permission_for(
username="alice",
resource_type="experiment",
resource_key="exp-1",
workspace_lookup_id="exp-1",
workspace_fetcher=lambda _id: SimpleNamespace(),
workspace_label="experiment",
)
perm = auth_module._get_role_permission_or_default(role_perm)
assert perm.name == READ.name
def test_role_permission_resolver_denies_in_non_default_workspace(monkeypatch):
"""The auto-grant only applies to the configured default workspace. An
ungranted user in any other workspace must still get ``NO_PERMISSIONS``,
even if ``grant_default_workspace_access`` is enabled.
"""
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(
default_permission=READ.name,
grant_default_workspace_access=True,
),
raising=False,
)
monkeypatch.setattr(auth_module, "_get_workspace_store", lambda: None, raising=False)
monkeypatch.setattr(
auth_module,
"get_default_workspace_optional",
lambda *args, **kwargs: (SimpleNamespace(name="team-default"), True),
raising=False,
)
class DummyStore:
def get_user(self, username):
return SimpleNamespace(id=42, username=username)
def get_role_permission_for_resource(self, *args, **kwargs):
return None
monkeypatch.setattr(auth_module, "store", DummyStore(), raising=False)
monkeypatch.setattr(
auth_module,
"_get_resource_workspace",
lambda *args, **kwargs: "other-workspace",
)
role_perm = auth_module._role_permission_for(
username="alice",
resource_type="experiment",
resource_key="exp-1",
workspace_lookup_id="exp-1",
workspace_fetcher=lambda _id: SimpleNamespace(),
workspace_label="experiment",
)
perm = auth_module._get_role_permission_or_default(role_perm)
assert perm.name == NO_PERMISSIONS.name
def test_list_user_role_permissions_workspace_is_default_when_workspaces_disabled(
tmp_path, monkeypatch
):
# ``_UserRolePermissionRow.workspace`` is typed ``str``, not ``str | None``.
# When workspaces are disabled, ``_get_active_workspace_name()`` returns the
# string ``"default"`` (``DEFAULT_WORKSPACE_NAME``) and every role write
# threads that through ``SqlRole.workspace`` (``nullable=False``). Pins
# that LIST surfaces ``"default"`` rather than ``None`` in this mode.
from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME
monkeypatch.delenv(MLFLOW_ENABLE_WORKSPACES.name, raising=False)
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
username = "alice"
auth_store.create_user(username, "supersecurepassword", is_admin=False)
auth_store.grant_user_resource_permission(username, "experiment", "exp-1", READ.name)
is_admin, rows = auth_module._list_user_role_permissions(username)
assert is_admin is False
assert len(rows) == 1
row = rows[0]
assert isinstance(row.workspace, str)
assert row.workspace == DEFAULT_WORKSPACE_NAME
assert row.resource_type == "experiment"
assert row.resource_pattern == "exp-1"
assert row.permission == READ.name
# Direct grants land on the synthetic ``__user_<id>__`` role.
assert row.role_name == f"__user_{auth_store.get_user(username).id}__"
def test_list_user_role_permissions_aggregates_synthetic_and_custom_roles(tmp_path, monkeypatch):
# Direct grants flow into the synthetic ``__user_<id>__`` role; a workspace
# admin can also assign the user to a named, hand-authored role. LIST must
# surface BOTH so the FE can split the "Direct permissions" tab (synthetic
# rows) from the "Role permissions" tab (everything else) by ``role_name``.
monkeypatch.delenv(MLFLOW_ENABLE_WORKSPACES.name, raising=False)
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
username = "alice"
user = auth_store.create_user(username, "supersecurepassword", is_admin=False)
auth_store.grant_user_resource_permission(username, "experiment", "exp-1", READ.name)
custom_role = auth_store.create_role(name="ml-reviewer", workspace="default")
auth_store.add_role_permission(custom_role.id, "registered_model", "*", READ.name)
auth_store.assign_role_to_user(user.id, custom_role.id)
is_admin, rows = auth_module._list_user_role_permissions(username)
assert is_admin is False
assert len(rows) == 2
by_role = {r.role_name: r for r in rows}
synthetic_name = f"__user_{user.id}__"
assert synthetic_name in by_role
assert "ml-reviewer" in by_role
syn_row = by_role[synthetic_name]
assert syn_row.resource_type == "experiment"
assert syn_row.resource_pattern == "exp-1"
assert syn_row.permission == READ.name
custom_row = by_role["ml-reviewer"]
assert custom_row.role_id == custom_role.id
assert custom_row.resource_type == "registered_model"
assert custom_row.resource_pattern == "*"
assert custom_row.permission == READ.name
# Distinct rows really do come from distinct roles (no aliasing on role_id).
assert syn_row.role_id != custom_row.role_id
def test_list_user_role_permissions_empty_for_user_with_no_roles(tmp_path, monkeypatch):
# A freshly-created non-admin user with no grants must surface as
# ``(is_admin=False, rows=[])`` — not raise, and not leak rows from other users.
monkeypatch.delenv(MLFLOW_ENABLE_WORKSPACES.name, raising=False)
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
auth_store.create_user("alice", "supersecurepassword", is_admin=False)
# Second user with a grant — must not leak into alice's rows.
auth_store.create_user("bob", "supersecurepassword", is_admin=False)
auth_store.grant_user_resource_permission("bob", "experiment", "exp-1", READ.name)
is_admin, rows = auth_module._list_user_role_permissions("alice")
assert is_admin is False
assert rows == []
def test_list_user_role_permissions_admin_flag_propagates(tmp_path, monkeypatch):
# ``is_admin`` comes from the User row, not from any grants — admins can
# have zero permission rows and still surface ``is_admin=True``. The FE
# relies on this to skip permission-fetch entirely for super admins.
monkeypatch.delenv(MLFLOW_ENABLE_WORKSPACES.name, raising=False)
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
auth_store.create_user("rootuser", "supersecurepassword", is_admin=True)
is_admin, rows = auth_module._list_user_role_permissions("rootuser")
assert is_admin is True
assert rows == []
def test_list_user_role_permissions_multiple_perms_on_one_role(tmp_path, monkeypatch):
# One role with two RolePermission rows must produce two LIST rows that
# share role_id / role_name / workspace but differ on resource_type /
# permission. Pins the flatten-on-role.permissions behavior.
monkeypatch.delenv(MLFLOW_ENABLE_WORKSPACES.name, raising=False)
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
user = auth_store.create_user("alice", "supersecurepassword", is_admin=False)
role = auth_store.create_role(name="ml-power-user", workspace="default")
auth_store.add_role_permission(role.id, "experiment", "*", READ.name)
auth_store.add_role_permission(role.id, "registered_model", "*", MANAGE.name)
auth_store.assign_role_to_user(user.id, role.id)
is_admin, rows = auth_module._list_user_role_permissions("alice")
assert is_admin is False
assert len(rows) == 2
assert {(r.resource_type, r.permission) for r in rows} == {
("experiment", READ.name),
("registered_model", MANAGE.name),
}
assert {r.role_id for r in rows} == {role.id}
assert {r.role_name for r in rows} == {"ml-power-user"}
def test_list_user_role_permissions_workspace_reflects_role_workspace(tmp_path, monkeypatch):
# With workspaces enabled, ``row.workspace`` is the *role's* workspace —
# i.e., the workspace where the resource lives — not always
# ``DEFAULT_WORKSPACE_NAME``. Pin against silent collapsing of the workspace
# field for non-default workspaces.
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
user = auth_store.create_user("alice", "supersecurepassword", is_admin=False)
role_a = auth_store.create_role(name="viewer", workspace="team-a")
auth_store.add_role_permission(role_a.id, "experiment", "*", READ.name)
auth_store.assign_role_to_user(user.id, role_a.id)
role_b = auth_store.create_role(name="viewer", workspace="team-b")
auth_store.add_role_permission(role_b.id, "experiment", "*", READ.name)
auth_store.assign_role_to_user(user.id, role_b.id)
_, rows = auth_module._list_user_role_permissions("alice")
assert {r.workspace for r in rows} == {"team-a", "team-b"}
def _list_user_permissions_response(monkeypatch, requester: str, target: str) -> dict[str, object]:
"""Invoke ``list_user_permissions`` as ``requester`` for ``target`` and return JSON."""
monkeypatch.setattr(
auth_module, "authenticate_request", lambda: SimpleNamespace(username=requester)
)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/list",
method="GET",
query_string={"username": target},
):
response = auth_module.list_user_permissions()
return json.loads(response.get_data(as_text=True))
def test_list_user_permissions_admin_sees_rows_across_all_workspaces(tmp_path, monkeypatch):
# Platform admins must see EVERY role-derived row for the target user,
# regardless of which workspace the row sits in. This is the path the
# admin UI's "User detail" page hits when an admin clicks any user.
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
auth_store.create_user("root", "supersecurepassword", is_admin=True)
target = auth_store.create_user("alice", "supersecurepassword", is_admin=False)
role_a = auth_store.create_role(name="viewer", workspace="team-a")
auth_store.add_role_permission(role_a.id, "experiment", "*", READ.name)
auth_store.assign_role_to_user(target.id, role_a.id)
role_b = auth_store.create_role(name="viewer", workspace="team-b")
auth_store.add_role_permission(role_b.id, "experiment", "*", READ.name)
auth_store.assign_role_to_user(target.id, role_b.id)
payload = _list_user_permissions_response(monkeypatch, requester="root", target="alice")
assert payload["is_admin"] is False # is_admin reflects the *target*, not the caller.
assert {p["workspace"] for p in payload["permissions"]} == {"team-a", "team-b"}
def test_list_user_permissions_self_sees_all_rows(tmp_path, monkeypatch):
# Self-view sees everything the user holds, across every workspace, with no
# admin-workspace filtering. ``is_admin`` here mirrors the caller because
# caller == target.
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
alice = auth_store.create_user("alice", "supersecurepassword", is_admin=False)
role_a = auth_store.create_role(name="viewer", workspace="team-a")
auth_store.add_role_permission(role_a.id, "experiment", "*", READ.name)
auth_store.assign_role_to_user(alice.id, role_a.id)
role_b = auth_store.create_role(name="viewer", workspace="team-b")
auth_store.add_role_permission(role_b.id, "experiment", "*", READ.name)
auth_store.assign_role_to_user(alice.id, role_b.id)
payload = _list_user_permissions_response(monkeypatch, requester="alice", target="alice")
assert payload["is_admin"] is False
assert {p["workspace"] for p in payload["permissions"]} == {"team-a", "team-b"}
def test_list_user_permissions_workspace_admin_scoped_to_own_workspaces(tmp_path, monkeypatch):
# A workspace admin of team-a viewing alice (who has rows in team-a + team-b)
# must see ONLY team-a rows. The team-b row would leak cross-workspace state
# the requester has no claim over. Security regression guard.
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
auth_store.create_user("wp-admin", "supersecurepassword", is_admin=False)
auth_store.set_workspace_permission("team-a", "wp-admin", MANAGE.name)
alice = auth_store.create_user("alice", "supersecurepassword", is_admin=False)
role_a = auth_store.create_role(name="viewer", workspace="team-a")
auth_store.add_role_permission(role_a.id, "experiment", "*", READ.name)
auth_store.assign_role_to_user(alice.id, role_a.id)
role_b = auth_store.create_role(name="viewer", workspace="team-b")
auth_store.add_role_permission(role_b.id, "experiment", "*", READ.name)
auth_store.assign_role_to_user(alice.id, role_b.id)
payload = _list_user_permissions_response(monkeypatch, requester="wp-admin", target="alice")
workspaces = {p["workspace"] for p in payload["permissions"]}
assert workspaces == {"team-a"}, workspaces
def test_list_current_user_permissions_returns_caller_rows_and_admin_flag(tmp_path, monkeypatch):
# ``/users/current/permissions`` is the unauthenticated-safe self path the
# FE uses on bootstrap to decide which nav items to render. Caller == target
# implicitly; ``is_admin`` reflects the caller; rows cover every workspace.
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}"
auth_store = SqlAlchemyStore()
auth_store.init_db(db_uri)
monkeypatch.setattr(auth_module, "store", auth_store, raising=False)
alice = auth_store.create_user("alice", "supersecurepassword", is_admin=False)
role = auth_store.create_role(name="viewer", workspace="team-a")
auth_store.add_role_permission(role.id, "experiment", "*", READ.name)
auth_store.assign_role_to_user(alice.id, role.id)
monkeypatch.setattr(
auth_module, "authenticate_request", lambda: SimpleNamespace(username="alice")
)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/current/permissions", method="GET"
):
response = auth_module.list_current_user_permissions()
payload = json.loads(response.get_data(as_text=True))
assert payload["is_admin"] is False
assert len(payload["permissions"]) == 1
assert payload["permissions"][0]["workspace"] == "team-a"
assert payload["permissions"][0]["role_name"] == "viewer"
def test_default_permission_floors_lesser_role_grant(monkeypatch):
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(default_permission=EDIT.name),
raising=False,
)
perm = auth_module._get_role_permission_or_default(lambda: READ)
assert perm.name == EDIT.name
def test_default_permission_does_not_downgrade_higher_role_grant(monkeypatch):
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(default_permission=READ.name),
raising=False,
)
perm = auth_module._get_role_permission_or_default(lambda: MANAGE)
assert perm.name == MANAGE.name
def test_default_permission_does_not_override_explicit_no_permissions(monkeypatch):
# NO_PERMISSIONS is the explicit-deny carve-out — must survive the floor.
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(default_permission=MANAGE.name),
raising=False,
)
perm = auth_module._get_role_permission_or_default(lambda: NO_PERMISSIONS)
assert perm.name == NO_PERMISSIONS.name
def test_default_permission_kicks_in_when_no_grant_matches(monkeypatch):
# None (no grant matched, workspaces disabled) → fall through to default.
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(default_permission=EDIT.name),
raising=False,
)
perm = auth_module._get_role_permission_or_default(lambda: None)
assert perm.name == EDIT.name
def test_user_can_create_in_default_workspace_via_autogrant(monkeypatch):
"""``_user_can_create_in_workspace`` must honor
``grant_default_workspace_access`` so an ungranted user in the default
workspace can still create when ``default_permission.can_use`` is true.
Regression guard for the legacy-endpoint simplification, which dropped
the auto-grant fallback when ``_workspace_permission`` was retired.
"""
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
auth = SimpleNamespace(username="alice")
monkeypatch.setattr(auth_module, "authenticate_request", lambda: auth)
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(
default_permission=USE.name,
grant_default_workspace_access=True,
),
raising=False,
)
default_workspace = "team-default"
monkeypatch.setattr(auth_module, "_get_workspace_store", lambda: None, raising=False)
monkeypatch.setattr(
auth_module,
"get_default_workspace_optional",
lambda *args, **kwargs: (SimpleNamespace(name=default_workspace), True),
raising=False,
)
class DummyStore:
def get_user(self, username):
return SimpleNamespace(id=42, username=username)
def get_role_permission_for_resource(self, *args, **kwargs):
return None
monkeypatch.setattr(auth_module, "store", DummyStore(), raising=False)
# Default workspace + autogrant + USE → allowed.
with workspace_context.WorkspaceContext(default_workspace):
assert auth_module._user_can_create_in_workspace()
# Same config but a non-default workspace → still denied.
with workspace_context.WorkspaceContext("team-other"):
assert not auth_module._user_can_create_in_workspace()
def test_user_cannot_create_via_autogrant_when_default_permission_lacks_use(monkeypatch):
"""The auto-grant create-gate gates on ``default_permission.can_use``. If
the operator pinned ``default_permission=READ`` (read-only access), the
fallback must not allow create.
"""
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
auth = SimpleNamespace(username="alice")
monkeypatch.setattr(auth_module, "authenticate_request", lambda: auth)
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(
default_permission=READ.name,
grant_default_workspace_access=True,
),
raising=False,
)
default_workspace = "team-default"
monkeypatch.setattr(auth_module, "_get_workspace_store", lambda: None, raising=False)
monkeypatch.setattr(
auth_module,
"get_default_workspace_optional",
lambda *args, **kwargs: (SimpleNamespace(name=default_workspace), True),
raising=False,
)
class DummyStore:
def get_user(self, username):
return SimpleNamespace(id=42, username=username)
def get_role_permission_for_resource(self, *args, **kwargs):
return None
monkeypatch.setattr(auth_module, "store", DummyStore(), raising=False)
with workspace_context.WorkspaceContext(default_workspace):
assert not auth_module._user_can_create_in_workspace()
def test_role_based_read_predicate_ignores_no_permissions_grants(monkeypatch):
monkeypatch.delenv(MLFLOW_ENABLE_WORKSPACES.name, raising=False)
monkeypatch.setattr(
auth_module,
"auth_config",
auth_module.auth_config._replace(default_permission=READ.name),
raising=False,
)
class DummyStore:
def get_user(self, username):
return SimpleNamespace(id=42, username=username)
def list_role_grants_for_user_in_workspace(self, *args, **kwargs):
return [
("*", NO_PERMISSIONS.name),
("exp-allowed", READ.name),
("exp-explicit-deny", NO_PERMISSIONS.name),
]
monkeypatch.setattr(auth_module, "store", DummyStore(), raising=False)
predicate = auth_module._role_based_read_predicate("alice", "experiment")
# Specific positive grant wins.
assert predicate("exp-allowed")
# NO_PERMISSIONS wildcard is ignored; default READ fallback applies.
assert predicate("exp-other")
# Per-resource NO_PERMISSIONS is ignored; default READ fallback applies.
assert predicate("exp-explicit-deny")
# =============================================================================
# Unified per-user permission convenience APIs — validator dispatcher tests
# =============================================================================
def _scorer_resource_id(experiment_id: str, scorer_name: str) -> str:
from mlflow.server.auth.sqlalchemy_store import SqlAlchemyStore
return SqlAlchemyStore._scorer_pattern(experiment_id, scorer_name)
@pytest.mark.parametrize(
("resource_type", "resource_id"),
[
("experiment", "exp-1"),
("registered_model", "model-xyz"),
("scorer", "exp-1/score-1"),
("gateway_secret", "secret-1"),
("gateway_endpoint", "endpoint-1"),
("gateway_model_definition", "model-def-1"),
],
)
def test_validate_can_manage_resource_workspace_manage_allows(
workspace_permission_setup, resource_type, resource_id
):
# Workspace MANAGE (the fixture's default) grants per-resource MANAGE on every
# resource type in the workspace — the dispatcher must route the request
# through the same code path the legacy validators use.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, MANAGE.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/grant",
method="POST",
json={
"username": username,
"resource_type": resource_type,
"resource_id": resource_id,
"permission": "READ",
},
):
assert auth_module.validate_can_manage_resource()
def test_validate_can_manage_resource_per_resource_manage_allows(
workspace_permission_setup,
):
# Per-resource MANAGE delegation: a user with MANAGE on exp-1 (and nothing
# else) can grant other users access to exp-1, mirroring the legacy
# ``validate_can_manage_experiment`` semantics.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
user_id = store.get_user(username).id
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="exp-1-manager", workspace="team-a")
store.add_role_permission(role.id, "experiment", "exp-1", MANAGE.name)
store.assign_role_to_user(user_id, role.id)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/grant",
method="POST",
json={
"username": username,
"resource_type": "experiment",
"resource_id": "exp-1",
"permission": "READ",
},
):
assert auth_module.validate_can_manage_resource()
def test_validate_can_manage_resource_other_resource_denied(workspace_permission_setup):
# MANAGE on exp-1 doesn't extend to exp-2.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
user_id = store.get_user(username).id
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
role = store.create_role(name="exp-1-manager", workspace="team-a")
store.add_role_permission(role.id, "experiment", "exp-1", MANAGE.name)
store.assign_role_to_user(user_id, role.id)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/grant",
method="POST",
json={
"username": username,
"resource_type": "experiment",
"resource_id": "exp-2",
"permission": "READ",
},
):
assert not auth_module.validate_can_manage_resource()
def test_validate_can_manage_resource_no_grant_denied(workspace_permission_setup):
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/grant",
method="POST",
json={
"username": username,
"resource_type": "experiment",
"resource_id": "exp-1",
"permission": "READ",
},
):
assert not auth_module.validate_can_manage_resource()
def test_validate_can_manage_resource_workspace_use_insufficient(workspace_permission_setup):
# Workspace USE (regular member) doesn't grant per-resource MANAGE — only
# the resource owner's grant tier does.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, USE.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/grant",
method="POST",
json={
"username": username,
"resource_type": "experiment",
"resource_id": "exp-1",
"permission": "READ",
},
):
assert not auth_module.validate_can_manage_resource()
def test_validate_can_manage_resource_scorer_dispatch(workspace_permission_setup):
# The scorer ``resource_id`` is the compound pattern
# ``"<experiment_id>/<url_quote(scorer_name)>"``. The dispatcher must split
# off the experiment_id prefix for workspace resolution.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, MANAGE.name)
pattern = _scorer_resource_id("exp-1", "score-1")
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/grant",
method="POST",
json={
"username": username,
"resource_type": "scorer",
"resource_id": pattern,
"permission": "READ",
},
):
assert auth_module.validate_can_manage_resource()
def test_validate_can_manage_resource_scorer_missing_delimiter_raises(workspace_permission_setup):
# ``resource_id`` without the ``/`` delimiter cannot be split into
# ``experiment_id`` for workspace resolution.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, MANAGE.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/grant",
method="POST",
json={
"username": username,
"resource_type": "scorer",
"resource_id": "missing-delimiter",
"permission": "READ",
},
):
with pytest.raises(MlflowException, match="Expected '<experiment_id>/<scorer_name>'"):
auth_module.validate_can_manage_resource()
def test_validate_can_manage_resource_workspace_resource_type_rejected(
workspace_permission_setup,
):
# ``workspace`` is intentionally excluded from the unified API — workspace
# grants live behind set_workspace_permission / delete_workspace_permission.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, MANAGE.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/grant",
method="POST",
json={
"username": username,
"resource_type": "workspace",
"resource_id": "*",
"permission": "MANAGE",
},
):
with pytest.raises(MlflowException, match="is not supported by the per-user"):
auth_module.validate_can_manage_resource()
def test_validate_can_get_user_permission_self_check_allowed(
workspace_permission_setup,
):
# A non-admin user can always check their own permissions, even without
# any workspace presence.
store = workspace_permission_setup["store"]
username = workspace_permission_setup["username"]
_set_workspace_permission(store, username, NO_PERMISSIONS.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/get",
method="GET",
query_string={
"username": username,
"resource_type": "experiment",
"resource_id": "exp-1",
},
):
assert auth_module.validate_can_get_user_permission()
def test_validate_can_get_user_permission_admin_short_circuits(
workspace_permission_setup, monkeypatch
):
# Platform admins bypass the workspace check.
store = workspace_permission_setup["store"]
admin_username = "platform-admin"
store.create_user(admin_username, "supersecurepassword", is_admin=True)
monkeypatch.setattr(
auth_module,
"authenticate_request",
lambda: SimpleNamespace(username=admin_username),
)
target = workspace_permission_setup["username"]
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/get",
method="GET",
query_string={
"username": target,
"resource_type": "experiment",
"resource_id": "exp-1",
},
):
assert auth_module.validate_can_get_user_permission()
def test_validate_can_get_user_permission_admin_probes_other_workspace(
workspace_permission_setup, monkeypatch
):
# Platform admins bypass the workspace check globally — even for resources in
# workspaces they have no role in. Pins the is_admin short-circuit on the
# cross-workspace path that ``_cross_workspace_probe_denied`` denies for non-admins.
store = workspace_permission_setup["store"]
admin_username = "platform-admin"
store.create_user(admin_username, "supersecurepassword", is_admin=True)
monkeypatch.setattr(
auth_module,
"authenticate_request",
lambda: SimpleNamespace(username=admin_username),
)
target = workspace_permission_setup["username"]
# team-b experiment — admin has no membership in team-b.
auth_module._get_tracking_store()._experiment_workspaces["exp-team-b"] = "team-b"
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/get",
method="GET",
query_string={
"username": target,
"resource_type": "experiment",
"resource_id": "exp-team-b",
},
):
assert auth_module.validate_can_get_user_permission()
def test_validate_can_get_user_permission_cross_user_requires_admin(
workspace_permission_setup,
):
# A non-admin requester without workspace MANAGE in the resource's workspace
# cannot check another user's permissions. ``alice`` holds USE in team-a (not
# MANAGE), so the workspace-admin gate fails even though the resource lives
# in alice's workspace.
store = workspace_permission_setup["store"]
requester = workspace_permission_setup["username"]
target = "bob"
store.create_user(target, "supersecurepassword", is_admin=False)
_set_workspace_permission(store, requester, USE.name)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/get",
method="GET",
query_string={
"username": target,
"resource_type": "experiment",
"resource_id": "exp-1",
},
):
assert not auth_module.validate_can_get_user_permission()
def test_validate_can_get_user_permission_wp_admin_scoped_to_resource_workspace(
workspace_permission_setup,
):
# A workspace admin in team-a can check another user's permissions on resources
# in team-a. The scoping is by **resource workspace** (not by target-user
# presence), so the target need not have a role in team-a for the gate to allow.
store = workspace_permission_setup["store"]
requester = workspace_permission_setup["username"]
_set_workspace_permission(store, requester, MANAGE.name)
target = "bob"
store.create_user(target, "supersecurepassword", is_admin=False)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/get",
method="GET",
query_string={
"username": target,
"resource_type": "experiment",
"resource_id": "exp-1",
},
):
assert auth_module.validate_can_get_user_permission()
def test_validate_can_get_user_permission_cross_workspace_probe_denied(
workspace_permission_setup,
):
# Security gate: a workspace admin of team-a must NOT be able to probe a
# target user's permissions on a resource in team-b. Closes the
# cross-workspace information-disclosure gap.
store = workspace_permission_setup["store"]
requester = workspace_permission_setup["username"]
_set_workspace_permission(store, requester, MANAGE.name)
target = "bob"
store.create_user(target, "supersecurepassword", is_admin=False)
auth_module._get_tracking_store()._experiment_workspaces["exp-team-b"] = "team-b"
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/get",
method="GET",
query_string={
"username": target,
"resource_type": "experiment",
"resource_id": "exp-team-b",
},
):
assert not auth_module.validate_can_get_user_permission()
def test_validate_can_get_user_permission_unknown_resource_denied(
workspace_permission_setup,
):
# If the resource can't be resolved to a workspace (e.g. it doesn't exist),
# the gate must deny — otherwise a caller could probe across all workspaces
# by using a non-existent ID.
store = workspace_permission_setup["store"]
requester = workspace_permission_setup["username"]
_set_workspace_permission(store, requester, MANAGE.name)
target = "bob"
store.create_user(target, "supersecurepassword", is_admin=False)
with auth_module.app.test_request_context(
"/api/3.0/mlflow/users/permissions/get",
method="GET",
query_string={
"username": target,
"resource_type": "experiment",
"resource_id": "exp-does-not-exist",
},
):
assert not auth_module.validate_can_get_user_permission()