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

358 lines
14 KiB
Python

from contextlib import contextmanager
import pytest
import requests
from mlflow import MlflowException
from mlflow.environment_variables import (
MLFLOW_AUTH_CONFIG_PATH,
MLFLOW_FLASK_SERVER_SECRET_KEY,
MLFLOW_TRACKING_PASSWORD,
MLFLOW_TRACKING_USERNAME,
)
from mlflow.protos.databricks_pb2 import (
PERMISSION_DENIED,
RESOURCE_ALREADY_EXISTS,
RESOURCE_DOES_NOT_EXIST,
UNAUTHENTICATED,
ErrorCode,
)
from mlflow.server.auth.client import AuthServiceClient
from mlflow.utils.os import is_windows
from tests.helper_functions import random_str
from tests.server.auth.auth_test_utils import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
User,
write_isolated_auth_config,
)
from tests.tracking.integration_test_utils import _init_server
@pytest.fixture(autouse=True)
def clear_credentials(monkeypatch):
monkeypatch.delenv(MLFLOW_TRACKING_USERNAME.name, raising=False)
monkeypatch.delenv(MLFLOW_TRACKING_PASSWORD.name, raising=False)
@pytest.fixture
def client(tmp_path):
auth_config_path = write_isolated_auth_config(tmp_path)
path = tmp_path.joinpath("sqlalchemy.db").as_uri()
backend_uri = ("sqlite://" if is_windows() else "sqlite:////") + path[len("file://") :]
with _init_server(
backend_uri=backend_uri,
root_artifact_uri=tmp_path.joinpath("artifacts").as_uri(),
app="mlflow.server.auth:create_app",
extra_env={
MLFLOW_FLASK_SERVER_SECRET_KEY.name: "my-secret-key",
MLFLOW_AUTH_CONFIG_PATH.name: str(auth_config_path),
},
server_type="flask",
) as url:
yield AuthServiceClient(url)
@contextmanager
def assert_unauthenticated():
with pytest.raises(MlflowException, match=r"You are not authenticated.") as exception_context:
yield
assert exception_context.value.error_code == ErrorCode.Name(UNAUTHENTICATED)
@contextmanager
def assert_unauthorized():
with pytest.raises(MlflowException, match=r"Permission denied.") as exception_context:
yield
assert exception_context.value.error_code == ErrorCode.Name(PERMISSION_DENIED)
def _create_admin_controlled_user(client, monkeypatch):
"""Create a non-admin user via the admin account and return (username, password)."""
username = random_str()
password = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username, password)
return username, password
def _make_user_wp_admin(client, monkeypatch, username, workspace):
"""Create a role with workspace-scope MANAGE in ``workspace`` and assign it to ``username``."""
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
role = client.create_role(workspace=workspace, name=f"wp-admin-{random_str()}")
client.add_role_permission(role.id, "workspace", "*", "MANAGE")
client.assign_role(username, role.id)
return role
# ---- Role CRUD ----
def test_create_role_requires_admin(client, monkeypatch):
username, password = _create_admin_controlled_user(client, monkeypatch)
with assert_unauthenticated():
client.create_role(workspace="ws1", name="viewer")
with User(username, password, monkeypatch), assert_unauthorized():
client.create_role(workspace="ws1", name="viewer")
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
role = client.create_role(workspace="ws1", name="viewer", description="read-only")
assert role.name == "viewer"
assert role.workspace == "ws1"
assert role.description == "read-only"
def test_create_role_duplicate(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_role(workspace="ws1", name="viewer")
with pytest.raises(MlflowException, match="already exists") as exc:
client.create_role(workspace="ws1", name="viewer")
assert exc.value.error_code == ErrorCode.Name(RESOURCE_ALREADY_EXISTS)
def test_get_role(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
created = client.create_role(workspace="ws1", name="viewer")
fetched = client.get_role(created.id)
assert fetched.id == created.id
assert fetched.name == "viewer"
def test_get_role_not_found(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
with pytest.raises(MlflowException, match="not found") as exc:
client.get_role(99999)
assert exc.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
def test_list_roles(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_role(workspace="ws1", name="viewer")
client.create_role(workspace="ws1", name="editor")
client.create_role(workspace="ws2", name="viewer")
ws1 = client.list_roles("ws1")
ws2 = client.list_roles("ws2")
assert {r.name for r in ws1} == {"viewer", "editor"}
assert {r.name for r in ws2} == {"viewer"}
def test_update_role(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
role = client.create_role(workspace="ws1", name="old")
updated = client.update_role(role.id, name="new", description="updated")
assert updated.name == "new"
assert updated.description == "updated"
def test_update_role_rejects_empty_update(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
role = client.create_role(workspace="ws1", name="viewer")
with pytest.raises(MlflowException, match="At least one of 'name' or 'description'"):
client.update_role(role.id)
def test_delete_role(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
role = client.create_role(workspace="ws1", name="viewer")
client.delete_role(role.id)
with pytest.raises(MlflowException, match="not found"):
client.get_role(role.id)
# ---- Role permission CRUD ----
def test_role_permission_crud(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
role = client.create_role(workspace="ws1", name="viewer")
rp = client.add_role_permission(role.id, "experiment", "42", "READ")
assert rp.resource_type == "experiment"
assert rp.resource_pattern == "42"
assert rp.permission == "READ"
perms = client.list_role_permissions(role.id)
assert len(perms) == 1
updated = client.update_role_permission(rp.id, "EDIT")
assert updated.permission == "EDIT"
client.remove_role_permission(rp.id)
assert client.list_role_permissions(role.id) == []
def test_add_role_permission_invalid_resource_type(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
role = client.create_role(workspace="ws1", name="viewer")
with pytest.raises(MlflowException, match="Invalid resource type"):
client.add_role_permission(role.id, "bogus", "42", "READ")
def test_add_workspace_resource_type(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
role = client.create_role(workspace="ws1", name="ws-admin")
rp = client.add_role_permission(role.id, "workspace", "*", "MANAGE")
assert rp.resource_type == "workspace"
assert rp.permission == "MANAGE"
# ---- User-role assignment ----
def test_assign_unassign_role(client, monkeypatch):
username, _ = _create_admin_controlled_user(client, monkeypatch)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
role = client.create_role(workspace="ws1", name="viewer")
assignment = client.assign_role(username, role.id)
assert assignment.role_id == role.id
user_roles = client.list_user_roles(username)
assert [r.id for r in user_roles] == [role.id]
role_users = client.list_role_users(role.id)
assert len(role_users) == 1
client.unassign_role(username, role.id)
assert client.list_user_roles(username) == []
def test_assign_nonexistent_role(client, monkeypatch):
username, _ = _create_admin_controlled_user(client, monkeypatch)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
with pytest.raises(MlflowException, match="not found"):
client.assign_role(username, 99999)
def test_assign_nonexistent_user(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
role = client.create_role(workspace="ws1", name="viewer")
with pytest.raises(MlflowException, match="not found"):
client.assign_role("nonexistent-user-" + random_str(), role.id)
# ---- Authorization: WP admin vs plain user vs super admin ----
def test_wp_admin_can_manage_roles_in_own_workspace(client, monkeypatch):
wp_admin, wp_admin_pw = _create_admin_controlled_user(client, monkeypatch)
_make_user_wp_admin(client, monkeypatch, wp_admin, "ws1")
# WP admin can create and manage roles in their workspace.
with User(wp_admin, wp_admin_pw, monkeypatch):
role = client.create_role(workspace="ws1", name="editor")
client.add_role_permission(role.id, "experiment", "*", "EDIT")
assert len(client.list_roles("ws1")) >= 2 # the wp-admin role + this one
def test_wp_admin_cannot_manage_other_workspace(client, monkeypatch):
wp_admin, wp_admin_pw = _create_admin_controlled_user(client, monkeypatch)
_make_user_wp_admin(client, monkeypatch, wp_admin, "ws1")
# WP admin of ws1 cannot create roles in ws2.
with User(wp_admin, wp_admin_pw, monkeypatch), assert_unauthorized():
client.create_role(workspace="ws2", name="editor")
def test_list_user_roles_self(client, monkeypatch):
username, password = _create_admin_controlled_user(client, monkeypatch)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
role = client.create_role(workspace="ws1", name="viewer")
client.assign_role(username, role.id)
# Users can list their own role assignments.
with User(username, password, monkeypatch):
roles = client.list_user_roles(username)
assert [r.id for r in roles] == [role.id]
def test_list_user_roles_not_self_requires_admin(client, monkeypatch):
target, _ = _create_admin_controlled_user(client, monkeypatch)
requester, requester_pw = _create_admin_controlled_user(client, monkeypatch)
# A plain user cannot view another user's roles.
with User(requester, requester_pw, monkeypatch), assert_unauthorized():
client.list_user_roles(target)
def test_list_user_roles_wp_admin_sees_only_own_workspace_scope(client, monkeypatch):
# Target has roles in two workspaces (ws1, ws2). Requester is WP admin of ws1 only.
# The response should be filtered to ws1 roles — ws2 membership must not leak.
target, _ = _create_admin_controlled_user(client, monkeypatch)
wp_admin, wp_admin_pw = _create_admin_controlled_user(client, monkeypatch)
_make_user_wp_admin(client, monkeypatch, wp_admin, "ws1")
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
ws1_role = client.create_role(workspace="ws1", name="ws1-viewer")
ws2_role = client.create_role(workspace="ws2", name="ws2-viewer")
client.assign_role(target, ws1_role.id)
client.assign_role(target, ws2_role.id)
with User(wp_admin, wp_admin_pw, monkeypatch):
visible = client.list_user_roles(target)
visible_names = {r.name for r in visible}
assert "ws1-viewer" in visible_names
assert "ws2-viewer" not in visible_names
def test_list_user_roles_super_admin_sees_all(client, monkeypatch):
target, _ = _create_admin_controlled_user(client, monkeypatch)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
ws1_role = client.create_role(workspace="ws1", name="r1")
ws2_role = client.create_role(workspace="ws2", name="r2")
client.assign_role(target, ws1_role.id)
client.assign_role(target, ws2_role.id)
visible = client.list_user_roles(target)
assert {r.name for r in visible} == {"r1", "r2"}
# ---- Cross-workspace admin list ----
def test_list_all_roles_admin_only(client, monkeypatch):
username, password = _create_admin_controlled_user(client, monkeypatch)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_role(workspace="ws1", name="r1")
client.create_role(workspace="ws2", name="r2")
all_roles = client.list_all_roles()
names = {r.name for r in all_roles}
assert {"r1", "r2"}.issubset(names)
with User(username, password, monkeypatch), assert_unauthorized():
client.list_all_roles()
# ---- /api/ and /ajax-api/ path parity ----
@pytest.mark.parametrize("api_prefix", ["api", "ajax-api"])
def test_rbac_endpoints_reachable_at_both_path_prefixes(client, monkeypatch, api_prefix):
# The MLflow frontend hits /ajax-api/ paths; the Python client hits /api/ paths.
# Every RBAC route must be reachable at both (see handlers._get_paths).
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
created = client.create_role(workspace="path-parity", name=f"r-{api_prefix}")
# GET list endpoint.
resp = requests.get(
f"{client.tracking_uri}/{api_prefix}/3.0/mlflow/roles/list",
params={"workspace": "path-parity"},
auth=(ADMIN_USERNAME, ADMIN_PASSWORD),
)
assert resp.status_code == 200, resp.text
names = {r["name"] for r in resp.json()["roles"]}
assert created.name in names
# GET single role by id.
resp = requests.get(
f"{client.tracking_uri}/{api_prefix}/3.0/mlflow/roles/get",
params={"role_id": str(created.id)},
auth=(ADMIN_USERNAME, ADMIN_PASSWORD),
)
assert resp.status_code == 200, resp.text
assert resp.json()["role"]["id"] == created.id