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

726 lines
30 KiB
Python

import json
from contextlib import contextmanager
from urllib.parse import quote
import pytest
import requests
import mlflow
from mlflow import MlflowClient, 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 (
BAD_REQUEST,
INVALID_PARAMETER_VALUE,
PERMISSION_DENIED,
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 test_get_client():
client = mlflow.server.get_app_client("basic-auth", "uri:/fake")
assert isinstance(client, AuthServiceClient)
def test_create_user(client, monkeypatch):
username = random_str()
password = random_str()
with assert_unauthenticated():
client.create_user(username, password)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
user = client.create_user(username, password)
assert user.username == username
assert user.is_admin is False
username2 = random_str()
password2 = random_str()
with User(username, password, monkeypatch), assert_unauthorized():
client.create_user(username2, password2)
def test_get_user(client, monkeypatch):
username = random_str()
password = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username, password)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
user = client.get_user(username)
assert user.username == username
with assert_unauthenticated():
client.get_user(username)
username2 = random_str()
password2 = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username2, password2)
with User(username2, password2, monkeypatch), assert_unauthorized():
client.get_user(username)
def test_get_current_user(client, monkeypatch):
# /users/current returns minimal identity for whoever the request is
# authenticated as. The admin UI relies on the response shape (id,
# username, is_admin) to gate admin-only links.
username = random_str()
password = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
created = client.create_user(username, password)
url = f"{client.tracking_uri}/api/2.0/mlflow/users/current"
resp = requests.get(url, auth=(username, password))
assert resp.status_code == 200
body = resp.json()
assert body["user"] == {
"id": created.id,
"username": username,
"is_admin": False,
}
# ``is_basic_auth`` lets the frontend gate Basic-Auth-only flows
# (logout XHR, change-password) when a custom authorization_function
# is configured.
assert body["is_basic_auth"] is True
resp = requests.get(url, auth=(ADMIN_USERNAME, ADMIN_PASSWORD))
assert resp.status_code == 200
admin_payload = resp.json()["user"]
assert admin_payload["username"] == ADMIN_USERNAME
assert admin_payload["is_admin"] is True
resp = requests.get(url)
assert resp.status_code == 401
@pytest.mark.parametrize(
("path", "method"),
[
("/api/2.0/mlflow/experiments/permissions/get", "GET"),
("/api/2.0/mlflow/experiments/permissions/create", "POST"),
("/api/2.0/mlflow/experiments/permissions/update", "PATCH"),
("/api/2.0/mlflow/experiments/permissions/delete", "DELETE"),
("/api/2.0/mlflow/registered-models/permissions/get", "GET"),
("/api/2.0/mlflow/registered-models/permissions/create", "POST"),
("/api/2.0/mlflow/registered-models/permissions/update", "PATCH"),
("/api/2.0/mlflow/registered-models/permissions/delete", "DELETE"),
("/api/3.0/mlflow/scorers/permissions/get", "GET"),
("/api/3.0/mlflow/scorers/permissions/create", "POST"),
("/api/3.0/mlflow/scorers/permissions/update", "PATCH"),
("/api/3.0/mlflow/scorers/permissions/delete", "DELETE"),
("/api/3.0/mlflow/gateway/secrets/permissions/get", "GET"),
("/api/3.0/mlflow/gateway/secrets/permissions/create", "POST"),
("/api/3.0/mlflow/gateway/secrets/permissions/update", "PATCH"),
("/api/3.0/mlflow/gateway/secrets/permissions/delete", "DELETE"),
("/api/3.0/mlflow/gateway/endpoints/permissions/get", "GET"),
("/api/3.0/mlflow/gateway/endpoints/permissions/create", "POST"),
("/api/3.0/mlflow/gateway/endpoints/permissions/update", "PATCH"),
("/api/3.0/mlflow/gateway/endpoints/permissions/delete", "DELETE"),
("/api/3.0/mlflow/gateway/model-definitions/permissions/get", "GET"),
("/api/3.0/mlflow/gateway/model-definitions/permissions/create", "POST"),
("/api/3.0/mlflow/gateway/model-definitions/permissions/update", "PATCH"),
("/api/3.0/mlflow/gateway/model-definitions/permissions/delete", "DELETE"),
],
)
def test_legacy_permission_endpoints_return_404(client, path, method):
# Regression guard: the deprecated per-resource permission endpoints were
# removed (RFC mprahl review M6+M9 — "rip the band-aid"). Replacement is
# the ``grant_user_permission`` / ``revoke_user_permission`` /
# ``check_user_permission`` convenience APIs.
resp = requests.request(
method, client.tracking_uri + path, auth=(ADMIN_USERNAME, ADMIN_PASSWORD)
)
assert resp.status_code == 404, (
f"{method} {path} unexpectedly returned {resp.status_code} — legacy "
"permission endpoints must be removed"
)
def _register_scorer(tracking_uri: str, experiment_id: str, name: str, auth) -> None:
resp = requests.post(
tracking_uri + "/api/3.0/mlflow/scorers/register",
json={
"experiment_id": experiment_id,
"name": name,
"serialized_scorer": json.dumps({"v": 1}),
},
auth=auth,
)
resp.raise_for_status()
def test_list_scorers_cross_experiment(client, monkeypatch):
# ``ListScorers`` with no ``experiment_id`` returns scorers across every
# experiment in the active workspace, populates ``experiment_name`` per
# row, and the SqlAlchemyStore output is globally sorted on
# ``(experiment_id, scorer_name)`` (pinned in store-level tests; smoke
# check here that the order survives through to the response).
url = client.tracking_uri + "/ajax-api/3.0/mlflow/scorers/list"
# Unauthenticated callers get 401 — ``_before_request`` runs before any
# per-route validator.
assert requests.get(url).status_code == 401
exp_a = _create_experiment(client.tracking_uri, monkeypatch, f"xex-a-{random_str()}")
exp_b = _create_experiment(client.tracking_uri, monkeypatch, f"xex-b-{random_str()}")
admin_auth = (ADMIN_USERNAME, ADMIN_PASSWORD)
_register_scorer(client.tracking_uri, exp_a, "alpha", admin_auth)
_register_scorer(client.tracking_uri, exp_b, "beta", admin_auth)
# Scorer name with a ``/`` exercises the URL-encoding contract; the
# client-side ``scorerResourcePattern`` must match the persisted grant
# byte-for-byte.
_register_scorer(client.tracking_uri, exp_b, "with/slash", admin_auth)
resp = requests.get(url, auth=admin_auth)
assert resp.status_code == 200, resp.text
scorers = resp.json()["scorers"]
by_name = {s["scorer_name"]: s for s in scorers}
assert set(by_name) >= {"alpha", "beta", "with/slash"}
assert str(by_name["alpha"]["experiment_id"]) == exp_a
# Reconstruct the resource pattern client-side and verify it matches the
# encoding the auth-side ``_scorer_pattern`` uses.
assert f"{by_name['with/slash']['experiment_id']}/{quote('with/slash', safe='')}" == (
f"{exp_b}/{quote('with/slash', safe='')}"
)
tuples = [(s["experiment_id"], s["scorer_name"]) for s in scorers]
assert tuples == sorted(tuples)
def test_list_scorers_cross_experiment_pattern_round_trip(client, monkeypatch):
# A scorer literally named ``*`` is the encoding edge case: Python's
# ``quote(safe='')`` encodes it as ``%2A`` while JS ``encodeURIComponent``
# leaves it bare. The picker reconstructs the composite pattern via the
# frontend ``scorerResourcePattern`` helper (which matches Python's
# encoding); this test pins that the backend's ``_scorer_pattern`` uses
# the same encoding, so a grant placed on the picker-submitted pattern
# resolves on subsequent permission checks.
admin_auth = (ADMIN_USERNAME, ADMIN_PASSWORD)
exp_id = _create_experiment(client.tracking_uri, monkeypatch, f"star-{random_str()}")
_register_scorer(client.tracking_uri, exp_id, "*", admin_auth)
# Grant on the composite pattern the picker would submit.
expected_pattern = f"{exp_id}/{quote('*', safe='')}"
target, _ = _new_user(client, monkeypatch)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.grant_user_permission(target, "scorer", expected_pattern, "READ")
roles = client.list_user_roles(target)
synthetic = next(r for r in roles if r.name.startswith("__user_"))
grants = [(p.resource_type, p.resource_pattern, p.permission) for p in synthetic.permissions]
assert ("scorer", expected_pattern, "READ") in grants
# Cross-experiment ListScorers should carry the ``*``-named row. Reconstruct
# the composite pattern client-side and verify it matches the granted one
# byte-for-byte.
url = client.tracking_uri + "/ajax-api/3.0/mlflow/scorers/list"
resp = requests.get(url, auth=admin_auth)
assert resp.status_code == 200, resp.text
by_name = {s["scorer_name"]: s for s in resp.json()["scorers"]}
assert "*" in by_name
reconstructed = f"{by_name['*']['experiment_id']}/{quote('*', safe='')}"
assert reconstructed == expected_pattern
def test_update_user_password(client, monkeypatch):
username = random_str()
password = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username, password)
new_password = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.update_user_password(username, new_password)
with User(username, password, monkeypatch), assert_unauthenticated():
client.get_user(username)
with User(username, new_password, monkeypatch):
client.get_user(username)
with assert_unauthenticated():
client.update_user_password(username, new_password)
username2 = random_str()
password2 = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username2, password2)
with User(username2, password2, monkeypatch), assert_unauthorized():
client.update_user_password(username, new_password)
def test_update_user_password_self_service_rejects_same_password(client, monkeypatch):
# Self-service: rotating to the existing value is a no-op and almost
# always a UI mistake. Cheap equality check against the supplied
# ``current_password`` rather than re-authenticating.
username = random_str()
password = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username, password)
with (
User(username, password, monkeypatch),
pytest.raises(MlflowException, match=r"differ from the current password") as exc,
):
client.update_user_password(username, password, current_password=password)
assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
def test_update_user_password_admin_allows_same_password(client, monkeypatch):
# Admin path has no ``current_password``, so we can't equality-check
# cheaply; rejecting via bcrypt would leak a same-vs-different oracle
# to any admin probing a candidate. Allow the silent no-op instead.
username = random_str()
password = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username, password)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.update_user_password(username, password)
with User(username, password, monkeypatch):
client.get_user(username)
def test_self_service_password_change_requires_current_password(client, monkeypatch):
# Defense-in-depth: a user changing their own password must re-assert the
# current password. Admins changing someone else's password don't (and
# can't) supply it — that path is exercised in `test_update_user_password`.
username = random_str()
password = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username, password)
new_password = random_str()
with User(username, password, monkeypatch):
# Missing current_password: rejected.
with pytest.raises(MlflowException, match="Current password is required"):
client.update_user_password(username, new_password)
# Wrong current_password: rejected.
with pytest.raises(MlflowException, match="Current password does not match"):
client.update_user_password(
username, new_password, current_password="not-the-current-password"
)
# Correct current_password: accepted.
client.update_user_password(username, new_password, current_password=password)
# Old password no longer authenticates; new one does.
with User(username, password, monkeypatch), assert_unauthenticated():
client.get_user(username)
with User(username, new_password, monkeypatch):
client.get_user(username)
def test_create_user_with_null_or_missing_json_body_returns_400(client, monkeypatch):
# Defensive: a JSON-typed POST whose body is literal ``null`` (or empty)
# used to crash ``_get_request_param`` with ``None | dict`` and surface as
# a 500. Make sure it's a clean 400 instead.
url = f"{client.tracking_uri}/api/2.0/mlflow/users/create"
headers = {"Content-Type": "application/json"}
auth = (ADMIN_USERNAME, ADMIN_PASSWORD)
# Literal null body.
resp = requests.post(url, data="null", headers=headers, auth=auth)
assert resp.status_code == 400
# Empty body.
resp = requests.post(url, data="", headers=headers, auth=auth)
assert resp.status_code == 400
def test_self_service_password_change_with_null_body_returns_400(client, monkeypatch):
# Defensive: self-service password changes used to crash with
# ``request.json.get(...)`` when the body was literal ``null`` (raises
# AttributeError -> 500). Make sure it's the standard 400 instead.
username = random_str()
password = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username, password)
url = f"{client.tracking_uri}/api/2.0/mlflow/users/update-password"
resp = requests.patch(
url,
data="null",
headers={"Content-Type": "application/json"},
auth=(username, password),
)
assert resp.status_code == 400
def test_update_user_admin(client, monkeypatch):
username = random_str()
password = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username, password)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.update_user_admin(username, True)
user = client.get_user(username)
assert user.is_admin is True
with assert_unauthenticated():
client.update_user_admin(username, True)
username2 = random_str()
password2 = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username2, password2)
with User(username2, password2, monkeypatch), assert_unauthorized():
client.update_user_admin(username, True)
def test_delete_user(client, monkeypatch):
username = random_str()
password = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username, password)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.update_user_admin(username, True)
client.delete_user(username)
with pytest.raises(
MlflowException,
match=rf"User with username={username} not found",
) as exception_context:
client.get_user(username)
assert exception_context.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
with assert_unauthenticated():
client.delete_user(username)
username2 = random_str()
password2 = random_str()
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.create_user(username2, password2)
with User(username2, password2, monkeypatch), assert_unauthorized():
client.delete_user(username)
def test_delete_user_rejects_self_delete(client, monkeypatch):
with (
User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch),
pytest.raises(MlflowException, match=r"cannot delete their own account") as exc,
):
client.delete_user(ADMIN_USERNAME)
assert exc.value.error_code == ErrorCode.Name(BAD_REQUEST)
# Admin account still exists and is still usable.
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
assert client.get_user(ADMIN_USERNAME).username == ADMIN_USERNAME
# ---- Unified per-user permission convenience APIs ----
def _create_experiment(tracking_uri: str, monkeypatch, name: str) -> str:
"""Create an experiment as admin so ``get_user_permission`` workspace
lookup succeeds; without a real row the resolver falls through to default.
"""
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
return MlflowClient(tracking_uri).create_experiment(name)
def _new_user(client, monkeypatch):
"""Create a fresh user as admin 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 test_grant_user_permission_roundtrip(client, monkeypatch):
# Admin grants READ on an experiment to a new user, then the user's
# synthetic role surfaces the row via list_user_roles.
username, _ = _new_user(client, monkeypatch)
exp_id = _create_experiment(client.tracking_uri, monkeypatch, f"grant-{random_str()}")
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.grant_user_permission(username, "experiment", exp_id, "READ")
roles = client.list_user_roles(username)
synthetic = [r for r in roles if r.name.startswith("__user_")]
assert len(synthetic) == 1
grants = [(p.resource_type, p.resource_pattern, p.permission) for p in synthetic[0].permissions]
assert ("experiment", exp_id, "READ") in grants
def test_grant_user_permission_duplicate_raises(client, monkeypatch):
username, _ = _new_user(client, monkeypatch)
exp_id = _create_experiment(client.tracking_uri, monkeypatch, f"dup-{random_str()}")
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.grant_user_permission(username, "experiment", exp_id, "READ")
with pytest.raises(MlflowException, match="already exists") as exc:
client.grant_user_permission(username, "experiment", exp_id, "EDIT")
from mlflow.protos.databricks_pb2 import RESOURCE_ALREADY_EXISTS
assert exc.value.error_code == ErrorCode.Name(RESOURCE_ALREADY_EXISTS)
def test_grant_user_permission_unknown_user_raises(client, monkeypatch):
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
with pytest.raises(MlflowException, match="not found") as exc:
client.grant_user_permission(
"no-such-user-" + random_str(), "experiment", "exp-1", "READ"
)
assert exc.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
def test_grant_user_permission_invalid_resource_type(client, monkeypatch):
username, _ = _new_user(client, monkeypatch)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
with pytest.raises(MlflowException, match="Invalid resource type"):
client.grant_user_permission(username, "bogus", "x", "READ")
def test_grant_user_permission_malformed_scorer_id_returns_clean_4xx(client, monkeypatch):
# Malformed scorer ``resource_id`` raises inside ``validate_can_manage_resource``
# (a before-request validator). Pin that the wrapping ``catch_mlflow_exception``
# turns it into a 400, not a 500.
target_username, _ = _new_user(client, monkeypatch)
requester_username, requester_password = _new_user(client, monkeypatch)
with User(requester_username, requester_password, monkeypatch):
with pytest.raises(MlflowException, match="Invalid scorer resource_id") as exc:
client.grant_user_permission(target_username, "scorer", "no-slash-here", "READ")
assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
@pytest.mark.parametrize(
("api_method", "args"),
[
("grant_user_permission", ("workspace", "*", "USE")),
("revoke_user_permission", ("workspace", "*")),
],
ids=["grant", "revoke"],
)
def test_admin_cannot_target_workspace_resource_type(client, monkeypatch, api_method, args):
# Super admins skip ``validate_can_manage_resource`` via ``sender_is_admin``.
# Store-level rejection is the only defense — must fire for the admin path.
username, _ = _new_user(client, monkeypatch)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
with pytest.raises(MlflowException, match="not supported by the per-user"):
getattr(client, api_method)(username, *args)
def test_grant_user_permission_invalid_permission_for_resource_type(client, monkeypatch):
# ``NO_PERMISSIONS`` is intentionally disallowed at the resource scope —
# absence of a grant + ``default_permission`` already expresses "no access".
username, _ = _new_user(client, monkeypatch)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
with pytest.raises(MlflowException, match="Invalid permission"):
client.grant_user_permission(username, "experiment", "exp-1", "NO_PERMISSIONS")
def test_revoke_user_permission_roundtrip(client, monkeypatch):
username, _ = _new_user(client, monkeypatch)
exp_id = _create_experiment(client.tracking_uri, monkeypatch, f"revoke-{random_str()}")
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.grant_user_permission(username, "experiment", exp_id, "READ")
roles_before = client.list_user_roles(username)
synthetic_before = next(r for r in roles_before if r.name.startswith("__user_"))
assert any(p.resource_pattern == exp_id for p in synthetic_before.permissions)
client.revoke_user_permission(username, "experiment", exp_id)
roles_after = client.list_user_roles(username)
synthetic_after = next((r for r in roles_after if r.name.startswith("__user_")), None)
remaining = synthetic_after.permissions if synthetic_after else []
assert not any(p.resource_pattern == exp_id for p in remaining)
def test_revoke_user_permission_missing_row_raises(client, monkeypatch):
username, _ = _new_user(client, monkeypatch)
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
with pytest.raises(MlflowException, match="not found") as exc:
client.revoke_user_permission(username, "experiment", "exp-not-granted")
assert exc.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST)
def test_get_user_permission_self_check(client, monkeypatch):
# A non-admin user can check their own permissions on any resource.
username, password = _new_user(client, monkeypatch)
exp_id = _create_experiment(client.tracking_uri, monkeypatch, f"self-{random_str()}")
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.grant_user_permission(username, "experiment", exp_id, "READ")
with User(username, password, monkeypatch):
result = client.get_user_permission(username, "experiment", exp_id)
# ``allowed`` mirrors ``Permission.can_use``. READ is defined with
# ``can_use=False`` in ``mlflow.server.auth.permissions`` (see ``READ =``),
# so a READ grant resolves to ``allowed=False``.
assert result.permission == "READ"
assert result.allowed is False
def test_get_user_permission_returns_max_grant(client, monkeypatch):
# An explicit MANAGE grant yields allowed=True (MANAGE.can_use=True).
username, password = _new_user(client, monkeypatch)
exp_id = _create_experiment(client.tracking_uri, monkeypatch, f"mgr-{random_str()}")
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.grant_user_permission(username, "experiment", exp_id, "MANAGE")
with User(username, password, monkeypatch):
result = client.get_user_permission(username, "experiment", exp_id)
assert result.permission == "MANAGE"
assert result.allowed is True
def test_get_user_permission_cross_user_requires_admin(client, monkeypatch):
# A plain user cannot check another user's permissions.
target, _ = _new_user(client, monkeypatch)
requester, requester_pw = _new_user(client, monkeypatch)
with User(requester, requester_pw, monkeypatch), assert_unauthorized():
client.get_user_permission(target, "experiment", "exp-x")
def test_get_user_permission_admin_can_check_any_user(client, monkeypatch):
username, _ = _new_user(client, monkeypatch)
exp_id = _create_experiment(client.tracking_uri, monkeypatch, f"admin-{random_str()}")
with User(ADMIN_USERNAME, ADMIN_PASSWORD, monkeypatch):
client.grant_user_permission(username, "experiment", exp_id, "EDIT")
result = client.get_user_permission(username, "experiment", exp_id)
assert result.permission == "EDIT"
assert result.allowed is True
def test_grant_user_permission_requires_authentication(client):
with assert_unauthenticated():
client.grant_user_permission("alice", "experiment", "exp-1", "READ")
def test_grant_user_permission_non_admin_without_manage_rejected(client, monkeypatch):
# A plain user without per-resource MANAGE cannot grant permissions.
requester, requester_pw = _new_user(client, monkeypatch)
target, _ = _new_user(client, monkeypatch)
with User(requester, requester_pw, monkeypatch), assert_unauthorized():
client.grant_user_permission(target, "experiment", "exp-1", "READ")
@pytest.mark.parametrize("api_prefix", ["api", "ajax-api"])
@pytest.mark.parametrize(
("endpoint", "method"),
[
("/3.0/mlflow/users/permissions/grant", "POST"),
("/3.0/mlflow/users/permissions/revoke", "POST"),
("/3.0/mlflow/users/permissions/get", "GET"),
],
)
def test_unified_permission_endpoints_reachable_at_both_path_prefixes(
client, api_prefix, endpoint, method
):
# The MLflow frontend hits /ajax-api/ paths; the Python client hits /api/ paths.
# Every unified permission route must be reachable at both — a 404 here would
# silently break the admin UI without surfacing as a permission-system failure.
resp = requests.request(
method,
f"{client.tracking_uri}/{api_prefix}{endpoint}",
auth=(ADMIN_USERNAME, ADMIN_PASSWORD),
)
assert resp.status_code != 404, f"{method} /{api_prefix}{endpoint} unexpectedly returned 404"
# Workspace permission methods that the RBAC migration removed from
# ``AuthServiceClient``. Their replacement is the unified
# ``grant_user_permission`` / ``revoke_user_permission`` surface. This list
# guards against accidental re-addition during refactors or merges.
_REMOVED_WORKSPACE_PERMISSION_METHODS = [
"set_workspace_permission",
"list_workspace_permissions",
"delete_workspace_permission",
"list_user_workspace_permissions",
]
@pytest.mark.parametrize("method_name", _REMOVED_WORKSPACE_PERMISSION_METHODS)
def test_removed_workspace_permission_methods_absent(method_name):
fake_client = AuthServiceClient("http://127.0.0.1:1")
assert not hasattr(fake_client, method_name), (
f"{method_name} was removed in the RBAC migration but is still defined on AuthServiceClient"
)
# Wire endpoints that the RBAC migration removed. Hitting these paths should
# not match any registered handler; Flask returns 404 for unregistered paths.
# Guards against accidental re-registration in ``create_app``.
@pytest.mark.parametrize(
("path", "method"),
[
("/api/2.0/mlflow/workspaces/team-a/permissions/get", "GET"),
("/api/2.0/mlflow/workspaces/team-a/permissions/create", "POST"),
("/api/2.0/mlflow/workspaces/team-a/permissions/update", "PATCH"),
("/api/2.0/mlflow/workspaces/team-a/permissions/delete", "DELETE"),
("/api/3.0/mlflow/workspaces/team-a/permissions/get", "GET"),
("/api/3.0/mlflow/workspaces/team-a/permissions/create", "POST"),
("/api/3.0/mlflow/workspaces/team-a/permissions/update", "PATCH"),
("/api/3.0/mlflow/workspaces/team-a/permissions/delete", "DELETE"),
],
)
def test_removed_workspace_permission_endpoints_return_404(client, path, method):
resp = requests.request(
method, client.tracking_uri + path, auth=(ADMIN_USERNAME, ADMIN_PASSWORD)
)
assert resp.status_code == 404, (
f"{method} {path} was removed in the RBAC migration but returned "
f"{resp.status_code} (expected 404)"
)