test_auth_workspace.py
1 import json 2 from contextlib import contextmanager 3 from types import SimpleNamespace 4 from unittest.mock import MagicMock, Mock 5 6 import pytest 7 from flask import Response, request 8 9 from mlflow.environment_variables import MLFLOW_ENABLE_WORKSPACES 10 from mlflow.exceptions import MlflowException 11 from mlflow.protos.databricks_pb2 import RESOURCE_DOES_NOT_EXIST 12 from mlflow.server import auth as auth_module 13 from mlflow.server.auth.permissions import EDIT, MANAGE, NO_PERMISSIONS, READ 14 from mlflow.server.auth.routes import ( 15 CREATE_PROMPTLAB_RUN, 16 GET_ARTIFACT, 17 GET_METRIC_HISTORY_BULK, 18 GET_METRIC_HISTORY_BULK_INTERVAL, 19 GET_MODEL_VERSION_ARTIFACT, 20 GET_TRACE_ARTIFACT, 21 SEARCH_DATASETS, 22 UPLOAD_ARTIFACT, 23 ) 24 from mlflow.server.auth.sqlalchemy_store import SqlAlchemyStore 25 from mlflow.utils import workspace_context 26 from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME 27 28 from tests.helper_functions import random_str 29 30 31 def test_cleanup_workspace_permissions_handler(monkeypatch): 32 mock_delete_workspace_perms = Mock() 33 mock_delete_roles = Mock() 34 35 monkeypatch.setattr( 36 auth_module.store, 37 "delete_workspace_permissions_for_workspace", 38 mock_delete_workspace_perms, 39 raising=True, 40 ) 41 monkeypatch.setattr( 42 auth_module.store, 43 "delete_roles_for_workspace", 44 mock_delete_roles, 45 raising=True, 46 ) 47 48 workspace_name = f"team-{random_str(10)}" 49 with auth_module.app.test_request_context( 50 f"/api/3.0/mlflow/workspaces/{workspace_name}", method="DELETE" 51 ): 52 request.view_args = {"workspace_name": workspace_name} 53 response = Response(status=204) 54 auth_module._after_request(response) 55 56 mock_delete_workspace_perms.assert_called_once_with(workspace_name) 57 mock_delete_roles.assert_called_once_with(workspace_name) 58 59 60 def _create_workspace_response(workspace_name: str) -> Response: 61 payload = {"workspace": {"name": workspace_name}} 62 return Response(json.dumps(payload), status=201, content_type="application/json") 63 64 65 def test_seed_default_workspace_roles_happy_path(monkeypatch): 66 monkeypatch.setenv("MLFLOW_RBAC_SEED_DEFAULT_ROLES", "true") 67 workspace_name = f"team-{random_str(10)}" 68 69 created_roles: list[dict[str, object]] = [] 70 added_perms: list[dict[str, object]] = [] 71 72 def fake_create_role(name, workspace, description=None): 73 role_id = len(created_roles) + 1 74 created_roles.append({ 75 "id": role_id, 76 "name": name, 77 "workspace": workspace, 78 "description": description, 79 }) 80 return SimpleNamespace(id=role_id, name=name, workspace=workspace) 81 82 def fake_add_role_permission(role_id, resource_type, resource_pattern, permission): 83 added_perms.append({ 84 "role_id": role_id, 85 "resource_type": resource_type, 86 "resource_pattern": resource_pattern, 87 "permission": permission, 88 }) 89 return SimpleNamespace(id=role_id) 90 91 monkeypatch.setattr(auth_module.store, "create_role", fake_create_role, raising=True) 92 monkeypatch.setattr( 93 auth_module.store, "add_role_permission", fake_add_role_permission, raising=True 94 ) 95 96 with auth_module.app.test_request_context("/api/3.0/mlflow/workspaces", method="POST"): 97 auth_module._seed_default_workspace_roles(_create_workspace_response(workspace_name)) 98 99 names = [r["name"] for r in created_roles] 100 assert names == ["workspace-admin", "editor", "viewer"] 101 assert all(r["workspace"] == workspace_name for r in created_roles) 102 103 # All three roles use resource_type='workspace' (the only supported workspace-wide 104 # resource_type in VALID_RESOURCE_TYPES). The permission level differentiates them. 105 assert [(p["resource_type"], p["permission"]) for p in added_perms] == [ 106 ("workspace", MANAGE.name), 107 ("workspace", EDIT.name), 108 ("workspace", READ.name), 109 ] 110 assert all(p["resource_pattern"] == "*" for p in added_perms) 111 112 113 def test_seed_default_workspace_roles_disabled_skips_seeding(monkeypatch): 114 # With seeding off, no roles are created. ``CreateWorkspace`` is gated to 115 # super-admins so the creator already bypasses RBAC — there is nothing to 116 # fall back to. 117 monkeypatch.setenv("MLFLOW_RBAC_SEED_DEFAULT_ROLES", "false") 118 workspace_name = f"team-{random_str(10)}" 119 120 mock_create_role = Mock() 121 mock_add_role_permission = Mock() 122 mock_assign_role_to_user = Mock() 123 mock_set_workspace_permission = Mock() 124 125 monkeypatch.setattr(auth_module.store, "create_role", mock_create_role, raising=True) 126 monkeypatch.setattr( 127 auth_module.store, "add_role_permission", mock_add_role_permission, raising=True 128 ) 129 monkeypatch.setattr( 130 auth_module.store, "assign_role_to_user", mock_assign_role_to_user, raising=True 131 ) 132 monkeypatch.setattr( 133 auth_module.store, 134 "set_workspace_permission", 135 mock_set_workspace_permission, 136 raising=True, 137 ) 138 139 with auth_module.app.test_request_context("/api/3.0/mlflow/workspaces", method="POST"): 140 auth_module._seed_default_workspace_roles(_create_workspace_response(workspace_name)) 141 142 mock_create_role.assert_not_called() 143 mock_add_role_permission.assert_not_called() 144 mock_assign_role_to_user.assert_not_called() 145 mock_set_workspace_permission.assert_not_called() 146 147 148 def test_seed_default_workspace_roles_admin_creation_fails_still_seeds_others(monkeypatch): 149 # Best-effort seeding: a failure on one role doesn't block the rest. 150 monkeypatch.setenv("MLFLOW_RBAC_SEED_DEFAULT_ROLES", "true") 151 workspace_name = f"team-{random_str(10)}" 152 153 def fake_create_role(name, workspace, description=None): 154 if name == "workspace-admin": 155 raise MlflowException("simulated admin role failure") 156 return SimpleNamespace(id=10, name=name, workspace=workspace) 157 158 mock_add_role_permission = Mock() 159 160 monkeypatch.setattr(auth_module.store, "create_role", fake_create_role, raising=True) 161 monkeypatch.setattr( 162 auth_module.store, "add_role_permission", mock_add_role_permission, raising=True 163 ) 164 165 with auth_module.app.test_request_context("/api/3.0/mlflow/workspaces", method="POST"): 166 auth_module._seed_default_workspace_roles(_create_workspace_response(workspace_name)) 167 168 # editor and viewer still got created (best-effort seeding). 169 assert mock_add_role_permission.call_count == 2 170 171 172 def test_seed_default_workspace_roles_permission_add_fails_rolls_back_role(monkeypatch): 173 # create_role succeeds but add_role_permission raises — the orphan role must be 174 # deleted so the workspace doesn't end up with a named role that grants nothing. 175 monkeypatch.setenv("MLFLOW_RBAC_SEED_DEFAULT_ROLES", "true") 176 workspace_name = f"team-{random_str(10)}" 177 178 def fake_create_role(name, workspace, description=None): 179 return SimpleNamespace( 180 id={"workspace-admin": 1, "editor": 2, "viewer": 3}[name], 181 name=name, 182 workspace=workspace, 183 ) 184 185 def fake_add_role_permission(role_id, resource_type, resource_pattern, permission): 186 if role_id == 1: # workspace-admin 187 raise MlflowException("simulated add_role_permission failure") 188 return SimpleNamespace(id=role_id) 189 190 mock_delete_role = Mock() 191 192 monkeypatch.setattr(auth_module.store, "create_role", fake_create_role, raising=True) 193 monkeypatch.setattr( 194 auth_module.store, "add_role_permission", fake_add_role_permission, raising=True 195 ) 196 monkeypatch.setattr(auth_module.store, "delete_role", mock_delete_role, raising=True) 197 198 with auth_module.app.test_request_context("/api/3.0/mlflow/workspaces", method="POST"): 199 auth_module._seed_default_workspace_roles(_create_workspace_response(workspace_name)) 200 201 # Orphan workspace-admin role (id=1) was rolled back. 202 mock_delete_role.assert_called_once_with(1) 203 204 205 class _TrackingStore: 206 def __init__( 207 self, 208 experiment_workspaces: dict[str, str], 209 run_experiments: dict[str, str], 210 trace_experiments: dict[str, str], 211 experiment_names: dict[str, str] | None = None, 212 logged_model_experiments: dict[str, str] | None = None, 213 gateway_secret_workspaces: dict[str, str] | None = None, 214 gateway_endpoint_workspaces: dict[str, str] | None = None, 215 gateway_model_def_workspaces: dict[str, str] | None = None, 216 engine=None, 217 ManagedSessionMaker=None, 218 ): 219 self._experiment_workspaces = experiment_workspaces 220 self._run_experiments = run_experiments 221 self._trace_experiments = trace_experiments 222 self._experiment_names = experiment_names or {} 223 self._logged_model_experiments = logged_model_experiments or {} 224 self._gateway_secret_workspaces = gateway_secret_workspaces or {} 225 self._gateway_endpoint_workspaces = gateway_endpoint_workspaces or {} 226 self._gateway_model_def_workspaces = gateway_model_def_workspaces or {} 227 self.engine = engine 228 self.ManagedSessionMaker = ManagedSessionMaker 229 230 def get_experiment(self, experiment_id: str): 231 return SimpleNamespace(workspace=self._experiment_workspaces[experiment_id]) 232 233 def get_experiment_by_name(self, experiment_name: str): 234 experiment_id = self._experiment_names.get(experiment_name) 235 if experiment_id is None: 236 return None 237 return SimpleNamespace( 238 experiment_id=experiment_id, 239 workspace=self._experiment_workspaces[experiment_id], 240 ) 241 242 def get_run(self, run_id: str): 243 return SimpleNamespace(info=SimpleNamespace(experiment_id=self._run_experiments[run_id])) 244 245 def get_trace_info(self, request_id: str): 246 return SimpleNamespace(experiment_id=self._trace_experiments[request_id]) 247 248 def get_logged_model(self, model_id: str): 249 experiment_id = self._logged_model_experiments[model_id] 250 return SimpleNamespace(experiment_id=experiment_id) 251 252 def get_secret_info(self, secret_id: str | None = None, secret_name: str | None = None): 253 if secret_id: 254 if secret_id not in self._gateway_secret_workspaces: 255 raise MlflowException( 256 f"GatewaySecret not found ({secret_id})", 257 error_code=RESOURCE_DOES_NOT_EXIST, 258 ) 259 # Add workspace attribute so _get_resource_workspace can extract it 260 return SimpleNamespace( 261 secret_id=secret_id, workspace=self._gateway_secret_workspaces[secret_id] 262 ) 263 raise ValueError("Must provide secret_id or secret_name") 264 265 def get_gateway_endpoint(self, endpoint_id: str | None = None, name: str | None = None): 266 # For test simplicity we treat ``name`` as a synonym for ``endpoint_id`` 267 # (our fixture data uses the same string for both). This mirrors how the 268 # real store resolves a name → id lookup before returning the endpoint. 269 if lookup_id := (endpoint_id or name): 270 if lookup_id not in self._gateway_endpoint_workspaces: 271 raise MlflowException( 272 f"GatewayEndpoint not found ({lookup_id})", 273 error_code=RESOURCE_DOES_NOT_EXIST, 274 ) 275 # Add workspace attribute so _get_resource_workspace can extract it 276 return SimpleNamespace( 277 endpoint_id=lookup_id, workspace=self._gateway_endpoint_workspaces[lookup_id] 278 ) 279 raise ValueError("Must provide endpoint_id or name") 280 281 def get_gateway_model_definition( 282 self, model_definition_id: str | None = None, name: str | None = None 283 ): 284 if model_definition_id: 285 if model_definition_id not in self._gateway_model_def_workspaces: 286 raise MlflowException( 287 f"GatewayModelDefinition not found ({model_definition_id})", 288 error_code=RESOURCE_DOES_NOT_EXIST, 289 ) 290 # Add workspace attribute so _get_resource_workspace can extract it 291 return SimpleNamespace( 292 model_definition_id=model_definition_id, 293 workspace=self._gateway_model_def_workspaces[model_definition_id], 294 ) 295 raise ValueError("Must provide model_definition_id or name") 296 297 def _create_mock_session(self): 298 """Create a mock session that can query gateway SQL models.""" 299 mock_session = MagicMock() 300 301 def _filter_by_secret_id(secret_id): 302 if secret_id in self._gateway_secret_workspaces: 303 mock_result = MagicMock() 304 mock_result.first.return_value = SimpleNamespace( 305 workspace=self._gateway_secret_workspaces[secret_id] 306 ) 307 return mock_result 308 mock_result = MagicMock() 309 mock_result.first.return_value = None 310 return mock_result 311 312 def _filter_by_endpoint_id(endpoint_id): 313 if endpoint_id in self._gateway_endpoint_workspaces: 314 mock_result = MagicMock() 315 mock_result.first.return_value = SimpleNamespace( 316 workspace=self._gateway_endpoint_workspaces[endpoint_id] 317 ) 318 return mock_result 319 mock_result = MagicMock() 320 mock_result.first.return_value = None 321 return mock_result 322 323 def _filter_by_model_def_id(model_definition_id): 324 if model_definition_id in self._gateway_model_def_workspaces: 325 mock_result = MagicMock() 326 mock_result.first.return_value = SimpleNamespace( 327 workspace=self._gateway_model_def_workspaces[model_definition_id] 328 ) 329 return mock_result 330 mock_result = MagicMock() 331 mock_result.first.return_value = None 332 return mock_result 333 334 def _query(model_class): 335 mock_query_result = MagicMock() 336 # Mock the filter method to return different results based on the filter 337 338 def _mock_filter(*args, **kwargs): 339 if "secret_id" in kwargs: 340 return _filter_by_secret_id(kwargs["secret_id"]) 341 elif "endpoint_id" in kwargs: 342 return _filter_by_endpoint_id(kwargs["endpoint_id"]) 343 elif "model_definition_id" in kwargs: 344 return _filter_by_model_def_id(kwargs["model_definition_id"]) 345 return mock_query_result 346 347 mock_query_result.filter = _mock_filter 348 return mock_query_result 349 350 mock_session.query = _query 351 return mock_session 352 353 def _create_mock_session_maker(self): 354 """Create a mock ManagedSessionMaker context manager.""" 355 356 @contextmanager 357 def _mock_session_maker(): 358 yield self._create_mock_session() 359 360 return _mock_session_maker 361 362 363 class _RegistryStore: 364 def __init__(self, model_workspaces: dict[str, str]): 365 self._model_workspaces = model_workspaces 366 367 def get_registered_model(self, name: str): 368 return SimpleNamespace(workspace=self._model_workspaces[name]) 369 370 371 @pytest.fixture 372 def workspace_permission_setup(tmp_path, monkeypatch): 373 monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true") 374 monkeypatch.setattr( 375 auth_module, 376 "auth_config", 377 auth_module.auth_config._replace(default_permission=NO_PERMISSIONS.name), 378 ) 379 380 db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}" 381 auth_store = SqlAlchemyStore() 382 auth_store.init_db(db_uri) 383 monkeypatch.setattr(auth_module, "store", auth_store, raising=False) 384 385 username = "alice" 386 auth_store.create_user(username, "supersecurepassword", is_admin=False) 387 388 tracking_store = _TrackingStore( 389 experiment_workspaces={"exp-1": "team-a", "exp-2": "team-a", "1": "team-a"}, 390 run_experiments={"run-1": "exp-1", "run-2": "exp-2"}, 391 trace_experiments={"trace-1": "exp-1"}, 392 experiment_names={"Primary Experiment": "exp-1"}, 393 logged_model_experiments={"model-1": "exp-1"}, 394 gateway_secret_workspaces={"secret-1": "team-a", "secret-2": "team-a"}, 395 gateway_endpoint_workspaces={"endpoint-1": "team-a", "endpoint-2": "team-a"}, 396 gateway_model_def_workspaces={"model-def-1": "team-a", "model-def-2": "team-a"}, 397 engine=MagicMock(), # Mock engine for SQL model queries 398 ) 399 # Set ManagedSessionMaker after creating the store 400 tracking_store.ManagedSessionMaker = tracking_store._create_mock_session_maker() 401 monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store) 402 403 registry_store = _RegistryStore({"model-xyz": "team-a"}) 404 monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store) 405 406 monkeypatch.setattr( 407 auth_module, 408 "authenticate_request", 409 lambda: SimpleNamespace(username=username), 410 ) 411 412 auth_store.set_workspace_permission("team-a", username, MANAGE.name) 413 414 with workspace_context.WorkspaceContext("team-a"): 415 yield {"store": auth_store, "username": username} 416 auth_store.engine.dispose() 417 418 419 def _set_workspace_permission(store: SqlAlchemyStore, username: str, permission: str): 420 store.set_workspace_permission("team-a", username, permission) 421 422 423 def test_workspace_permission_grants_default_access(monkeypatch): 424 monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true") 425 426 default_permission = MANAGE.name 427 monkeypatch.setattr( 428 auth_module, 429 "auth_config", 430 auth_module.auth_config._replace( 431 default_permission=default_permission, 432 grant_default_workspace_access=True, 433 ), 434 raising=False, 435 ) 436 437 class DummyStore: 438 def get_workspace_permission(self, workspace_name, username): 439 return None 440 441 def list_accessible_workspace_names(self, username): 442 return [] 443 444 dummy_store = DummyStore() 445 monkeypatch.setattr(auth_module, "store", dummy_store, raising=False) 446 447 default_workspace = DEFAULT_WORKSPACE_NAME 448 monkeypatch.setattr(auth_module, "_get_workspace_store", lambda: None, raising=False) 449 monkeypatch.setattr( 450 auth_module, 451 "get_default_workspace_optional", 452 lambda *args, **kwargs: (SimpleNamespace(name=default_workspace), True), 453 raising=False, 454 ) 455 456 auth = SimpleNamespace(username="alice") 457 permission = auth_module._workspace_permission(auth.username, default_workspace) 458 assert permission is not None 459 assert permission.can_manage 460 461 with workspace_context.WorkspaceContext(default_workspace): 462 monkeypatch.setattr(auth_module, "authenticate_request", lambda: auth) 463 assert auth_module.validate_can_create_experiment() 464 465 466 def test_filter_list_workspaces_includes_default_when_autogrant(monkeypatch): 467 monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False) 468 auth = SimpleNamespace(username="alice") 469 monkeypatch.setattr(auth_module, "authenticate_request", lambda: auth) 470 monkeypatch.setattr( 471 auth_module, 472 "auth_config", 473 auth_module.auth_config._replace( 474 grant_default_workspace_access=True, 475 default_permission=READ.name, 476 ), 477 raising=False, 478 ) 479 480 default_workspace = "team-default" 481 monkeypatch.setattr(auth_module, "_get_workspace_store", lambda: None, raising=False) 482 monkeypatch.setattr( 483 auth_module, 484 "get_default_workspace_optional", 485 lambda *args, **kwargs: (SimpleNamespace(name=default_workspace), True), 486 raising=False, 487 ) 488 489 class DummyStore: 490 def list_accessible_workspace_names(self, username): 491 return [] 492 493 monkeypatch.setattr(auth_module, "store", DummyStore(), raising=False) 494 495 response = Response( 496 json.dumps({ 497 "workspaces": [ 498 {"name": default_workspace}, 499 {"name": "other-workspace"}, 500 ] 501 }), 502 mimetype="application/json", 503 ) 504 505 auth_module.filter_list_workspaces(response) 506 payload = json.loads(response.get_data(as_text=True)) 507 assert payload["workspaces"] == [{"name": default_workspace}] 508 509 510 def test_filter_list_workspaces_filters_to_allowed(monkeypatch): 511 monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False) 512 auth = SimpleNamespace(username="alice") 513 monkeypatch.setattr(auth_module, "authenticate_request", lambda: auth) 514 monkeypatch.setattr( 515 auth_module, 516 "auth_config", 517 auth_module.auth_config._replace( 518 grant_default_workspace_access=False, 519 ), 520 raising=False, 521 ) 522 523 class DummyStore: 524 def list_accessible_workspace_names(self, username): 525 return ["team-a"] 526 527 monkeypatch.setattr(auth_module, "store", DummyStore(), raising=False) 528 529 response = Response( 530 json.dumps({"workspaces": [{"name": "team-a"}, {"name": "team-b"}]}), 531 mimetype="application/json", 532 ) 533 534 auth_module.filter_list_workspaces(response) 535 payload = json.loads(response.get_data(as_text=True)) 536 assert [ws["name"] for ws in payload["workspaces"]] == ["team-a"] 537 538 539 def test_list_workspaces_filters_to_role_assigned_workspaces(tmp_path, monkeypatch): 540 # End-to-end guard for the list_accessible_workspace_names fix: alice has NO 541 # legacy workspace_permissions rows — her only workspace membership is via a 542 # role assignment in ws-alpha. The ListWorkspaces filter must treat that role 543 # assignment as workspace visibility and surface ws-alpha but not ws-beta. 544 # Before the fix, the legacy-only query returned an empty set and alice saw 545 # no workspaces in the UI. 546 monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true") 547 monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False) 548 monkeypatch.setattr( 549 auth_module, 550 "auth_config", 551 auth_module.auth_config._replace(grant_default_workspace_access=False), 552 raising=False, 553 ) 554 555 db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}" 556 auth_store = SqlAlchemyStore() 557 auth_store.init_db(db_uri) 558 monkeypatch.setattr(auth_module, "store", auth_store, raising=False) 559 560 alice = auth_store.create_user("alice", "supersecurepassword", is_admin=False) 561 role = auth_store.create_role(name="viewer", workspace="ws-alpha") 562 auth_store.add_role_permission(role.id, "experiment", "*", READ.name) 563 auth_store.assign_role_to_user(alice.id, role.id) 564 565 monkeypatch.setattr( 566 auth_module, "authenticate_request", lambda: SimpleNamespace(username="alice") 567 ) 568 569 response = Response( 570 json.dumps({"workspaces": [{"name": "ws-alpha"}, {"name": "ws-beta"}]}), 571 mimetype="application/json", 572 ) 573 574 auth_module.filter_list_workspaces(response) 575 payload = json.loads(response.get_data(as_text=True)) 576 assert [ws["name"] for ws in payload["workspaces"]] == ["ws-alpha"] 577 578 auth_store.engine.dispose() 579 580 581 def test_validate_can_view_workspace_allows_default_autogrant(monkeypatch): 582 monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true") 583 monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False) 584 auth = SimpleNamespace(username="alice") 585 monkeypatch.setattr(auth_module, "authenticate_request", lambda: auth) 586 monkeypatch.setattr( 587 auth_module, 588 "auth_config", 589 auth_module.auth_config._replace( 590 grant_default_workspace_access=True, 591 default_permission=READ.name, 592 ), 593 raising=False, 594 ) 595 596 default_workspace = "team-default" 597 monkeypatch.setattr(auth_module, "_get_workspace_store", lambda: None, raising=False) 598 monkeypatch.setattr( 599 auth_module, 600 "get_default_workspace_optional", 601 lambda *args, **kwargs: (SimpleNamespace(name=default_workspace), True), 602 raising=False, 603 ) 604 605 class DummyStore: 606 def list_accessible_workspace_names(self, username): 607 return [] 608 609 monkeypatch.setattr(auth_module, "store", DummyStore(), raising=False) 610 611 with auth_module.app.test_request_context( 612 f"/api/3.0/mlflow/workspaces/{default_workspace}", method="GET" 613 ): 614 request.view_args = {"workspace_name": default_workspace} 615 assert auth_module.validate_can_view_workspace() 616 617 with auth_module.app.test_request_context( 618 "/api/3.0/mlflow/workspaces/other-team", method="GET" 619 ): 620 request.view_args = {"workspace_name": "other-team"} 621 assert not auth_module.validate_can_view_workspace() 622 623 624 def test_experiment_validators_allow_manage_permission(workspace_permission_setup): 625 store = workspace_permission_setup["store"] 626 username = workspace_permission_setup["username"] 627 _set_workspace_permission(store, username, MANAGE.name) 628 629 with auth_module.app.test_request_context( 630 "/api/2.0/mlflow/experiments/get", method="GET", query_string={"experiment_id": "exp-1"} 631 ): 632 assert auth_module.validate_can_read_experiment() 633 assert auth_module.validate_can_update_experiment() 634 assert auth_module.validate_can_delete_experiment() 635 assert auth_module.validate_can_manage_experiment() 636 637 with auth_module.app.test_request_context( 638 "/api/2.0/mlflow/experiments/get-by-name", 639 method="GET", 640 query_string={"experiment_name": "Primary Experiment"}, 641 ): 642 assert auth_module.validate_can_read_experiment_by_name() 643 644 with workspace_context.WorkspaceContext("team-a"): 645 assert auth_module.validate_can_create_experiment() 646 647 648 def test_experiment_validators_read_permission_blocks_writes(workspace_permission_setup): 649 store = workspace_permission_setup["store"] 650 username = workspace_permission_setup["username"] 651 _set_workspace_permission(store, username, READ.name) 652 653 with auth_module.app.test_request_context( 654 "/api/2.0/mlflow/experiments/get", method="GET", query_string={"experiment_id": "exp-1"} 655 ): 656 assert auth_module.validate_can_read_experiment() 657 assert not auth_module.validate_can_update_experiment() 658 assert not auth_module.validate_can_delete_experiment() 659 assert not auth_module.validate_can_manage_experiment() 660 661 with auth_module.app.test_request_context( 662 "/api/2.0/mlflow/experiments/get-by-name", 663 method="GET", 664 query_string={"experiment_name": "Primary Experiment"}, 665 ): 666 assert auth_module.validate_can_read_experiment_by_name() 667 668 with workspace_context.WorkspaceContext("team-a"): 669 assert not auth_module.validate_can_create_experiment() 670 671 672 def test_experiment_artifact_proxy_validators_respect_permissions(workspace_permission_setup): 673 store = workspace_permission_setup["store"] 674 username = workspace_permission_setup["username"] 675 _set_workspace_permission(store, username, MANAGE.name) 676 677 with auth_module.app.test_request_context( 678 "/ajax-api/2.0/mlflow-artifacts/artifacts/1/path", 679 method="GET", 680 ): 681 request.view_args = {"artifact_path": "1/path"} 682 assert auth_module.validate_can_read_experiment_artifact_proxy() 683 assert auth_module.validate_can_update_experiment_artifact_proxy() 684 assert auth_module.validate_can_delete_experiment_artifact_proxy() 685 686 _set_workspace_permission(store, username, READ.name) 687 688 with auth_module.app.test_request_context( 689 "/ajax-api/2.0/mlflow-artifacts/artifacts/1/path", 690 method="GET", 691 ): 692 request.view_args = {"artifact_path": "1/path"} 693 assert auth_module.validate_can_read_experiment_artifact_proxy() 694 assert not auth_module.validate_can_update_experiment_artifact_proxy() 695 assert not auth_module.validate_can_delete_experiment_artifact_proxy() 696 697 698 def test_experiment_artifact_proxy_without_experiment_id_uses_workspace_permissions( 699 workspace_permission_setup, 700 ): 701 store = workspace_permission_setup["store"] 702 username = workspace_permission_setup["username"] 703 _set_workspace_permission(store, username, READ.name) 704 705 with auth_module.app.test_request_context( 706 "/ajax-api/2.0/mlflow-artifacts/artifacts/uploads/path", 707 method="GET", 708 ): 709 request.view_args = {"artifact_path": "uploads/path"} 710 assert auth_module.validate_can_read_experiment_artifact_proxy() 711 assert not auth_module.validate_can_update_experiment_artifact_proxy() 712 713 714 def test_experiment_artifact_proxy_without_experiment_id_denied_without_workspace_permission( 715 workspace_permission_setup, 716 ): 717 store = workspace_permission_setup["store"] 718 username = workspace_permission_setup["username"] 719 _set_workspace_permission(store, username, NO_PERMISSIONS.name) 720 721 with auth_module.app.test_request_context( 722 "/ajax-api/2.0/mlflow-artifacts/artifacts/uploads/path", 723 method="GET", 724 ): 725 request.view_args = {"artifact_path": "uploads/path"} 726 assert not auth_module.validate_can_read_experiment_artifact_proxy() 727 728 729 def test_filter_experiment_ids_respects_workspace_permissions( 730 workspace_permission_setup, monkeypatch 731 ): 732 store = workspace_permission_setup["store"] 733 username = workspace_permission_setup["username"] 734 monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False) 735 736 experiment_ids = ["exp-1", "exp-2"] 737 assert auth_module.filter_experiment_ids(experiment_ids) == experiment_ids 738 739 _set_workspace_permission(store, username, NO_PERMISSIONS.name) 740 assert auth_module.filter_experiment_ids(experiment_ids) == [] 741 742 743 def test_filter_experiment_ids_role_wildcard_grant(workspace_permission_setup, monkeypatch): 744 # Role granting experiment(*) in the active workspace should include all experiments. 745 store = workspace_permission_setup["store"] 746 username = workspace_permission_setup["username"] 747 user_id = store.get_user(username).id 748 monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False) 749 # Start from NO_PERMISSIONS: workspace fallback would exclude everything. 750 _set_workspace_permission(store, username, NO_PERMISSIONS.name) 751 752 role = store.create_role(name="exp-reader", workspace="team-a") 753 store.add_role_permission(role.id, "experiment", "*", "READ") 754 store.assign_role_to_user(user_id, role.id) 755 756 token = workspace_context.set_server_request_workspace("team-a") 757 try: 758 assert auth_module.filter_experiment_ids(["exp-1", "exp-2"]) == ["exp-1", "exp-2"] 759 finally: 760 workspace_context._WORKSPACE.reset(token) 761 762 763 def test_filter_experiment_ids_role_specific_grant(workspace_permission_setup, monkeypatch): 764 # Role granting a specific experiment id should include that id only (plus direct grants). 765 store = workspace_permission_setup["store"] 766 username = workspace_permission_setup["username"] 767 user_id = store.get_user(username).id 768 monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False) 769 _set_workspace_permission(store, username, NO_PERMISSIONS.name) 770 771 role = store.create_role(name="exp-1-reader", workspace="team-a") 772 store.add_role_permission(role.id, "experiment", "exp-1", "READ") 773 store.assign_role_to_user(user_id, role.id) 774 775 token = workspace_context.set_server_request_workspace("team-a") 776 try: 777 # Only exp-1 (via role); exp-2 is filtered out. 778 assert auth_module.filter_experiment_ids(["exp-1", "exp-2"]) == ["exp-1"] 779 finally: 780 workspace_context._WORKSPACE.reset(token) 781 782 783 def test_filter_experiment_ids_workspace_scope_role(workspace_permission_setup, monkeypatch): 784 # Role with (resource_type='workspace', '*', READ) should grant access to all experiments. 785 store = workspace_permission_setup["store"] 786 username = workspace_permission_setup["username"] 787 user_id = store.get_user(username).id 788 monkeypatch.setattr(auth_module, "sender_is_admin", lambda: False) 789 _set_workspace_permission(store, username, NO_PERMISSIONS.name) 790 791 role = store.create_role(name="ws-reader", workspace="team-a") 792 store.add_role_permission(role.id, "workspace", "*", "READ") 793 store.assign_role_to_user(user_id, role.id) 794 795 token = workspace_context.set_server_request_workspace("team-a") 796 try: 797 assert auth_module.filter_experiment_ids(["exp-1", "exp-2"]) == ["exp-1", "exp-2"] 798 finally: 799 workspace_context._WORKSPACE.reset(token) 800 801 802 def test_run_validators_allow_manage_permission(workspace_permission_setup): 803 store = workspace_permission_setup["store"] 804 username = workspace_permission_setup["username"] 805 _set_workspace_permission(store, username, MANAGE.name) 806 807 with auth_module.app.test_request_context( 808 "/api/2.0/mlflow/runs/get", method="GET", query_string={"run_id": "run-1"} 809 ): 810 assert auth_module.validate_can_read_run() 811 assert auth_module.validate_can_update_run() 812 assert auth_module.validate_can_delete_run() 813 assert auth_module.validate_can_manage_run() 814 815 816 def test_run_validators_read_permission_blocks_writes(workspace_permission_setup): 817 store = workspace_permission_setup["store"] 818 username = workspace_permission_setup["username"] 819 _set_workspace_permission(store, username, READ.name) 820 821 with auth_module.app.test_request_context( 822 "/api/2.0/mlflow/runs/get", method="GET", query_string={"run_id": "run-1"} 823 ): 824 assert auth_module.validate_can_read_run() 825 assert not auth_module.validate_can_update_run() 826 assert not auth_module.validate_can_delete_run() 827 assert not auth_module.validate_can_manage_run() 828 829 830 def test_logged_model_validators_respect_permissions(workspace_permission_setup): 831 store = workspace_permission_setup["store"] 832 username = workspace_permission_setup["username"] 833 834 _set_workspace_permission(store, username, MANAGE.name) 835 with auth_module.app.test_request_context( 836 "/api/2.0/mlflow/logged-models/get", 837 method="GET", 838 query_string={"model_id": "model-1"}, 839 ): 840 assert auth_module.validate_can_read_logged_model() 841 assert auth_module.validate_can_update_logged_model() 842 assert auth_module.validate_can_delete_logged_model() 843 assert auth_module.validate_can_manage_logged_model() 844 845 _set_workspace_permission(store, username, READ.name) 846 with auth_module.app.test_request_context( 847 "/api/2.0/mlflow/logged-models/get", 848 method="GET", 849 query_string={"model_id": "model-1"}, 850 ): 851 assert auth_module.validate_can_read_logged_model() 852 assert not auth_module.validate_can_update_logged_model() 853 assert not auth_module.validate_can_delete_logged_model() 854 assert not auth_module.validate_can_manage_logged_model() 855 856 857 def test_scorer_validators_use_workspace_permissions(workspace_permission_setup): 858 store = workspace_permission_setup["store"] 859 username = workspace_permission_setup["username"] 860 _set_workspace_permission(store, username, MANAGE.name) 861 862 with auth_module.app.test_request_context( 863 "/api/3.0/mlflow/scorers/get", 864 method="GET", 865 query_string={"experiment_id": "exp-1", "name": "score-1"}, 866 ): 867 assert auth_module.validate_can_read_scorer() 868 assert auth_module.validate_can_update_scorer() 869 assert auth_module.validate_can_delete_scorer() 870 assert auth_module.validate_can_manage_scorer() 871 872 with auth_module.app.test_request_context( 873 "/api/3.0/mlflow/scorers/permissions/create", 874 method="POST", 875 json={ 876 "experiment_id": "exp-1", 877 "scorer_name": "score-1", 878 "username": "bob", 879 "permission": "READ", 880 }, 881 ): 882 assert auth_module.validate_can_manage_scorer_permission() 883 884 885 def test_scorer_validators_read_permission_blocks_writes(workspace_permission_setup): 886 store = workspace_permission_setup["store"] 887 username = workspace_permission_setup["username"] 888 _set_workspace_permission(store, username, READ.name) 889 890 with auth_module.app.test_request_context( 891 "/api/3.0/mlflow/scorers/get", 892 method="GET", 893 query_string={"experiment_id": "exp-1", "name": "score-1"}, 894 ): 895 assert auth_module.validate_can_read_scorer() 896 assert not auth_module.validate_can_update_scorer() 897 assert not auth_module.validate_can_delete_scorer() 898 assert not auth_module.validate_can_manage_scorer() 899 900 with auth_module.app.test_request_context( 901 "/api/3.0/mlflow/scorers/permissions/create", 902 method="POST", 903 json={ 904 "experiment_id": "exp-1", 905 "scorer_name": "score-1", 906 "username": "bob", 907 "permission": "READ", 908 }, 909 ): 910 assert not auth_module.validate_can_manage_scorer_permission() 911 912 913 def test_registered_model_validators_require_manage_for_writes(workspace_permission_setup): 914 store = workspace_permission_setup["store"] 915 username = workspace_permission_setup["username"] 916 917 with workspace_context.WorkspaceContext("team-a"): 918 _set_workspace_permission(store, username, MANAGE.name) 919 with auth_module.app.test_request_context( 920 "/api/2.0/mlflow/registered-models/get", 921 method="GET", 922 query_string={"name": "model-xyz"}, 923 ): 924 assert auth_module.validate_can_read_registered_model() 925 assert auth_module.validate_can_update_registered_model() 926 assert auth_module.validate_can_delete_registered_model() 927 assert auth_module.validate_can_manage_registered_model() 928 perm = auth_module._workspace_permission( 929 auth_module.authenticate_request().username, "team-a" 930 ) 931 assert perm is not None 932 assert perm.can_manage 933 assert workspace_context.get_request_workspace() == "team-a" 934 assert auth_module.validate_can_create_registered_model() 935 936 _set_workspace_permission(store, username, READ.name) 937 with auth_module.app.test_request_context( 938 "/api/2.0/mlflow/registered-models/get", 939 method="GET", 940 query_string={"name": "model-xyz"}, 941 ): 942 assert auth_module.validate_can_read_registered_model() 943 assert not auth_module.validate_can_update_registered_model() 944 assert not auth_module.validate_can_delete_registered_model() 945 assert not auth_module.validate_can_manage_registered_model() 946 assert not auth_module.validate_can_create_registered_model() 947 948 949 def test_validate_can_view_workspace_requires_access(workspace_permission_setup): 950 store = workspace_permission_setup["store"] 951 username = workspace_permission_setup["username"] 952 953 with auth_module.app.test_request_context( 954 "/api/3.0/mlflow/workspaces/team-a", 955 method="GET", 956 ): 957 request.view_args = {"workspace_name": "team-a"} 958 assert auth_module.validate_can_view_workspace() 959 960 store.delete_workspace_permission("team-a", username) 961 962 with auth_module.app.test_request_context( 963 "/api/3.0/mlflow/workspaces/team-a", 964 method="GET", 965 ): 966 request.view_args = {"workspace_name": "team-a"} 967 assert not auth_module.validate_can_view_workspace() 968 969 970 def test_run_artifact_validators_use_workspace_permissions(workspace_permission_setup): 971 with auth_module.app.test_request_context( 972 GET_ARTIFACT, 973 method="GET", 974 query_string={"run_id": "run-1"}, 975 ): 976 assert auth_module.validate_can_read_run_artifact() 977 978 with auth_module.app.test_request_context( 979 UPLOAD_ARTIFACT, 980 method="POST", 981 query_string={"run_id": "run-1"}, 982 ): 983 assert auth_module.validate_can_update_run_artifact() 984 985 986 def test_model_version_artifact_validator_uses_workspace_permissions(workspace_permission_setup): 987 with auth_module.app.test_request_context( 988 GET_MODEL_VERSION_ARTIFACT, 989 method="GET", 990 query_string={"name": "model-xyz"}, 991 ): 992 assert auth_module.validate_can_read_model_version_artifact() 993 994 995 def test_metric_history_bulk_validator_uses_workspace_permissions(workspace_permission_setup): 996 with auth_module.app.test_request_context( 997 GET_METRIC_HISTORY_BULK, 998 method="GET", 999 query_string=[("run_id", "run-1"), ("run_id", "run-2")], 1000 ): 1001 assert auth_module.validate_can_read_metric_history_bulk() 1002 1003 1004 def test_metric_history_bulk_interval_validator_uses_workspace_permissions( 1005 workspace_permission_setup, 1006 ): 1007 with auth_module.app.test_request_context( 1008 GET_METRIC_HISTORY_BULK_INTERVAL, 1009 method="GET", 1010 query_string=[ 1011 ("run_ids", "run-1"), 1012 ("run_ids", "run-2"), 1013 ("metric_key", "loss"), 1014 ], 1015 ): 1016 assert auth_module.validate_can_read_metric_history_bulk_interval() 1017 1018 1019 def test_search_datasets_validator_uses_workspace_permissions(workspace_permission_setup): 1020 with auth_module.app.test_request_context( 1021 SEARCH_DATASETS, 1022 method="POST", 1023 json={"experiment_ids": ["exp-1", "exp-2"]}, 1024 ): 1025 assert auth_module.validate_can_search_datasets() 1026 1027 1028 def test_create_promptlab_run_validator_uses_workspace_permissions(workspace_permission_setup): 1029 with auth_module.app.test_request_context( 1030 CREATE_PROMPTLAB_RUN, 1031 method="POST", 1032 json={"experiment_id": "exp-2"}, 1033 ): 1034 assert auth_module.validate_can_create_promptlab_run() 1035 1036 1037 def test_trace_artifact_validator_uses_workspace_permissions(workspace_permission_setup): 1038 with auth_module.app.test_request_context( 1039 GET_TRACE_ARTIFACT, 1040 method="GET", 1041 query_string={"request_id": "trace-1"}, 1042 ): 1043 assert auth_module.validate_can_read_trace_artifact() 1044 1045 1046 def test_experiment_artifact_proxy_without_workspaces_falls_back_to_default(monkeypatch): 1047 monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "false") 1048 monkeypatch.setattr( 1049 auth_module, 1050 "auth_config", 1051 auth_module.auth_config._replace(default_permission=READ.name), 1052 raising=False, 1053 ) 1054 monkeypatch.setattr( 1055 auth_module, 1056 "authenticate_request", 1057 lambda: SimpleNamespace(username="carol"), 1058 ) 1059 1060 with auth_module.app.test_request_context( 1061 "/ajax-api/2.0/mlflow-artifacts/artifacts/uploads/path", 1062 method="GET", 1063 ): 1064 request.view_args = {"artifact_path": "uploads/path"} 1065 assert auth_module.validate_can_read_experiment_artifact_proxy() 1066 1067 1068 def test_run_artifact_validators_denied_without_workspace_permission(workspace_permission_setup): 1069 store = workspace_permission_setup["store"] 1070 username = workspace_permission_setup["username"] 1071 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1072 1073 with auth_module.app.test_request_context( 1074 GET_ARTIFACT, 1075 method="GET", 1076 query_string={"run_id": "run-1"}, 1077 ): 1078 assert not auth_module.validate_can_read_run_artifact() 1079 1080 with auth_module.app.test_request_context( 1081 UPLOAD_ARTIFACT, 1082 method="POST", 1083 query_string={"run_id": "run-1"}, 1084 ): 1085 assert not auth_module.validate_can_update_run_artifact() 1086 1087 1088 def test_model_version_artifact_validator_denied_without_workspace_permission( 1089 workspace_permission_setup, 1090 ): 1091 store = workspace_permission_setup["store"] 1092 username = workspace_permission_setup["username"] 1093 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1094 1095 with auth_module.app.test_request_context( 1096 GET_MODEL_VERSION_ARTIFACT, 1097 method="GET", 1098 query_string={"name": "model-xyz"}, 1099 ): 1100 assert not auth_module.validate_can_read_model_version_artifact() 1101 1102 1103 def test_metric_history_bulk_validator_denied_without_workspace_permission( 1104 workspace_permission_setup, 1105 ): 1106 store = workspace_permission_setup["store"] 1107 username = workspace_permission_setup["username"] 1108 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1109 1110 with auth_module.app.test_request_context( 1111 GET_METRIC_HISTORY_BULK, 1112 method="GET", 1113 query_string=[("run_id", "run-1"), ("run_id", "run-2")], 1114 ): 1115 assert not auth_module.validate_can_read_metric_history_bulk() 1116 1117 1118 def test_metric_history_bulk_interval_validator_denied_without_workspace_permission( 1119 workspace_permission_setup, 1120 ): 1121 store = workspace_permission_setup["store"] 1122 username = workspace_permission_setup["username"] 1123 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1124 1125 with auth_module.app.test_request_context( 1126 GET_METRIC_HISTORY_BULK_INTERVAL, 1127 method="GET", 1128 query_string=[ 1129 ("run_ids", "run-1"), 1130 ("run_ids", "run-2"), 1131 ("metric_key", "loss"), 1132 ], 1133 ): 1134 assert not auth_module.validate_can_read_metric_history_bulk_interval() 1135 1136 1137 def test_search_datasets_validator_denied_without_workspace_permission( 1138 workspace_permission_setup, 1139 ): 1140 store = workspace_permission_setup["store"] 1141 username = workspace_permission_setup["username"] 1142 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1143 1144 with auth_module.app.test_request_context( 1145 SEARCH_DATASETS, 1146 method="POST", 1147 json={"experiment_ids": ["exp-1", "exp-2"]}, 1148 ): 1149 assert not auth_module.validate_can_search_datasets() 1150 1151 1152 def test_create_promptlab_run_validator_denied_without_workspace_permission( 1153 workspace_permission_setup, 1154 ): 1155 store = workspace_permission_setup["store"] 1156 username = workspace_permission_setup["username"] 1157 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1158 1159 with auth_module.app.test_request_context( 1160 CREATE_PROMPTLAB_RUN, 1161 method="POST", 1162 json={"experiment_id": "exp-2"}, 1163 ): 1164 assert not auth_module.validate_can_create_promptlab_run() 1165 1166 1167 def test_trace_artifact_validator_denied_without_workspace_permission( 1168 workspace_permission_setup, 1169 ): 1170 store = workspace_permission_setup["store"] 1171 username = workspace_permission_setup["username"] 1172 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1173 1174 with auth_module.app.test_request_context( 1175 GET_TRACE_ARTIFACT, 1176 method="GET", 1177 query_string={"request_id": "trace-1"}, 1178 ): 1179 assert not auth_module.validate_can_read_trace_artifact() 1180 1181 1182 def test_cross_workspace_access_denied(workspace_permission_setup, monkeypatch): 1183 tracking_store = _TrackingStore( 1184 experiment_workspaces={"exp-other-ws": "team-b"}, 1185 run_experiments={"run-other-ws": "exp-other-ws"}, 1186 trace_experiments={}, 1187 ) 1188 monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store) 1189 1190 with auth_module.app.test_request_context( 1191 "/api/2.0/mlflow/experiments/get", 1192 method="GET", 1193 query_string={"experiment_id": "exp-other-ws"}, 1194 ): 1195 assert not auth_module.validate_can_read_experiment() 1196 assert not auth_module.validate_can_update_experiment() 1197 assert not auth_module.validate_can_delete_experiment() 1198 1199 with auth_module.app.test_request_context( 1200 "/api/2.0/mlflow/runs/get", 1201 method="GET", 1202 query_string={"run_id": "run-other-ws"}, 1203 ): 1204 assert not auth_module.validate_can_read_run() 1205 assert not auth_module.validate_can_update_run() 1206 1207 1208 def test_cross_workspace_registered_model_access_denied(workspace_permission_setup, monkeypatch): 1209 registry_store = _RegistryStore({"model-other-ws": "team-b"}) 1210 monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store) 1211 1212 with auth_module.app.test_request_context( 1213 "/api/2.0/mlflow/registered-models/get", 1214 method="GET", 1215 query_string={"name": "model-other-ws"}, 1216 ): 1217 assert not auth_module.validate_can_read_registered_model() 1218 assert not auth_module.validate_can_update_registered_model() 1219 assert not auth_module.validate_can_delete_registered_model() 1220 1221 1222 def test_explicit_experiment_permission_overrides_workspace( 1223 workspace_permission_setup, 1224 ): 1225 store = workspace_permission_setup["store"] 1226 username = workspace_permission_setup["username"] 1227 1228 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1229 store.create_experiment_permission("exp-1", username, READ.name) 1230 1231 with auth_module.app.test_request_context( 1232 "/api/2.0/mlflow/experiments/get", 1233 method="GET", 1234 query_string={"experiment_id": "exp-1"}, 1235 ): 1236 assert auth_module.validate_can_read_experiment() 1237 assert not auth_module.validate_can_update_experiment() 1238 1239 with auth_module.app.test_request_context( 1240 "/api/2.0/mlflow/experiments/get", 1241 method="GET", 1242 query_string={"experiment_id": "exp-2"}, 1243 ): 1244 assert not auth_module.validate_can_read_experiment() 1245 1246 1247 def test_cross_workspace_gateway_secret_access_denied(workspace_permission_setup, monkeypatch): 1248 tracking_store = _TrackingStore( 1249 experiment_workspaces={"exp-1": "team-a"}, 1250 run_experiments={}, 1251 trace_experiments={}, 1252 gateway_secret_workspaces={"secret-other-ws": "team-b"}, 1253 engine=MagicMock(), 1254 ) 1255 tracking_store.ManagedSessionMaker = tracking_store._create_mock_session_maker() 1256 monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store) 1257 1258 with auth_module.app.test_request_context( 1259 "/api/3.0/mlflow/gateway/secrets/get", 1260 method="GET", 1261 query_string={"secret_id": "secret-other-ws"}, 1262 ): 1263 assert not auth_module.validate_can_read_gateway_secret() 1264 assert not auth_module.validate_can_update_gateway_secret() 1265 assert not auth_module.validate_can_delete_gateway_secret() 1266 1267 1268 def test_cross_workspace_gateway_endpoint_access_denied(workspace_permission_setup, monkeypatch): 1269 tracking_store = _TrackingStore( 1270 experiment_workspaces={"exp-1": "team-a"}, 1271 run_experiments={}, 1272 trace_experiments={}, 1273 gateway_endpoint_workspaces={"endpoint-other-ws": "team-b"}, 1274 engine=MagicMock(), 1275 ) 1276 tracking_store.ManagedSessionMaker = tracking_store._create_mock_session_maker() 1277 monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store) 1278 1279 with auth_module.app.test_request_context( 1280 "/api/3.0/mlflow/gateway/endpoints/get", 1281 method="GET", 1282 query_string={"endpoint_id": "endpoint-other-ws"}, 1283 ): 1284 assert not auth_module.validate_can_read_gateway_endpoint() 1285 assert not auth_module.validate_can_update_gateway_endpoint() 1286 assert not auth_module.validate_can_delete_gateway_endpoint() 1287 1288 1289 def test_cross_workspace_gateway_model_definition_access_denied( 1290 workspace_permission_setup, monkeypatch 1291 ): 1292 tracking_store = _TrackingStore( 1293 experiment_workspaces={"exp-1": "team-a"}, 1294 run_experiments={}, 1295 trace_experiments={}, 1296 gateway_model_def_workspaces={"model-def-other-ws": "team-b"}, 1297 engine=MagicMock(), 1298 ) 1299 tracking_store.ManagedSessionMaker = tracking_store._create_mock_session_maker() 1300 monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store) 1301 1302 with auth_module.app.test_request_context( 1303 "/api/3.0/mlflow/gateway/model-definitions/get", 1304 method="GET", 1305 query_string={"model_definition_id": "model-def-other-ws"}, 1306 ): 1307 assert not auth_module.validate_can_read_gateway_model_definition() 1308 assert not auth_module.validate_can_update_gateway_model_definition() 1309 assert not auth_module.validate_can_delete_gateway_model_definition() 1310 1311 1312 def test_workspace_permission_required_for_gateway_creation(workspace_permission_setup): 1313 store = workspace_permission_setup["store"] 1314 username = workspace_permission_setup["username"] 1315 1316 # Remove workspace permission 1317 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1318 1319 with auth_module.app.test_request_context( 1320 "/api/3.0/mlflow/gateway/endpoints/create", 1321 method="POST", 1322 json={"name": "test-endpoint", "model_configs": []}, 1323 ): 1324 assert not auth_module.validate_can_create_gateway_endpoint() 1325 1326 with auth_module.app.test_request_context( 1327 "/api/3.0/mlflow/gateway/model-definitions/create", 1328 method="POST", 1329 json={ 1330 "name": "test-model", 1331 "secret_id": "secret-1", 1332 "provider": "openai", 1333 "model_name": "gpt-4", 1334 }, 1335 ): 1336 assert not auth_module.validate_can_create_gateway_model_definition() 1337 1338 # Restore workspace permission 1339 store.set_workspace_permission("team-a", username, MANAGE.name) 1340 1341 with auth_module.app.test_request_context( 1342 "/api/3.0/mlflow/gateway/endpoints/create", 1343 method="POST", 1344 json={"name": "test-endpoint", "model_configs": []}, 1345 ): 1346 assert auth_module.validate_can_create_gateway_endpoint() 1347 1348 1349 def test_prompt_optimization_job_validators_use_workspace_permissions( 1350 workspace_permission_setup, monkeypatch 1351 ): 1352 store = workspace_permission_setup["store"] 1353 username = workspace_permission_setup["username"] 1354 1355 # Mock get_job to return a job associated with exp-1 (in team-a) 1356 mock_job = SimpleNamespace(params='{"experiment_id": "exp-1"}') 1357 monkeypatch.setattr(auth_module, "get_job", lambda job_id: mock_job) 1358 1359 _set_workspace_permission(store, username, MANAGE.name) 1360 1361 with auth_module.app.test_request_context( 1362 "/api/3.0/mlflow/prompt-optimization/jobs/get", 1363 method="GET", 1364 query_string={"job_id": "job-1"}, 1365 ): 1366 assert auth_module.validate_can_read_prompt_optimization_job() 1367 assert auth_module.validate_can_update_prompt_optimization_job() 1368 assert auth_module.validate_can_delete_prompt_optimization_job() 1369 1370 1371 def test_prompt_optimization_job_validators_read_permission_blocks_writes( 1372 workspace_permission_setup, monkeypatch 1373 ): 1374 store = workspace_permission_setup["store"] 1375 username = workspace_permission_setup["username"] 1376 1377 # Mock get_job to return a job associated with exp-1 (in team-a) 1378 mock_job = SimpleNamespace(params='{"experiment_id": "exp-1"}') 1379 monkeypatch.setattr(auth_module, "get_job", lambda job_id: mock_job) 1380 1381 _set_workspace_permission(store, username, READ.name) 1382 1383 with auth_module.app.test_request_context( 1384 "/api/3.0/mlflow/prompt-optimization/jobs/get", 1385 method="GET", 1386 query_string={"job_id": "job-1"}, 1387 ): 1388 assert auth_module.validate_can_read_prompt_optimization_job() 1389 assert not auth_module.validate_can_update_prompt_optimization_job() 1390 assert not auth_module.validate_can_delete_prompt_optimization_job() 1391 1392 1393 def test_prompt_optimization_job_validators_denied_without_workspace_permission( 1394 workspace_permission_setup, monkeypatch 1395 ): 1396 store = workspace_permission_setup["store"] 1397 username = workspace_permission_setup["username"] 1398 1399 # Mock get_job to return a job associated with exp-1 (in team-a) 1400 mock_job = SimpleNamespace(params='{"experiment_id": "exp-1"}') 1401 monkeypatch.setattr(auth_module, "get_job", lambda job_id: mock_job) 1402 1403 _set_workspace_permission(store, username, NO_PERMISSIONS.name) 1404 1405 with auth_module.app.test_request_context( 1406 "/api/3.0/mlflow/prompt-optimization/jobs/get", 1407 method="GET", 1408 query_string={"job_id": "job-1"}, 1409 ): 1410 assert not auth_module.validate_can_read_prompt_optimization_job() 1411 assert not auth_module.validate_can_update_prompt_optimization_job() 1412 assert not auth_module.validate_can_delete_prompt_optimization_job() 1413 1414 1415 def test_graphql_permission_functions_use_workspace_permissions(workspace_permission_setup): 1416 store = workspace_permission_setup["store"] 1417 username = workspace_permission_setup["username"] 1418 1419 _set_workspace_permission(store, username, MANAGE.name) 1420 1421 # Test experiment permission 1422 assert auth_module._graphql_can_read_experiment("exp-1", username) 1423 1424 # Test run permission (inherits from experiment) 1425 assert auth_module._graphql_can_read_run("run-1", username) 1426 1427 # Test registered model permission 1428 assert auth_module._graphql_can_read_model("model-xyz", username) 1429 1430 1431 def test_graphql_permission_functions_denied_without_workspace_permission( 1432 workspace_permission_setup, 1433 ): 1434 store = workspace_permission_setup["store"] 1435 username = workspace_permission_setup["username"] 1436 1437 _set_workspace_permission(store, username, NO_PERMISSIONS.name) 1438 1439 # Test experiment permission denied 1440 assert not auth_module._graphql_can_read_experiment("exp-1", username) 1441 1442 # Test run permission denied (inherits from experiment) 1443 assert not auth_module._graphql_can_read_run("run-1", username) 1444 1445 # Test registered model permission denied 1446 assert not auth_module._graphql_can_read_model("model-xyz", username) 1447 1448 1449 def test_cross_workspace_graphql_access_denied(workspace_permission_setup, monkeypatch): 1450 # User has MANAGE in team-a but tries to access resources in team-b 1451 tracking_store = _TrackingStore( 1452 experiment_workspaces={"exp-other-ws": "team-b"}, 1453 run_experiments={"run-other-ws": "exp-other-ws"}, 1454 trace_experiments={}, 1455 ) 1456 monkeypatch.setattr(auth_module, "_get_tracking_store", lambda: tracking_store) 1457 1458 registry_store = _RegistryStore({"model-other-ws": "team-b"}) 1459 monkeypatch.setattr(auth_module, "_get_model_registry_store", lambda: registry_store) 1460 1461 username = workspace_permission_setup["username"] 1462 1463 # Should be denied access to resources in team-b 1464 assert not auth_module._graphql_can_read_experiment("exp-other-ws", username) 1465 assert not auth_module._graphql_can_read_run("run-other-ws", username) 1466 assert not auth_module._graphql_can_read_model("model-other-ws", username) 1467 1468 1469 # ============================================================================= 1470 # Role-based permission coverage for gateway resources 1471 # ============================================================================= 1472 # 1473 # The fixture grants workspace MANAGE by default. These tests first strip that 1474 # grant (set to NO_PERMISSIONS) so the only path to a positive permission is 1475 # the role assignment being exercised. That isolates the role-based resolver 1476 # from the legacy workspace_permissions fallback. 1477 1478 1479 def _assign_role_with_permission( 1480 store: SqlAlchemyStore, username: str, workspace: str, resource_type: str, permission: str 1481 ) -> None: 1482 """Create a role in ``workspace`` with a wildcard grant of ``permission`` on 1483 ``resource_type``, and assign ``username`` to it. 1484 1485 Using ``random_str`` keeps the role names unique so multiple calls within a 1486 single test don't collide on the (workspace, name) unique constraint. 1487 """ 1488 role = store.create_role(name=random_str(), workspace=workspace) 1489 store.add_role_permission(role.id, resource_type, "*", permission) 1490 user = store.get_user(username) 1491 store.assign_role_to_user(user.id, role.id) 1492 1493 1494 # ---- Gateway endpoint: role-based permission levels ---- 1495 1496 1497 @pytest.mark.parametrize( 1498 ("granted", "expected_read", "expected_delete", "expected_manage"), 1499 [ 1500 ("READ", True, False, False), 1501 ("USE", True, False, False), 1502 ("EDIT", True, False, False), 1503 ("MANAGE", True, True, True), 1504 ], 1505 ) 1506 def test_role_grant_on_gateway_endpoint_gates_validator_capabilities( 1507 workspace_permission_setup, granted, expected_read, expected_delete, expected_manage 1508 ): 1509 """A role grant at permission level ``granted`` exposes exactly the 1510 capabilities that level implies on the endpoint validators — no more, no 1511 less. Catches regressions where a validator starts accepting a weaker 1512 permission than it should (or refuses a stronger one). 1513 """ 1514 store = workspace_permission_setup["store"] 1515 username = workspace_permission_setup["username"] 1516 1517 # Strip the default workspace MANAGE so the only positive grant is the role. 1518 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1519 1520 _assign_role_with_permission(store, username, "team-a", "gateway_endpoint", granted) 1521 1522 with auth_module.app.test_request_context( 1523 "/api/3.0/mlflow/gateway/endpoints/get", 1524 method="GET", 1525 query_string={"endpoint_id": "endpoint-1"}, 1526 ): 1527 assert auth_module.validate_can_read_gateway_endpoint() is expected_read 1528 assert auth_module.validate_can_delete_gateway_endpoint() is expected_delete 1529 assert auth_module.validate_can_manage_gateway_endpoint() is expected_manage 1530 1531 1532 def test_role_grant_read_on_gateway_endpoint_does_not_permit_use( 1533 workspace_permission_setup, 1534 ): 1535 """Regression guard specific to the bug class the user called out: 1536 a user with only READ on a gateway endpoint should not be able to *invoke* 1537 it. USE is a stricter capability than READ and has its own validator. 1538 """ 1539 store = workspace_permission_setup["store"] 1540 username = workspace_permission_setup["username"] 1541 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1542 1543 _assign_role_with_permission(store, username, "team-a", "gateway_endpoint", "READ") 1544 1545 # _validate_gateway_use_permission looks up the endpoint by name, resolves 1546 # the endpoint id, then checks ``can_use`` via the permission resolver. 1547 with auth_module.app.test_request_context("/"): 1548 assert auth_module._validate_gateway_use_permission("endpoint-1", username) is False 1549 1550 1551 def test_role_grant_use_on_gateway_endpoint_permits_use(workspace_permission_setup): 1552 store = workspace_permission_setup["store"] 1553 username = workspace_permission_setup["username"] 1554 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1555 1556 _assign_role_with_permission(store, username, "team-a", "gateway_endpoint", "USE") 1557 1558 with auth_module.app.test_request_context("/"): 1559 assert auth_module._validate_gateway_use_permission("endpoint-1", username) is True 1560 1561 1562 @pytest.mark.parametrize( 1563 ("granted", "expected_can_use"), 1564 [ 1565 ("READ", False), # READ does not imply USE. 1566 ("USE", True), 1567 ("EDIT", True), # EDIT implies USE. 1568 ("MANAGE", True), # MANAGE implies USE. 1569 ], 1570 ) 1571 def test_role_grant_permission_level_determines_use_capability( 1572 workspace_permission_setup, granted, expected_can_use 1573 ): 1574 """Parametrized matrix for the USE capability specifically. READ should NOT 1575 let the user invoke; every stronger permission should. 1576 """ 1577 store = workspace_permission_setup["store"] 1578 username = workspace_permission_setup["username"] 1579 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1580 1581 _assign_role_with_permission(store, username, "team-a", "gateway_endpoint", granted) 1582 1583 with auth_module.app.test_request_context("/"): 1584 assert ( 1585 auth_module._validate_gateway_use_permission("endpoint-1", username) is expected_can_use 1586 ) 1587 1588 1589 # ---- Workspace-wide role grants on gateway resources ---- 1590 1591 1592 @pytest.mark.parametrize("granted", ["READ", "USE", "EDIT", "MANAGE"]) 1593 def test_role_workspace_wide_grant_applies_to_gateway_endpoints( 1594 workspace_permission_setup, granted 1595 ): 1596 """``(workspace, *, X)`` grants apply to every resource type in the 1597 workspace — including gateway endpoints. Confirms the workspace-wide 1598 short-circuit isn't accidentally gated behind resource_type=='experiment' 1599 or similar, which would silently lock workspace admins out of gateway 1600 resources. 1601 """ 1602 store = workspace_permission_setup["store"] 1603 username = workspace_permission_setup["username"] 1604 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1605 1606 _assign_role_with_permission(store, username, "team-a", "workspace", granted) 1607 1608 with auth_module.app.test_request_context( 1609 "/api/3.0/mlflow/gateway/endpoints/get", 1610 method="GET", 1611 query_string={"endpoint_id": "endpoint-1"}, 1612 ): 1613 # All four levels grant READ. 1614 assert auth_module.validate_can_read_gateway_endpoint() is True 1615 1616 # Only MANAGE grants can_delete / can_manage. 1617 assert auth_module.validate_can_delete_gateway_endpoint() is (granted == "MANAGE") 1618 assert auth_module.validate_can_manage_gateway_endpoint() is (granted == "MANAGE") 1619 1620 1621 def test_role_workspace_wide_read_does_not_imply_use_on_gateway_endpoint( 1622 workspace_permission_setup, 1623 ): 1624 """``(workspace, *, READ)`` grants READ on every resource but not USE. 1625 Users with a workspace-wide viewer role shouldn't be able to invoke 1626 gateway endpoints. 1627 """ 1628 store = workspace_permission_setup["store"] 1629 username = workspace_permission_setup["username"] 1630 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1631 1632 _assign_role_with_permission(store, username, "team-a", "workspace", "READ") 1633 1634 with auth_module.app.test_request_context("/"): 1635 assert auth_module._validate_gateway_use_permission("endpoint-1", username) is False 1636 1637 1638 @pytest.mark.parametrize("granted", ["USE", "EDIT", "MANAGE"]) 1639 def test_role_workspace_wide_non_read_grants_imply_use_on_gateway_endpoint( 1640 workspace_permission_setup, granted 1641 ): 1642 """``(workspace, *, {USE,EDIT,MANAGE})`` all imply USE → invocation 1643 allowed. 1644 """ 1645 store = workspace_permission_setup["store"] 1646 username = workspace_permission_setup["username"] 1647 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1648 1649 _assign_role_with_permission(store, username, "team-a", "workspace", granted) 1650 1651 with auth_module.app.test_request_context("/"): 1652 assert auth_module._validate_gateway_use_permission("endpoint-1", username) is True 1653 1654 1655 # ---- Gateway secret and model definition parity ---- 1656 1657 1658 @pytest.mark.parametrize( 1659 ("granted", "expected_read", "expected_delete"), 1660 [ 1661 ("READ", True, False), 1662 ("EDIT", True, False), 1663 ("MANAGE", True, True), 1664 ], 1665 ) 1666 def test_role_grant_on_gateway_secret_gates_validator( 1667 workspace_permission_setup, granted, expected_read, expected_delete 1668 ): 1669 store = workspace_permission_setup["store"] 1670 username = workspace_permission_setup["username"] 1671 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1672 1673 _assign_role_with_permission(store, username, "team-a", "gateway_secret", granted) 1674 1675 with auth_module.app.test_request_context( 1676 "/api/3.0/mlflow/gateway/secrets/get", 1677 method="GET", 1678 query_string={"secret_id": "secret-1"}, 1679 ): 1680 assert auth_module.validate_can_read_gateway_secret() is expected_read 1681 assert auth_module.validate_can_delete_gateway_secret() is expected_delete 1682 1683 1684 @pytest.mark.parametrize( 1685 ("granted", "expected_read", "expected_delete"), 1686 [ 1687 ("READ", True, False), 1688 ("EDIT", True, False), 1689 ("MANAGE", True, True), 1690 ], 1691 ) 1692 def test_role_grant_on_gateway_model_definition_gates_validator( 1693 workspace_permission_setup, granted, expected_read, expected_delete 1694 ): 1695 store = workspace_permission_setup["store"] 1696 username = workspace_permission_setup["username"] 1697 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1698 1699 _assign_role_with_permission(store, username, "team-a", "gateway_model_definition", granted) 1700 1701 with auth_module.app.test_request_context( 1702 "/api/3.0/mlflow/gateway/model-definitions/get", 1703 method="GET", 1704 query_string={"model_definition_id": "model-def-1"}, 1705 ): 1706 assert auth_module.validate_can_read_gateway_model_definition() is expected_read 1707 assert auth_module.validate_can_delete_gateway_model_definition() is expected_delete 1708 1709 1710 # ---- Cross-workspace isolation for role-based gateway grants ---- 1711 1712 1713 def test_role_in_other_workspace_does_not_grant_gateway_endpoint_access( 1714 workspace_permission_setup, 1715 ): 1716 """A role in team-b with MANAGE on gateway_endpoints must not grant any 1717 access when resolving an endpoint that belongs to team-a. The resolver 1718 scopes role permissions to the role's workspace. 1719 """ 1720 store = workspace_permission_setup["store"] 1721 username = workspace_permission_setup["username"] 1722 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1723 1724 # Role with MANAGE in team-b — should NOT apply to team-a endpoints. 1725 _assign_role_with_permission(store, username, "team-b", "gateway_endpoint", "MANAGE") 1726 1727 with auth_module.app.test_request_context( 1728 "/api/3.0/mlflow/gateway/endpoints/get", 1729 method="GET", 1730 query_string={"endpoint_id": "endpoint-1"}, # endpoint-1 is in team-a. 1731 ): 1732 assert auth_module.validate_can_read_gateway_endpoint() is False 1733 assert auth_module.validate_can_manage_gateway_endpoint() is False 1734 1735 1736 def test_role_in_other_workspace_does_not_grant_gateway_use(workspace_permission_setup): 1737 store = workspace_permission_setup["store"] 1738 username = workspace_permission_setup["username"] 1739 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1740 1741 _assign_role_with_permission(store, username, "team-b", "gateway_endpoint", "USE") 1742 1743 with auth_module.app.test_request_context("/"): 1744 # endpoint-1 is in team-a; role grant is in team-b. 1745 assert auth_module._validate_gateway_use_permission("endpoint-1", username) is False 1746 1747 1748 # ---- Multi-role union: best grant wins ---- 1749 1750 1751 def test_role_union_best_permission_wins_for_gateway_endpoint(workspace_permission_setup): 1752 """Two roles: one grants READ, the other grants MANAGE. Validator should 1753 reflect the max (MANAGE). 1754 """ 1755 store = workspace_permission_setup["store"] 1756 username = workspace_permission_setup["username"] 1757 store.set_workspace_permission("team-a", username, NO_PERMISSIONS.name) 1758 1759 _assign_role_with_permission(store, username, "team-a", "gateway_endpoint", "READ") 1760 _assign_role_with_permission(store, username, "team-a", "gateway_endpoint", "MANAGE") 1761 1762 with auth_module.app.test_request_context( 1763 "/api/3.0/mlflow/gateway/endpoints/get", 1764 method="GET", 1765 query_string={"endpoint_id": "endpoint-1"}, 1766 ): 1767 assert auth_module.validate_can_manage_gateway_endpoint() is True 1768 1769 1770 # ============================================================================= 1771 # Authorization for role management endpoints (Batch 5) 1772 # ============================================================================= 1773 # 1774 # Four validators guard the role endpoints: 1775 # - validate_can_manage_roles: create/update/delete role, add/remove/update 1776 # role_permission, assign/unassign role. Super admin OR workspace admin 1777 # in the resolved workspace. 1778 # - validate_can_view_roles: get_role, list_role_permissions. Super admin 1779 # OR any role assignment in the resolved workspace. 1780 # - validate_can_list_roles: list_roles. Super admin unconditionally; for 1781 # non-admins the request must scope to a workspace where the caller holds 1782 # at least one role. 1783 # - validate_can_view_user_roles: list_user_roles. Super admin, the target 1784 # themselves, or a workspace admin over any workspace the target is in. 1785 # 1786 # _get_role_workspace_from_request resolves the workspace from role_id, 1787 # role_permission_id, or a literal ``workspace`` param. These tests exercise 1788 # all three shapes and every actor x endpoint combination. 1789 1790 1791 @pytest.fixture 1792 def role_auth_setup(tmp_path, monkeypatch): 1793 monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true") 1794 monkeypatch.setattr( 1795 auth_module, 1796 "auth_config", 1797 auth_module.auth_config._replace(default_permission=NO_PERMISSIONS.name), 1798 ) 1799 1800 db_uri = f"sqlite:///{tmp_path / 'auth-store.db'}" 1801 auth_store = SqlAlchemyStore() 1802 auth_store.init_db(db_uri) 1803 monkeypatch.setattr(auth_module, "store", auth_store, raising=False) 1804 1805 auth_store.create_user("super_admin", "supersecurepassword", is_admin=True) 1806 for name in ("ws_admin_foo", "ws_admin_bar", "ws_member_foo", "outsider"): 1807 auth_store.create_user(name, "supersecurepassword", is_admin=False) 1808 1809 admin_role_foo = auth_store.create_role(name="admin-foo", workspace="foo") 1810 auth_store.add_role_permission(admin_role_foo.id, "workspace", "*", MANAGE.name) 1811 auth_store.assign_role_to_user(auth_store.get_user("ws_admin_foo").id, admin_role_foo.id) 1812 1813 admin_role_bar = auth_store.create_role(name="admin-bar", workspace="bar") 1814 auth_store.add_role_permission(admin_role_bar.id, "workspace", "*", MANAGE.name) 1815 auth_store.assign_role_to_user(auth_store.get_user("ws_admin_bar").id, admin_role_bar.id) 1816 1817 member_role_foo = auth_store.create_role(name="member-foo", workspace="foo") 1818 auth_store.add_role_permission(member_role_foo.id, "experiment", "*", READ.name) 1819 auth_store.assign_role_to_user(auth_store.get_user("ws_member_foo").id, member_role_foo.id) 1820 1821 role_foo = auth_store.create_role(name="target-foo", workspace="foo") 1822 role_bar = auth_store.create_role(name="target-bar", workspace="bar") 1823 rp_foo = auth_store.add_role_permission(role_foo.id, "experiment", "*", READ.name) 1824 rp_bar = auth_store.add_role_permission(role_bar.id, "experiment", "*", READ.name) 1825 1826 def login_as(username: str) -> None: 1827 monkeypatch.setattr( 1828 auth_module, 1829 "authenticate_request", 1830 lambda: SimpleNamespace(username=username), 1831 ) 1832 1833 yield { 1834 "store": auth_store, 1835 "login_as": login_as, 1836 "role_foo_id": role_foo.id, 1837 "role_bar_id": role_bar.id, 1838 "role_permission_foo_id": rp_foo.id, 1839 "role_permission_bar_id": rp_bar.id, 1840 } 1841 auth_store.engine.dispose() 1842 1843 1844 def _request_context_for_shape(shape, role_auth_setup, workspace): 1845 match shape: 1846 case "role_id": 1847 role_id = ( 1848 role_auth_setup["role_foo_id"] 1849 if workspace == "foo" 1850 else role_auth_setup["role_bar_id"] 1851 ) 1852 return auth_module.app.test_request_context( 1853 "/api/3.0/mlflow/roles/get", 1854 method="GET", 1855 query_string={"role_id": str(role_id)}, 1856 ) 1857 case "role_permission_id": 1858 rp_id = ( 1859 role_auth_setup["role_permission_foo_id"] 1860 if workspace == "foo" 1861 else role_auth_setup["role_permission_bar_id"] 1862 ) 1863 return auth_module.app.test_request_context( 1864 "/api/3.0/mlflow/roles/permissions/update", 1865 method="PATCH", 1866 json={"role_permission_id": rp_id, "permission": READ.name}, 1867 ) 1868 case "workspace": 1869 return auth_module.app.test_request_context( 1870 "/api/3.0/mlflow/roles/create", 1871 method="POST", 1872 json={"name": "new-role", "workspace": workspace}, 1873 ) 1874 case _: 1875 raise ValueError(f"Unknown shape: {shape}") 1876 1877 1878 # Authorization matrices are exercised with a single request shape (role_id); 1879 # shape-resolution itself is covered independently below so we don't multiply 1880 # every actor-case by three shape-cases. 1881 1882 1883 @pytest.mark.parametrize( 1884 ("actor", "workspace", "expected"), 1885 [ 1886 # Super admin short-circuits regardless of workspace — one case suffices. 1887 ("super_admin", "foo", True), 1888 # Outsider has no role anywhere — one case suffices. 1889 ("outsider", "foo", False), 1890 # Workspace admins manage only their own workspace. 1891 ("ws_admin_foo", "foo", True), 1892 ("ws_admin_foo", "bar", False), 1893 ("ws_admin_bar", "foo", False), 1894 ("ws_admin_bar", "bar", True), 1895 # Plain role membership is not enough to manage — needs workspace MANAGE. 1896 ("ws_member_foo", "foo", False), 1897 ("ws_member_foo", "bar", False), 1898 ], 1899 ) 1900 def test_validate_can_manage_roles_authorization(role_auth_setup, actor, workspace, expected): 1901 role_auth_setup["login_as"](actor) 1902 with _request_context_for_shape("role_id", role_auth_setup, workspace): 1903 assert auth_module.validate_can_manage_roles() is expected 1904 1905 1906 @pytest.mark.parametrize("workspace", ["foo", "bar"]) 1907 @pytest.mark.parametrize("shape", ["role_id", "role_permission_id", "workspace"]) 1908 def test_manage_roles_resolves_workspace_from_each_shape(role_auth_setup, shape, workspace): 1909 # Sanity check that _get_role_workspace_from_request correctly dispatches 1910 # on every request shape. Use ws_admin_foo — their answer differs by 1911 # workspace, so an incorrectly resolved (or swapped) workspace flips the 1912 # result and the test fails. 1913 role_auth_setup["login_as"]("ws_admin_foo") 1914 expected = workspace == "foo" 1915 with _request_context_for_shape(shape, role_auth_setup, workspace): 1916 assert auth_module.validate_can_manage_roles() is expected 1917 1918 1919 @pytest.mark.parametrize( 1920 ("actor", "workspace", "expected"), 1921 [ 1922 ("super_admin", "foo", True), 1923 ("outsider", "foo", False), 1924 ("ws_admin_foo", "foo", True), 1925 ("ws_admin_foo", "bar", False), 1926 ("ws_admin_bar", "foo", False), 1927 ("ws_admin_bar", "bar", True), 1928 # Unlike manage, a plain workspace member can view roles. 1929 ("ws_member_foo", "foo", True), 1930 ("ws_member_foo", "bar", False), 1931 ], 1932 ) 1933 def test_validate_can_view_roles_authorization(role_auth_setup, actor, workspace, expected): 1934 role_auth_setup["login_as"](actor) 1935 with _request_context_for_shape("role_id", role_auth_setup, workspace): 1936 assert auth_module.validate_can_view_roles() is expected 1937 1938 1939 @pytest.mark.parametrize( 1940 ("actor", "expected"), 1941 [ 1942 ("super_admin", True), 1943 # Any non-admin is denied regardless of their workspace memberships — 1944 # one representative non-admin is enough. 1945 ("ws_admin_foo", False), 1946 ], 1947 ) 1948 def test_validate_can_list_roles_unscoped_is_super_admin_only(role_auth_setup, actor, expected): 1949 # No workspace param: only super admins may list every role in the system. 1950 role_auth_setup["login_as"](actor) 1951 with auth_module.app.test_request_context("/api/3.0/mlflow/roles/list", method="GET"): 1952 assert auth_module.validate_can_list_roles() is expected 1953 1954 1955 @pytest.mark.parametrize( 1956 ("actor", "workspace", "expected"), 1957 [ 1958 ("super_admin", "foo", True), 1959 ("outsider", "foo", False), 1960 ("ws_admin_foo", "foo", True), 1961 ("ws_admin_foo", "bar", False), 1962 ("ws_admin_bar", "foo", False), 1963 ("ws_admin_bar", "bar", True), 1964 ("ws_member_foo", "foo", True), 1965 ("ws_member_foo", "bar", False), 1966 ], 1967 ) 1968 def test_validate_can_list_roles_workspace_scoped(role_auth_setup, actor, workspace, expected): 1969 role_auth_setup["login_as"](actor) 1970 with auth_module.app.test_request_context( 1971 "/api/3.0/mlflow/roles/list", method="GET", query_string={"workspace": workspace} 1972 ): 1973 assert auth_module.validate_can_list_roles() is expected 1974 1975 1976 def test_validate_can_list_roles_blank_workspace_denied_for_non_admin(role_auth_setup): 1977 # Blank workspace param hits a *different* branch from the missing-param 1978 # case: validate_can_list_roles checks ``workspace.strip()`` and denies 1979 # rather than raising, unlike _get_role_workspace_from_request which would 1980 # raise on blank workspace. Kept as a guard for that specific branch. 1981 role_auth_setup["login_as"]("ws_admin_foo") 1982 with auth_module.app.test_request_context( 1983 "/api/3.0/mlflow/roles/list", 1984 method="GET", 1985 query_string={"workspace": " "}, 1986 ): 1987 assert auth_module.validate_can_list_roles() is False 1988 1989 1990 def test_validate_can_view_user_roles_self_always_allowed(role_auth_setup): 1991 # A user can always read their own role list, even one with no roles. 1992 # Using ``outsider`` (zero roles) exercises the self-short-circuit without 1993 # any membership helping. 1994 role_auth_setup["login_as"]("outsider") 1995 with auth_module.app.test_request_context( 1996 "/api/3.0/mlflow/users/roles/list", 1997 method="GET", 1998 query_string={"username": "outsider"}, 1999 ): 2000 assert auth_module.validate_can_view_user_roles() is True 2001 2002 2003 @pytest.mark.parametrize( 2004 ("requester", "target", "expected"), 2005 [ 2006 ("super_admin", "ws_member_foo", True), 2007 ("ws_admin_foo", "ws_member_foo", True), 2008 ("ws_admin_bar", "ws_member_foo", False), 2009 ("ws_member_foo", "ws_admin_foo", False), 2010 ("outsider", "ws_member_foo", False), 2011 ], 2012 ) 2013 def test_validate_can_view_user_roles_cross_user(role_auth_setup, requester, target, expected): 2014 role_auth_setup["login_as"](requester) 2015 with auth_module.app.test_request_context( 2016 "/api/3.0/mlflow/users/roles/list", 2017 method="GET", 2018 query_string={"username": target}, 2019 ): 2020 assert auth_module.validate_can_view_user_roles() is expected 2021 2022 2023 def test_validate_can_view_user_roles_nonexistent_target_denied_for_non_admin( 2024 role_auth_setup, 2025 ): 2026 # Non-existent target: return False rather than leaking existence via the 2027 # RESOURCE_DOES_NOT_EXIST the handler would raise downstream. 2028 role_auth_setup["login_as"]("ws_admin_foo") 2029 with auth_module.app.test_request_context( 2030 "/api/3.0/mlflow/users/roles/list", 2031 method="GET", 2032 query_string={"username": "ghost"}, 2033 ): 2034 assert auth_module.validate_can_view_user_roles() is False 2035 2036 2037 def test_validate_can_view_user_roles_nonexistent_target_allowed_for_super_admin( 2038 role_auth_setup, 2039 ): 2040 # Super admin short-circuits before the target lookup — they're authorized 2041 # regardless of whether the target exists (the handler then 404s cleanly). 2042 role_auth_setup["login_as"]("super_admin") 2043 with auth_module.app.test_request_context( 2044 "/api/3.0/mlflow/users/roles/list", 2045 method="GET", 2046 query_string={"username": "ghost"}, 2047 ): 2048 assert auth_module.validate_can_view_user_roles() is True 2049 2050 2051 @pytest.mark.parametrize("shape", ["role_id", "role_permission_id"]) 2052 def test_validate_can_manage_roles_nonexistent_resource_denied(role_auth_setup, shape): 2053 # A non-admin pointing at a role/role_permission that doesn't exist fails 2054 # closed: _get_role_workspace_from_request returns None and the validator 2055 # treats that as unauthorized rather than leaking existence. 2056 role_auth_setup["login_as"]("ws_admin_foo") 2057 bogus_id = 999_999 2058 if shape == "role_id": 2059 ctx = auth_module.app.test_request_context( 2060 "/api/3.0/mlflow/roles/get", 2061 method="GET", 2062 query_string={"role_id": str(bogus_id)}, 2063 ) 2064 else: 2065 ctx = auth_module.app.test_request_context( 2066 "/api/3.0/mlflow/roles/permissions/update", 2067 method="PATCH", 2068 json={"role_permission_id": bogus_id, "permission": READ.name}, 2069 ) 2070 with ctx: 2071 assert auth_module.validate_can_manage_roles() is False 2072 2073 2074 def test_validate_can_manage_roles_nonexistent_role_id_bypassed_by_super_admin( 2075 role_auth_setup, 2076 ): 2077 # Super admins skip the workspace resolution entirely — an unresolvable 2078 # role_id still produces True at the validator layer. 2079 role_auth_setup["login_as"]("super_admin") 2080 with auth_module.app.test_request_context( 2081 "/api/3.0/mlflow/roles/get", 2082 method="GET", 2083 query_string={"role_id": "999999"}, 2084 ): 2085 assert auth_module.validate_can_manage_roles() is True 2086 2087 2088 def test_validate_can_manage_roles_missing_workspace_params_raises(role_auth_setup): 2089 # No role_id / role_permission_id / workspace in the request body: the 2090 # resolver raises INVALID_PARAMETER_VALUE — callers that hit this path have 2091 # a client bug, and we surface it instead of silently denying. 2092 role_auth_setup["login_as"]("ws_admin_foo") 2093 with auth_module.app.test_request_context( 2094 "/api/3.0/mlflow/roles/create", method="POST", json={} 2095 ): 2096 with pytest.raises(MlflowException, match="must include one of"): 2097 auth_module.validate_can_manage_roles() 2098 2099 2100 def test_validate_can_manage_roles_blank_workspace_raises(role_auth_setup): 2101 role_auth_setup["login_as"]("ws_admin_foo") 2102 with auth_module.app.test_request_context( 2103 "/api/3.0/mlflow/roles/create", 2104 method="POST", 2105 json={"name": "new-role", "workspace": " "}, 2106 ): 2107 with pytest.raises(MlflowException, match="non-empty string"): 2108 auth_module.validate_can_manage_roles() 2109 2110 2111 def test_validate_can_manage_roles_propagates_param_coercion_errors(role_auth_setup): 2112 # Integration check: a non-integer role_id in the request surfaces the 2113 # coercion error through the validator chain rather than silently denying. 2114 role_auth_setup["login_as"]("ws_admin_foo") 2115 with auth_module.app.test_request_context( 2116 "/api/3.0/mlflow/roles/get", 2117 method="GET", 2118 query_string={"role_id": "not-an-int"}, 2119 ): 2120 with pytest.raises(MlflowException, match="must be an integer"): 2121 auth_module.validate_can_manage_roles()