358 lines
14 KiB
Python
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
|