2175 lines
92 KiB
Python
2175 lines
92 KiB
Python
import logging
|
|
import re
|
|
from collections.abc import Iterable
|
|
from urllib.parse import quote, unquote
|
|
|
|
from sqlalchemy import and_, or_, select, text
|
|
from sqlalchemy.exc import IntegrityError, MultipleResultsFound, NoResultFound
|
|
from sqlalchemy.orm import selectinload, sessionmaker
|
|
from werkzeug.security import check_password_hash, generate_password_hash
|
|
|
|
from mlflow.environment_variables import MLFLOW_ENABLE_WORKSPACES
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.protos.databricks_pb2 import (
|
|
INVALID_STATE,
|
|
RESOURCE_ALREADY_EXISTS,
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
from mlflow.server.auth.db import utils as dbutils
|
|
from mlflow.server.auth.db.models import (
|
|
SqlRole,
|
|
SqlRolePermission,
|
|
SqlUser,
|
|
SqlUserRoleAssignment,
|
|
)
|
|
from mlflow.server.auth.entities import (
|
|
ExperimentPermission,
|
|
GatewayEndpointPermission,
|
|
GatewayModelDefinitionPermission,
|
|
GatewaySecretPermission,
|
|
RegisteredModelPermission,
|
|
Role,
|
|
RolePermission,
|
|
ScorerPermission,
|
|
User,
|
|
UserRoleAssignment,
|
|
WorkspacePermission,
|
|
)
|
|
from mlflow.server.auth.permissions import (
|
|
MANAGE,
|
|
RESOURCE_TYPE_EXPERIMENT,
|
|
RESOURCE_TYPE_GATEWAY_ENDPOINT,
|
|
RESOURCE_TYPE_GATEWAY_MODEL_DEFINITION,
|
|
RESOURCE_TYPE_GATEWAY_SECRET,
|
|
RESOURCE_TYPE_REGISTERED_MODEL,
|
|
RESOURCE_TYPE_SCORER,
|
|
RESOURCE_TYPE_WORKSPACE,
|
|
Permission,
|
|
_validate_permission_for_resource_type,
|
|
_validate_resource_type,
|
|
get_permission,
|
|
max_permission,
|
|
)
|
|
from mlflow.store.db.utils import (
|
|
_get_managed_session_maker,
|
|
_get_routing_session_maker,
|
|
create_sqlalchemy_engine_with_retry,
|
|
)
|
|
from mlflow.utils import workspace_context
|
|
from mlflow.utils.uri import extract_db_type_from_uri
|
|
from mlflow.utils.validation import _validate_password, _validate_username
|
|
from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Pre-RBAC permission tables retained on disk by
|
|
# ``e5f6a7b8c9d0_migrate_permissions_to_roles``. The runtime no longer reads or
|
|
# writes these tables — they exist solely so operators can roll back the
|
|
# simplification migration without restoring from backup. Their FKs to
|
|
# ``users.id`` are non-cascading from earlier migrations
|
|
# (``8606fa83a998_initial_migration``), so ``delete_user`` must scrub the user's
|
|
# rows here before deleting the user row itself.
|
|
#
|
|
# GRADUATION LEVER. When the drop migration
|
|
# (``f6a7b8c9d0e1_drop_legacy_permission_tables``, scheduled for MLflow 3.X+2 —
|
|
# tracking: https://github.com/mlflow/mlflow/issues/23087) lands and removes
|
|
# these tables from the schema, this tuple becomes ``()`` and the loop in
|
|
# ``delete_user`` below becomes a no-op. The graduation PR should:
|
|
# 1. Add ``f6a7b8c9d0e1_drop_legacy_permission_tables`` that ``op.drop_table``s
|
|
# every entry in this tuple.
|
|
# 2. Set this tuple to ``()`` (or remove the constant + loop entirely).
|
|
# 3. Update ``test_auth_and_tracking_store_coexist`` and
|
|
# ``test_upgrade_from_legacy_database`` to assert the tables are absent
|
|
# post-upgrade.
|
|
# 4. Drop ``test_delete_user_clears_retained_legacy_permission_rows`` —
|
|
# cleanup-by-cascade is sufficient once the legacy FKs are gone.
|
|
_RETAINED_LEGACY_PERMISSION_TABLES: tuple[str, ...] = (
|
|
"experiment_permissions",
|
|
"registered_model_permissions",
|
|
"scorer_permissions",
|
|
"gateway_secret_permissions",
|
|
"gateway_endpoint_permissions",
|
|
"gateway_model_definition_permissions",
|
|
"workspace_permissions",
|
|
)
|
|
|
|
|
|
class SqlAlchemyStore:
|
|
@classmethod
|
|
def _get_active_workspace_name(cls) -> str:
|
|
if not MLFLOW_ENABLE_WORKSPACES.get():
|
|
return DEFAULT_WORKSPACE_NAME
|
|
|
|
if workspace_name := workspace_context.get_request_workspace():
|
|
return workspace_name
|
|
|
|
raise MlflowException.invalid_parameter_value(
|
|
"Active workspace is required. Configure a default workspace or call "
|
|
"mlflow.set_workspace() before interacting with the authentication store."
|
|
)
|
|
|
|
def init_db(self, db_uri, read_db_uri=None):
|
|
self.db_uri = db_uri
|
|
self.db_type = extract_db_type_from_uri(db_uri)
|
|
self.engine = create_sqlalchemy_engine_with_retry(db_uri)
|
|
dbutils.migrate_if_needed(self.engine, "head")
|
|
|
|
# Set up read replica engine if provided
|
|
if read_db_uri and read_db_uri != db_uri:
|
|
self.read_engine = create_sqlalchemy_engine_with_retry(read_db_uri)
|
|
WriteSessionMaker = sessionmaker(bind=self.engine)
|
|
ReadSessionMaker = sessionmaker(bind=self.read_engine)
|
|
self.ManagedSessionMaker = _get_routing_session_maker(
|
|
WriteSessionMaker, ReadSessionMaker, self.db_type
|
|
)
|
|
else:
|
|
if read_db_uri and read_db_uri == db_uri:
|
|
_logger.warning(
|
|
"read_db_uri is the same as the primary db_uri; "
|
|
"read replica routing will not be enabled. "
|
|
"This is likely a configuration mistake."
|
|
)
|
|
self.read_engine = None
|
|
SessionMaker = sessionmaker(bind=self.engine)
|
|
self.ManagedSessionMaker = _get_managed_session_maker(SessionMaker, self.db_type)
|
|
|
|
def authenticate_user(self, username: str, password: str) -> bool:
|
|
with self.ManagedSessionMaker() as session:
|
|
try:
|
|
user = self._get_user(session, username)
|
|
return check_password_hash(user.password_hash, password)
|
|
except MlflowException:
|
|
return False
|
|
|
|
def create_user(self, username: str, password: str, is_admin: bool = False) -> User:
|
|
_validate_username(username)
|
|
_validate_password(password)
|
|
pwhash = generate_password_hash(password)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
try:
|
|
user = SqlUser(username=username, password_hash=pwhash, is_admin=is_admin)
|
|
session.add(user)
|
|
session.flush()
|
|
return user.to_mlflow_entity()
|
|
except IntegrityError as e:
|
|
raise MlflowException(
|
|
f"User (username={username}) already exists. Error: {e}",
|
|
RESOURCE_ALREADY_EXISTS,
|
|
) from e
|
|
|
|
@staticmethod
|
|
def _get_user(session, username: str) -> SqlUser:
|
|
try:
|
|
return session.query(SqlUser).filter(SqlUser.username == username).one()
|
|
except NoResultFound:
|
|
raise MlflowException(
|
|
f"User with username={username} not found",
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
except MultipleResultsFound:
|
|
raise MlflowException(
|
|
f"Found multiple users with username={username}",
|
|
INVALID_STATE,
|
|
)
|
|
|
|
def has_user(self, username: str) -> bool:
|
|
with self.ManagedSessionMaker() as session:
|
|
return session.query(SqlUser).filter(SqlUser.username == username).first() is not None
|
|
|
|
def get_user(self, username: str) -> User:
|
|
with self.ManagedSessionMaker() as session:
|
|
return self._get_user(session, username).to_mlflow_entity()
|
|
|
|
def list_users(self) -> list[User]:
|
|
with self.ManagedSessionMaker() as session:
|
|
users = session.query(SqlUser).all()
|
|
return [u.to_mlflow_entity() for u in users]
|
|
|
|
def list_users_with_roles(self) -> list[tuple[User, list[Role]]]:
|
|
"""
|
|
Return every user paired with their role assignments. Eager-loads
|
|
assignments / roles / role-permissions in batched queries so the admin
|
|
Users tab can render per-user role info without N per-row requests.
|
|
"""
|
|
with self.ManagedSessionMaker() as session:
|
|
users = (
|
|
session
|
|
.query(SqlUser)
|
|
.options(
|
|
selectinload(SqlUser.user_role_assignments)
|
|
.selectinload(SqlUserRoleAssignment.role)
|
|
.selectinload(SqlRole.permissions)
|
|
)
|
|
.all()
|
|
)
|
|
return [
|
|
(u.to_mlflow_entity(), [a.role.to_mlflow_entity() for a in u.user_role_assignments])
|
|
for u in users
|
|
]
|
|
|
|
def update_user(
|
|
self, username: str, password: str | None = None, is_admin: bool | None = None
|
|
) -> User:
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user = self._get_user(session, username)
|
|
if password is not None:
|
|
pwhash = generate_password_hash(password)
|
|
user.password_hash = pwhash
|
|
if is_admin is not None:
|
|
user.is_admin = is_admin
|
|
return user.to_mlflow_entity()
|
|
|
|
def delete_user(self, username: str):
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user = self._get_user(session, username)
|
|
# The user's per-resource grants live as role_permissions rows under a
|
|
# synthetic `__user_<id>__` role. Delete those first so their assignments
|
|
# and permissions don't block the user-row delete on strict FK backends.
|
|
synthetic_name = self._synthetic_user_role_name(user.id)
|
|
for role in session.query(SqlRole).filter(SqlRole.name == synthetic_name).all():
|
|
session.delete(role)
|
|
# Scrub the user's rows from the retained legacy permission tables.
|
|
# See ``_RETAINED_LEGACY_PERMISSION_TABLES`` (top of module) for why
|
|
# this loop exists and the steps to retire it once the drop migration
|
|
# ships. When that constant is empty, this is a no-op.
|
|
for table in _RETAINED_LEGACY_PERMISSION_TABLES:
|
|
session.execute(
|
|
text(f"DELETE FROM {table} WHERE user_id = :uid"),
|
|
{"uid": user.id},
|
|
)
|
|
session.flush()
|
|
session.delete(user)
|
|
|
|
# ---- Synthetic user-role helpers ----
|
|
#
|
|
# The auth system's user-facing permission shape (e.g. `get_experiment_permission`) is
|
|
# expressed as "user X has permission P on resource R". The underlying storage is
|
|
# ``role_permissions``, which binds grants to a role rather than to a user directly.
|
|
# We bridge the two by maintaining a hidden per-user role named `__user_<user_id>__`
|
|
# in each workspace the user has grants in, assigning the user to it, and attaching
|
|
# their per-resource grants as rows on that role.
|
|
#
|
|
# The synthetic namespace (``__user_<int>__``) is RESERVED: ``create_role`` and
|
|
# ``update_role`` reject names matching the pattern so an API consumer can't hijack a
|
|
# synthetic role to receive another user's grants. Bulk cleanup / rename helpers
|
|
# also restrict their scope to synthetic roles so they never touch admin-created
|
|
# roles that happen to hold overlapping grants.
|
|
_SYNTHETIC_ROLE_PREFIX = "__user_"
|
|
_SYNTHETIC_ROLE_SUFFIX = "__"
|
|
_SYNTHETIC_ROLE_NAME_RE = re.compile(r"^__user_\d+__$")
|
|
|
|
@classmethod
|
|
def _synthetic_user_role_name(cls, user_id: int) -> str:
|
|
return f"{cls._SYNTHETIC_ROLE_PREFIX}{user_id}{cls._SYNTHETIC_ROLE_SUFFIX}"
|
|
|
|
@classmethod
|
|
def _is_synthetic_role_name(cls, name: str | None) -> bool:
|
|
return name is not None and cls._SYNTHETIC_ROLE_NAME_RE.match(name) is not None
|
|
|
|
@classmethod
|
|
def _reject_synthetic_role_name(cls, name: str) -> None:
|
|
"""Guard user-facing role CRUD against the reserved synthetic namespace.
|
|
|
|
Reject any name starting with ``__user_`` (not just the strict
|
|
``__user_<digits>__`` synthetic pattern): the whole prefix is reserved
|
|
so a future change to the synthetic naming scheme can't be hijacked by
|
|
an admin-created role that lined up with the older format.
|
|
"""
|
|
if name.startswith(cls._SYNTHETIC_ROLE_PREFIX):
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Role name {name!r} uses the reserved '__user_' prefix, which "
|
|
"is held for the per-user permission representation. Choose a "
|
|
"different name."
|
|
)
|
|
|
|
def _get_or_create_synthetic_user_role(self, session, user_id: int, workspace: str) -> SqlRole:
|
|
# Race-proof: two concurrent permission mutations for the same (user, workspace)
|
|
# can both see "role doesn't exist yet" and both try to INSERT. Wrap the INSERT
|
|
# in a SAVEPOINT so the UniqueConstraint violation rolls back only that nested
|
|
# scope and we can recover by re-querying the winner's row. Same pattern for
|
|
# the user->role assignment.
|
|
name = self._synthetic_user_role_name(user_id)
|
|
role = (
|
|
session
|
|
.query(SqlRole)
|
|
.filter(SqlRole.workspace == workspace, SqlRole.name == name)
|
|
.first()
|
|
)
|
|
if role is None:
|
|
try:
|
|
with session.begin_nested():
|
|
role = SqlRole(name=name, workspace=workspace, description=None)
|
|
session.add(role)
|
|
session.flush()
|
|
except IntegrityError:
|
|
# No other-assignee check needed here: the SAVEPOINT only
|
|
# rolls back when our own INSERT lost the race for the same
|
|
# ``(workspace, __user_<user_id>__)`` tuple, so the recovered
|
|
# row is guaranteed to be this user's synthetic role.
|
|
role = (
|
|
session
|
|
.query(SqlRole)
|
|
.filter(SqlRole.workspace == workspace, SqlRole.name == name)
|
|
.one()
|
|
)
|
|
else:
|
|
# Defense-in-depth: if a role with this reserved synthetic name already
|
|
# exists but has assignments for other users, we would leak grants
|
|
# across accounts by attaching this user's grants to it. This shouldn't
|
|
# happen in practice (``create_role``/``update_role`` reject the
|
|
# synthetic pattern), but on databases that predate the reservation or
|
|
# after a manual SQL insert it would slip through. Refuse to proceed.
|
|
other_assignee = (
|
|
session
|
|
.query(SqlUserRoleAssignment.user_id)
|
|
.filter(
|
|
SqlUserRoleAssignment.role_id == role.id,
|
|
SqlUserRoleAssignment.user_id != user_id,
|
|
)
|
|
.first()
|
|
)
|
|
if other_assignee is not None:
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Role {name!r} in workspace {workspace!r} collides with the "
|
|
"reserved '__user_<id>__' synthetic namespace but is already "
|
|
f"assigned to user_id={other_assignee[0]}. Rename or delete "
|
|
"the conflicting role before granting per-user permissions."
|
|
)
|
|
assignment = (
|
|
session
|
|
.query(SqlUserRoleAssignment)
|
|
.filter(
|
|
SqlUserRoleAssignment.user_id == user_id,
|
|
SqlUserRoleAssignment.role_id == role.id,
|
|
)
|
|
.first()
|
|
)
|
|
if assignment is None:
|
|
try:
|
|
with session.begin_nested():
|
|
session.add(SqlUserRoleAssignment(user_id=user_id, role_id=role.id))
|
|
session.flush()
|
|
except IntegrityError:
|
|
# Another concurrent write already assigned the user.
|
|
pass
|
|
return role
|
|
|
|
def _synthetic_role_ids(self, session, workspace: str | None = None) -> list[int]:
|
|
"""
|
|
Return ids of synthetic ``__user_<id>__`` roles, optionally scoped to
|
|
``workspace``. Bulk cleanup/rename helpers route through this so they never
|
|
touch admin-created roles that happen to share a grant.
|
|
"""
|
|
query = session.query(SqlRole.id, SqlRole.name)
|
|
if workspace is not None:
|
|
query = query.filter(SqlRole.workspace == workspace)
|
|
return [rid for (rid, name) in query.all() if self._is_synthetic_role_name(name)]
|
|
|
|
# ---- Per-user grant helpers ----
|
|
#
|
|
# Generic API for per-user permission grants on the user's synthetic
|
|
# ``__user_<id>__`` role in the active workspace. Cascade handlers
|
|
# (``set_can_manage_*_permission``, ``delete_*_permissions_cascade``,
|
|
# ``rename_registered_model_permission``) call these directly. Per-resource
|
|
# CRUD methods below remain only as tombstones backing the deprecated REST
|
|
# surface; remove them when the deprecated handlers are dropped.
|
|
|
|
def grant_user_permission(
|
|
self,
|
|
username: str,
|
|
resource_type: str,
|
|
resource_pattern: str,
|
|
permission: str,
|
|
) -> None:
|
|
"""
|
|
Upsert a ``permission`` grant on ``(resource_type, resource_pattern)`` for
|
|
``username`` via their synthetic role in the active workspace.
|
|
"""
|
|
_validate_permission_for_resource_type(permission, resource_type)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
role = self._get_or_create_synthetic_user_role(session, user.id, workspace_name)
|
|
existing = (
|
|
session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == resource_type,
|
|
SqlRolePermission.resource_pattern == resource_pattern,
|
|
)
|
|
.first()
|
|
)
|
|
if existing is None:
|
|
session.add(
|
|
SqlRolePermission(
|
|
role_id=role.id,
|
|
resource_type=resource_type,
|
|
resource_pattern=resource_pattern,
|
|
permission=permission,
|
|
)
|
|
)
|
|
else:
|
|
existing.permission = permission
|
|
|
|
@staticmethod
|
|
def _reject_workspace_resource_type(resource_type: str) -> None:
|
|
# Defense in depth — closes the ``sender_is_admin()`` bypass on the
|
|
# ``validate_can_manage_resource`` gate.
|
|
if resource_type == RESOURCE_TYPE_WORKSPACE:
|
|
raise MlflowException.invalid_parameter_value(
|
|
"resource_type 'workspace' is not supported by the per-user permission "
|
|
"convenience APIs. Use set_workspace_permission / "
|
|
"delete_workspace_permission for workspace-wide grants."
|
|
)
|
|
|
|
def grant_user_resource_permission(
|
|
self,
|
|
username: str,
|
|
resource_type: str,
|
|
resource_pattern: str,
|
|
permission: str,
|
|
) -> None:
|
|
"""Insert one grant on ``username``'s synthetic role in the active workspace;
|
|
raises ``RESOURCE_ALREADY_EXISTS`` if a row exists (matches the legacy
|
|
``create_*_permission`` contract).
|
|
"""
|
|
self._reject_workspace_resource_type(resource_type)
|
|
_validate_permission_for_resource_type(permission, resource_type)
|
|
duplicate_message = (
|
|
f"Permission for user={username} on "
|
|
f"resource_type={resource_type}, resource_id={resource_pattern} already exists."
|
|
)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
role = self._get_or_create_synthetic_user_role(session, user.id, workspace_name)
|
|
existing = (
|
|
session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == resource_type,
|
|
SqlRolePermission.resource_pattern == resource_pattern,
|
|
)
|
|
.first()
|
|
)
|
|
if existing is not None:
|
|
raise MlflowException(duplicate_message, RESOURCE_ALREADY_EXISTS)
|
|
try:
|
|
with session.begin_nested():
|
|
session.add(
|
|
SqlRolePermission(
|
|
role_id=role.id,
|
|
resource_type=resource_type,
|
|
resource_pattern=resource_pattern,
|
|
permission=permission,
|
|
)
|
|
)
|
|
session.flush()
|
|
except IntegrityError as e:
|
|
# Concurrent create lost the unique-constraint race. Surface as
|
|
# a clean RESOURCE_ALREADY_EXISTS instead of a 500.
|
|
raise MlflowException(duplicate_message, RESOURCE_ALREADY_EXISTS) from e
|
|
|
|
def revoke_user_resource_permission(
|
|
self,
|
|
username: str,
|
|
resource_type: str,
|
|
resource_pattern: str,
|
|
) -> None:
|
|
"""Remove one grant from ``username``'s synthetic role in the active workspace;
|
|
raises ``RESOURCE_DOES_NOT_EXIST`` if no row matches (matches the legacy
|
|
``delete_*_permission`` contract).
|
|
"""
|
|
self._reject_workspace_resource_type(resource_type)
|
|
_validate_resource_type(resource_type)
|
|
not_found_message = (
|
|
f"Permission for user={username} on "
|
|
f"resource_type={resource_type}, resource_id={resource_pattern} not found."
|
|
)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
role = (
|
|
session
|
|
.query(SqlRole)
|
|
.filter(
|
|
SqlRole.workspace == workspace_name,
|
|
SqlRole.name == self._synthetic_user_role_name(user.id),
|
|
)
|
|
.first()
|
|
)
|
|
rp = (
|
|
None
|
|
if role is None
|
|
else session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == resource_type,
|
|
SqlRolePermission.resource_pattern == resource_pattern,
|
|
)
|
|
.first()
|
|
)
|
|
if rp is None:
|
|
raise MlflowException(not_found_message, RESOURCE_DOES_NOT_EXIST)
|
|
session.delete(rp)
|
|
|
|
def delete_grants_for_resource(
|
|
self,
|
|
resource_type: str,
|
|
resource_pattern: str,
|
|
*,
|
|
workspace_scoped: bool = False,
|
|
) -> None:
|
|
"""
|
|
Delete every synthetic-role grant matching ``(resource_type,
|
|
resource_pattern)``. ``workspace_scoped=True`` restricts to the active
|
|
workspace — for resources whose pattern can collide across workspaces
|
|
(e.g. registered-model names). Admin-created roles are never touched.
|
|
"""
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
workspace = self._get_active_workspace_name() if workspace_scoped else None
|
|
role_ids = self._synthetic_role_ids(session, workspace=workspace)
|
|
if not role_ids:
|
|
return
|
|
session.query(SqlRolePermission).filter(
|
|
SqlRolePermission.role_id.in_(role_ids),
|
|
SqlRolePermission.resource_type == resource_type,
|
|
SqlRolePermission.resource_pattern == resource_pattern,
|
|
).delete(synchronize_session=False)
|
|
|
|
def rename_grants_for_resource(
|
|
self,
|
|
resource_type: str,
|
|
old_pattern: str,
|
|
new_pattern: str,
|
|
*,
|
|
workspace_scoped: bool = False,
|
|
) -> None:
|
|
"""
|
|
Rewrite every synthetic-role grant on ``(resource_type, old_pattern)`` to
|
|
``(resource_type, new_pattern)``. Used for resources whose pattern is the
|
|
primary key and can change (e.g. registered-model rename).
|
|
"""
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
workspace = self._get_active_workspace_name() if workspace_scoped else None
|
|
role_ids = self._synthetic_role_ids(session, workspace=workspace)
|
|
if not role_ids:
|
|
return
|
|
session.query(SqlRolePermission).filter(
|
|
SqlRolePermission.role_id.in_(role_ids),
|
|
SqlRolePermission.resource_type == resource_type,
|
|
SqlRolePermission.resource_pattern == old_pattern,
|
|
).update(
|
|
{SqlRolePermission.resource_pattern: new_pattern},
|
|
synchronize_session=False,
|
|
)
|
|
|
|
# ---- Legacy per-resource CRUD (tombstones) ----
|
|
#
|
|
# The methods below back the deprecated REST handlers in ``__init__.py``.
|
|
# Internal callers should use ``grant_user_permission`` /
|
|
# ``delete_grants_for_resource`` / ``rename_grants_for_resource`` instead.
|
|
# Remove these when the deprecated REST surface is dropped.
|
|
|
|
def create_experiment_permission(
|
|
self, experiment_id: str, username: str, permission: str
|
|
) -> ExperimentPermission:
|
|
_validate_permission_for_resource_type(permission, RESOURCE_TYPE_EXPERIMENT)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
role = self._get_or_create_synthetic_user_role(session, user.id, workspace_name)
|
|
existing = (
|
|
session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_EXPERIMENT,
|
|
SqlRolePermission.resource_pattern == experiment_id,
|
|
)
|
|
.first()
|
|
)
|
|
duplicate_message = (
|
|
f"Experiment permission (experiment_id={experiment_id}, "
|
|
f"username={username}) already exists."
|
|
)
|
|
if existing is not None:
|
|
raise MlflowException(duplicate_message, RESOURCE_ALREADY_EXISTS)
|
|
try:
|
|
with session.begin_nested():
|
|
session.add(
|
|
SqlRolePermission(
|
|
role_id=role.id,
|
|
resource_type=RESOURCE_TYPE_EXPERIMENT,
|
|
resource_pattern=experiment_id,
|
|
permission=permission,
|
|
)
|
|
)
|
|
session.flush()
|
|
except IntegrityError as e:
|
|
# Concurrent create lost the unique-constraint race. Surface as
|
|
# a clean RESOURCE_ALREADY_EXISTS instead of a 500.
|
|
raise MlflowException(duplicate_message, RESOURCE_ALREADY_EXISTS) from e
|
|
return ExperimentPermission(
|
|
experiment_id=experiment_id, user_id=user.id, permission=permission
|
|
)
|
|
|
|
def _get_experiment_permission_row(
|
|
self, session, experiment_id: str, username: str
|
|
) -> tuple[SqlUser, SqlRolePermission]:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
role = (
|
|
session
|
|
.query(SqlRole)
|
|
.filter(
|
|
SqlRole.workspace == workspace_name,
|
|
SqlRole.name == self._synthetic_user_role_name(user.id),
|
|
)
|
|
.first()
|
|
)
|
|
rp = (
|
|
None
|
|
if role is None
|
|
else session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_EXPERIMENT,
|
|
SqlRolePermission.resource_pattern == experiment_id,
|
|
)
|
|
.first()
|
|
)
|
|
if rp is None:
|
|
raise MlflowException(
|
|
f"Experiment permission with experiment_id={experiment_id} and "
|
|
f"username={username} not found",
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
return user, rp
|
|
|
|
def get_experiment_permission(self, experiment_id: str, username: str) -> ExperimentPermission:
|
|
with self.ManagedSessionMaker() as session:
|
|
user, rp = self._get_experiment_permission_row(session, experiment_id, username)
|
|
return ExperimentPermission(
|
|
experiment_id=experiment_id, user_id=user.id, permission=rp.permission
|
|
)
|
|
|
|
def list_experiment_permissions(self, username: str) -> list[ExperimentPermission]:
|
|
with self.ManagedSessionMaker() as session:
|
|
user = self._get_user(session, username=username)
|
|
rows = (
|
|
session
|
|
.query(SqlRolePermission.resource_pattern, SqlRolePermission.permission)
|
|
.join(SqlRole, SqlRole.id == SqlRolePermission.role_id)
|
|
.filter(
|
|
SqlRole.name == self._synthetic_user_role_name(user.id),
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_EXPERIMENT,
|
|
)
|
|
.all()
|
|
)
|
|
return [
|
|
ExperimentPermission(experiment_id=pattern, user_id=user.id, permission=permission)
|
|
for pattern, permission in rows
|
|
]
|
|
|
|
def update_experiment_permission(
|
|
self, experiment_id: str, username: str, permission: str
|
|
) -> ExperimentPermission:
|
|
_validate_permission_for_resource_type(permission, RESOURCE_TYPE_EXPERIMENT)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user, rp = self._get_experiment_permission_row(session, experiment_id, username)
|
|
rp.permission = permission
|
|
return ExperimentPermission(
|
|
experiment_id=experiment_id, user_id=user.id, permission=permission
|
|
)
|
|
|
|
def delete_experiment_permission(self, experiment_id: str, username: str):
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
_, rp = self._get_experiment_permission_row(session, experiment_id, username)
|
|
session.delete(rp)
|
|
|
|
def delete_workspace_permissions_for_workspace(self, workspace_name: str) -> None:
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
role_ids = self._synthetic_role_ids(session, workspace=workspace_name)
|
|
if not role_ids:
|
|
return
|
|
session.query(SqlRolePermission).filter(
|
|
SqlRolePermission.role_id.in_(role_ids),
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_WORKSPACE,
|
|
SqlRolePermission.resource_pattern == "*",
|
|
).delete(synchronize_session=False)
|
|
|
|
def create_registered_model_permission(
|
|
self, name: str, username: str, permission: str
|
|
) -> RegisteredModelPermission:
|
|
_validate_permission_for_resource_type(permission, RESOURCE_TYPE_REGISTERED_MODEL)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
role = self._get_or_create_synthetic_user_role(session, user.id, workspace_name)
|
|
existing = (
|
|
session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_REGISTERED_MODEL,
|
|
SqlRolePermission.resource_pattern == name,
|
|
)
|
|
.first()
|
|
)
|
|
duplicate_message = (
|
|
"Registered model permission "
|
|
f"with workspace={workspace_name}, name={name} and username={username} "
|
|
"already exists."
|
|
)
|
|
if existing is not None:
|
|
raise MlflowException(duplicate_message, RESOURCE_ALREADY_EXISTS)
|
|
try:
|
|
with session.begin_nested():
|
|
session.add(
|
|
SqlRolePermission(
|
|
role_id=role.id,
|
|
resource_type=RESOURCE_TYPE_REGISTERED_MODEL,
|
|
resource_pattern=name,
|
|
permission=permission,
|
|
)
|
|
)
|
|
session.flush()
|
|
except IntegrityError as e:
|
|
# Concurrent create lost the unique-constraint race. Surface as
|
|
# a clean RESOURCE_ALREADY_EXISTS instead of a 500.
|
|
raise MlflowException(duplicate_message, RESOURCE_ALREADY_EXISTS) from e
|
|
return RegisteredModelPermission(
|
|
workspace=workspace_name, name=name, user_id=user.id, permission=permission
|
|
)
|
|
|
|
def _get_registered_model_permission_row(
|
|
self, session, name: str, username: str
|
|
) -> tuple[SqlUser, str, SqlRolePermission]:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
role = (
|
|
session
|
|
.query(SqlRole)
|
|
.filter(
|
|
SqlRole.workspace == workspace_name,
|
|
SqlRole.name == self._synthetic_user_role_name(user.id),
|
|
)
|
|
.first()
|
|
)
|
|
rp = (
|
|
None
|
|
if role is None
|
|
else session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_REGISTERED_MODEL,
|
|
SqlRolePermission.resource_pattern == name,
|
|
)
|
|
.first()
|
|
)
|
|
if rp is None:
|
|
raise MlflowException(
|
|
"Registered model permission "
|
|
f"with workspace={workspace_name}, name={name} and username={username} not found",
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
return user, workspace_name, rp
|
|
|
|
def get_registered_model_permission(
|
|
self, name: str, username: str
|
|
) -> RegisteredModelPermission:
|
|
with self.ManagedSessionMaker() as session:
|
|
user, workspace_name, rp = self._get_registered_model_permission_row(
|
|
session, name, username
|
|
)
|
|
return RegisteredModelPermission(
|
|
workspace=workspace_name, name=name, user_id=user.id, permission=rp.permission
|
|
)
|
|
|
|
def list_registered_model_permissions(self, username: str) -> list[RegisteredModelPermission]:
|
|
with self.ManagedSessionMaker() as session:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
rows = (
|
|
session
|
|
.query(SqlRolePermission.resource_pattern, SqlRolePermission.permission)
|
|
.join(SqlRole, SqlRole.id == SqlRolePermission.role_id)
|
|
.filter(
|
|
SqlRole.workspace == workspace_name,
|
|
SqlRole.name == self._synthetic_user_role_name(user.id),
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_REGISTERED_MODEL,
|
|
)
|
|
.all()
|
|
)
|
|
return [
|
|
RegisteredModelPermission(
|
|
workspace=workspace_name,
|
|
name=pattern,
|
|
user_id=user.id,
|
|
permission=permission,
|
|
)
|
|
for pattern, permission in rows
|
|
]
|
|
|
|
def list_all_registered_model_permissions(
|
|
self, username: str
|
|
) -> list[RegisteredModelPermission]:
|
|
"""
|
|
Cross-workspace variant for callers without an active workspace
|
|
(e.g. the global ``/users/current/permissions`` endpoint backing
|
|
the ``/account`` page). Mirrors ``list_registered_model_permissions``
|
|
but skips the workspace filter so the returned rows span every
|
|
workspace the user has grants in — each row carries its own
|
|
``workspace`` value (taken from the synthetic role's workspace),
|
|
so the caller can still attribute correctly.
|
|
"""
|
|
with self.ManagedSessionMaker() as session:
|
|
user = self._get_user(session, username=username)
|
|
rows = (
|
|
session
|
|
.query(
|
|
SqlRole.workspace,
|
|
SqlRolePermission.resource_pattern,
|
|
SqlRolePermission.permission,
|
|
)
|
|
.join(SqlRolePermission, SqlRole.id == SqlRolePermission.role_id)
|
|
.filter(
|
|
SqlRole.name == self._synthetic_user_role_name(user.id),
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_REGISTERED_MODEL,
|
|
)
|
|
.all()
|
|
)
|
|
return [
|
|
RegisteredModelPermission(
|
|
workspace=workspace,
|
|
name=pattern,
|
|
user_id=user.id,
|
|
permission=permission,
|
|
)
|
|
for workspace, pattern, permission in rows
|
|
]
|
|
|
|
def update_registered_model_permission(
|
|
self, name: str, username: str, permission: str
|
|
) -> RegisteredModelPermission:
|
|
_validate_permission_for_resource_type(permission, RESOURCE_TYPE_REGISTERED_MODEL)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user, workspace_name, rp = self._get_registered_model_permission_row(
|
|
session, name, username
|
|
)
|
|
rp.permission = permission
|
|
return RegisteredModelPermission(
|
|
workspace=workspace_name, name=name, user_id=user.id, permission=permission
|
|
)
|
|
|
|
def delete_registered_model_permission(self, name: str, username: str):
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
_, _, rp = self._get_registered_model_permission_row(session, name, username)
|
|
session.delete(rp)
|
|
|
|
def _list_workspace_perm_rows(
|
|
self,
|
|
session,
|
|
*,
|
|
workspace_name: str | None = None,
|
|
user_id: int | None = None,
|
|
) -> list[tuple[SqlRole, SqlRolePermission]]:
|
|
query = (
|
|
session
|
|
.query(SqlRole, SqlRolePermission)
|
|
.join(SqlRolePermission, SqlRolePermission.role_id == SqlRole.id)
|
|
.filter(
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_WORKSPACE,
|
|
SqlRolePermission.resource_pattern == "*",
|
|
)
|
|
)
|
|
if workspace_name is not None:
|
|
query = query.filter(SqlRole.workspace == workspace_name)
|
|
if user_id is not None:
|
|
query = query.join(
|
|
SqlUserRoleAssignment, SqlUserRoleAssignment.role_id == SqlRole.id
|
|
).filter(SqlUserRoleAssignment.user_id == user_id)
|
|
return [(role, rp) for (role, rp) in query.all() if self._is_synthetic_role_name(role.name)]
|
|
|
|
@classmethod
|
|
def _user_id_from_synthetic_role_name(cls, name: str) -> int:
|
|
return int(name[len(cls._SYNTHETIC_ROLE_PREFIX) : -len(cls._SYNTHETIC_ROLE_SUFFIX)])
|
|
|
|
def list_workspace_permissions(self, workspace_name: str) -> list[WorkspacePermission]:
|
|
with self.ManagedSessionMaker() as session:
|
|
rows = self._list_workspace_perm_rows(session, workspace_name=workspace_name)
|
|
return [
|
|
WorkspacePermission(
|
|
workspace=role.workspace,
|
|
user_id=self._user_id_from_synthetic_role_name(role.name),
|
|
permission=rp.permission,
|
|
)
|
|
for (role, rp) in rows
|
|
]
|
|
|
|
def list_user_workspace_permissions(self, username: str) -> list[WorkspacePermission]:
|
|
with self.ManagedSessionMaker() as session:
|
|
user = self._get_user(session, username=username)
|
|
rows = self._list_workspace_perm_rows(session, user_id=user.id)
|
|
return [
|
|
WorkspacePermission(
|
|
workspace=role.workspace,
|
|
user_id=user.id,
|
|
permission=rp.permission,
|
|
)
|
|
for (role, rp) in rows
|
|
]
|
|
|
|
def set_workspace_permission(
|
|
self, workspace_name: str, username: str, permission: str
|
|
) -> WorkspacePermission:
|
|
_validate_permission_for_resource_type(permission, RESOURCE_TYPE_WORKSPACE)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user = self._get_user(session, username=username)
|
|
role = self._get_or_create_synthetic_user_role(session, user.id, workspace_name)
|
|
existing = (
|
|
session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_WORKSPACE,
|
|
SqlRolePermission.resource_pattern == "*",
|
|
)
|
|
.first()
|
|
)
|
|
if existing is None:
|
|
session.add(
|
|
SqlRolePermission(
|
|
role_id=role.id,
|
|
resource_type=RESOURCE_TYPE_WORKSPACE,
|
|
resource_pattern="*",
|
|
permission=permission,
|
|
)
|
|
)
|
|
else:
|
|
existing.permission = permission
|
|
session.flush()
|
|
return WorkspacePermission(
|
|
workspace=workspace_name, user_id=user.id, permission=permission
|
|
)
|
|
|
|
def delete_workspace_permission(self, workspace_name: str, username: str) -> None:
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user = self._get_user(session, username=username)
|
|
role_name = self._synthetic_user_role_name(user.id)
|
|
role = (
|
|
session
|
|
.query(SqlRole)
|
|
.filter(SqlRole.workspace == workspace_name, SqlRole.name == role_name)
|
|
.first()
|
|
)
|
|
existing = None
|
|
if role is not None:
|
|
existing = (
|
|
session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_WORKSPACE,
|
|
SqlRolePermission.resource_pattern == "*",
|
|
)
|
|
.first()
|
|
)
|
|
if existing is None:
|
|
raise MlflowException(
|
|
(
|
|
"Workspace permission does not exist for "
|
|
f"workspace='{workspace_name}', username='{username}'"
|
|
),
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
session.delete(existing)
|
|
|
|
def list_accessible_workspace_names(self, username: str | None) -> set[str]:
|
|
"""
|
|
Return the set of workspaces ``username`` can see. Two-source union that
|
|
mirrors master's #22864 logic, with the legacy half adapted to the
|
|
post-migration storage shape:
|
|
|
|
- **Non-synthetic role assignments**: any role the user is assigned to
|
|
(admin-created, no permissions or otherwise) confers visibility on
|
|
its workspace. Matches #22864's "any role assignment counts".
|
|
- **Synthetic ``__user_<id>__`` roles**: only confer visibility if their
|
|
``('workspace', '*')`` grant has ``can_read``. This preserves the legacy
|
|
``workspace_permissions`` semantic where a row with
|
|
``permission='NO_PERMISSIONS'`` hides the workspace; those rows now
|
|
live as the ``('workspace', '*')`` grant on the synthetic role.
|
|
"""
|
|
if username is None:
|
|
return set()
|
|
with self.ManagedSessionMaker() as session:
|
|
user = self._get_user(session, username=username)
|
|
# All workspace/role-name pairs the user is assigned to.
|
|
assigned = (
|
|
session
|
|
.query(SqlRole.workspace, SqlRole.name)
|
|
.join(SqlUserRoleAssignment, SqlRole.id == SqlUserRoleAssignment.role_id)
|
|
.filter(SqlUserRoleAssignment.user_id == user.id)
|
|
.distinct()
|
|
.all()
|
|
)
|
|
accessible: set[str] = set()
|
|
synthetic_workspaces: set[str] = set()
|
|
for ws, role_name in assigned:
|
|
if self._is_synthetic_role_name(role_name):
|
|
synthetic_workspaces.add(ws)
|
|
else:
|
|
accessible.add(ws)
|
|
|
|
if synthetic_workspaces:
|
|
# For synthetic roles, only count workspaces where the (*, *) grant
|
|
# has can_read. A migrated NO_PERMISSIONS row keeps the workspace
|
|
# hidden.
|
|
synthetic_rows = (
|
|
session
|
|
.query(SqlRole.workspace, SqlRolePermission.permission)
|
|
.join(SqlRolePermission, SqlRolePermission.role_id == SqlRole.id)
|
|
.join(SqlUserRoleAssignment, SqlUserRoleAssignment.role_id == SqlRole.id)
|
|
.filter(
|
|
SqlUserRoleAssignment.user_id == user.id,
|
|
SqlRole.workspace.in_(synthetic_workspaces),
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_WORKSPACE,
|
|
SqlRolePermission.resource_pattern == "*",
|
|
)
|
|
.all()
|
|
)
|
|
accessible.update(
|
|
ws for (ws, perm) in synthetic_rows if get_permission(perm).can_read
|
|
)
|
|
return accessible
|
|
|
|
def get_workspace_permission(self, workspace_name: str, username: str) -> Permission | None:
|
|
"""
|
|
Get the user's **direct** workspace permission — the
|
|
``('workspace', '*', PERMISSION)`` grant on their synthetic
|
|
``__user_<id>__`` role for ``workspace_name``, if any. Pre-migration this
|
|
was a row in the legacy ``workspace_permissions`` table; the
|
|
``e5f6a7b8c9d0`` migration moved it to ``role_permissions`` under the
|
|
synthetic role, which is what this method now queries.
|
|
|
|
Does NOT include grants conferred by other (admin-managed) roles.
|
|
Callers that need the full authorization picture should also consult
|
|
``get_role_workspace_permission`` and ``max_permission``-merge the two.
|
|
See ``mlflow.server.auth.__init__._workspace_permission`` for the
|
|
canonical aggregation.
|
|
"""
|
|
with self.ManagedSessionMaker() as session:
|
|
user = self._get_user(session, username=username)
|
|
role_name = self._synthetic_user_role_name(user.id)
|
|
rp = (
|
|
session
|
|
.query(SqlRolePermission)
|
|
.join(SqlRole, SqlRole.id == SqlRolePermission.role_id)
|
|
.filter(
|
|
SqlRole.workspace == workspace_name,
|
|
SqlRole.name == role_name,
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_WORKSPACE,
|
|
SqlRolePermission.resource_pattern == "*",
|
|
)
|
|
.first()
|
|
)
|
|
if rp is not None:
|
|
return get_permission(rp.permission)
|
|
return None
|
|
|
|
def get_role_workspace_permission(
|
|
self, workspace_name: str, username: str
|
|
) -> Permission | None:
|
|
"""
|
|
Highest **role-based** permission ``username`` has on ``workspace_name``
|
|
where the role grant is workspace-wide. Returns ``None`` when there are
|
|
no such grants.
|
|
|
|
The simplified model uses a single workspace-wide grant slot,
|
|
``(resource_type='workspace', resource_pattern='*')``. The permission
|
|
tier (USE for regular member, MANAGE for workspace admin) carries the
|
|
admin signal — both seeded roles (``user`` and ``admin``) and migrated
|
|
legacy ``workspace_permissions`` rows land in this slot.
|
|
|
|
Complements ``get_workspace_permission`` — that helper restricts to the
|
|
user's own synthetic ``__user_<id>__`` role, this one walks every role
|
|
the user is assigned to (admin-managed and synthetic alike). Callers
|
|
that need the effective workspace-level permission should max-merge the
|
|
two.
|
|
"""
|
|
with self.ManagedSessionMaker() as session:
|
|
user = self._get_user(session, username=username)
|
|
permissions = (
|
|
session
|
|
.query(SqlRolePermission.permission)
|
|
.join(SqlRole, SqlRole.id == SqlRolePermission.role_id)
|
|
.join(SqlUserRoleAssignment, SqlRole.id == SqlUserRoleAssignment.role_id)
|
|
.filter(
|
|
SqlUserRoleAssignment.user_id == user.id,
|
|
SqlRole.workspace == workspace_name,
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_WORKSPACE,
|
|
SqlRolePermission.resource_pattern == "*",
|
|
)
|
|
.distinct()
|
|
.all()
|
|
)
|
|
if not permissions:
|
|
return None
|
|
best: str | None = None
|
|
for (perm,) in permissions:
|
|
best = perm if best is None else max_permission(best, perm)
|
|
return get_permission(best)
|
|
|
|
@staticmethod
|
|
def _scorer_pattern(experiment_id: str, scorer_name: str) -> str:
|
|
# Scorer names may contain arbitrary characters including ``/`` (see
|
|
# ``validate_scorer_name``, which only forbids empty/whitespace). We
|
|
# URL-encode the name component so the pattern ``<experiment_id>/<name>``
|
|
# is unambiguous; the migration (``e5f6a7b8c9d0``) uses the same
|
|
# encoding, so post-migration and live grants line up.
|
|
return f"{experiment_id}/{quote(scorer_name, safe='')}"
|
|
|
|
def create_scorer_permission(
|
|
self, experiment_id: str, scorer_name: str, username: str, permission: str
|
|
) -> ScorerPermission:
|
|
_validate_permission_for_resource_type(permission, RESOURCE_TYPE_SCORER)
|
|
pattern = self._scorer_pattern(experiment_id, scorer_name)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
role = self._get_or_create_synthetic_user_role(session, user.id, workspace_name)
|
|
existing = (
|
|
session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_SCORER,
|
|
SqlRolePermission.resource_pattern == pattern,
|
|
)
|
|
.first()
|
|
)
|
|
duplicate_message = (
|
|
f"Scorer permission (experiment_id={experiment_id}, "
|
|
f"scorer_name={scorer_name}, username={username}) already exists."
|
|
)
|
|
if existing is not None:
|
|
raise MlflowException(duplicate_message, RESOURCE_ALREADY_EXISTS)
|
|
try:
|
|
with session.begin_nested():
|
|
session.add(
|
|
SqlRolePermission(
|
|
role_id=role.id,
|
|
resource_type=RESOURCE_TYPE_SCORER,
|
|
resource_pattern=pattern,
|
|
permission=permission,
|
|
)
|
|
)
|
|
session.flush()
|
|
except IntegrityError as e:
|
|
# Concurrent create lost the unique-constraint race. Surface as
|
|
# a clean RESOURCE_ALREADY_EXISTS instead of a 500.
|
|
raise MlflowException(duplicate_message, RESOURCE_ALREADY_EXISTS) from e
|
|
return ScorerPermission(
|
|
experiment_id=experiment_id,
|
|
scorer_name=scorer_name,
|
|
user_id=user.id,
|
|
permission=permission,
|
|
)
|
|
|
|
def _get_scorer_permission_row(
|
|
self, session, experiment_id: str, scorer_name: str, username: str
|
|
) -> tuple[SqlUser, SqlRolePermission]:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
role = (
|
|
session
|
|
.query(SqlRole)
|
|
.filter(
|
|
SqlRole.workspace == workspace_name,
|
|
SqlRole.name == self._synthetic_user_role_name(user.id),
|
|
)
|
|
.first()
|
|
)
|
|
pattern = self._scorer_pattern(experiment_id, scorer_name)
|
|
rp = (
|
|
None
|
|
if role is None
|
|
else session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_SCORER,
|
|
SqlRolePermission.resource_pattern == pattern,
|
|
)
|
|
.first()
|
|
)
|
|
if rp is None:
|
|
raise MlflowException(
|
|
f"Scorer permission with experiment_id={experiment_id}, "
|
|
f"scorer_name={scorer_name}, and username={username} not found",
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
return user, rp
|
|
|
|
def get_scorer_permission(
|
|
self, experiment_id: str, scorer_name: str, username: str
|
|
) -> ScorerPermission:
|
|
with self.ManagedSessionMaker() as session:
|
|
user, rp = self._get_scorer_permission_row(
|
|
session, experiment_id, scorer_name, username
|
|
)
|
|
return ScorerPermission(
|
|
experiment_id=experiment_id,
|
|
scorer_name=scorer_name,
|
|
user_id=user.id,
|
|
permission=rp.permission,
|
|
)
|
|
|
|
def list_scorer_permissions(self, username: str) -> list[ScorerPermission]:
|
|
with self.ManagedSessionMaker() as session:
|
|
user = self._get_user(session, username=username)
|
|
rows = (
|
|
session
|
|
.query(SqlRolePermission.resource_pattern, SqlRolePermission.permission)
|
|
.join(SqlRole, SqlRole.id == SqlRolePermission.role_id)
|
|
.filter(
|
|
SqlRole.name == self._synthetic_user_role_name(user.id),
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_SCORER,
|
|
)
|
|
.all()
|
|
)
|
|
out = []
|
|
for pattern, permission in rows:
|
|
# Compound key encoded as ``{experiment_id}/{url_quote(scorer_name)}``
|
|
# — see ``_scorer_pattern``. The first ``/`` is the delimiter; the
|
|
# scorer name is URL-decoded back to its raw form.
|
|
exp_id, _, sname_encoded = pattern.partition("/")
|
|
sname = unquote(sname_encoded)
|
|
out.append(
|
|
ScorerPermission(
|
|
experiment_id=exp_id,
|
|
scorer_name=sname,
|
|
user_id=user.id,
|
|
permission=permission,
|
|
)
|
|
)
|
|
return out
|
|
|
|
def update_scorer_permission(
|
|
self, experiment_id: str, scorer_name: str, username: str, permission: str
|
|
) -> ScorerPermission:
|
|
_validate_permission_for_resource_type(permission, RESOURCE_TYPE_SCORER)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user, rp = self._get_scorer_permission_row(
|
|
session, experiment_id, scorer_name, username
|
|
)
|
|
rp.permission = permission
|
|
return ScorerPermission(
|
|
experiment_id=experiment_id,
|
|
scorer_name=scorer_name,
|
|
user_id=user.id,
|
|
permission=permission,
|
|
)
|
|
|
|
def delete_scorer_permission(self, experiment_id: str, scorer_name: str, username: str):
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
_, rp = self._get_scorer_permission_row(session, experiment_id, scorer_name, username)
|
|
session.delete(rp)
|
|
|
|
# ---- Gateway permission helpers ----
|
|
#
|
|
# The three gateway resource types (secret / endpoint / model_definition) share a
|
|
# common shape: one resource id, per-user permission, no workspace column. These
|
|
# helpers factor out the synthetic-role plumbing so the public methods stay a
|
|
# thin wrapper with type-specific entity construction.
|
|
|
|
def _create_per_resource_permission(
|
|
self,
|
|
*,
|
|
resource_type: str,
|
|
resource_pattern: str,
|
|
username: str,
|
|
permission: str,
|
|
entity_factory,
|
|
duplicate_message: str,
|
|
):
|
|
_validate_permission_for_resource_type(permission, resource_type)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
role = self._get_or_create_synthetic_user_role(session, user.id, workspace_name)
|
|
existing = (
|
|
session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == resource_type,
|
|
SqlRolePermission.resource_pattern == resource_pattern,
|
|
)
|
|
.first()
|
|
)
|
|
if existing is not None:
|
|
raise MlflowException(duplicate_message, RESOURCE_ALREADY_EXISTS)
|
|
try:
|
|
with session.begin_nested():
|
|
session.add(
|
|
SqlRolePermission(
|
|
role_id=role.id,
|
|
resource_type=resource_type,
|
|
resource_pattern=resource_pattern,
|
|
permission=permission,
|
|
)
|
|
)
|
|
session.flush()
|
|
except IntegrityError as e:
|
|
# Concurrent create lost the unique-constraint race. Surface as
|
|
# a clean RESOURCE_ALREADY_EXISTS instead of a 500.
|
|
raise MlflowException(duplicate_message, RESOURCE_ALREADY_EXISTS) from e
|
|
return entity_factory(user=user, permission=permission)
|
|
|
|
def _get_per_resource_permission_row(
|
|
self,
|
|
session,
|
|
*,
|
|
resource_type: str,
|
|
resource_pattern: str,
|
|
username: str,
|
|
not_found_message: str,
|
|
) -> tuple[SqlUser, SqlRolePermission]:
|
|
user = self._get_user(session, username=username)
|
|
workspace_name = self._get_active_workspace_name()
|
|
role = (
|
|
session
|
|
.query(SqlRole)
|
|
.filter(
|
|
SqlRole.workspace == workspace_name,
|
|
SqlRole.name == self._synthetic_user_role_name(user.id),
|
|
)
|
|
.first()
|
|
)
|
|
rp = (
|
|
None
|
|
if role is None
|
|
else session
|
|
.query(SqlRolePermission)
|
|
.filter(
|
|
SqlRolePermission.role_id == role.id,
|
|
SqlRolePermission.resource_type == resource_type,
|
|
SqlRolePermission.resource_pattern == resource_pattern,
|
|
)
|
|
.first()
|
|
)
|
|
if rp is None:
|
|
raise MlflowException(not_found_message, RESOURCE_DOES_NOT_EXIST)
|
|
return user, rp
|
|
|
|
def _update_per_resource_permission(
|
|
self,
|
|
*,
|
|
resource_type: str,
|
|
resource_pattern: str,
|
|
username: str,
|
|
permission: str,
|
|
entity_factory,
|
|
not_found_message: str,
|
|
):
|
|
_validate_permission_for_resource_type(permission, resource_type)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
user, rp = self._get_per_resource_permission_row(
|
|
session,
|
|
resource_type=resource_type,
|
|
resource_pattern=resource_pattern,
|
|
username=username,
|
|
not_found_message=not_found_message,
|
|
)
|
|
rp.permission = permission
|
|
return entity_factory(user=user, permission=permission)
|
|
|
|
def _delete_per_resource_permission(
|
|
self,
|
|
*,
|
|
resource_type: str,
|
|
resource_pattern: str,
|
|
username: str,
|
|
not_found_message: str,
|
|
) -> None:
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
_, rp = self._get_per_resource_permission_row(
|
|
session,
|
|
resource_type=resource_type,
|
|
resource_pattern=resource_pattern,
|
|
username=username,
|
|
not_found_message=not_found_message,
|
|
)
|
|
session.delete(rp)
|
|
|
|
def _list_per_resource_permissions(self, username: str, resource_type: str):
|
|
with self.ManagedSessionMaker() as session:
|
|
user = self._get_user(session, username=username)
|
|
user_id = user.id
|
|
rows = (
|
|
session
|
|
.query(SqlRolePermission.resource_pattern, SqlRolePermission.permission)
|
|
.join(SqlRole, SqlRole.id == SqlRolePermission.role_id)
|
|
.filter(
|
|
SqlRole.name == self._synthetic_user_role_name(user_id),
|
|
SqlRolePermission.resource_type == resource_type,
|
|
)
|
|
.all()
|
|
)
|
|
# Read ``user.id`` while the session is still open and return the
|
|
# plain int — callers that build entity objects outside the session
|
|
# block would otherwise hit ``DetachedInstanceError`` on attribute
|
|
# access.
|
|
return user_id, rows
|
|
|
|
# ---- gateway_secret ----
|
|
|
|
def create_gateway_secret_permission(
|
|
self, secret_id: str, username: str, permission: str
|
|
) -> GatewaySecretPermission:
|
|
return self._create_per_resource_permission(
|
|
resource_type=RESOURCE_TYPE_GATEWAY_SECRET,
|
|
resource_pattern=secret_id,
|
|
username=username,
|
|
permission=permission,
|
|
entity_factory=lambda user, permission: GatewaySecretPermission(
|
|
secret_id=secret_id, user_id=user.id, permission=permission
|
|
),
|
|
duplicate_message=(
|
|
f"Gateway secret permission (secret_id={secret_id}, username={username}) "
|
|
"already exists."
|
|
),
|
|
)
|
|
|
|
def get_gateway_secret_permission(
|
|
self, secret_id: str, username: str
|
|
) -> GatewaySecretPermission:
|
|
with self.ManagedSessionMaker() as session:
|
|
user, rp = self._get_per_resource_permission_row(
|
|
session,
|
|
resource_type=RESOURCE_TYPE_GATEWAY_SECRET,
|
|
resource_pattern=secret_id,
|
|
username=username,
|
|
not_found_message=(
|
|
f"Gateway secret permission with secret_id={secret_id} and "
|
|
f"username={username} not found"
|
|
),
|
|
)
|
|
return GatewaySecretPermission(
|
|
secret_id=secret_id, user_id=user.id, permission=rp.permission
|
|
)
|
|
|
|
def list_gateway_secret_permissions(self, username: str) -> list[GatewaySecretPermission]:
|
|
user_id, rows = self._list_per_resource_permissions(username, RESOURCE_TYPE_GATEWAY_SECRET)
|
|
return [
|
|
GatewaySecretPermission(secret_id=p, user_id=user_id, permission=perm)
|
|
for p, perm in rows
|
|
]
|
|
|
|
def update_gateway_secret_permission(
|
|
self, secret_id: str, username: str, permission: str
|
|
) -> GatewaySecretPermission:
|
|
return self._update_per_resource_permission(
|
|
resource_type=RESOURCE_TYPE_GATEWAY_SECRET,
|
|
resource_pattern=secret_id,
|
|
username=username,
|
|
permission=permission,
|
|
entity_factory=lambda user, permission: GatewaySecretPermission(
|
|
secret_id=secret_id, user_id=user.id, permission=permission
|
|
),
|
|
not_found_message=(
|
|
f"Gateway secret permission with secret_id={secret_id} and "
|
|
f"username={username} not found"
|
|
),
|
|
)
|
|
|
|
def delete_gateway_secret_permission(self, secret_id: str, username: str) -> None:
|
|
self._delete_per_resource_permission(
|
|
resource_type=RESOURCE_TYPE_GATEWAY_SECRET,
|
|
resource_pattern=secret_id,
|
|
username=username,
|
|
not_found_message=(
|
|
f"Gateway secret permission with secret_id={secret_id} and "
|
|
f"username={username} not found"
|
|
),
|
|
)
|
|
|
|
# ---- gateway_endpoint ----
|
|
|
|
def create_gateway_endpoint_permission(
|
|
self, endpoint_id: str, username: str, permission: str
|
|
) -> GatewayEndpointPermission:
|
|
return self._create_per_resource_permission(
|
|
resource_type=RESOURCE_TYPE_GATEWAY_ENDPOINT,
|
|
resource_pattern=endpoint_id,
|
|
username=username,
|
|
permission=permission,
|
|
entity_factory=lambda user, permission: GatewayEndpointPermission(
|
|
endpoint_id=endpoint_id, user_id=user.id, permission=permission
|
|
),
|
|
duplicate_message=(
|
|
f"Gateway endpoint permission (endpoint_id={endpoint_id}, "
|
|
f"username={username}) already exists."
|
|
),
|
|
)
|
|
|
|
def get_gateway_endpoint_permission(
|
|
self, endpoint_id: str, username: str
|
|
) -> GatewayEndpointPermission:
|
|
with self.ManagedSessionMaker() as session:
|
|
user, rp = self._get_per_resource_permission_row(
|
|
session,
|
|
resource_type=RESOURCE_TYPE_GATEWAY_ENDPOINT,
|
|
resource_pattern=endpoint_id,
|
|
username=username,
|
|
not_found_message=(
|
|
f"Gateway endpoint permission with endpoint_id={endpoint_id} and "
|
|
f"username={username} not found"
|
|
),
|
|
)
|
|
return GatewayEndpointPermission(
|
|
endpoint_id=endpoint_id, user_id=user.id, permission=rp.permission
|
|
)
|
|
|
|
def list_gateway_endpoint_permissions(self, username: str) -> list[GatewayEndpointPermission]:
|
|
user_id, rows = self._list_per_resource_permissions(
|
|
username, RESOURCE_TYPE_GATEWAY_ENDPOINT
|
|
)
|
|
return [
|
|
GatewayEndpointPermission(endpoint_id=p, user_id=user_id, permission=perm)
|
|
for p, perm in rows
|
|
]
|
|
|
|
def update_gateway_endpoint_permission(
|
|
self, endpoint_id: str, username: str, permission: str
|
|
) -> GatewayEndpointPermission:
|
|
return self._update_per_resource_permission(
|
|
resource_type=RESOURCE_TYPE_GATEWAY_ENDPOINT,
|
|
resource_pattern=endpoint_id,
|
|
username=username,
|
|
permission=permission,
|
|
entity_factory=lambda user, permission: GatewayEndpointPermission(
|
|
endpoint_id=endpoint_id, user_id=user.id, permission=permission
|
|
),
|
|
not_found_message=(
|
|
f"Gateway endpoint permission with endpoint_id={endpoint_id} and "
|
|
f"username={username} not found"
|
|
),
|
|
)
|
|
|
|
def delete_gateway_endpoint_permission(self, endpoint_id: str, username: str) -> None:
|
|
self._delete_per_resource_permission(
|
|
resource_type=RESOURCE_TYPE_GATEWAY_ENDPOINT,
|
|
resource_pattern=endpoint_id,
|
|
username=username,
|
|
not_found_message=(
|
|
f"Gateway endpoint permission with endpoint_id={endpoint_id} and "
|
|
f"username={username} not found"
|
|
),
|
|
)
|
|
|
|
# ---- gateway_model_definition ----
|
|
|
|
def create_gateway_model_definition_permission(
|
|
self, model_definition_id: str, username: str, permission: str
|
|
) -> GatewayModelDefinitionPermission:
|
|
return self._create_per_resource_permission(
|
|
resource_type=RESOURCE_TYPE_GATEWAY_MODEL_DEFINITION,
|
|
resource_pattern=model_definition_id,
|
|
username=username,
|
|
permission=permission,
|
|
entity_factory=lambda user, permission: GatewayModelDefinitionPermission(
|
|
model_definition_id=model_definition_id,
|
|
user_id=user.id,
|
|
permission=permission,
|
|
),
|
|
duplicate_message=(
|
|
f"Gateway model definition permission "
|
|
f"(model_definition_id={model_definition_id}, username={username}) "
|
|
"already exists."
|
|
),
|
|
)
|
|
|
|
def get_gateway_model_definition_permission(
|
|
self, model_definition_id: str, username: str
|
|
) -> GatewayModelDefinitionPermission:
|
|
with self.ManagedSessionMaker() as session:
|
|
user, rp = self._get_per_resource_permission_row(
|
|
session,
|
|
resource_type=RESOURCE_TYPE_GATEWAY_MODEL_DEFINITION,
|
|
resource_pattern=model_definition_id,
|
|
username=username,
|
|
not_found_message=(
|
|
f"Gateway model definition permission with "
|
|
f"model_definition_id={model_definition_id} and "
|
|
f"username={username} not found"
|
|
),
|
|
)
|
|
return GatewayModelDefinitionPermission(
|
|
model_definition_id=model_definition_id,
|
|
user_id=user.id,
|
|
permission=rp.permission,
|
|
)
|
|
|
|
def list_gateway_model_definition_permissions(
|
|
self, username: str
|
|
) -> list[GatewayModelDefinitionPermission]:
|
|
user_id, rows = self._list_per_resource_permissions(
|
|
username, RESOURCE_TYPE_GATEWAY_MODEL_DEFINITION
|
|
)
|
|
return [
|
|
GatewayModelDefinitionPermission(
|
|
model_definition_id=p, user_id=user_id, permission=perm
|
|
)
|
|
for p, perm in rows
|
|
]
|
|
|
|
def update_gateway_model_definition_permission(
|
|
self, model_definition_id: str, username: str, permission: str
|
|
) -> GatewayModelDefinitionPermission:
|
|
return self._update_per_resource_permission(
|
|
resource_type=RESOURCE_TYPE_GATEWAY_MODEL_DEFINITION,
|
|
resource_pattern=model_definition_id,
|
|
username=username,
|
|
permission=permission,
|
|
entity_factory=lambda user, permission: GatewayModelDefinitionPermission(
|
|
model_definition_id=model_definition_id,
|
|
user_id=user.id,
|
|
permission=permission,
|
|
),
|
|
not_found_message=(
|
|
f"Gateway model definition permission with "
|
|
f"model_definition_id={model_definition_id} and username={username} not found"
|
|
),
|
|
)
|
|
|
|
def delete_gateway_model_definition_permission(
|
|
self, model_definition_id: str, username: str
|
|
) -> None:
|
|
self._delete_per_resource_permission(
|
|
resource_type=RESOURCE_TYPE_GATEWAY_MODEL_DEFINITION,
|
|
resource_pattern=model_definition_id,
|
|
username=username,
|
|
not_found_message=(
|
|
f"Gateway model definition permission with "
|
|
f"model_definition_id={model_definition_id} and username={username} not found"
|
|
),
|
|
)
|
|
|
|
# ---- Role CRUD ----
|
|
|
|
def create_role(
|
|
self,
|
|
name: str,
|
|
workspace: str,
|
|
description: str | None = None,
|
|
) -> Role:
|
|
self._reject_synthetic_role_name(name)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
try:
|
|
role = SqlRole(
|
|
name=name,
|
|
workspace=workspace,
|
|
description=description,
|
|
)
|
|
session.add(role)
|
|
session.flush()
|
|
return role.to_mlflow_entity()
|
|
except IntegrityError as e:
|
|
raise MlflowException(
|
|
f"Role (name={name}, workspace={workspace}) already exists. Error: {e}",
|
|
RESOURCE_ALREADY_EXISTS,
|
|
) from e
|
|
|
|
@staticmethod
|
|
def _get_role(session, role_id: int) -> SqlRole:
|
|
try:
|
|
return session.query(SqlRole).filter(SqlRole.id == role_id).one()
|
|
except NoResultFound:
|
|
raise MlflowException(
|
|
f"Role with id={role_id} not found",
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
except MultipleResultsFound:
|
|
raise MlflowException(
|
|
f"Found multiple roles with id={role_id}",
|
|
INVALID_STATE,
|
|
)
|
|
|
|
@staticmethod
|
|
def _get_role_by_name(session, workspace: str, name: str) -> SqlRole:
|
|
try:
|
|
return (
|
|
session
|
|
.query(SqlRole)
|
|
.filter(SqlRole.workspace == workspace, SqlRole.name == name)
|
|
.one()
|
|
)
|
|
except NoResultFound:
|
|
raise MlflowException(
|
|
f"Role with name={name} in workspace={workspace} not found",
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
except MultipleResultsFound:
|
|
raise MlflowException(
|
|
f"Found multiple roles with name={name} in workspace={workspace}",
|
|
INVALID_STATE,
|
|
)
|
|
|
|
def get_role(self, role_id: int) -> Role:
|
|
with self.ManagedSessionMaker() as session:
|
|
return self._get_role(session, role_id).to_mlflow_entity()
|
|
|
|
def get_role_by_name(self, workspace: str, name: str) -> Role:
|
|
with self.ManagedSessionMaker() as session:
|
|
return self._get_role_by_name(session, workspace, name).to_mlflow_entity()
|
|
|
|
def list_roles(self, workspaces: Iterable[str] | None = None) -> list[Role]:
|
|
# ``None`` lists every role across the system (admin path); an explicit
|
|
# iterable scopes the listing to those workspaces. An empty iterable is
|
|
# interpreted literally and returns no roles.
|
|
if workspaces is None:
|
|
with self.ManagedSessionMaker() as session:
|
|
roles = session.query(SqlRole).options(selectinload(SqlRole.permissions)).all()
|
|
return [r.to_mlflow_entity() for r in roles]
|
|
names = list(workspaces)
|
|
if not names:
|
|
return []
|
|
with self.ManagedSessionMaker() as session:
|
|
roles = (
|
|
session
|
|
.query(SqlRole)
|
|
.options(selectinload(SqlRole.permissions))
|
|
.filter(SqlRole.workspace.in_(names))
|
|
.all()
|
|
)
|
|
return [r.to_mlflow_entity() for r in roles]
|
|
|
|
def update_role(
|
|
self,
|
|
role_id: int,
|
|
name: str | None = None,
|
|
description: str | None = None,
|
|
) -> Role:
|
|
if name is not None:
|
|
self._reject_synthetic_role_name(name)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
role = self._get_role(session, role_id)
|
|
if name is not None:
|
|
# Check for name conflicts before updating
|
|
existing = (
|
|
session
|
|
.query(SqlRole)
|
|
.filter(
|
|
SqlRole.workspace == role.workspace,
|
|
SqlRole.name == name,
|
|
SqlRole.id != role_id,
|
|
)
|
|
.first()
|
|
)
|
|
if existing is not None:
|
|
raise MlflowException(
|
|
f"Role with name={name} already exists in workspace={role.workspace}",
|
|
RESOURCE_ALREADY_EXISTS,
|
|
)
|
|
role.name = name
|
|
if description is not None:
|
|
role.description = description
|
|
return role.to_mlflow_entity()
|
|
|
|
def delete_role(self, role_id: int) -> None:
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
role = self._get_role(session, role_id)
|
|
session.delete(role)
|
|
|
|
def delete_roles_for_workspace(self, workspace_name: str) -> None:
|
|
# Batch delete: ORM-level ``cascade="all, delete-orphan"`` only fires when calling
|
|
# ``session.delete(instance)``, so for a bulk delete we must explicitly remove
|
|
# child rows (``role_permissions``, ``user_role_assignments``) before the roles
|
|
# themselves. The FK doesn't declare ``ON DELETE CASCADE`` at the DB level.
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
role_id_subq = (
|
|
session.query(SqlRole.id).filter(SqlRole.workspace == workspace_name).subquery()
|
|
)
|
|
session.query(SqlRolePermission).filter(
|
|
SqlRolePermission.role_id.in_(select(role_id_subq))
|
|
).delete(synchronize_session=False)
|
|
session.query(SqlUserRoleAssignment).filter(
|
|
SqlUserRoleAssignment.role_id.in_(select(role_id_subq))
|
|
).delete(synchronize_session=False)
|
|
session.query(SqlRole).filter(SqlRole.workspace == workspace_name).delete(
|
|
synchronize_session=False
|
|
)
|
|
|
|
# ---- RolePermission CRUD ----
|
|
|
|
def add_role_permission(
|
|
self,
|
|
role_id: int,
|
|
resource_type: str,
|
|
resource_pattern: str,
|
|
permission: str,
|
|
) -> RolePermission:
|
|
_validate_permission_for_resource_type(permission, resource_type)
|
|
# Workspace-scope and type-wildcard grants only support the "*" pattern. Any
|
|
# other pattern would be silently ignored by the resolver, so reject it up front.
|
|
if resource_type == RESOURCE_TYPE_WORKSPACE and resource_pattern != "*":
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"resource_type='{resource_type}' requires resource_pattern='*'. "
|
|
f"Got resource_pattern='{resource_pattern}'."
|
|
)
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
self._get_role(session, role_id)
|
|
try:
|
|
rp = SqlRolePermission(
|
|
role_id=role_id,
|
|
resource_type=resource_type,
|
|
resource_pattern=resource_pattern,
|
|
permission=permission,
|
|
)
|
|
session.add(rp)
|
|
session.flush()
|
|
return rp.to_mlflow_entity()
|
|
except IntegrityError as e:
|
|
raise MlflowException(
|
|
f"Role permission (role_id={role_id}, resource_type={resource_type}, "
|
|
f"resource_pattern={resource_pattern}) already exists. Error: {e}",
|
|
RESOURCE_ALREADY_EXISTS,
|
|
) from e
|
|
|
|
@staticmethod
|
|
def _get_role_permission(session, role_permission_id: int) -> SqlRolePermission:
|
|
try:
|
|
return (
|
|
session
|
|
.query(SqlRolePermission)
|
|
.filter(SqlRolePermission.id == role_permission_id)
|
|
.one()
|
|
)
|
|
except NoResultFound:
|
|
raise MlflowException(
|
|
f"Role permission with id={role_permission_id} not found",
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
except MultipleResultsFound:
|
|
raise MlflowException(
|
|
f"Found multiple role permissions with id={role_permission_id}",
|
|
INVALID_STATE,
|
|
)
|
|
|
|
def get_role_permission(self, role_permission_id: int) -> RolePermission:
|
|
with self.ManagedSessionMaker() as session:
|
|
return self._get_role_permission(session, role_permission_id).to_mlflow_entity()
|
|
|
|
def remove_role_permission(self, role_permission_id: int) -> None:
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
rp = self._get_role_permission(session, role_permission_id)
|
|
session.delete(rp)
|
|
|
|
def list_role_permissions(self, role_id: int) -> list[RolePermission]:
|
|
with self.ManagedSessionMaker() as session:
|
|
self._get_role(session, role_id)
|
|
perms = (
|
|
session.query(SqlRolePermission).filter(SqlRolePermission.role_id == role_id).all()
|
|
)
|
|
return [p.to_mlflow_entity() for p in perms]
|
|
|
|
def update_role_permission(self, role_permission_id: int, permission: str) -> RolePermission:
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
rp = self._get_role_permission(session, role_permission_id)
|
|
_validate_permission_for_resource_type(permission, rp.resource_type)
|
|
rp.permission = permission
|
|
return rp.to_mlflow_entity()
|
|
|
|
# ---- UserRoleAssignment CRUD ----
|
|
|
|
def assign_role_to_user(self, user_id: int, role_id: int) -> UserRoleAssignment:
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
# Validate both user and role exist before attempting assignment
|
|
user = session.get(SqlUser, user_id)
|
|
if user is None:
|
|
raise MlflowException(
|
|
f"User with id={user_id} not found",
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
self._get_role(session, role_id)
|
|
# Check for duplicate assignment before insert
|
|
existing = (
|
|
session
|
|
.query(SqlUserRoleAssignment)
|
|
.filter(
|
|
SqlUserRoleAssignment.user_id == user_id,
|
|
SqlUserRoleAssignment.role_id == role_id,
|
|
)
|
|
.first()
|
|
)
|
|
if existing is not None:
|
|
raise MlflowException(
|
|
f"User role assignment (user_id={user_id}, role_id={role_id}) already exists",
|
|
RESOURCE_ALREADY_EXISTS,
|
|
)
|
|
assignment = SqlUserRoleAssignment(user_id=user_id, role_id=role_id)
|
|
session.add(assignment)
|
|
session.flush()
|
|
return assignment.to_mlflow_entity()
|
|
|
|
def unassign_role_from_user(self, user_id: int, role_id: int) -> None:
|
|
with self.ManagedSessionMaker(read_only=False) as session:
|
|
try:
|
|
assignment = (
|
|
session
|
|
.query(SqlUserRoleAssignment)
|
|
.filter(
|
|
SqlUserRoleAssignment.user_id == user_id,
|
|
SqlUserRoleAssignment.role_id == role_id,
|
|
)
|
|
.one()
|
|
)
|
|
except NoResultFound:
|
|
raise MlflowException(
|
|
f"User role assignment (user_id={user_id}, role_id={role_id}) not found",
|
|
RESOURCE_DOES_NOT_EXIST,
|
|
)
|
|
session.delete(assignment)
|
|
|
|
def list_user_roles(self, user_id: int) -> list[Role]:
|
|
with self.ManagedSessionMaker() as session:
|
|
roles = (
|
|
session
|
|
.query(SqlRole)
|
|
.options(selectinload(SqlRole.permissions))
|
|
.join(SqlUserRoleAssignment, SqlRole.id == SqlUserRoleAssignment.role_id)
|
|
.filter(SqlUserRoleAssignment.user_id == user_id)
|
|
.all()
|
|
)
|
|
return [r.to_mlflow_entity() for r in roles]
|
|
|
|
def list_user_roles_for_workspace(self, user_id: int, workspace: str) -> list[Role]:
|
|
with self.ManagedSessionMaker() as session:
|
|
roles = (
|
|
session
|
|
.query(SqlRole)
|
|
.options(selectinload(SqlRole.permissions))
|
|
.join(SqlUserRoleAssignment, SqlRole.id == SqlUserRoleAssignment.role_id)
|
|
.filter(
|
|
SqlUserRoleAssignment.user_id == user_id,
|
|
SqlRole.workspace == workspace,
|
|
)
|
|
.all()
|
|
)
|
|
return [r.to_mlflow_entity() for r in roles]
|
|
|
|
def user_has_any_role_in_workspace(self, user_id: int, workspace: str) -> bool:
|
|
"""
|
|
Lightweight existence check — returns True iff the user has at least one role
|
|
assignment in the given workspace. Used by validators that only need membership,
|
|
not the full role entities.
|
|
"""
|
|
with self.ManagedSessionMaker() as session:
|
|
return (
|
|
session
|
|
.query(SqlUserRoleAssignment.id)
|
|
.join(SqlRole, SqlRole.id == SqlUserRoleAssignment.role_id)
|
|
.filter(
|
|
SqlUserRoleAssignment.user_id == user_id,
|
|
SqlRole.workspace == workspace,
|
|
)
|
|
.first()
|
|
is not None
|
|
)
|
|
|
|
def list_user_present_workspaces(self, user_id: int) -> set[str]:
|
|
"""
|
|
Return every workspace where the user has at least one role assignment.
|
|
Bulk membership check used by validators that need to authorize a request
|
|
spanning multiple workspaces in a single query.
|
|
"""
|
|
with self.ManagedSessionMaker() as session:
|
|
return self._user_present_workspaces(session, user_id)
|
|
|
|
def list_role_users(self, role_id: int) -> list[UserRoleAssignment]:
|
|
with self.ManagedSessionMaker() as session:
|
|
self._get_role(session, role_id)
|
|
assignments = (
|
|
session
|
|
.query(SqlUserRoleAssignment)
|
|
.filter(SqlUserRoleAssignment.role_id == role_id)
|
|
.all()
|
|
)
|
|
return [a.to_mlflow_entity() for a in assignments]
|
|
|
|
# ---- Role-based permission resolution ----
|
|
|
|
def get_role_permission_for_resource(
|
|
self, user_id: int, resource_type: str, resource_id: str, workspace: str
|
|
) -> Permission | None:
|
|
with self.ManagedSessionMaker() as session:
|
|
roles = (
|
|
session
|
|
.query(SqlRole)
|
|
.options(selectinload(SqlRole.permissions))
|
|
.join(SqlUserRoleAssignment, SqlRole.id == SqlUserRoleAssignment.role_id)
|
|
.filter(
|
|
SqlUserRoleAssignment.user_id == user_id,
|
|
SqlRole.workspace == workspace,
|
|
)
|
|
.all()
|
|
)
|
|
if not roles:
|
|
return None
|
|
|
|
best_permission_name: str | None = None
|
|
for role in roles:
|
|
for rp in role.permissions:
|
|
# (workspace, *) folds into resource-type queries only for
|
|
# MANAGE (workspace admin); USE is the "member can join +
|
|
# create" signal and folds only for workspace-tier queries.
|
|
if rp.resource_type == RESOURCE_TYPE_WORKSPACE and rp.resource_pattern == "*":
|
|
if resource_type == RESOURCE_TYPE_WORKSPACE or rp.permission == MANAGE.name:
|
|
best_permission_name = (
|
|
max_permission(best_permission_name, rp.permission)
|
|
if best_permission_name is not None
|
|
else rp.permission
|
|
)
|
|
continue
|
|
# Resource-type-specific permission.
|
|
if rp.resource_type != resource_type:
|
|
continue
|
|
if rp.resource_pattern in ("*", resource_id):
|
|
best_permission_name = (
|
|
max_permission(best_permission_name, rp.permission)
|
|
if best_permission_name is not None
|
|
else rp.permission
|
|
)
|
|
|
|
if best_permission_name is None:
|
|
return None
|
|
return get_permission(best_permission_name)
|
|
|
|
@staticmethod
|
|
def _workspace_admin_workspaces(session, user_id: int) -> set[str]:
|
|
"""
|
|
Return the set of workspaces where ``user_id`` is a workspace admin.
|
|
|
|
A user is a workspace admin in ``workspace`` if they have any role in
|
|
``workspace`` carrying a
|
|
``(resource_type='workspace', resource_pattern='*', permission=MANAGE)``
|
|
grant — the simplified model's single admin shape, used by both the
|
|
seeded ``admin`` role and migrated legacy ``workspace_permissions(MANAGE)``
|
|
rows.
|
|
"""
|
|
rows = (
|
|
session
|
|
.query(SqlRole.workspace)
|
|
.join(SqlRolePermission, SqlRole.id == SqlRolePermission.role_id)
|
|
.join(SqlUserRoleAssignment, SqlRole.id == SqlUserRoleAssignment.role_id)
|
|
.filter(
|
|
SqlUserRoleAssignment.user_id == user_id,
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_WORKSPACE,
|
|
SqlRolePermission.resource_pattern == "*",
|
|
SqlRolePermission.permission == MANAGE.name,
|
|
)
|
|
.distinct()
|
|
.all()
|
|
)
|
|
return {w for (w,) in rows}
|
|
|
|
@staticmethod
|
|
def _user_present_workspaces(session, user_id: int) -> set[str]:
|
|
"""
|
|
Return every workspace the user has some presence in via a role assignment.
|
|
Used when scoping cross-user authorization decisions.
|
|
"""
|
|
rows = (
|
|
session
|
|
.query(SqlRole.workspace)
|
|
.join(SqlUserRoleAssignment, SqlRole.id == SqlUserRoleAssignment.role_id)
|
|
.filter(SqlUserRoleAssignment.user_id == user_id)
|
|
.distinct()
|
|
.all()
|
|
)
|
|
return {w for (w,) in rows}
|
|
|
|
def is_workspace_admin(self, user_id: int, workspace: str) -> bool:
|
|
"""
|
|
True if the user is a workspace admin in ``workspace`` — i.e. they hold a role
|
|
in that workspace with ``resource_pattern='*'``, ``permission=MANAGE``, and
|
|
``resource_type`` either ``'workspace'`` (explicit admin grant) or ``'*'`` (the
|
|
workspace-wide MANAGE form the migration produces from legacy
|
|
``workspace_permissions`` rows).
|
|
"""
|
|
with self.ManagedSessionMaker() as session:
|
|
return workspace in self._workspace_admin_workspaces(session, user_id)
|
|
|
|
def list_role_grants_for_user_in_workspace(
|
|
self, user_id: int, workspace: str, resource_type: str
|
|
) -> list[tuple[str, str]]:
|
|
"""
|
|
Return the user's **role-based** permission grants in ``workspace`` that apply
|
|
to resources of ``resource_type``. Direct per-resource grants (e.g. rows in
|
|
``experiment_permissions``) are intentionally **not** included — callers that
|
|
need the full authorization picture fold them in separately (see
|
|
``filter_experiment_ids``, which unions the result of this query with
|
|
``list_experiment_permissions`` from the legacy table).
|
|
|
|
Includes both grants on the specific resource_type and workspace-wide grants
|
|
(``resource_type='workspace'``, ``resource_pattern='*'``) since those apply to
|
|
every resource type.
|
|
|
|
Returns a list of ``(resource_pattern, permission)`` tuples.
|
|
"""
|
|
_validate_resource_type(resource_type)
|
|
with self.ManagedSessionMaker() as session:
|
|
rows = (
|
|
session
|
|
.query(SqlRolePermission.resource_pattern, SqlRolePermission.permission)
|
|
.join(SqlRole, SqlRole.id == SqlRolePermission.role_id)
|
|
.join(SqlUserRoleAssignment, SqlRole.id == SqlUserRoleAssignment.role_id)
|
|
.filter(
|
|
SqlUserRoleAssignment.user_id == user_id,
|
|
SqlRole.workspace == workspace,
|
|
or_(
|
|
SqlRolePermission.resource_type == resource_type,
|
|
and_(
|
|
SqlRolePermission.resource_type == RESOURCE_TYPE_WORKSPACE,
|
|
SqlRolePermission.resource_pattern == "*",
|
|
),
|
|
),
|
|
)
|
|
.all()
|
|
)
|
|
return [(pattern, permission) for pattern, permission in rows]
|
|
|
|
def list_workspace_admin_workspaces(self, user_id: int) -> set[str]:
|
|
"""
|
|
Return the set of workspaces where ``user_id`` is a workspace admin. Includes
|
|
both role-based admin grants
|
|
(``(resource_type='workspace', resource_pattern='*', permission=MANAGE)``) and
|
|
legacy ``workspace_permissions`` MANAGE grants. See
|
|
``_workspace_admin_workspaces`` for the consolidation rationale.
|
|
"""
|
|
with self.ManagedSessionMaker() as session:
|
|
return self._workspace_admin_workspaces(session, user_id)
|
|
|
|
def is_workspace_admin_of_any_of_users_workspaces(
|
|
self, admin_user_id: int, target_user_id: int
|
|
) -> bool:
|
|
"""
|
|
True if ``admin_user_id`` is a workspace admin in at least one workspace where
|
|
``target_user_id`` has presence. Both sides consult role assignments AND legacy
|
|
``workspace_permissions`` so operators mid-migration see consistent behavior.
|
|
"""
|
|
with self.ManagedSessionMaker() as session:
|
|
admin_workspaces = self._workspace_admin_workspaces(session, admin_user_id)
|
|
if not admin_workspaces:
|
|
return False
|
|
target_workspaces = self._user_present_workspaces(session, target_user_id)
|
|
return bool(admin_workspaces & target_workspaces)
|