diff --git a/src/mavedb/lib/permissions.py b/src/mavedb/lib/permissions.py deleted file mode 100644 index 99b2ada0..00000000 --- a/src/mavedb/lib/permissions.py +++ /dev/null @@ -1,506 +0,0 @@ -import logging -from enum import Enum -from typing import Optional - -from mavedb.db.base import Base -from mavedb.lib.authentication import UserData -from mavedb.lib.logging.context import logging_context, save_to_logging_context -from mavedb.models.collection import Collection -from mavedb.models.enums.contribution_role import ContributionRole -from mavedb.models.enums.user_role import UserRole -from mavedb.models.experiment import Experiment -from mavedb.models.experiment_set import ExperimentSet -from mavedb.models.score_calibration import ScoreCalibration -from mavedb.models.score_set import ScoreSet -from mavedb.models.user import User - -logger = logging.getLogger(__name__) - - -class Action(Enum): - LOOKUP = "lookup" - READ = "read" - UPDATE = "update" - DELETE = "delete" - ADD_EXPERIMENT = "add_experiment" - ADD_SCORE_SET = "add_score_set" - SET_SCORES = "set_scores" - ADD_ROLE = "add_role" - PUBLISH = "publish" - ADD_BADGE = "add_badge" - CHANGE_RANK = "change_rank" - - -class PermissionResponse: - def __init__(self, permitted: bool, http_code: int = 403, message: Optional[str] = None): - self.permitted = permitted - self.http_code = http_code if not permitted else None - self.message = message if not permitted else None - - save_to_logging_context({"permission_message": self.message, "access_permitted": self.permitted}) - if self.permitted: - logger.debug( - msg="Access to the requested resource is permitted.", - extra=logging_context(), - ) - else: - logger.debug( - msg="Access to the requested resource is not permitted.", - extra=logging_context(), - ) - - -class PermissionException(Exception): - def __init__(self, http_code: int, message: str): - self.http_code = http_code - self.message = message - - -def roles_permitted(user_roles: list[UserRole], permitted_roles: list[UserRole]) -> bool: - save_to_logging_context({"permitted_roles": [role.name for role in permitted_roles]}) - - if not user_roles: - logger.debug(msg="User has no associated roles.", extra=logging_context()) - return False - - return any(role in permitted_roles for role in user_roles) - - -def has_permission(user_data: Optional[UserData], item: Base, action: Action) -> PermissionResponse: - private = False - user_is_owner = False - user_is_self = False - user_may_edit = False - user_may_view_private = False - active_roles = user_data.active_roles if user_data else [] - - if isinstance(item, ExperimentSet) or isinstance(item, Experiment) or isinstance(item, ScoreSet): - assert item.private is not None - private = item.private - published = item.published_date is not None - user_is_owner = item.created_by_id == user_data.user.id if user_data is not None else False - user_may_edit = user_is_owner or ( - user_data is not None and user_data.user.username in [c.orcid_id for c in item.contributors] - ) - - save_to_logging_context({"resource_is_published": published}) - - if isinstance(item, Collection): - assert item.private is not None - private = item.private - published = item.private is False - user_is_owner = item.created_by_id == user_data.user.id if user_data is not None else False - admin_user_ids = set() - editor_user_ids = set() - viewer_user_ids = set() - for user_association in item.user_associations: - if user_association.contribution_role == ContributionRole.admin: - admin_user_ids.add(user_association.user_id) - elif user_association.contribution_role == ContributionRole.editor: - editor_user_ids.add(user_association.user_id) - elif user_association.contribution_role == ContributionRole.viewer: - viewer_user_ids.add(user_association.user_id) - user_is_admin = user_is_owner or (user_data is not None and user_data.user.id in admin_user_ids) - user_may_edit = user_is_admin or (user_data is not None and user_data.user.id in editor_user_ids) - user_may_view_private = user_may_edit or (user_data is not None and (user_data.user.id in viewer_user_ids)) - - save_to_logging_context({"resource_is_published": published}) - - if isinstance(item, ScoreCalibration): - assert item.private is not None - private = item.private - published = item.private is False - user_is_owner = item.created_by_id == user_data.user.id if user_data is not None else False - - # If the calibration is investigator provided, treat permissions like score set permissions where contributors - # may also make changes to the calibration. Otherwise, only allow the calibration owner to edit the calibration. - if item.investigator_provided: - user_may_edit = user_is_owner or ( - user_data is not None and user_data.user.username in [c.orcid_id for c in item.score_set.contributors] - ) - else: - user_may_edit = user_is_owner - - if isinstance(item, User): - user_is_self = item.id == user_data.user.id if user_data is not None else False - user_may_edit = user_is_self - - save_to_logging_context( - { - "resource_is_private": private, - "user_is_owner_of_resource": user_is_owner, - "user_is_may_edit_resource": user_may_edit, - "user_is_self": user_is_self, - } - ) - - if isinstance(item, ExperimentSet): - if action == Action.READ: - if user_may_edit or not private: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment set with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.UPDATE: - if user_may_edit: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment set with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.DELETE: - # Owner may only delete an experiment set if it has not already been published. - if user_may_edit: - return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment set with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - elif action == Action.ADD_EXPERIMENT: - # Only permitted users can add an experiment to an existing experiment set. - return PermissionResponse( - user_may_edit or roles_permitted(active_roles, [UserRole.admin]), - 404 if private else 403, - ( - f"experiment set with URN '{item.urn}' not found" - if private - else f"insufficient permissions for URN '{item.urn}'" - ), - ) - else: - raise NotImplementedError(f"has_permission(User, ExperimentSet, {action}, Role)") - - elif isinstance(item, Experiment): - if action == Action.READ: - if user_may_edit or not private: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.UPDATE: - if user_may_edit: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.DELETE: - # Owner may only delete an experiment if it has not already been published. - if user_may_edit: - return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment set with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - elif action == Action.ADD_SCORE_SET: - # Only permitted users can add a score set to a private experiment. - if user_may_edit or roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - return PermissionResponse(False, 404, f"experiment with URN '{item.urn}' not found") - # Any signed in user has permissions to add a score set to a public experiment - elif user_data is not None: - return PermissionResponse(True) - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - else: - raise NotImplementedError(f"has_permission(User, Experiment, {action}, Role)") - - elif isinstance(item, ScoreSet): - if action == Action.READ: - if user_may_edit or not private: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.UPDATE: - if user_may_edit: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.DELETE: - # Owner may only delete a score set if it has not already been published. - if user_may_edit: - return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"experiment set with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - # Only the owner may publish a private score set. - elif action == Action.PUBLISH: - if user_may_edit: - return PermissionResponse(True) - elif roles_permitted(active_roles, []): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - elif action == Action.SET_SCORES: - return PermissionResponse( - (user_may_edit or roles_permitted(active_roles, [UserRole.admin])), - 404 if private else 403, - ( - f"score set with URN '{item.urn}' not found" - if private - else f"insufficient permissions for URN '{item.urn}'" - ), - ) - else: - raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)") - - elif isinstance(item, Collection): - if action == Action.READ: - if user_may_view_private or not private: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.UPDATE: - if user_may_edit: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private and not user_may_view_private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.DELETE: - # A collection may be deleted even if it has been published, as long as it is not an official collection. - if user_is_owner: - return PermissionResponse( - not item.badge_name, - 403, - f"insufficient permissions for URN '{item.urn}'", - ) - # MaveDB admins may delete official collections. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private and not user_may_view_private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - elif action == Action.PUBLISH: - if user_is_admin: - return PermissionResponse(True) - elif roles_permitted(active_roles, []): - return PermissionResponse(True) - elif private and not user_may_view_private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score set with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - elif action == Action.ADD_SCORE_SET: - # Whether the collection is private or public, only permitted users can add a score set to a collection. - if user_may_edit or roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private and not user_may_view_private: - return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.ADD_EXPERIMENT: - # Only permitted users can add an experiment to an existing collection. - return PermissionResponse( - user_may_edit or roles_permitted(active_roles, [UserRole.admin]), - 404 if private and not user_may_view_private else 403, - ( - f"collection with URN '{item.urn}' not found" - if private and not user_may_view_private - else f"insufficient permissions for URN '{item.urn}'" - ), - ) - elif action == Action.ADD_ROLE: - # Both collection admins and MaveDB admins can add a user to a collection role - if user_is_admin or roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - else: - return PermissionResponse(False, 403, "Insufficient permissions to add user role.") - # only MaveDB admins may add a badge name to a collection, which makes the collection considered "official" - elif action == Action.ADD_BADGE: - # Roles which may perform this operation. - if roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"collection with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - else: - raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)") - elif isinstance(item, ScoreCalibration): - if action == Action.READ: - if user_may_edit or not private: - return PermissionResponse(True) - # Roles which may perform this operation. - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.UPDATE: - if roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - # TODO#549: Allow editing of certain fields even if published. For now, - # Owner may only edit if a calibration is not published. - elif user_may_edit: - return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") - elif user_data is None or user_data.user is None: - return PermissionResponse(False, 401, f"insufficient permissions for URN '{item.urn}'") - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - elif action == Action.DELETE: - # Roles which may perform this operation. - if roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - # Owner may only delete a calibration if it has not already been published. - elif user_may_edit: - return PermissionResponse(not published, 403, f"insufficient permissions for URN '{item.urn}'") - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - # Only the owner may publish a private calibration. - elif action == Action.PUBLISH: - if user_may_edit: - return PermissionResponse(True) - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - elif private: - # Do not acknowledge the existence of a private entity. - return PermissionResponse(False, 404, f"score calibration with URN '{item.urn}' not found") - else: - return PermissionResponse(False) - elif action == Action.CHANGE_RANK: - if user_may_edit: - return PermissionResponse(True) - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - else: - return PermissionResponse(False, 403, f"insufficient permissions for URN '{item.urn}'") - - else: - raise NotImplementedError(f"has_permission(User, ScoreCalibration, {action}, Role)") - - elif isinstance(item, User): - if action == Action.LOOKUP: - # any existing user can look up any mavedb user by Orcid ID - # lookup differs from read because lookup means getting the first name, last name, and orcid ID of the user, - # while read means getting an admin view of the user's details - if user_data is not None and user_data.user is not None: - return PermissionResponse(True) - else: - # TODO is this inappropriately acknowledging the existence of the user? - return PermissionResponse(False, 401, "Insufficient permissions for user lookup.") - if action == Action.READ: - if user_is_self: - return PermissionResponse(True) - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - else: - return PermissionResponse(False, 403, "Insufficient permissions for user update.") - elif action == Action.UPDATE: - if user_is_self: - return PermissionResponse(True) - elif roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - else: - return PermissionResponse(False, 403, "Insufficient permissions for user update.") - elif action == Action.ADD_ROLE: - if roles_permitted(active_roles, [UserRole.admin]): - return PermissionResponse(True) - else: - return PermissionResponse(False, 403, "Insufficient permissions to add user role.") - elif action == Action.DELETE: - raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)") - else: - raise NotImplementedError(f"has_permission(User, ScoreSet, {action}, Role)") - - else: - raise NotImplementedError(f"has_permission(User, {item.__class__}, {action}, Role)") - - -def assert_permission(user_data: Optional[UserData], item: Base, action: Action) -> PermissionResponse: - save_to_logging_context({"permission_boundary": action.name}) - permission = has_permission(user_data, item, action) - - if not permission.permitted: - assert permission.http_code and permission.message - raise PermissionException(http_code=permission.http_code, message=permission.message) - - return permission diff --git a/src/mavedb/lib/permissions/__init__.py b/src/mavedb/lib/permissions/__init__.py new file mode 100644 index 00000000..2f226cef --- /dev/null +++ b/src/mavedb/lib/permissions/__init__.py @@ -0,0 +1,27 @@ +""" +Permission system for MaveDB entities. + +This module provides a comprehensive permission system for checking user access +to various entity types including ScoreSets, Experiments, Collections, etc. + +Main Functions: + has_permission: Check if a user has permission for an action on an entity + assert_permission: Assert permission or raise exception + +Usage: + >>> from mavedb.lib.permissions import Action, has_permission, assert_permission + >>> + >>> # Check permission and handle response + >>> result = has_permission(user_data, score_set, Action.READ) + >>> if result.permitted: + ... # User has access + ... pass + >>> + >>> # Assert permission (raises exception if denied) + >>> assert_permission(user_data, score_set, Action.UPDATE) +""" + +from .actions import Action +from .core import assert_permission, has_permission + +__all__ = ["has_permission", "assert_permission", "Action"] diff --git a/src/mavedb/lib/permissions/actions.py b/src/mavedb/lib/permissions/actions.py new file mode 100644 index 00000000..cc3a9559 --- /dev/null +++ b/src/mavedb/lib/permissions/actions.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class Action(Enum): + LOOKUP = "lookup" + READ = "read" + UPDATE = "update" + DELETE = "delete" + ADD_EXPERIMENT = "add_experiment" + ADD_SCORE_SET = "add_score_set" + SET_SCORES = "set_scores" + ADD_ROLE = "add_role" + PUBLISH = "publish" + ADD_BADGE = "add_badge" + CHANGE_RANK = "change_rank" diff --git a/src/mavedb/lib/permissions/collection.py b/src/mavedb/lib/permissions/collection.py new file mode 100644 index 00000000..916db06b --- /dev/null +++ b/src/mavedb/lib/permissions/collection.py @@ -0,0 +1,405 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted +from mavedb.models.collection import Collection +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.enums.user_role import UserRole + + +def has_permission(user_data: Optional[UserData], entity: Collection, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on a Collection entity. + + This function evaluates user permissions based on Collection role associations, + ownership, and user roles. Collections use a special permission model with + role-based user associations. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + entity: The Collection entity to check permissions for. + action: The action to be performed (READ, UPDATE, DELETE, ADD_EXPERIMENT, ADD_SCORE_SET, ADD_ROLE, ADD_BADGE). + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + ValueError: If the entity's private attribute is not set. + NotImplementedError: If the action is not supported for Collection entities. + + Note: + Collections use CollectionUserAssociation objects to define user roles + (admin, editor, viewer) rather than simple contributor lists. + """ + if entity.private is None: + raise ValueError("Collection entity must have 'private' attribute set for permission checks.") + + user_is_owner = False + collection_roles = [] + active_roles = [] + + if user_data is not None: + user_is_owner = entity.created_by_id == user_data.user.id + + # Find the user's collection roles in this collection through user_associations. + user_associations = [assoc for assoc in entity.user_associations if assoc.user_id == user_data.user.id] + if user_associations: + collection_roles = [assoc.contribution_role for assoc in user_associations] + + active_roles = user_data.active_roles + + save_to_logging_context( + { + "resource_is_private": entity.private, + "user_is_owner": user_is_owner, + "collection_roles": [role.value for role in collection_roles] if collection_roles else None, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.PUBLISH: _handle_publish_action, + Action.ADD_EXPERIMENT: _handle_add_experiment_action, + Action.ADD_SCORE_SET: _handle_add_score_set_action, + Action.ADD_ROLE: _handle_add_role_action, + Action.ADD_BADGE: _handle_add_badge_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for collection entities. " + f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + entity.private, + entity.badge_name is not None, + user_is_owner, + collection_roles, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for Collection entities. + + Public Collections are readable by anyone. Private Collections are only readable + by users with Collection roles, owners, admins, and mappers. + + Args: + user_data: The user's authentication data. + entity: The Collection entity being accessed. + private: Whether the Collection is private. + official_collection: Whether the Collection is an official collection. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow read access under the following conditions: + # Any user may read a non-private collection. + if not private: + return PermissionResponse(True) + # The owner may read a private collection. + if user_is_owner: + return PermissionResponse(True) + # Collection role holders may read a private collection. + if roles_permitted(collection_roles, [ContributionRole.admin, ContributionRole.editor, ContributionRole.viewer]): + return PermissionResponse(True) + # Users with these specific roles may read a private collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") + + +def _handle_update_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for Collection entities. + + Only owners, Collection admins/editors, and system admins can update Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity being updated. + private: Whether the Collection is private. + official_collection: Whether the Collection is an official collection. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # The owner may update the collection. + if user_is_owner: + return PermissionResponse(True) + # Collection admins and editors may update the collection. + if roles_permitted(collection_roles, [ContributionRole.admin, ContributionRole.editor]): + return PermissionResponse(True) + # Users with these specific roles may update the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") + + +def _handle_delete_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle DELETE action permission check for Collection entities. + + System admins can delete any Collection. Owners and Collection admins can only + delete unpublished Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity being deleted. + private: Whether the Collection is private. + official_collection: Whether the Collection is official. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow delete access under the following conditions: + # System admins may delete any collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Other users may only delete non-official collections. + if not official_collection: + # Owners may delete a collection only if it is still private. + # Collection admins/editors/viewers may not delete collections. + if user_is_owner and private: + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") + + +def _handle_publish_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle PUBLISH action permission check for Collection entities. + + Only owners, Collection admins, and system admins can publish Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity being published. + private: Whether the Collection is private. + official_collection: Whether the Collection is official. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow publish access under the following conditions: + # The owner may publish a collection. + if user_is_owner: + return PermissionResponse(True) + # Collection admins may publish the collection. + if roles_permitted(collection_roles, [ContributionRole.admin]): + return PermissionResponse(True) + # Users with these specific roles may publish the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") + + +def _handle_add_experiment_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_EXPERIMENT action permission check for Collection entities. + + Only owners, Collection admins/editors, and system admins can add experiment sets + to private Collections. Any authenticated user can add to public Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity to add an experiment to. + private: Whether the Collection is private. + official_collection: Whether the Collection is official. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add experiment add access under the following conditions: + # The owner may add an experiment to a private collection. + if user_is_owner: + return PermissionResponse(True) + # Collection admins/editors may add an experiment to the collection. + if roles_permitted(collection_roles, [ContributionRole.admin, ContributionRole.editor]): + return PermissionResponse(True) + # Users with these specific roles may add an experiment to the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") + + +def _handle_add_score_set_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_SCORE_SET action permission check for Collection entities. + + Only owners, Collection admins/editors, and system admins can add score sets + to private Collections. Any authenticated user can add to public Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity to add a score set to. + private: Whether the Collection is private. + official_collection: Whether the Collection is official. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add score set access under the following conditions: + # The owner may add a score set to a private collection. + if user_is_owner: + return PermissionResponse(True) + # Collection admins/editors may add a score set to the collection. + if roles_permitted(collection_roles, [ContributionRole.admin, ContributionRole.editor]): + return PermissionResponse(True) + # Users with these specific roles may add a score set to the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") + + +def _handle_add_role_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_ROLE action permission check for Collection entities. + + Only owners and Collection admins can add roles to Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity to add a role to. + private: Whether the Collection is private. + official_collection: Whether the Collection is official. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add role access under the following conditions: + # The owner may add a role. + if user_is_owner: + return PermissionResponse(True) + # Collection admins may add a role to the collection. + if roles_permitted(collection_roles, [ContributionRole.admin]): + return PermissionResponse(True) + # Users with these specific roles may add a role to the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") + + +def _handle_add_badge_action( + user_data: Optional[UserData], + entity: Collection, + private: bool, + official_collection: bool, + user_is_owner: bool, + collection_roles: list[ContributionRole], + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_BADGE action permission check for Collection entities. + + Only system admins can add badges to Collections. + + Args: + user_data: The user's authentication data. + entity: The Collection entity to add a badge to. + private: Whether the Collection is private. + official_collection: Whether the Collection is official. + user_is_owner: Whether the user owns the Collection. + collection_roles: The user's roles in this Collection (admin/editor/viewer). + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add badge access under the following conditions: + # Users with these specific roles may add a badge to the collection. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, bool(collection_roles) or user_is_owner, "collection") diff --git a/src/mavedb/lib/permissions/core.py b/src/mavedb/lib/permissions/core.py new file mode 100644 index 00000000..c14190ea --- /dev/null +++ b/src/mavedb/lib/permissions/core.py @@ -0,0 +1,114 @@ +from typing import Any, Callable, Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.exceptions import PermissionException +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.types.permissions import EntityType +from mavedb.models.collection import Collection +from mavedb.models.experiment import Experiment +from mavedb.models.experiment_set import ExperimentSet +from mavedb.models.score_calibration import ScoreCalibration +from mavedb.models.score_set import ScoreSet +from mavedb.models.user import User + +# Import entity-specific permission modules +from . import ( + collection, + experiment, + experiment_set, + score_calibration, + score_set, + user, +) + + +def has_permission(user_data: Optional[UserData], entity: EntityType, action: Action) -> PermissionResponse: + """ + Main dispatcher function for permission checks across all entity types. + + This function routes permission checks to the appropriate entity-specific + module based on the type of the entity provided. Each entity type has + its own permission logic and supported actions. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + entity: The entity to check permissions for. Must be one of the supported types. + action: The action to be performed on the entity. + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + NotImplementedError: If the entity type is not supported. + + Example: + >>> from mavedb.lib.permissions.core import has_permission + >>> from mavedb.lib.permissions.actions import Action + >>> result = has_permission(user_data, score_set, Action.READ) + >>> if result.permitted: + ... # User has permission + ... pass + + Note: + This is the main entry point for all permission checks in the application. + Each entity type delegates to its own module for specific permission logic. + """ + # Dictionary mapping entity types to their corresponding permission modules + entity_handlers: dict[type, Callable[[Optional[UserData], Any, Action], PermissionResponse]] = { + Collection: collection.has_permission, + Experiment: experiment.has_permission, + ExperimentSet: experiment_set.has_permission, + ScoreCalibration: score_calibration.has_permission, + ScoreSet: score_set.has_permission, + User: user.has_permission, + } + + entity_type = type(entity) + + if entity_type not in entity_handlers: + supported_types = ", ".join(cls.__name__ for cls in entity_handlers.keys()) + raise NotImplementedError( + f"Permission checks are not implemented for entity type '{entity_type.__name__}'. " + f"Supported entity types: {supported_types}" + ) + + handler = entity_handlers[entity_type] + return handler(user_data, entity, action) + + +def assert_permission(user_data: Optional[UserData], entity: EntityType, action: Action) -> PermissionResponse: + """ + Assert that a user has permission to perform an action on an entity. + + This function checks permissions and raises an exception if the user lacks + the necessary permissions. It's a convenience wrapper around has_permission + for cases where you want to fail fast on permission denials. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + entity: The entity to check permissions for. + action: The action to be performed on the entity. + + Returns: + PermissionResponse: The permission result if access is granted. + + Raises: + PermissionException: If the user lacks sufficient permissions. + + Example: + >>> from mavedb.lib.permissions.core import assert_permission + >>> from mavedb.lib.permissions.actions import Action + >>> # This will raise PermissionException if user can't update + >>> assert_permission(user_data, score_set, Action.UPDATE) + """ + save_to_logging_context({"permission_boundary": action.name}) + permission = has_permission(user_data, entity, action) + + if not permission.permitted: + http_code = permission.http_code if permission.http_code is not None else 403 + message = permission.message if permission.message is not None else "Permission denied" + raise PermissionException(http_code=http_code, message=message) + + return permission diff --git a/src/mavedb/lib/permissions/exceptions.py b/src/mavedb/lib/permissions/exceptions.py new file mode 100644 index 00000000..d3ebf87e --- /dev/null +++ b/src/mavedb/lib/permissions/exceptions.py @@ -0,0 +1,4 @@ +class PermissionException(Exception): + def __init__(self, http_code: int, message: str): + self.http_code = http_code + self.message = message diff --git a/src/mavedb/lib/permissions/experiment.py b/src/mavedb/lib/permissions/experiment.py new file mode 100644 index 00000000..834de45b --- /dev/null +++ b/src/mavedb/lib/permissions/experiment.py @@ -0,0 +1,221 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted +from mavedb.models.enums.user_role import UserRole +from mavedb.models.experiment import Experiment + + +def has_permission(user_data: Optional[UserData], entity: Experiment, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on an Experiment entity. + + This function evaluates user permissions based on ownership, contributor status, + and user roles. It handles both private and public Experiments with different + access control rules. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + entity: The Experiment entity to check permissions for. + action: The action to be performed (READ, UPDATE, DELETE, ADD_SCORE_SET). + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + ValueError: If the entity's private attribute is not set. + NotImplementedError: If the action is not supported for Experiment entities. + """ + if entity.private is None: + raise ValueError("Experiment entity must have 'private' attribute set for permission checks.") + + user_is_owner = False + user_is_contributor = False + active_roles = [] + if user_data is not None: + user_is_owner = entity.created_by_id == user_data.user.id + user_is_contributor = user_data.user.username in [c.orcid_id for c in entity.contributors] + active_roles = user_data.active_roles + + save_to_logging_context( + { + "resource_is_private": entity.private, + "user_is_owner": user_is_owner, + "user_is_contributor": user_is_contributor, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.ADD_SCORE_SET: _handle_add_score_set_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for experiment entities. " + f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + entity.private, + user_is_owner, + user_is_contributor, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: Experiment, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for Experiment entities. + + Public Experiments are readable by anyone. Private Experiments are only readable + by owners, contributors, admins, and mappers. + + Args: + user_data: The user's authentication data. + entity: The Experiment entity being accessed. + private: Whether the Experiment is private. + user_is_owner: Whether the user owns the Experiment. + user_is_contributor: Whether the user is a contributor to the Experiment. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow read access under the following conditions: + # Any user may read a non-private experiment. + if not private: + return PermissionResponse(True) + # The owner or contributors may read a private experiment. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may read a private experiment. + if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment") + + +def _handle_update_action( + user_data: Optional[UserData], + entity: Experiment, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for Experiment entities. + + Only owners, contributors, and admins can update Experiments. + + Args: + user_data: The user's authentication data. + entity: The Experiment entity being updated. + private: Whether the Experiment is private. + user_is_owner: Whether the user owns the Experiment. + user_is_contributor: Whether the user is a contributor to the Experiment. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # The owner or contributors may update the experiment. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may update the experiment. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment") + + +def _handle_delete_action( + user_data: Optional[UserData], + entity: Experiment, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle DELETE action permission check for Experiment entities. + + Admins can delete any Experiment. Owners can only delete unpublished Experiments. + Contributors cannot delete Experiments. + + Args: + user_data: The user's authentication data. + entity: The Experiment entity being deleted. + private: Whether the Experiment is private. + user_is_owner: Whether the user owns the Experiment. + user_is_contributor: Whether the user is a contributor to the Experiment. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow delete access under the following conditions: + # Admins may delete any experiment. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may delete an experiment only if it is still private. Contributors may not delete an experiment. + if user_is_owner and private: + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment") + + +def _handle_add_score_set_action( + user_data: Optional[UserData], + entity: Experiment, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_SCORE_SET action permission check for Experiment entities. + + Only permitted users can add a score set to a private experiment. + Any authenticated user can add a score set to a public experiment. + + Args: + user_data: The user's authentication data. + entity: The Experiment entity to add a score set to. + private: Whether the Experiment is private. + user_is_owner: Whether the user owns the Experiment. + user_is_contributor: Whether the user is a contributor to the Experiment. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add score set access under the following conditions: + # Owners or contributors may add a score set. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may update the experiment. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Any authenticated user may add a score set to a non-private experiment. + if not private and user_data is not None: + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment") diff --git a/src/mavedb/lib/permissions/experiment_set.py b/src/mavedb/lib/permissions/experiment_set.py new file mode 100644 index 00000000..13497fb3 --- /dev/null +++ b/src/mavedb/lib/permissions/experiment_set.py @@ -0,0 +1,218 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted +from mavedb.models.enums.user_role import UserRole +from mavedb.models.experiment_set import ExperimentSet + + +def has_permission(user_data: Optional[UserData], entity: ExperimentSet, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on an ExperimentSet entity. + + This function evaluates user permissions based on ownership, contributor status, + and user roles. It handles both private and public ExperimentSets with different + access control rules. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + entity: The ExperimentSet entity to check permissions for. + action: The action to be performed (READ, UPDATE, DELETE, ADD_EXPERIMENT). + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + ValueError: If the entity's private attribute is not set. + NotImplementedError: If the action is not supported for ExperimentSet entities. + """ + if entity.private is None: + raise ValueError("ExperimentSet entity must have 'private' attribute set for permission checks.") + + user_is_owner = False + user_is_contributor = False + active_roles = [] + if user_data is not None: + user_is_owner = entity.created_by_id == user_data.user.id + user_is_contributor = user_data.user.username in [c.orcid_id for c in entity.contributors] + active_roles = user_data.active_roles + + save_to_logging_context( + { + "resource_is_private": entity.private, + "user_is_owner": user_is_owner, + "user_is_contributor": user_is_contributor, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.ADD_EXPERIMENT: _handle_add_experiment_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for experiment set entities. " + f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + entity.private, + user_is_owner, + user_is_contributor, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: ExperimentSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for ExperimentSet entities. + + Public ExperimentSets are readable by anyone. Private ExperimentSets are only readable + by owners, contributors, admins, and mappers. + + Args: + user_data: The user's authentication data. + entity: The ExperimentSet entity being accessed. + private: Whether the ExperimentSet is private. + user_is_owner: Whether the user owns the ExperimentSet. + user_is_contributor: Whether the user is a contributor to the ExperimentSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow read access under the following conditions: + # Any user may read a non-private experiment set. + if not private: + return PermissionResponse(True) + # The owner or contributors may read a private experiment set. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may read a private experiment set. + if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment set") + + +def _handle_update_action( + user_data: Optional[UserData], + entity: ExperimentSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for ExperimentSet entities. + + Only owners, contributors, and admins can update ExperimentSets. + + Args: + user_data: The user's authentication data. + entity: The ExperimentSet entity being updated. + private: Whether the ExperimentSet is private. + user_is_owner: Whether the user owns the ExperimentSet. + user_is_contributor: Whether the user is a contributor to the ExperimentSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # The owner or contributors may update the experiment set. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may update the experiment set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment set") + + +def _handle_delete_action( + user_data: Optional[UserData], + entity: ExperimentSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle DELETE action permission check for ExperimentSet entities. + + Admins can delete any ExperimentSet. Owners can only delete unpublished ExperimentSets. + Contributors cannot delete ExperimentSets. + + Args: + user_data: The user's authentication data. + entity: The ExperimentSet entity being deleted. + private: Whether the ExperimentSet is private. + user_is_owner: Whether the user owns the ExperimentSet. + user_is_contributor: Whether the user is a contributor to the ExperimentSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow delete access under the following conditions: + # Admins may delete any experiment set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may delete an experiment set only if it is still private. Contributors may not delete an experiment set. + if user_is_owner and private: + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment set") + + +def _handle_add_experiment_action( + user_data: Optional[UserData], + entity: ExperimentSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_EXPERIMENT action permission check for ExperimentSet entities. + + Only permitted users can add an experiment to a private experiment set. + Any authenticated user can add an experiment to a public experiment set. + + Args: + user_data: The user's authentication data. + entity: The ExperimentSet entity to add an experiment to. + private: Whether the ExperimentSet is private. + user_is_owner: Whether the user owns the ExperimentSet. + user_is_contributor: Whether the user is a contributor to the ExperimentSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add experiment access under the following conditions: + # Owners or contributors may add an experiment. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may add an experiment to the experiment set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "experiment set") diff --git a/src/mavedb/lib/permissions/models.py b/src/mavedb/lib/permissions/models.py new file mode 100644 index 00000000..0145fc08 --- /dev/null +++ b/src/mavedb/lib/permissions/models.py @@ -0,0 +1,25 @@ +import logging +from typing import Optional + +from mavedb.lib.logging.context import logging_context, save_to_logging_context + +logger = logging.getLogger(__name__) + + +class PermissionResponse: + def __init__(self, permitted: bool, http_code: int = 403, message: Optional[str] = None): + self.permitted = permitted + self.http_code = http_code if not permitted else None + self.message = message if not permitted else None + + save_to_logging_context({"permission_message": self.message, "access_permitted": self.permitted}) + if self.permitted: + logger.debug( + msg="Access to the requested resource is permitted.", + extra=logging_context(), + ) + else: + logger.debug( + msg="Access to the requested resource is not permitted.", + extra=logging_context(), + ) diff --git a/src/mavedb/lib/permissions/score_calibration.py b/src/mavedb/lib/permissions/score_calibration.py new file mode 100644 index 00000000..08c27068 --- /dev/null +++ b/src/mavedb/lib/permissions/score_calibration.py @@ -0,0 +1,277 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted +from mavedb.models.enums.user_role import UserRole +from mavedb.models.score_calibration import ScoreCalibration + + +def has_permission(user_data: Optional[UserData], entity: ScoreCalibration, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on a ScoreCalibration entity. + + This function evaluates user permissions for ScoreCalibration entities, which are + typically administrative objects that require special permissions to modify. + ScoreCalibrations don't have traditional ownership but are tied to ScoreSets. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + entity: The ScoreCalibration entity to check permissions for. + action: The action to be performed (READ, UPDATE, DELETE, CREATE). + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + NotImplementedError: If the action is not supported for ScoreCalibration entities. + """ + if entity.private is None: + raise ValueError("ScoreCalibration entity must have 'private' attribute set for permission checks.") + + user_is_owner = False + user_is_contributor_to_score_set = False + active_roles = [] + if user_data is not None: + user_is_owner = entity.created_by_id == user_data.user.id + # Contributor status is determined by matching the user's username (ORCID ID) against the contributors' ORCID IDs, + # as well as by matching the user's ID against the created_by_id and modified_by_id fields of the ScoreSet. + user_is_contributor_to_score_set = ( + user_data.user.username in [c.orcid_id for c in entity.score_set.contributors] + or user_data.user.id == entity.score_set.created_by_id + or user_data.user.id == entity.score_set.modified_by_id + ) + active_roles = user_data.active_roles + + save_to_logging_context( + { + "user_is_owner": user_is_owner, + "user_is_contributor_to_score_set": user_is_contributor_to_score_set, + "score_calibration_id": entity.id, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.PUBLISH: _handle_publish_action, + Action.CHANGE_RANK: _handle_change_rank_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for score calibration entities. " + f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + user_is_owner, + user_is_contributor_to_score_set, + entity.private, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: ScoreCalibration, + user_is_owner: bool, + user_is_contributor_to_score_set: bool, + private: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for ScoreCalibration entities. + + ScoreCalibrations are generally readable by anyone who can access the + associated ScoreSet, as they provide important contextual information + about the score data. + + Args: + user_data: The user's authentication data. + entity: The ScoreCalibration entity being accessed. + user_is_owner: Whether the user created the ScoreCalibration. + user_is_contributor_to_score_set: Whether the user is a contributor to the associated ScoreSet. + private: Whether the ScoreCalibration is private. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow read access under the following conditions: + # Any user may read a ScoreCalibration if it is not private. + if not private: + return PermissionResponse(True) + # Owners of the ScoreCalibration may read it. + if user_is_owner: + return PermissionResponse(True) + # If the calibration is investigator provided, contributors to the ScoreSet may read it. + if entity.investigator_provided and user_is_contributor_to_score_set: + return PermissionResponse(True) + # System admins may read any ScoreCalibration. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) + return deny_action_for_entity(entity, private, user_data, user_may_view_private, "score calibration") + + +def _handle_update_action( + user_data: Optional[UserData], + entity: ScoreCalibration, + user_is_owner: bool, + user_is_contributor_to_score_set: bool, + private: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for ScoreCalibration entities. + + Updating ScoreCalibrations is typically restricted to administrators + or the original creators, as changes can significantly impact + the interpretation of score data. + + Args: + user_data: The user's authentication data. + entity: The ScoreCalibration entity being accessed. + user_is_owner: Whether the user crated the ScoreCalibration. + user_is_contributor_to_score_set: Whether the user is a contributor to the associated ScoreSet. + private: Whether the ScoreCalibration is private. + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # System admins may update any ScoreCalibration. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # TODO#549: Allow editing of certain fields if the calibration is published. + # For now, published calibrations cannot be updated. + if entity.private: + # Owners may update their own ScoreCalibration if it is not published. + if user_is_owner: + return PermissionResponse(True) + # If the calibration is investigator provided, contributors to the ScoreSet may update it if not published. + if entity.investigator_provided and user_is_contributor_to_score_set: + return PermissionResponse(True) + + user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) + return deny_action_for_entity(entity, private, user_data, user_may_view_private, "score calibration") + + +def _handle_delete_action( + user_data: Optional[UserData], + entity: ScoreCalibration, + user_is_owner: bool, + user_is_contributor_to_score_set: bool, + private: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle DELETE action permission check for ScoreCalibration entities. + + Deleting ScoreCalibrations is a sensitive operation typically reserved + for administrators or the original creators, as it can affect data integrity. + + Args: + user_data: The user's authentication data. + entity: The ScoreCalibration entity being accessed. + user_is_owner: Whether the user created the ScoreCalibration. + user_is_contributor_to_score_set: Whether the user is a contributor to the associated ScoreSet. + private: Whether the ScoreCalibration is private. + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow delete access under the following conditions: + # System admins may delete any ScoreCalibration. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may delete their own ScoreCalibration if it is still private. Contributors may not delete ScoreCalibrations. + if user_is_owner and private: + return PermissionResponse(True) + + user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) + return deny_action_for_entity(entity, private, user_data, user_may_view_private, "score calibration") + + +def _handle_publish_action( + user_data: Optional[UserData], + entity: ScoreCalibration, + user_is_owner: bool, + user_is_contributor_to_score_set: bool, + private: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle PUBLISH action permission check for ScoreCalibration entities. + + Publishing ScoreCalibrations is typically restricted to administrators + or the original creators, as it signifies that the calibration is + finalized and ready for public use. + + Args: + user_data: The user's authentication data. + entity: The ScoreCalibration entity being accessed. + user_is_owner: Whether the user created the ScoreCalibration. + user_is_contributor_to_score_set: Whether the user is a contributor to the associated ScoreSet. + private: Whether the ScoreCalibration is private. + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow publish access under the following conditions: + # System admins may publish any ScoreCalibration. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may publish their own ScoreCalibration. + if user_is_owner: + return PermissionResponse(True) + + user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) + return deny_action_for_entity(entity, private, user_data, user_may_view_private, "score calibration") + + +def _handle_change_rank_action( + user_data: Optional[UserData], + entity: ScoreCalibration, + user_is_owner: bool, + user_is_contributor_to_score_set: bool, + private: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle CHANGE_RANK action permission check for ScoreCalibration entities. + + Changing the rank of ScoreCalibrations is typically restricted to administrators + or the original creators, as it affects the order in which calibrations are applied. + + Args: + user_data: The user's authentication data. + entity: The ScoreCalibration entity being accessed. + user_is_owner: Whether the user created the ScoreCalibration. + user_is_contributor_to_score_set: Whether the user is a contributor to the associated ScoreSet. + private: Whether the ScoreCalibration is private. + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow change rank access under the following conditions: + # System admins may change the rank of any ScoreCalibration. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may change the rank of their own ScoreCalibration. + if user_is_owner: + return PermissionResponse(True) + # If the calibration is investigator provided, contributors to the ScoreSet may change its rank. + if entity.investigator_provided and user_is_contributor_to_score_set: + return PermissionResponse(True) + + user_may_view_private = user_is_owner or (entity.investigator_provided and user_is_contributor_to_score_set) + return deny_action_for_entity(entity, private, user_data, user_may_view_private, "score calibration") diff --git a/src/mavedb/lib/permissions/score_set.py b/src/mavedb/lib/permissions/score_set.py new file mode 100644 index 00000000..6a992240 --- /dev/null +++ b/src/mavedb/lib/permissions/score_set.py @@ -0,0 +1,255 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted +from mavedb.models.enums.user_role import UserRole +from mavedb.models.score_set import ScoreSet + + +def has_permission(user_data: Optional[UserData], entity: ScoreSet, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on a ScoreSet entity. + + This function evaluates user permissions based on ownership, contributor status, + and user roles. It handles both private and public ScoreSets with different + access control rules. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + entity: The ScoreSet entity to check permissions for. + action: The action to be performed (READ, UPDATE, DELETE, PUBLISH, SET_SCORES). + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + ValueError: If the entity's private attribute is not set. + NotImplementedError: If the action is not supported for ScoreSet entities. + """ + if entity.private is None: + raise ValueError("ScoreSet entity must have 'private' attribute set for permission checks.") + + user_is_owner = False + user_is_contributor = False + active_roles = [] + if user_data is not None: + user_is_owner = entity.created_by_id == user_data.user.id + user_is_contributor = user_data.user.username in [c.orcid_id for c in entity.contributors] + active_roles = user_data.active_roles + + save_to_logging_context( + { + "resource_is_private": entity.private, + "user_is_owner": user_is_owner, + "user_is_contributor": user_is_contributor, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.PUBLISH: _handle_publish_action, + Action.SET_SCORES: _handle_set_scores_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for score set entities. " + f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + entity.private, + user_is_owner, + user_is_contributor, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: ScoreSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for ScoreSet entities. + + Public ScoreSets are readable by anyone. Private ScoreSets are only readable + by owners, contributors, admins, and mappers. + + Args: + user_data: The user's authentication data. + entity: The ScoreSet entity being accessed. + private: Whether the ScoreSet is private. + user_is_owner: Whether the user owns the ScoreSet. + user_is_contributor: Whether the user is a contributor to the ScoreSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow read access under the following conditions: + # Any user may read a non-private score set. + if not private: + return PermissionResponse(True) + # The owner or contributors may read a private score set. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may read a private score set. + if roles_permitted(active_roles, [UserRole.admin, UserRole.mapper]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set") + + +def _handle_update_action( + user_data: Optional[UserData], + entity: ScoreSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for ScoreSet entities. + + Only owners, contributors, and admins can update ScoreSets. + + Args: + user_data: The user's authentication data. + entity: The ScoreSet entity being updated. + private: Whether the ScoreSet is private. + user_is_owner: Whether the user owns the ScoreSet. + user_is_contributor: Whether the user is a contributor to the ScoreSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # The owner or contributors may update the score set. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may update the score set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set") + + +def _handle_delete_action( + user_data: Optional[UserData], + entity: ScoreSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle DELETE action permission check for ScoreSet entities. + + Admins can delete any ScoreSet. Owners can only delete unpublished ScoreSets. + Contributors cannot delete ScoreSets. + + Args: + user_data: The user's authentication data. + entity: The ScoreSet entity being deleted. + private: Whether the ScoreSet is private. + user_is_owner: Whether the user owns the ScoreSet. + user_is_contributor: Whether the user is a contributor to the ScoreSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow delete access under the following conditions: + # Admins may delete any score set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + # Owners may delete a score set only if it is still private. Contributors may not delete a score set. + if user_is_owner and private: + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set") + + +def _handle_publish_action( + user_data: Optional[UserData], + entity: ScoreSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle PUBLISH action permission check for ScoreSet entities. + + Owners, and admins can publish private ScoreSets to make them + publicly accessible. + + Args: + user_data: The user's authentication data. + entity: The ScoreSet entity being published. + private: Whether the ScoreSet is private. + user_is_owner: Whether the user owns the ScoreSet. + user_is_contributor: Whether the user is a contributor to the ScoreSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow publish access under the following conditions: + # The owner may publish the score set. + if user_is_owner: + return PermissionResponse(True) + # Users with these specific roles may publish the score set. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set") + + +def _handle_set_scores_action( + user_data: Optional[UserData], + entity: ScoreSet, + private: bool, + user_is_owner: bool, + user_is_contributor: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle SET_SCORES action permission check for ScoreSet entities. + + Only owners, contributors, and admins can modify the scores data within + a ScoreSet. This is a critical operation that affects the scientific data. + + Args: + user_data: The user's authentication data. + entity: The ScoreSet entity whose scores are being modified. + private: Whether the ScoreSet is private. + user_is_owner: Whether the user owns the ScoreSet. + user_is_contributor: Whether the user is a contributor to the ScoreSet. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow set scores access under the following conditions: + # The owner or contributors may set scores. + if user_is_owner or user_is_contributor: + return PermissionResponse(True) + # Users with these specific roles may set scores. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, private, user_data, user_is_contributor or user_is_owner, "score set") diff --git a/src/mavedb/lib/permissions/user.py b/src/mavedb/lib/permissions/user.py new file mode 100644 index 00000000..908c84d6 --- /dev/null +++ b/src/mavedb/lib/permissions/user.py @@ -0,0 +1,192 @@ +from typing import Optional + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import save_to_logging_context +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted +from mavedb.models.enums.user_role import UserRole +from mavedb.models.user import User + + +def has_permission(user_data: Optional[UserData], entity: User, action: Action) -> PermissionResponse: + """ + Check if a user has permission to perform an action on a User entity. + + This function evaluates user permissions based on user identity and roles. + User entities have different access patterns since they don't have public/private + states or ownership in the traditional sense. + + Args: + user_data: The user's authentication data and roles. None for anonymous users. + entity: The User entity to check permissions for. + action: The action to be performed (READ, UPDATE, LOOKUP, ADD_ROLE). + + Returns: + PermissionResponse: Contains permission result, HTTP status code, and message. + + Raises: + NotImplementedError: If the action is not supported for User entities. + + Note: + User entities do not have private/public states or traditional ownership models. + Permissions are based on user identity and administrative roles. + """ + user_is_self = False + active_roles = [] + + if user_data is not None: + user_is_self = entity.id == user_data.user.id + active_roles = user_data.active_roles + + save_to_logging_context( + { + "user_is_self": user_is_self, + "target_user_id": entity.id, + } + ) + + handlers = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.LOOKUP: _handle_lookup_action, + Action.ADD_ROLE: _handle_add_role_action, + } + + if action not in handlers: + supported_actions = ", ".join(a.value for a in handlers.keys()) + raise NotImplementedError( + f"Action '{action.value}' is not supported for user profile entities. " + f"Supported actions: {supported_actions}" + ) + + return handlers[action]( + user_data, + entity, + user_is_self, + active_roles, + ) + + +def _handle_read_action( + user_data: Optional[UserData], + entity: User, + user_is_self: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle READ action permission check for User entities. + + Users can read their own profile. Admins can read any user profile. + READ access to profiles refers to admin level properties. Basic user info + is handled by the LOOKUP action. + + Args: + user_data: The user's authentication data. + entity: The User entity being accessed. + user_is_self: Whether the user is viewing their own profile. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + + Note: + Basic user information (username, display name) is typically public, + but sensitive information requires appropriate permissions. + """ + ## Allow read access under the following conditions: + # Users can always read their own profile. + if user_is_self: + return PermissionResponse(True) + # Admins can read any user profile. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, False, user_data, False, "user profile") + + +def _handle_lookup_action( + user_data: Optional[UserData], + entity: User, + user_is_self: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle LOOKUP action permission check for User entities. + + Any authenticated user can look up basic information about other users. + Anonymous users cannot perform LOOKUP actions. + + Args: + user_data: The user's authentication data. + entity: The User entity being looked up. + user_is_self: Whether the user is looking up their own profile. + active_roles: List of the user's active roles. + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow lookup access under the following conditions: + # Any authenticated user can look up basic user information. + if user_data is not None and user_data.user is not None: + return PermissionResponse(True) + + return deny_action_for_entity(entity, False, user_data, False, "user profile") + + +def _handle_update_action( + user_data: Optional[UserData], + entity: User, + user_is_self: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle UPDATE action permission check for User entities. + + Users can update their own profile. Admins can update any user profile. + + Args: + user_data: The user's authentication data. + entity: The User entity being updated. + user_is_self: Whether the user is updating their own profile. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow update access under the following conditions: + # Users can update their own profile. + if user_is_self: + return PermissionResponse(True) + # Admins can update any user profile. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, False, user_data, False, "user profile") + + +def _handle_add_role_action( + user_data: Optional[UserData], + entity: User, + user_is_self: bool, + active_roles: list[UserRole], +) -> PermissionResponse: + """ + Handle ADD_ROLE action permission check for User entities. + + Only admins can add roles to users. + + Args: + user_data: The user's authentication data. + entity: The User entity being modified. + user_is_self: Whether the user is modifying their own profile. + active_roles: List of the user's active roles. + + Returns: + PermissionResponse: Permission result with appropriate HTTP status. + """ + ## Allow add role access under the following conditions: + # Only admins can add roles to users. + if roles_permitted(active_roles, [UserRole.admin]): + return PermissionResponse(True) + + return deny_action_for_entity(entity, False, user_data, False, "user profile") diff --git a/src/mavedb/lib/permissions/utils.py b/src/mavedb/lib/permissions/utils.py new file mode 100644 index 00000000..4d3a32bf --- /dev/null +++ b/src/mavedb/lib/permissions/utils.py @@ -0,0 +1,132 @@ +import logging +from typing import Optional, Union, overload + +from mavedb.lib.authentication import UserData +from mavedb.lib.logging.context import logging_context, save_to_logging_context +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.lib.types.permissions import EntityType +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.enums.user_role import UserRole + +logger = logging.getLogger(__name__) + + +@overload +def roles_permitted( + user_roles: list[UserRole], + permitted_roles: list[UserRole], +) -> bool: ... + + +@overload +def roles_permitted( + user_roles: list[ContributionRole], + permitted_roles: list[ContributionRole], +) -> bool: ... + + +def roles_permitted( + user_roles: Union[list[UserRole], list[ContributionRole]], + permitted_roles: Union[list[UserRole], list[ContributionRole]], +) -> bool: + """ + Check if any user role is permitted based on a list of allowed roles. + + This function validates that both user_roles and permitted_roles are lists of the same enum type + (either all UserRole or all ContributionRole), and checks if any user role is present in the permitted roles. + Raises ValueError if either list contains mixed role types or if the lists are of different types. + + Args: + user_roles: List of roles assigned to the user (UserRole or ContributionRole). + permitted_roles: List of roles that are permitted for the action (UserRole or ContributionRole). + + Returns: + bool: True if any user role is permitted, False otherwise. + + Raises: + ValueError: If user_roles or permitted_roles contain mixed role types, or if the lists are of different types. + + Example: + >>> roles_permitted([UserRole.admin], [UserRole.admin, UserRole.editor]) + True + >>> roles_permitted([ContributionRole.admin], [ContributionRole.editor]) + False + + Note: + This function is used to enforce type safety and prevent mixing of role enums in permission checks. + """ + save_to_logging_context({"permitted_roles": [role.name for role in permitted_roles]}) + + if not user_roles: + logger.debug(msg="User has no associated roles.", extra=logging_context()) + return False + + # Validate that both lists contain the same enum type + if user_roles and permitted_roles: + user_role_types = {type(role) for role in user_roles} + permitted_role_types = {type(role) for role in permitted_roles} + + # Check if either list has mixed types + if len(user_role_types) > 1: + raise ValueError("user_roles list cannot contain mixed role types (UserRole and ContributionRole)") + if len(permitted_role_types) > 1: + raise ValueError("permitted_roles list cannot contain mixed role types (UserRole and ContributionRole)") + + # Check if the lists have different role types + if user_role_types != permitted_role_types: + raise ValueError( + "user_roles and permitted_roles must contain the same role type (both UserRole or both ContributionRole)" + ) + + return any(role in permitted_roles for role in user_roles) + + +def deny_action_for_entity( + entity: EntityType, + private: bool, + user_data: Optional[UserData], + user_may_view_private: bool, + user_facing_model_name: str = "entity", +) -> PermissionResponse: + """ + Generate appropriate denial response for entity permission checks. + + This helper function determines the correct HTTP status code and message + when denying access to an entity based on its privacy and user authentication. + + Args: + entity: The entity being accessed. + private: Whether the entity is private. + user_data: The user's authentication data (None for anonymous). + user_may_view_private: Whether the user has permission to view private entities. + + Returns: + PermissionResponse: Denial response with appropriate HTTP status and message. + + Note: + Returns 404 for private entities to avoid information disclosure, + 401 for unauthenticated users, and 403 for insufficient permissions. + """ + + def _identifier_for_entity(entity: EntityType) -> tuple[str, str]: + if hasattr(entity, "urn") and entity.urn is not None: + return "URN", entity.urn + elif hasattr(entity, "id") and entity.id is not None: + return "ID", str(entity.id) + else: + return "unknown", "unknown" + + field, identifier = _identifier_for_entity(entity) + # Do not acknowledge the existence of a private score set. + if private and not user_may_view_private: + return PermissionResponse(False, 404, f"{user_facing_model_name} with {field} '{identifier}' not found") + # No authenticated user is present. + if user_data is None or user_data.user is None: + return PermissionResponse( + False, 401, f"authentication required to access {user_facing_model_name} with {field} '{identifier}'" + ) + + # The authenticated user lacks sufficient permissions. + return PermissionResponse( + False, 403, f"insufficient permissions on {user_facing_model_name} with {field} '{identifier}'" + ) diff --git a/src/mavedb/lib/types/permissions.py b/src/mavedb/lib/types/permissions.py new file mode 100644 index 00000000..aa9628c7 --- /dev/null +++ b/src/mavedb/lib/types/permissions.py @@ -0,0 +1,18 @@ +from typing import Union + +from mavedb.models.collection import Collection +from mavedb.models.experiment import Experiment +from mavedb.models.experiment_set import ExperimentSet +from mavedb.models.score_calibration import ScoreCalibration +from mavedb.models.score_set import ScoreSet +from mavedb.models.user import User + +# Define the supported entity types +EntityType = Union[ + Collection, + Experiment, + ExperimentSet, + ScoreCalibration, + ScoreSet, + User, +] diff --git a/src/mavedb/models/score_calibration.py b/src/mavedb/models/score_calibration.py index 988d4d04..ef32c107 100644 --- a/src/mavedb/models/score_calibration.py +++ b/src/mavedb/models/score_calibration.py @@ -33,7 +33,7 @@ class ScoreCalibration(Base): title = Column(String, nullable=False) research_use_only = Column(Boolean, nullable=False, default=False) primary = Column(Boolean, nullable=False, default=False) - investigator_provided = Column(Boolean, nullable=False, default=False) + investigator_provided: Mapped[bool] = Column(Boolean, nullable=False, default=False) private = Column(Boolean, nullable=False, default=True) notes = Column(String, nullable=True) diff --git a/src/mavedb/routers/experiment_sets.py b/src/mavedb/routers/experiment_sets.py index 386da37b..1166fb7f 100644 --- a/src/mavedb/routers/experiment_sets.py +++ b/src/mavedb/routers/experiment_sets.py @@ -57,7 +57,7 @@ def fetch_experiment_set( # the exception is raised, not returned - you will get a validation # error otherwise. logger.debug(msg="The requested resources does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"Experiment set with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"experiment set with URN {urn} not found") else: item.experiments.sort(key=attrgetter("urn")) diff --git a/src/mavedb/routers/experiments.py b/src/mavedb/routers/experiments.py index 5d37ecb3..2064196b 100644 --- a/src/mavedb/routers/experiments.py +++ b/src/mavedb/routers/experiments.py @@ -155,7 +155,7 @@ def fetch_experiment( if not item: logger.debug(msg="The requested experiment does not exist.", extra=logging_context()) - raise HTTPException(status_code=404, detail=f"Experiment with URN {urn} not found") + raise HTTPException(status_code=404, detail=f"experiment with URN {urn} not found") assert_permission(user_data, item, Action.READ) return enrich_experiment_with_num_score_sets(item, user_data) diff --git a/src/mavedb/routers/score_calibrations.py b/src/mavedb/routers/score_calibrations.py index daac1950..d5bceb88 100644 --- a/src/mavedb/routers/score_calibrations.py +++ b/src/mavedb/routers/score_calibrations.py @@ -1,31 +1,30 @@ import logging +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query -from typing import Optional -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from mavedb import deps +from mavedb.lib.authentication import UserData, get_current_user +from mavedb.lib.authorization import require_current_user from mavedb.lib.logging import LoggedRoute from mavedb.lib.logging.context import ( logging_context, save_to_logging_context, ) -from mavedb.lib.authentication import get_current_user, UserData -from mavedb.lib.authorization import require_current_user from mavedb.lib.permissions import Action, assert_permission, has_permission from mavedb.lib.score_calibrations import ( create_score_calibration_in_score_set, - modify_score_calibration, delete_score_calibration, demote_score_calibration_from_primary, + modify_score_calibration, promote_score_calibration_to_primary, publish_score_calibration, ) from mavedb.models.score_calibration import ScoreCalibration -from mavedb.routers.score_sets import fetch_score_set_by_urn +from mavedb.models.score_set import ScoreSet from mavedb.view_models import score_calibration - logger = logging.getLogger(__name__) router = APIRouter( @@ -52,7 +51,12 @@ def get_score_calibration( """ save_to_logging_context({"requested_resource": urn}) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") @@ -76,12 +80,23 @@ async def get_score_calibrations_for_score_set( Retrieve all score calibrations for a given score set URN. """ save_to_logging_context({"requested_resource": score_set_urn, "resource_property": "calibrations"}) - score_set = await fetch_score_set_by_urn(db, score_set_urn, user_data, None, False) + score_set = db.query(ScoreSet).filter(ScoreSet.urn == score_set_urn).one_or_none() + + if not score_set: + logger.debug("ScoreSet not found", extra=logging_context()) + raise HTTPException(status_code=404, detail=f"score set with URN '{score_set_urn}' not found") + + assert_permission(user_data, score_set, Action.READ) + + calibrations = ( + db.query(ScoreCalibration) + .filter(ScoreCalibration.score_set_id == score_set.id) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .all() + ) permitted_calibrations = [ - calibration - for calibration in score_set.score_calibrations - if has_permission(user_data, calibration, Action.READ).permitted + calibration for calibration in calibrations if has_permission(user_data, calibration, Action.READ).permitted ] if not permitted_calibrations: logger.debug("No score calibrations found for the requested score set", extra=logging_context()) @@ -105,12 +120,23 @@ async def get_primary_score_calibrations_for_score_set( Retrieve the primary score calibration for a given score set URN. """ save_to_logging_context({"requested_resource": score_set_urn, "resource_property": "calibrations"}) - score_set = await fetch_score_set_by_urn(db, score_set_urn, user_data, None, False) + + score_set = db.query(ScoreSet).filter(ScoreSet.urn == score_set_urn).one_or_none() + if not score_set: + logger.debug("ScoreSet not found", extra=logging_context()) + raise HTTPException(status_code=404, detail=f"score set with URN '{score_set_urn}' not found") + + assert_permission(user_data, score_set, Action.READ) + + calibrations = ( + db.query(ScoreCalibration) + .filter(ScoreCalibration.score_set_id == score_set.id) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .all() + ) permitted_calibrations = [ - calibration - for calibration in score_set.score_calibrations - if has_permission(user_data, calibration, Action.READ) + calibration for calibration in calibrations if has_permission(user_data, calibration, Action.READ).permitted ] if not permitted_calibrations: logger.debug("No score calibrations found for the requested score set", extra=logging_context()) @@ -155,7 +181,11 @@ async def create_score_calibration_route( save_to_logging_context({"requested_resource": calibration.score_set_urn, "resource_property": "calibrations"}) - score_set = await fetch_score_set_by_urn(db, calibration.score_set_urn, user_data, None, False) + score_set = db.query(ScoreSet).filter(ScoreSet.urn == calibration.score_set_urn).one_or_none() + if not score_set: + logger.debug("ScoreSet not found", extra=logging_context()) + raise HTTPException(status_code=404, detail=f"score set with URN '{calibration.score_set_urn}' not found") + # TODO#539: Allow any authenticated user to upload a score calibration for a score set, not just those with # permission to update the score set itself. assert_permission(user_data, score_set, Action.UPDATE) @@ -187,13 +217,24 @@ async def modify_score_calibration_route( # If the user supplies a new score_set_urn, validate it exists and the user has permission to use it. if calibration_update.score_set_urn is not None: - score_set = await fetch_score_set_by_urn(db, calibration_update.score_set_urn, user_data, None, False) + score_set = db.query(ScoreSet).filter(ScoreSet.urn == calibration_update.score_set_urn).one_or_none() + + if not score_set: + logger.debug("ScoreSet not found", extra=logging_context()) + raise HTTPException( + status_code=404, detail=f"score set with URN '{calibration_update.score_set_urn}' not found" + ) # TODO#539: Allow any authenticated user to upload a score calibration for a score set, not just those with # permission to update the score set itself. assert_permission(user_data, score_set, Action.UPDATE) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") @@ -225,7 +266,12 @@ async def delete_score_calibration_route( """ save_to_logging_context({"requested_resource": urn}) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") @@ -259,7 +305,12 @@ async def promote_score_calibration_to_primary_route( {"requested_resource": urn, "resource_property": "primary", "demote_existing_primary": demote_existing_primary} ) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") @@ -318,7 +369,12 @@ def demote_score_calibration_from_primary_route( """ save_to_logging_context({"requested_resource": urn, "resource_property": "primary"}) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") @@ -352,7 +408,12 @@ def publish_score_calibration_route( """ save_to_logging_context({"requested_resource": urn, "resource_property": "private"}) - item = db.query(ScoreCalibration).where(ScoreCalibration.urn == urn).one_or_none() + item = ( + db.query(ScoreCalibration) + .options(selectinload(ScoreCalibration.score_set).selectinload(ScoreSet.contributors)) + .where(ScoreCalibration.urn == urn) + .one_or_none() + ) if not item: logger.debug("The requested score calibration does not exist", extra=logging_context()) raise HTTPException(status_code=404, detail="The requested score calibration does not exist") diff --git a/src/mavedb/routers/users.py b/src/mavedb/routers/users.py index fd3a4d95..79c9cb88 100644 --- a/src/mavedb/routers/users.py +++ b/src/mavedb/routers/users.py @@ -104,7 +104,7 @@ async def show_user_admin( msg="Could not show user; Requested user does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"User with ID {id} not found") + raise HTTPException(status_code=404, detail=f"user profile with ID {id} not found") # moving toward always accessing permissions module, even though this function does already require admin role to access assert_permission(user_data, item, Action.READ) @@ -135,7 +135,7 @@ async def show_user( msg="Could not show user; Requested user does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"User with ID {orcid_id} not found") + raise HTTPException(status_code=404, detail=f"user profile with ID {orcid_id} not found") # moving toward always accessing permissions module, even though this function does already require existing user in order to access assert_permission(user_data, item, Action.LOOKUP) @@ -217,7 +217,7 @@ async def update_user( msg="Could not update user; Requested user does not exist.", extra=logging_context(), ) - raise HTTPException(status_code=404, detail=f"User with id {id} not found.") + raise HTTPException(status_code=404, detail=f"user profile with id {id} not found.") assert_permission(user_data, item, Action.UPDATE) assert_permission(user_data, item, Action.ADD_ROLE) diff --git a/src/mavedb/server_main.py b/src/mavedb/server_main.py index 80db5403..23717e43 100644 --- a/src/mavedb/server_main.py +++ b/src/mavedb/server_main.py @@ -31,11 +31,12 @@ logging_context, save_to_logging_context, ) -from mavedb.lib.permissions import PermissionException +from mavedb.lib.permissions.exceptions import PermissionException from mavedb.lib.slack import send_slack_error from mavedb.models import * # noqa: F403 from mavedb.routers import ( access_keys, + alphafold, api_information, collections, controlled_keywords, @@ -59,7 +60,6 @@ taxonomies, users, variants, - alphafold, ) logger = logging.getLogger(__name__) diff --git a/tests/conftest.py b/tests/conftest.py index c79c033e..b11f728c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import logging # noqa: F401 +import sys from datetime import datetime from unittest import mock -import sys import email_validator import pytest @@ -11,35 +11,33 @@ from sqlalchemy.pool import NullPool from mavedb.db.base import Base +from mavedb.models import * # noqa: F403 +from mavedb.models.experiment import Experiment from mavedb.models.experiment_set import ExperimentSet -from mavedb.models.score_set_publication_identifier import ScoreSetPublicationIdentifierAssociation -from mavedb.models.user import User, UserRole, Role from mavedb.models.license import License -from mavedb.models.taxonomy import Taxonomy -from mavedb.models.publication_identifier import PublicationIdentifier -from mavedb.models.experiment import Experiment -from mavedb.models.variant import Variant from mavedb.models.mapped_variant import MappedVariant +from mavedb.models.publication_identifier import PublicationIdentifier from mavedb.models.score_set import ScoreSet - -from mavedb.models import * # noqa: F403 - +from mavedb.models.score_set_publication_identifier import ScoreSetPublicationIdentifierAssociation +from mavedb.models.taxonomy import Taxonomy +from mavedb.models.user import Role, User, UserRole +from mavedb.models.variant import Variant from tests.helpers.constants import ( ADMIN_USER, EXTRA_USER, - TEST_LICENSE, + TEST_BRNICH_SCORE_CALIBRATION, TEST_INACTIVE_LICENSE, + TEST_LICENSE, + TEST_PATHOGENICITY_SCORE_CALIBRATION, + TEST_PUBMED_IDENTIFIER, TEST_SAVED_TAXONOMY, TEST_USER, - VALID_VARIANT_URN, - VALID_SCORE_SET_URN, - VALID_EXPERIMENT_URN, - VALID_EXPERIMENT_SET_URN, - TEST_PUBMED_IDENTIFIER, TEST_VALID_POST_MAPPED_VRS_ALLELE_VRS2_X, TEST_VALID_PRE_MAPPED_VRS_ALLELE_VRS2_X, - TEST_BRNICH_SCORE_CALIBRATION, - TEST_PATHOGENICITY_SCORE_CALIBRATION, + VALID_EXPERIMENT_SET_URN, + VALID_EXPERIMENT_URN, + VALID_SCORE_SET_URN, + VALID_VARIANT_URN, ) sys.path.append(".") @@ -56,7 +54,7 @@ assert pytest_postgresql.factories # Allow the @test domain name through our email validator. -email_validator.SPECIAL_USE_DOMAIN_NAMES.remove("test") +email_validator.TEST_ENVIRONMENT = True @pytest.fixture() diff --git a/tests/lib/permissions/__init__.py b/tests/lib/permissions/__init__.py new file mode 100644 index 00000000..78b319a5 --- /dev/null +++ b/tests/lib/permissions/__init__.py @@ -0,0 +1 @@ +"""Tests for the modular permissions system.""" diff --git a/tests/lib/permissions/conftest.py b/tests/lib/permissions/conftest.py new file mode 100644 index 00000000..302159f5 --- /dev/null +++ b/tests/lib/permissions/conftest.py @@ -0,0 +1,196 @@ +"""Shared fixtures and helpers for permissions tests.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional, Union +from unittest.mock import Mock + +import pytest + +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.enums.user_role import UserRole + +if TYPE_CHECKING: + from mavedb.lib.permissions.actions import Action + + +@dataclass +class PermissionTest: + """Represents a single permission test case for action handler testing. + + Used for parametrized testing of individual action handlers (_handle_read_action, etc.) + rather than comprehensive end-to-end permission testing. + + Args: + entity_type: Entity type name for context (not used in handler tests) + entity_state: "private" or "published" (None for stateless entities like User) + user_type: "admin", "owner", "contributor", "other_user", "anonymous", "self" + action: Action enum value (for documentation, handlers test specific actions) + should_be_permitted: True/False for normal cases, "NotImplementedError" for unsupported + expected_code: HTTP error code when denied (403, 404, 401, etc.) + description: Human-readable test description + collection_role: For Collection tests: "collection_admin", "collection_editor", "collection_viewer" + investigator_provided: For ScoreCalibration tests: True=investigator, False=community + """ + + entity_type: str + entity_state: Optional[str] + user_type: str + action: "Action" + should_be_permitted: Union[bool, str] + expected_code: Optional[int] = None + description: Optional[str] = None + collection_role: Optional[str] = None + collection_badge: Optional[str] = None + investigator_provided: Optional[bool] = None + + +class EntityTestHelper: + """Helper class to create test entities and user data with consistent properties.""" + + @staticmethod + def create_user_data(user_type: str): + """Create UserData mock for different user types. + + Args: + user_type: "admin", "owner", "contributor", "other_user", "anonymous", "self", "mapper" + + Returns: + Mock UserData object or None for anonymous users + """ + user_configs = { + "admin": (1, "1111-1111-1111-111X", [UserRole.admin]), + "owner": (2, "2222-2222-2222-222X", []), + "contributor": (3, "3333-3333-3333-333X", []), + "other_user": (4, "4444-4444-4444-444X", []), + "self": (5, "5555-5555-5555-555X", []), + "mapper": (6, "6666-6666-6666-666X", [UserRole.mapper]), + } + + if user_type == "anonymous": + return None + + if user_type not in user_configs: + raise ValueError(f"Unknown user type: {user_type}") + + user_id, username, roles = user_configs[user_type] + return Mock(user=Mock(id=user_id, username=username), active_roles=roles) + + @staticmethod + def create_score_set(entity_state: str = "private", owner_id: int = 2): + """Create a ScoreSet mock for testing.""" + private = entity_state == "private" + published_date = None if private else "2023-01-01" + contributors = [Mock(orcid_id="3333-3333-3333-333X")] + + return Mock( + id=1, + urn="urn:mavedb:00000001-a-1", + private=private, + created_by_id=owner_id, + published_date=published_date, + contributors=contributors, + ) + + @staticmethod + def create_experiment(entity_state: str = "private", owner_id: int = 2): + """Create an Experiment mock for testing.""" + private = entity_state == "private" + published_date = None if private else "2023-01-01" + contributors = [Mock(orcid_id="3333-3333-3333-333X")] + + return Mock( + id=1, + urn="urn:mavedb:00000001-a", + private=private, + created_by_id=owner_id, + published_date=published_date, + contributors=contributors, + ) + + @staticmethod + def create_experiment_set(entity_state: str = "private", owner_id: int = 2): + """Create an ExperimentSet mock for testing.""" + private = entity_state == "private" + published_date = None if private else "2023-01-01" + contributors = [Mock(orcid_id="3333-3333-3333-333X")] + + return Mock( + id=1, + urn="urn:mavedb:00000001", + private=private, + created_by_id=owner_id, + published_date=published_date, + contributors=contributors, + ) + + @staticmethod + def create_collection( + entity_state: str = "private", + owner_id: int = 2, + collection_role: Optional[str] = None, + badge_name: Optional[str] = None, + ): + """Create a Collection mock for testing. + + Args: + entity_state: "private" or "published" + owner_id: ID of the collection owner + collection_role: "collection_admin", "collection_editor", or "collection_viewer" + to create user association for contributor user (ID=3) + """ + private = entity_state == "private" + published_date = None if private else "2023-01-01" + + user_associations = [] + if collection_role: + role_map = { + "collection_admin": ContributionRole.admin, + "collection_editor": ContributionRole.editor, + "collection_viewer": ContributionRole.viewer, + } + user_associations.append(Mock(user_id=3, contribution_role=role_map[collection_role])) + + return Mock( + id=1, + urn="urn:mavedb:collection-001", + private=private, + created_by_id=owner_id, + published_date=published_date, + user_associations=user_associations, + badge_name=badge_name, + ) + + @staticmethod + def create_user(user_id: int = 5): + """Create a User mock for testing.""" + return Mock( + id=user_id, + username=f"{user_id}{user_id}{user_id}{user_id}-{user_id}{user_id}{user_id}{user_id}-{user_id}{user_id}{user_id}{user_id}-{user_id}{user_id}{user_id}X", + ) + + @staticmethod + def create_score_calibration(entity_state: str = "private", investigator_provided: bool = False): + """Create a ScoreCalibration mock for testing. + + Args: + entity_state: "private" or "published" (affects score_set and private property) + investigator_provided: True if investigator-provided, False if community-provided + """ + private = entity_state == "private" + score_set = EntityTestHelper.create_score_set(entity_state) + + # ScoreCalibrations have their own private property plus associated ScoreSet + return Mock( + id=1, + private=private, + score_set=score_set, + investigator_provided=investigator_provided, + created_by_id=2, # owner + modified_by_id=2, # owner + ) + + +@pytest.fixture +def entity_helper(): + """Fixture providing EntityTestHelper instance.""" + return EntityTestHelper() diff --git a/tests/lib/permissions/test_collection.py b/tests/lib/permissions/test_collection.py new file mode 100644 index 00000000..ab0593bb --- /dev/null +++ b/tests/lib/permissions/test_collection.py @@ -0,0 +1,732 @@ +# ruff: noqa: E402 + +"""Tests for Collection permissions module.""" + +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + +from typing import Callable, List +from unittest import mock + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.collection import ( + _handle_add_badge_action, + _handle_add_experiment_action, + _handle_add_role_action, + _handle_add_score_set_action, + _handle_delete_action, + _handle_publish_action, + _handle_read_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +COLLECTION_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.PUBLISH: _handle_publish_action, + Action.ADD_EXPERIMENT: _handle_add_experiment_action, + Action.ADD_SCORE_SET: _handle_add_score_set_action, + Action.ADD_ROLE: _handle_add_role_action, + Action.ADD_BADGE: _handle_add_badge_action, +} + +COLLECTION_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.LOOKUP, + Action.CHANGE_RANK, + Action.SET_SCORES, +] + +COLLECTION_ROLE_MAP = { + "collection_admin": ContributionRole.admin, + "collection_editor": ContributionRole.editor, + "collection_viewer": ContributionRole.viewer, +} + + +def test_collection_handles_all_actions() -> None: + """Test that all Collection actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(COLLECTION_SUPPORTED_ACTIONS) + unsupported = set(COLLECTION_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for collections." + + +class TestCollectionHasPermission: + """Test the main has_permission dispatcher function for Collection entities.""" + + @pytest.mark.parametrize("action, handler", COLLECTION_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + collection = entity_helper.create_collection() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.collection." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, collection, action) + mock_handler.assert_called_once_with( + admin_user, + collection, + collection.private, + collection.badge_name is not None, + False, # admin is not the owner + [], # admin has no collection roles + [UserRole.admin], + ) + + def test_has_permission_calls_helper_with_collection_roles_when_present(self, entity_helper: EntityTestHelper): + """Test that has_permission passes collection roles to action handlers.""" + collection = entity_helper.create_collection(collection_role="collection_editor") + contributor_user = entity_helper.create_user_data("contributor") + + with mock.patch( + "mavedb.lib.permissions.collection._handle_read_action", wraps=_handle_read_action + ) as mock_handler: + has_permission(contributor_user, collection, Action.READ) + mock_handler.assert_called_once_with( + contributor_user, + collection, + collection.private, + collection.badge_name is not None, + False, # contributor is not the owner + [ContributionRole.editor], # collection role + [], # user has no active roles + ) + + @pytest.mark.parametrize("action", COLLECTION_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + collection = entity_helper.create_collection() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, collection, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in COLLECTION_SUPPORTED_ACTIONS) + + def test_requires_private_attribute(self, entity_helper: EntityTestHelper) -> None: + """Test that ValueError is raised if Collection.private is None.""" + collection = entity_helper.create_collection() + collection.private = None + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(ValueError) as exc_info: + has_permission(admin_user, collection, Action.READ) + + assert "private" in str(exc_info.value) + + +class TestCollectionReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can read any Collection + PermissionTest("Collection", "published", "admin", Action.READ, True), + PermissionTest("Collection", "private", "admin", Action.READ, True), + # Owners can read any Collection they own + PermissionTest("Collection", "published", "owner", Action.READ, True), + PermissionTest("Collection", "private", "owner", Action.READ, True), + # Collection admins can read any Collection they have admin role for + PermissionTest( + "Collection", "published", "contributor", Action.READ, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "private", "contributor", Action.READ, True, collection_role="collection_admin" + ), + # Collection editors can read any Collection they have editor role for + PermissionTest( + "Collection", "published", "contributor", Action.READ, True, collection_role="collection_editor" + ), + PermissionTest( + "Collection", "private", "contributor", Action.READ, True, collection_role="collection_editor" + ), + # Collection viewers can read any Collection they have viewer role for + PermissionTest( + "Collection", "published", "contributor", Action.READ, True, collection_role="collection_viewer" + ), + PermissionTest( + "Collection", "private", "contributor", Action.READ, True, collection_role="collection_viewer" + ), + # Other users can only read published Collections + PermissionTest("Collection", "published", "other_user", Action.READ, True), + PermissionTest("Collection", "private", "other_user", Action.READ, False, 404), + # Anonymous users can only read published Collections + PermissionTest("Collection", "published", "anonymous", Action.READ, True), + PermissionTest("Collection", "private", "anonymous", Action.READ, False, 404), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can update any Collection + PermissionTest("Collection", "private", "admin", Action.UPDATE, True), + PermissionTest("Collection", "published", "admin", Action.UPDATE, True), + # Owners can update any Collection they own + PermissionTest("Collection", "private", "owner", Action.UPDATE, True), + PermissionTest("Collection", "published", "owner", Action.UPDATE, True), + # Collection admins can update any Collection they have admin role for + PermissionTest( + "Collection", "private", "contributor", Action.UPDATE, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "published", "contributor", Action.UPDATE, True, collection_role="collection_admin" + ), + # Collection editors can update any Collection they have editor role for + PermissionTest( + "Collection", "private", "contributor", Action.UPDATE, True, collection_role="collection_editor" + ), + PermissionTest( + "Collection", "published", "contributor", Action.UPDATE, True, collection_role="collection_editor" + ), + # Collection viewers cannot update Collections + PermissionTest( + "Collection", "private", "contributor", Action.UPDATE, False, 403, collection_role="collection_viewer" + ), + PermissionTest( + "Collection", "published", "contributor", Action.UPDATE, False, 403, collection_role="collection_viewer" + ), + # Other users cannot update Collections + PermissionTest("Collection", "private", "other_user", Action.UPDATE, False, 404), + PermissionTest("Collection", "published", "other_user", Action.UPDATE, False, 403), + # Anonymous users cannot update Collections + PermissionTest("Collection", "private", "anonymous", Action.UPDATE, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.UPDATE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionDeleteActionHandler: + """Test the _handle_delete_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can delete any Collection + PermissionTest("Collection", "private", "admin", Action.DELETE, True), + PermissionTest("Collection", "published", "admin", Action.DELETE, True), + PermissionTest("Collection", "private", "admin", Action.DELETE, True, collection_badge="official"), + PermissionTest("Collection", "published", "admin", Action.DELETE, True, collection_badge="official"), + # Owners can only delete unpublished, unofficial Collections + PermissionTest("Collection", "private", "owner", Action.DELETE, True), + PermissionTest("Collection", "published", "owner", Action.DELETE, False, 403), + PermissionTest("Collection", "private", "owner", Action.DELETE, False, 403, collection_badge="official"), + PermissionTest("Collection", "published", "owner", Action.DELETE, False, 403, collection_badge="official"), + # Collection admins cannot delete Collections + PermissionTest( + "Collection", "private", "contributor", Action.DELETE, False, 403, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "published", "contributor", Action.DELETE, False, 403, collection_role="collection_admin" + ), + # Collection editors cannot delete Collections + PermissionTest( + "Collection", "private", "contributor", Action.DELETE, False, 403, collection_role="collection_editor" + ), + PermissionTest( + "Collection", "published", "contributor", Action.DELETE, False, 403, collection_role="collection_editor" + ), + # Collection viewers cannot delete Collections + PermissionTest( + "Collection", "private", "contributor", Action.DELETE, False, 403, collection_role="collection_viewer" + ), + PermissionTest( + "Collection", "published", "contributor", Action.DELETE, False, 403, collection_role="collection_viewer" + ), + # Other users cannot delete Collections + PermissionTest("Collection", "private", "other_user", Action.DELETE, False, 404), + PermissionTest("Collection", "published", "other_user", Action.DELETE, False, 403), + # Anonymous users cannot delete Collections + PermissionTest("Collection", "private", "anonymous", Action.DELETE, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.DELETE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_delete_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_delete_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection( + test_case.entity_state, collection_role=test_case.collection_role, badge_name=test_case.collection_badge + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_delete_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionPublishActionHandler: + """Test the _handle_publish_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can publish any Collection + PermissionTest("Collection", "private", "admin", Action.PUBLISH, True), + PermissionTest("Collection", "published", "admin", Action.PUBLISH, True), + # Owners can publish any Collection they own + PermissionTest("Collection", "private", "owner", Action.PUBLISH, True), + PermissionTest("Collection", "published", "owner", Action.PUBLISH, True), + # Collection admins can publish any Collection they have admin role for + PermissionTest( + "Collection", "private", "contributor", Action.PUBLISH, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "published", "contributor", Action.PUBLISH, True, collection_role="collection_admin" + ), + # Collection editors cannot publish Collections + PermissionTest( + "Collection", "private", "contributor", Action.PUBLISH, False, 403, collection_role="collection_editor" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.PUBLISH, + False, + 403, + collection_role="collection_editor", + ), + # Collection viewers cannot publish Collections + PermissionTest( + "Collection", "private", "contributor", Action.PUBLISH, False, 403, collection_role="collection_viewer" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.PUBLISH, + False, + 403, + collection_role="collection_viewer", + ), + # Other users cannot publish Collections + PermissionTest("Collection", "private", "other_user", Action.PUBLISH, False, 404), + PermissionTest("Collection", "published", "other_user", Action.PUBLISH, False, 403), + # Anonymous users cannot publish Collections + PermissionTest("Collection", "private", "anonymous", Action.PUBLISH, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.PUBLISH, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_publish_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_publish_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_publish_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionAddExperimentActionHandler: + """Test the _handle_add_experiment_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can add experiments to any Collection + PermissionTest("Collection", "private", "admin", Action.ADD_EXPERIMENT, True), + PermissionTest("Collection", "published", "admin", Action.ADD_EXPERIMENT, True), + # Owners can add experiments to any Collection they own + PermissionTest("Collection", "private", "owner", Action.ADD_EXPERIMENT, True), + PermissionTest("Collection", "published", "owner", Action.ADD_EXPERIMENT, True), + # Collection admins can add experiments to any Collection they have admin role for + PermissionTest( + "Collection", "private", "contributor", Action.ADD_EXPERIMENT, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_EXPERIMENT, + True, + collection_role="collection_admin", + ), + # Collection editors can add experiments to any Collection they have editor role for + PermissionTest( + "Collection", "private", "contributor", Action.ADD_EXPERIMENT, True, collection_role="collection_editor" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_EXPERIMENT, + True, + collection_role="collection_editor", + ), + # Collection viewers cannot add experiments to Collections + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_EXPERIMENT, + False, + 403, + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_EXPERIMENT, + False, + 403, + collection_role="collection_viewer", + ), + # Other users cannot add experiments to Collections + PermissionTest("Collection", "private", "other_user", Action.ADD_EXPERIMENT, False, 404), + PermissionTest("Collection", "published", "other_user", Action.ADD_EXPERIMENT, False, 403), + # Anonymous users cannot add experiments to Collections + PermissionTest("Collection", "private", "anonymous", Action.ADD_EXPERIMENT, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.ADD_EXPERIMENT, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_experiment_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_experiment_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_experiment_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionAddScoreSetActionHandler: + """Test the _handle_add_score_set_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can add score sets to any Collection + PermissionTest("Collection", "private", "admin", Action.ADD_SCORE_SET, True), + PermissionTest("Collection", "published", "admin", Action.ADD_SCORE_SET, True), + # Owners can add score sets to any Collection they own + PermissionTest("Collection", "private", "owner", Action.ADD_SCORE_SET, True), + PermissionTest("Collection", "published", "owner", Action.ADD_SCORE_SET, True), + # Collection admins can add score sets to any Collection they have admin role for + PermissionTest( + "Collection", "private", "contributor", Action.ADD_SCORE_SET, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "published", "contributor", Action.ADD_SCORE_SET, True, collection_role="collection_admin" + ), + # Collection editors can add score sets to any Collection they have editor role for + PermissionTest( + "Collection", "private", "contributor", Action.ADD_SCORE_SET, True, collection_role="collection_editor" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_SCORE_SET, + True, + collection_role="collection_editor", + ), + # Collection viewers cannot add score sets to Collections + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_SCORE_SET, + False, + 403, + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_SCORE_SET, + False, + 403, + collection_role="collection_viewer", + ), + # Other users cannot add score sets to Collections + PermissionTest("Collection", "private", "other_user", Action.ADD_SCORE_SET, False, 404), + PermissionTest("Collection", "published", "other_user", Action.ADD_SCORE_SET, False, 403), + # Anonymous users cannot add score sets to Collections + PermissionTest("Collection", "private", "anonymous", Action.ADD_SCORE_SET, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.ADD_SCORE_SET, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_score_set_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_score_set_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_score_set_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionAddRoleActionHandler: + """Test the _handle_add_role_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can add roles to any Collection + PermissionTest("Collection", "private", "admin", Action.ADD_ROLE, True), + PermissionTest("Collection", "published", "admin", Action.ADD_ROLE, True), + # Owners can add roles to any Collection they own + PermissionTest("Collection", "private", "owner", Action.ADD_ROLE, True), + PermissionTest("Collection", "published", "owner", Action.ADD_ROLE, True), + # Collection admins can add roles to any Collection they have admin role for + PermissionTest( + "Collection", "private", "contributor", Action.ADD_ROLE, True, collection_role="collection_admin" + ), + PermissionTest( + "Collection", "published", "contributor", Action.ADD_ROLE, True, collection_role="collection_admin" + ), + # Collection editors cannot add roles to Collections + PermissionTest( + "Collection", "private", "contributor", Action.ADD_ROLE, False, 403, collection_role="collection_editor" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_ROLE, + False, + 403, + collection_role="collection_editor", + ), + # Collection viewers cannot add roles to Collections + PermissionTest( + "Collection", "private", "contributor", Action.ADD_ROLE, False, 403, collection_role="collection_viewer" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_ROLE, + False, + 403, + collection_role="collection_viewer", + ), + # Other users cannot add roles to Collections + PermissionTest("Collection", "private", "other_user", Action.ADD_ROLE, False, 404), + PermissionTest("Collection", "published", "other_user", Action.ADD_ROLE, False, 403), + # Anonymous users cannot add roles to Collections + PermissionTest("Collection", "private", "anonymous", Action.ADD_ROLE, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.ADD_ROLE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_role_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_role_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_role_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestCollectionAddBadgeActionHandler: + """Test the _handle_add_badge_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins can add badges to any Collection + PermissionTest("Collection", "private", "admin", Action.ADD_BADGE, True), + PermissionTest("Collection", "published", "admin", Action.ADD_BADGE, True), + # Owners cannot add badges to Collections (admin-only operation) + PermissionTest("Collection", "private", "owner", Action.ADD_BADGE, False, 403), + PermissionTest("Collection", "published", "owner", Action.ADD_BADGE, False, 403), + # Collection admins cannot add badges to Collections (system admin-only) + PermissionTest( + "Collection", "private", "contributor", Action.ADD_BADGE, False, 403, collection_role="collection_admin" + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_BADGE, + False, + 403, + collection_role="collection_admin", + ), + # Collection editors cannot add badges to Collections + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_BADGE, + False, + 403, + collection_role="collection_editor", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_BADGE, + False, + 403, + collection_role="collection_editor", + ), + # Collection viewers cannot add badges to Collections + PermissionTest( + "Collection", + "private", + "contributor", + Action.ADD_BADGE, + False, + 403, + collection_role="collection_viewer", + ), + PermissionTest( + "Collection", + "published", + "contributor", + Action.ADD_BADGE, + False, + 403, + collection_role="collection_viewer", + ), + # Other users cannot add badges to Collections + PermissionTest("Collection", "private", "other_user", Action.ADD_BADGE, False, 404), + PermissionTest("Collection", "published", "other_user", Action.ADD_BADGE, False, 403), + # Anonymous users cannot add badges to Collections + PermissionTest("Collection", "private", "anonymous", Action.ADD_BADGE, False, 404), + PermissionTest("Collection", "published", "anonymous", Action.ADD_BADGE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.collection_role if tc.collection_role else 'no_role'}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_badge_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_badge_action helper function directly.""" + assert test_case.entity_state is not None, "Collection tests must have entity_state" + collection = entity_helper.create_collection(test_case.entity_state, collection_role=test_case.collection_role) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + official_collection = collection.badge_name is not None + user_is_owner = test_case.user_type == "owner" + collection_roles = [COLLECTION_ROLE_MAP[test_case.collection_role]] if test_case.collection_role else [] + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_badge_action( + user_data, collection, private, official_collection, user_is_owner, collection_roles, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code diff --git a/tests/lib/permissions/test_core.py b/tests/lib/permissions/test_core.py new file mode 100644 index 00000000..55a99107 --- /dev/null +++ b/tests/lib/permissions/test_core.py @@ -0,0 +1,132 @@ +# ruff: noqa: E402 + +"""Tests for core permissions functionality.""" + +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + +from unittest.mock import Mock, patch + +from mavedb.lib.permissions import ( + assert_permission, + collection, + experiment, + experiment_set, + score_calibration, + score_set, + user, +) +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.core import has_permission as core_has_permission +from mavedb.lib.permissions.exceptions import PermissionException +from mavedb.lib.permissions.models import PermissionResponse +from mavedb.models.collection import Collection +from mavedb.models.experiment import Experiment +from mavedb.models.experiment_set import ExperimentSet +from mavedb.models.score_calibration import ScoreCalibration +from mavedb.models.score_set import ScoreSet +from mavedb.models.user import User + +SUPPORTED_ENTITY_TYPES = { + ScoreSet: score_set.has_permission, + Experiment: experiment.has_permission, + ExperimentSet: experiment_set.has_permission, + Collection: collection.has_permission, + User: user.has_permission, + ScoreCalibration: score_calibration.has_permission, +} + + +class TestCoreDispatcher: + """Test the core permission dispatcher functionality.""" + + @pytest.mark.parametrize("entity, handler", SUPPORTED_ENTITY_TYPES.items()) + def test_dispatcher_routes_to_correct_entity_handler(self, entity_helper, entity, handler): + """Test that the dispatcher routes requests to the correct entity-specific handler.""" + admin_user = entity_helper.create_user_data("admin") + + with ( + patch("mavedb.lib.permissions.core.type", return_value=entity), + patch( + f"mavedb.lib.permissions.core.{handler.__module__.split('.')[-1]}.{handler.__name__}", + return_value=PermissionResponse(True), + ) as mocked_handler, + ): + core_has_permission(admin_user, entity, Action.READ) + mocked_handler.assert_called_once_with(admin_user, entity, Action.READ) + + def test_dispatcher_raises_for_unsupported_entity_type(self, entity_helper): + """Test that unsupported entity types raise NotImplementedError.""" + admin_user = entity_helper.create_user_data("admin") + unsupported_entity = Mock() # Some random object + + with pytest.raises(NotImplementedError) as exc_info: + core_has_permission(admin_user, unsupported_entity, Action.READ) + + error_msg = str(exc_info.value) + assert "not implemented" in error_msg.lower() + assert "Mock" in error_msg # Should mention the actual type + assert "Supported entity types" in error_msg + + +class TestAssertPermission: + """Test the assert_permission function.""" + + def test_assert_permission_returns_result_when_permitted(self, entity_helper): + """Test that assert_permission returns the PermissionResponse when access is granted.""" + + with patch("mavedb.lib.permissions.core.has_permission", return_value=PermissionResponse(True)): + user_data = entity_helper.create_user_data("admin") + score_set = entity_helper.create_score_set("published") + + result = assert_permission(user_data, score_set, Action.READ) + + assert isinstance(result, PermissionResponse) + assert result.permitted is True + + def test_assert_permission_raises_when_denied(self, entity_helper): + """Test that assert_permission raises PermissionException when access is denied.""" + + with ( + patch( + "mavedb.lib.permissions.core.has_permission", + return_value=PermissionResponse(False, http_code=404, message="Not found"), + ), + pytest.raises(PermissionException) as exc_info, + ): + user_data = entity_helper.create_user_data("admin") + score_set = entity_helper.create_score_set("published") + + assert_permission(user_data, score_set, Action.READ) + + exception = exc_info.value + assert hasattr(exception, "http_code") + assert hasattr(exception, "message") + assert exception.http_code == 404 + assert "not found" in exception.message.lower() + + @pytest.mark.parametrize( + "http_code,message", + [ + (403, "Forbidden"), + (401, "Unauthorized"), + (404, "Not Found"), + ], + ) + def test_assert_permission_preserves_error_details(self, entity_helper, http_code, message): + """Test that assert_permission preserves HTTP codes and messages from permission check.""" + + with ( + patch( + "mavedb.lib.permissions.core.has_permission", + return_value=PermissionResponse(False, http_code=http_code, message=message), + ), + pytest.raises(PermissionException) as exc_info, + ): + user_data = entity_helper.create_user_data("admin") + score_set = entity_helper.create_score_set("published") + + assert_permission(user_data, score_set, Action.READ) + + assert exc_info.value.http_code == http_code, f"Expected {http_code} for {http_code} on {message} entity" diff --git a/tests/lib/permissions/test_experiment.py b/tests/lib/permissions/test_experiment.py new file mode 100644 index 00000000..b4e5dc24 --- /dev/null +++ b/tests/lib/permissions/test_experiment.py @@ -0,0 +1,280 @@ +# ruff: noqa: E402 + +"""Tests for Experiment permissions module.""" + +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + +from typing import Callable, List +from unittest import mock + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.experiment import ( + _handle_add_score_set_action, + _handle_delete_action, + _handle_read_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +EXPERIMENT_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.ADD_SCORE_SET: _handle_add_score_set_action, +} + +EXPERIMENT_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.ADD_EXPERIMENT, + Action.ADD_ROLE, + Action.LOOKUP, + Action.ADD_BADGE, + Action.CHANGE_RANK, + Action.SET_SCORES, + Action.PUBLISH, +] + + +def test_experiment_handles_all_actions() -> None: + """Test that all Experiment actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(EXPERIMENT_SUPPORTED_ACTIONS) + unsupported = set(EXPERIMENT_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for experiments." + + +class TestExperimentHasPermission: + """Test the main has_permission dispatcher function for Experiment entities.""" + + @pytest.mark.parametrize("action, handler", EXPERIMENT_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + experiment = entity_helper.create_experiment() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.experiment." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, experiment, action) + mock_handler.assert_called_once_with( + admin_user, + experiment, + experiment.private, + False, # admin is not the owner + False, # admin is not a contributor + [UserRole.admin], + ) + + @pytest.mark.parametrize("action", EXPERIMENT_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + experiment = entity_helper.create_experiment() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, experiment, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in EXPERIMENT_SUPPORTED_ACTIONS) + + def test_requires_private_attribute(self, entity_helper: EntityTestHelper) -> None: + """Test that ValueError is raised if Experiment.private is None.""" + experiment = entity_helper.create_experiment() + experiment.private = None + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(ValueError) as exc_info: + has_permission(admin_user, experiment, Action.READ) + + assert "private" in str(exc_info.value) + + +class TestExperimentReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can read any Experiment + PermissionTest("Experiment", "published", "admin", Action.READ, True), + PermissionTest("Experiment", "private", "admin", Action.READ, True), + # Owners can read any Experiment they own + PermissionTest("Experiment", "published", "owner", Action.READ, True), + PermissionTest("Experiment", "private", "owner", Action.READ, True), + # Contributors can read any Experiment they contribute to + PermissionTest("Experiment", "published", "contributor", Action.READ, True), + PermissionTest("Experiment", "private", "contributor", Action.READ, True), + # Mappers can read any Experiment (including private) + PermissionTest("Experiment", "published", "mapper", Action.READ, True), + PermissionTest("Experiment", "private", "mapper", Action.READ, True), + # Other users can only read published Experiments + PermissionTest("Experiment", "published", "other_user", Action.READ, True), + PermissionTest("Experiment", "private", "other_user", Action.READ, False, 404), + # Anonymous users can only read published Experiments + PermissionTest("Experiment", "published", "anonymous", Action.READ, True), + PermissionTest("Experiment", "private", "anonymous", Action.READ, False, 404), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + assert test_case.entity_state is not None, "Experiment tests must have entity_state" + experiment = entity_helper.create_experiment(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action(user_data, experiment, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can update any Experiment + PermissionTest("Experiment", "private", "admin", Action.UPDATE, True), + PermissionTest("Experiment", "published", "admin", Action.UPDATE, True), + # Owners can update any Experiment they own + PermissionTest("Experiment", "private", "owner", Action.UPDATE, True), + PermissionTest("Experiment", "published", "owner", Action.UPDATE, True), + # Contributors can update any Experiment they contribute to + PermissionTest("Experiment", "private", "contributor", Action.UPDATE, True), + PermissionTest("Experiment", "published", "contributor", Action.UPDATE, True), + # Mappers cannot update Experiments + PermissionTest("Experiment", "private", "mapper", Action.UPDATE, False, 404), + PermissionTest("Experiment", "published", "mapper", Action.UPDATE, False, 403), + # Other users cannot update Experiments + PermissionTest("Experiment", "private", "other_user", Action.UPDATE, False, 404), + PermissionTest("Experiment", "published", "other_user", Action.UPDATE, False, 403), + # Anonymous users cannot update Experiments + PermissionTest("Experiment", "private", "anonymous", Action.UPDATE, False, 404), + PermissionTest("Experiment", "published", "anonymous", Action.UPDATE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + assert test_case.entity_state is not None, "Experiment tests must have entity_state" + experiment = entity_helper.create_experiment(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action(user_data, experiment, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentDeleteActionHandler: + """Test the _handle_delete_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can delete any Experiment + PermissionTest("Experiment", "private", "admin", Action.DELETE, True), + PermissionTest("Experiment", "published", "admin", Action.DELETE, True), + # Owners can only delete unpublished Experiments + PermissionTest("Experiment", "private", "owner", Action.DELETE, True), + PermissionTest("Experiment", "published", "owner", Action.DELETE, False, 403), + # Contributors cannot delete + PermissionTest("Experiment", "private", "contributor", Action.DELETE, False, 403), + PermissionTest("Experiment", "published", "contributor", Action.DELETE, False, 403), + # Other users cannot delete + PermissionTest("Experiment", "private", "other_user", Action.DELETE, False, 404), + PermissionTest("Experiment", "published", "other_user", Action.DELETE, False, 403), + # Anonymous users cannot delete + PermissionTest("Experiment", "private", "anonymous", Action.DELETE, False, 404), + PermissionTest("Experiment", "published", "anonymous", Action.DELETE, False, 401), + # Mappers cannot delete + PermissionTest("Experiment", "private", "mapper", Action.DELETE, False, 404), + PermissionTest("Experiment", "published", "mapper", Action.DELETE, False, 403), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_delete_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_delete_action helper function directly.""" + assert test_case.entity_state is not None, "Experiment tests must have entity_state" + experiment = entity_helper.create_experiment(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_delete_action(user_data, experiment, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentAddScoreSetActionHandler: + """Test the _handle_add_score_set_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can add score sets to any Experiment + PermissionTest("Experiment", "private", "admin", Action.ADD_SCORE_SET, True), + PermissionTest("Experiment", "published", "admin", Action.ADD_SCORE_SET, True), + # Owners can add score sets to any Experiment they own + PermissionTest("Experiment", "private", "owner", Action.ADD_SCORE_SET, True), + PermissionTest("Experiment", "published", "owner", Action.ADD_SCORE_SET, True), + # Contributors can add score sets to any Experiment they contribute to + PermissionTest("Experiment", "private", "contributor", Action.ADD_SCORE_SET, True), + PermissionTest("Experiment", "published", "contributor", Action.ADD_SCORE_SET, True), + # Mappers can add score sets to public Experiments + PermissionTest("Experiment", "private", "mapper", Action.ADD_SCORE_SET, False, 404), + PermissionTest("Experiment", "published", "mapper", Action.ADD_SCORE_SET, True), + # Other users can add score sets to public Experiments + PermissionTest("Experiment", "private", "other_user", Action.ADD_SCORE_SET, False, 404), + PermissionTest("Experiment", "published", "other_user", Action.ADD_SCORE_SET, True), + # Anonymous users cannot add score sets to Experiments + PermissionTest("Experiment", "private", "anonymous", Action.ADD_SCORE_SET, False, 404), + PermissionTest("Experiment", "published", "anonymous", Action.ADD_SCORE_SET, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_score_set_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_score_set_action helper function directly.""" + assert test_case.entity_state is not None, "Experiment tests must have entity_state" + experiment = entity_helper.create_experiment(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_score_set_action( + user_data, experiment, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code diff --git a/tests/lib/permissions/test_experiment_set.py b/tests/lib/permissions/test_experiment_set.py new file mode 100644 index 00000000..adf109fb --- /dev/null +++ b/tests/lib/permissions/test_experiment_set.py @@ -0,0 +1,286 @@ +# ruff: noqa: E402 + +"""Tests for ExperimentSet permissions module.""" + +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + +from typing import Callable, List +from unittest import mock + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.experiment_set import ( + _handle_add_experiment_action, + _handle_delete_action, + _handle_read_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +EXPERIMENT_SET_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.ADD_EXPERIMENT: _handle_add_experiment_action, +} + +EXPERIMENT_SET_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.ADD_SCORE_SET, + Action.ADD_ROLE, + Action.LOOKUP, + Action.ADD_BADGE, + Action.CHANGE_RANK, + Action.SET_SCORES, + Action.PUBLISH, +] + + +def test_experiment_set_handles_all_actions() -> None: + """Test that all ExperimentSet actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(EXPERIMENT_SET_SUPPORTED_ACTIONS) + unsupported = set(EXPERIMENT_SET_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for experiment sets." + + +class TestExperimentSetHasPermission: + """Test the main has_permission dispatcher function for ExperimentSet entities.""" + + @pytest.mark.parametrize("action, handler", EXPERIMENT_SET_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + experiment_set = entity_helper.create_experiment_set() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.experiment_set." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, experiment_set, action) + mock_handler.assert_called_once_with( + admin_user, + experiment_set, + experiment_set.private, + False, # admin is not the owner + False, # admin is not a contributor + [UserRole.admin], + ) + + @pytest.mark.parametrize("action", EXPERIMENT_SET_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + experiment_set = entity_helper.create_experiment_set() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, experiment_set, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in EXPERIMENT_SET_SUPPORTED_ACTIONS) + + def test_requires_private_attribute(self, entity_helper: EntityTestHelper) -> None: + """Test that ValueError is raised if ExperimentSet.private is None.""" + experiment_set = entity_helper.create_experiment_set() + experiment_set.private = None + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(ValueError) as exc_info: + has_permission(admin_user, experiment_set, Action.READ) + + assert "private" in str(exc_info.value) + + +class TestExperimentSetReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can read any ExperimentSet + PermissionTest("ExperimentSet", "published", "admin", Action.READ, True), + PermissionTest("ExperimentSet", "private", "admin", Action.READ, True), + # Owners can read any ExperimentSet they own + PermissionTest("ExperimentSet", "published", "owner", Action.READ, True), + PermissionTest("ExperimentSet", "private", "owner", Action.READ, True), + # Contributors can read any ExperimentSet they contribute to + PermissionTest("ExperimentSet", "published", "contributor", Action.READ, True), + PermissionTest("ExperimentSet", "private", "contributor", Action.READ, True), + # Mappers can read any ExperimentSet (including private) + PermissionTest("ExperimentSet", "published", "mapper", Action.READ, True), + PermissionTest("ExperimentSet", "private", "mapper", Action.READ, True), + # Other users can only read published ExperimentSets + PermissionTest("ExperimentSet", "published", "other_user", Action.READ, True), + PermissionTest("ExperimentSet", "private", "other_user", Action.READ, False, 404), + # Anonymous users can only read published ExperimentSets + PermissionTest("ExperimentSet", "published", "anonymous", Action.READ, True), + PermissionTest("ExperimentSet", "private", "anonymous", Action.READ, False, 404), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + assert test_case.entity_state is not None, "ExperimentSet tests must have entity_state" + experiment_set = entity_helper.create_experiment_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action( + user_data, experiment_set, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentSetUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can update any ExperimentSet + PermissionTest("ExperimentSet", "private", "admin", Action.UPDATE, True), + PermissionTest("ExperimentSet", "published", "admin", Action.UPDATE, True), + # Owners can update any ExperimentSet they own + PermissionTest("ExperimentSet", "private", "owner", Action.UPDATE, True), + PermissionTest("ExperimentSet", "published", "owner", Action.UPDATE, True), + # Contributors can update any ExperimentSet they contribute to + PermissionTest("ExperimentSet", "private", "contributor", Action.UPDATE, True), + PermissionTest("ExperimentSet", "published", "contributor", Action.UPDATE, True), + # Mappers cannot update ExperimentSets + PermissionTest("ExperimentSet", "private", "mapper", Action.UPDATE, False, 404), + PermissionTest("ExperimentSet", "published", "mapper", Action.UPDATE, False, 403), + # Other users cannot update ExperimentSets + PermissionTest("ExperimentSet", "private", "other_user", Action.UPDATE, False, 404), + PermissionTest("ExperimentSet", "published", "other_user", Action.UPDATE, False, 403), + # Anonymous users cannot update ExperimentSets + PermissionTest("ExperimentSet", "private", "anonymous", Action.UPDATE, False, 404), + PermissionTest("ExperimentSet", "published", "anonymous", Action.UPDATE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + assert test_case.entity_state is not None, "ExperimentSet tests must have entity_state" + experiment_set = entity_helper.create_experiment_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action( + user_data, experiment_set, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentSetDeleteActionHandler: + """Test the _handle_delete_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can delete any ExperimentSet + PermissionTest("ExperimentSet", "private", "admin", Action.DELETE, True), + PermissionTest("ExperimentSet", "published", "admin", Action.DELETE, True), + # Owners can only delete unpublished ExperimentSets + PermissionTest("ExperimentSet", "private", "owner", Action.DELETE, True), + PermissionTest("ExperimentSet", "published", "owner", Action.DELETE, False, 403), + # Contributors cannot delete + PermissionTest("ExperimentSet", "private", "contributor", Action.DELETE, False, 403), + PermissionTest("ExperimentSet", "published", "contributor", Action.DELETE, False, 403), + # Other users cannot delete + PermissionTest("ExperimentSet", "private", "other_user", Action.DELETE, False, 404), + PermissionTest("ExperimentSet", "published", "other_user", Action.DELETE, False, 403), + # Anonymous users cannot delete + PermissionTest("ExperimentSet", "private", "anonymous", Action.DELETE, False, 404), + PermissionTest("ExperimentSet", "published", "anonymous", Action.DELETE, False, 401), + # Mappers cannot delete + PermissionTest("ExperimentSet", "private", "mapper", Action.DELETE, False, 404), + PermissionTest("ExperimentSet", "published", "mapper", Action.DELETE, False, 403), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_delete_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_delete_action helper function directly.""" + assert test_case.entity_state is not None, "ExperimentSet tests must have entity_state" + experiment_set = entity_helper.create_experiment_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_delete_action( + user_data, experiment_set, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestExperimentSetAddExperimentActionHandler: + """Test the _handle_add_experiment_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can add experiments to any ExperimentSet + PermissionTest("ExperimentSet", "private", "admin", Action.ADD_EXPERIMENT, True), + PermissionTest("ExperimentSet", "published", "admin", Action.ADD_EXPERIMENT, True), + # Owners can add experiments to any ExperimentSet they own + PermissionTest("ExperimentSet", "private", "owner", Action.ADD_EXPERIMENT, True), + PermissionTest("ExperimentSet", "published", "owner", Action.ADD_EXPERIMENT, True), + # Contributors can add experiments to any ExperimentSet they contribute to + PermissionTest("ExperimentSet", "private", "contributor", Action.ADD_EXPERIMENT, True), + PermissionTest("ExperimentSet", "published", "contributor", Action.ADD_EXPERIMENT, True), + # Mappers cannot add experiments to ExperimentSets + PermissionTest("ExperimentSet", "private", "mapper", Action.ADD_EXPERIMENT, False, 404), + PermissionTest("ExperimentSet", "published", "mapper", Action.ADD_EXPERIMENT, False, 403), + # Other users cannot add experiments to ExperimentSets + PermissionTest("ExperimentSet", "private", "other_user", Action.ADD_EXPERIMENT, False, 404), + PermissionTest("ExperimentSet", "published", "other_user", Action.ADD_EXPERIMENT, False, 403), + # Anonymous users cannot add experiments to ExperimentSets + PermissionTest("ExperimentSet", "private", "anonymous", Action.ADD_EXPERIMENT, False, 404), + PermissionTest("ExperimentSet", "published", "anonymous", Action.ADD_EXPERIMENT, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_experiment_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_experiment_action helper function directly.""" + assert test_case.entity_state is not None, "ExperimentSet tests must have entity_state" + experiment_set = entity_helper.create_experiment_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_experiment_action( + user_data, experiment_set, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code diff --git a/tests/lib/permissions/test_models.py b/tests/lib/permissions/test_models.py new file mode 100644 index 00000000..7627d56a --- /dev/null +++ b/tests/lib/permissions/test_models.py @@ -0,0 +1,45 @@ +# ruff: noqa: E402 + +"""Tests for permissions models module.""" + +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + +from mavedb.lib.permissions.models import PermissionResponse + + +class TestPermissionResponse: + """Test the PermissionResponse class.""" + + def test_permitted_response_creation(self): + """Test creating a PermissionResponse for permitted access.""" + response = PermissionResponse(permitted=True) + + assert response.permitted is True + assert response.http_code is None + assert response.message is None + + def test_denied_response_creation_with_defaults(self): + """Test creating a PermissionResponse for denied access with default values.""" + response = PermissionResponse(permitted=False) + + assert response.permitted is False + assert response.http_code == 403 + assert response.message is None + + def test_denied_response_creation_with_custom_values(self): + """Test creating a PermissionResponse for denied access with custom values.""" + response = PermissionResponse(permitted=False, http_code=404, message="Resource not found") + + assert response.permitted is False + assert response.http_code == 404 + assert response.message == "Resource not found" + + def test_permitted_response_ignores_error_parameters(self): + """Test that permitted responses ignore http_code and message parameters.""" + response = PermissionResponse(permitted=True, http_code=404, message="This should be ignored") + + assert response.permitted is True + assert response.http_code is None + assert response.message is None diff --git a/tests/lib/permissions/test_score_calibration.py b/tests/lib/permissions/test_score_calibration.py new file mode 100644 index 00000000..a3384368 --- /dev/null +++ b/tests/lib/permissions/test_score_calibration.py @@ -0,0 +1,554 @@ +# ruff: noqa: E402 + +"""Tests for ScoreCalibration permissions module.""" + +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + +from typing import Callable, List +from unittest import mock + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.score_calibration import ( + _handle_change_rank_action, + _handle_delete_action, + _handle_publish_action, + _handle_read_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +SCORE_CALIBRATION_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.PUBLISH: _handle_publish_action, + Action.CHANGE_RANK: _handle_change_rank_action, +} + +SCORE_CALIBRATION_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.ADD_EXPERIMENT, + Action.ADD_SCORE_SET, + Action.ADD_ROLE, + Action.LOOKUP, + Action.ADD_BADGE, + Action.SET_SCORES, +] + + +def test_score_calibration_handles_all_actions() -> None: + """Test that all ScoreCalibration actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(SCORE_CALIBRATION_SUPPORTED_ACTIONS) + unsupported = set(SCORE_CALIBRATION_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for score calibrations." + + +class TestScoreCalibrationHasPermission: + """Test the main has_permission dispatcher function for ScoreCalibration entities.""" + + @pytest.mark.parametrize("action, handler", SCORE_CALIBRATION_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + score_calibration = entity_helper.create_score_calibration() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.score_calibration." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, score_calibration, action) + mock_handler.assert_called_once_with( + admin_user, + score_calibration, + False, # admin is not the owner + False, # admin is not a contributor to score set + score_calibration.private, + [UserRole.admin], + ) + + @pytest.mark.parametrize("action", SCORE_CALIBRATION_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + score_calibration = entity_helper.create_score_calibration() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, score_calibration, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in SCORE_CALIBRATION_SUPPORTED_ACTIONS) + + def test_requires_private_attribute(self, entity_helper: EntityTestHelper) -> None: + """Test that ValueError is raised if ScoreCalibration.private is None.""" + score_calibration = entity_helper.create_score_calibration() + score_calibration.private = None + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(ValueError) as exc_info: + has_permission(admin_user, score_calibration, Action.READ) + + assert "private" in str(exc_info.value) + + +class TestScoreCalibrationReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins: Can read any ScoreCalibration regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "published", "admin", Action.READ, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "admin", Action.READ, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "private", "admin", Action.READ, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "admin", Action.READ, True, investigator_provided=False), + # Owners: Can read any ScoreCalibration they created regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "published", "owner", Action.READ, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "owner", Action.READ, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "private", "owner", Action.READ, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "owner", Action.READ, True, investigator_provided=False), + # Contributors to associated ScoreSet: Can read published ScoreCalibrations (any type) and private investigator-provided ScoreCalibrations, but NOT private community-provided ones + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.READ, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.READ, True, investigator_provided=False + ), + PermissionTest("ScoreCalibration", "private", "contributor", Action.READ, True, investigator_provided=True), + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.READ, False, 404, investigator_provided=False + ), + # Other users: Can only read published ScoreCalibrations, cannot access any private ones + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.READ, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.READ, True, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.READ, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.READ, False, 404, investigator_provided=False + ), + # Anonymous users: Can only read published ScoreCalibrations, cannot access any private ones + PermissionTest("ScoreCalibration", "published", "anonymous", Action.READ, True, investigator_provided=True), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.READ, True, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.READ, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.READ, False, 404, investigator_provided=False + ), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{'investigator' if tc.investigator_provided else 'community'}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreCalibration tests must have entity_state" + assert test_case.investigator_provided is not None, "ScoreCalibration tests must have investigator_provided" + score_calibration = entity_helper.create_score_calibration( + test_case.entity_state, test_case.investigator_provided + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor_to_score_set = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action( + user_data, score_calibration, user_is_owner, user_is_contributor_to_score_set, private, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreCalibrationUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins: Can update any ScoreCalibration regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "private", "admin", Action.UPDATE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "admin", Action.UPDATE, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "published", "admin", Action.UPDATE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "admin", Action.UPDATE, True, investigator_provided=False), + # Owners: Can update only their own private ScoreCalibrations, cannot update published ones (even their own) + PermissionTest("ScoreCalibration", "private", "owner", Action.UPDATE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "owner", Action.UPDATE, True, investigator_provided=False), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.UPDATE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.UPDATE, False, 403, investigator_provided=False + ), + # Contributors to associated ScoreSet: Can update only private investigator-provided ScoreCalibrations, cannot update community-provided or published ones + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.UPDATE, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.UPDATE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.UPDATE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.UPDATE, False, 403, investigator_provided=False + ), + # Other users: Cannot update any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.UPDATE, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.UPDATE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.UPDATE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.UPDATE, False, 403, investigator_provided=False + ), + # Anonymous users: Cannot update any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.UPDATE, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.UPDATE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.UPDATE, False, 401, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.UPDATE, False, 401, investigator_provided=False + ), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{'investigator' if tc.investigator_provided else 'community'}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreCalibration tests must have entity_state" + assert test_case.investigator_provided is not None, "ScoreCalibration tests must have investigator_provided" + score_calibration = entity_helper.create_score_calibration( + test_case.entity_state, test_case.investigator_provided + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor_to_score_set = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action( + user_data, score_calibration, user_is_owner, user_is_contributor_to_score_set, private, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreCalibrationDeleteActionHandler: + """Test the _handle_delete_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins: Can delete any ScoreCalibration regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "private", "admin", Action.DELETE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "admin", Action.DELETE, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "published", "admin", Action.DELETE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "admin", Action.DELETE, True, investigator_provided=False), + # Owners: Can delete only their own private ScoreCalibrations, cannot delete published ones (even their own) + PermissionTest("ScoreCalibration", "private", "owner", Action.DELETE, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "owner", Action.DELETE, True, investigator_provided=False), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.DELETE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.DELETE, False, 403, investigator_provided=False + ), + # Contributors to associated ScoreSet: Cannot delete any ScoreCalibrations (even investigator-provided ones they can read/update) + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.DELETE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.DELETE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.DELETE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.DELETE, False, 403, investigator_provided=False + ), + # Other users: Cannot delete any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.DELETE, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.DELETE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.DELETE, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.DELETE, False, 403, investigator_provided=False + ), + # Anonymous users: Cannot delete any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.DELETE, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.DELETE, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.DELETE, False, 401, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.DELETE, False, 401, investigator_provided=False + ), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{'investigator' if tc.investigator_provided else 'community'}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_delete_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_delete_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreCalibration tests must have entity_state" + assert test_case.investigator_provided is not None, "ScoreCalibration tests must have investigator_provided" + score_calibration = entity_helper.create_score_calibration( + test_case.entity_state, test_case.investigator_provided + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor_to_score_set = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_delete_action( + user_data, score_calibration, user_is_owner, user_is_contributor_to_score_set, private, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreCalibrationPublishActionHandler: + """Test the _handle_publish_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins: Can publish any ScoreCalibration regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "private", "admin", Action.PUBLISH, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "admin", Action.PUBLISH, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "published", "admin", Action.PUBLISH, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "admin", Action.PUBLISH, True, investigator_provided=False), + # Owners: Can publish their own ScoreCalibrations regardless of state or investigator_provided flag + PermissionTest("ScoreCalibration", "private", "owner", Action.PUBLISH, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "private", "owner", Action.PUBLISH, True, investigator_provided=False), + PermissionTest("ScoreCalibration", "published", "owner", Action.PUBLISH, True, investigator_provided=True), + PermissionTest("ScoreCalibration", "published", "owner", Action.PUBLISH, True, investigator_provided=False), + # Contributors to associated ScoreSet: Cannot publish any ScoreCalibrations (even investigator-provided ones they can read/update) + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.PUBLISH, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.PUBLISH, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.PUBLISH, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.PUBLISH, False, 403, investigator_provided=False + ), + # Other users: Cannot publish any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.PUBLISH, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.PUBLISH, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.PUBLISH, False, 403, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "other_user", Action.PUBLISH, False, 403, investigator_provided=False + ), + # Anonymous users: Cannot publish any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.PUBLISH, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.PUBLISH, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.PUBLISH, False, 401, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.PUBLISH, False, 401, investigator_provided=False + ), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{'investigator' if tc.investigator_provided else 'community'}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_publish_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_publish_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreCalibration tests must have entity_state" + assert test_case.investigator_provided is not None, "ScoreCalibration tests must have investigator_provided" + score_calibration = entity_helper.create_score_calibration( + test_case.entity_state, test_case.investigator_provided + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor_to_score_set = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_publish_action( + user_data, score_calibration, user_is_owner, user_is_contributor_to_score_set, private, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreCalibrationChangeRankActionHandler: + """Test the _handle_change_rank_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # System admins: Can change rank of any ScoreCalibration regardless of state or investigator_provided flag + PermissionTest( + "ScoreCalibration", "private", "admin", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "admin", Action.CHANGE_RANK, True, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "admin", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "admin", Action.CHANGE_RANK, True, investigator_provided=False + ), + # Owners: Can change rank of their own ScoreCalibrations regardless of state or investigator_provided flag + PermissionTest( + "ScoreCalibration", "private", "owner", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "owner", Action.CHANGE_RANK, True, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "published", "owner", Action.CHANGE_RANK, True, investigator_provided=False + ), + # Contributors to associated ScoreSet: Can change rank of investigator-provided ScoreCalibrations (private or published), but cannot change rank of community-provided ones + PermissionTest( + "ScoreCalibration", "private", "contributor", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", + "private", + "contributor", + Action.CHANGE_RANK, + False, + 404, + investigator_provided=False, + ), + PermissionTest( + "ScoreCalibration", "published", "contributor", Action.CHANGE_RANK, True, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", + "published", + "contributor", + Action.CHANGE_RANK, + False, + 403, + investigator_provided=False, + ), + # Other users: Cannot change rank of any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.CHANGE_RANK, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "other_user", Action.CHANGE_RANK, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", + "published", + "other_user", + Action.CHANGE_RANK, + False, + 403, + investigator_provided=True, + ), + PermissionTest( + "ScoreCalibration", + "published", + "other_user", + Action.CHANGE_RANK, + False, + 403, + investigator_provided=False, + ), + # Anonymous users: Cannot change rank of any ScoreCalibrations + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.CHANGE_RANK, False, 404, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", "private", "anonymous", Action.CHANGE_RANK, False, 404, investigator_provided=False + ), + PermissionTest( + "ScoreCalibration", "published", "anonymous", Action.CHANGE_RANK, False, 401, investigator_provided=True + ), + PermissionTest( + "ScoreCalibration", + "published", + "anonymous", + Action.CHANGE_RANK, + False, + 401, + investigator_provided=False, + ), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{'investigator' if tc.investigator_provided else 'community'}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_change_rank_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_change_rank_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreCalibration tests must have entity_state" + assert test_case.investigator_provided is not None, "ScoreCalibration tests must have investigator_provided" + score_calibration = entity_helper.create_score_calibration( + test_case.entity_state, test_case.investigator_provided + ) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor_to_score_set = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_change_rank_action( + user_data, score_calibration, user_is_owner, user_is_contributor_to_score_set, private, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code diff --git a/tests/lib/permissions/test_score_set.py b/tests/lib/permissions/test_score_set.py new file mode 100644 index 00000000..2349359f --- /dev/null +++ b/tests/lib/permissions/test_score_set.py @@ -0,0 +1,326 @@ +# ruff: noqa: E402 + +"""Tests for ScoreSet permissions module.""" + +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + +from typing import Callable, List +from unittest import mock + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.score_set import ( + _handle_delete_action, + _handle_publish_action, + _handle_read_action, + _handle_set_scores_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +SCORE_SET_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.DELETE: _handle_delete_action, + Action.SET_SCORES: _handle_set_scores_action, + Action.PUBLISH: _handle_publish_action, +} + +SCORE_SET_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.ADD_EXPERIMENT, + Action.ADD_SCORE_SET, + Action.ADD_ROLE, + Action.LOOKUP, + Action.ADD_BADGE, + Action.CHANGE_RANK, +] + + +def test_score_set_handles_all_actions() -> None: + """Test that all ScoreSet actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(SCORE_SET_SUPPORTED_ACTIONS) + unsupported = set(SCORE_SET_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for score sets." + + +class TestScoreSetHasPermission: + """Test the main has_permission dispatcher function for ScoreSet entities.""" + + @pytest.mark.parametrize("action, handler", SCORE_SET_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + score_set = entity_helper.create_score_set() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.score_set." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, score_set, action) + mock_handler.assert_called_once_with( + admin_user, + score_set, + score_set.private, + False, # admin is not the owner + False, # admin is not a contributor + [UserRole.admin], + ) + + @pytest.mark.parametrize("action", SCORE_SET_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + score_set = entity_helper.create_score_set() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, score_set, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in SCORE_SET_SUPPORTED_ACTIONS) + + def test_requires_private_attribute(self, entity_helper: EntityTestHelper) -> None: + """Test that ValueError is raised if ScoreSet.private is None.""" + score_set = entity_helper.create_score_set() + score_set.private = None + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(ValueError) as exc_info: + has_permission(admin_user, score_set, Action.READ) + + assert "private" in str(exc_info.value) + + +class TestScoreSetReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can read any ScoreSet + PermissionTest("ScoreSet", "published", "admin", Action.READ, True), + PermissionTest("ScoreSet", "private", "admin", Action.READ, True), + # Owners can read any ScoreSet they own + PermissionTest("ScoreSet", "published", "owner", Action.READ, True), + PermissionTest("ScoreSet", "private", "owner", Action.READ, True), + # Contributors can read any ScoreSet they contribute to + PermissionTest("ScoreSet", "published", "contributor", Action.READ, True), + PermissionTest("ScoreSet", "private", "contributor", Action.READ, True), + # Mappers can read any ScoreSet (including private) + PermissionTest("ScoreSet", "published", "mapper", Action.READ, True), + PermissionTest("ScoreSet", "private", "mapper", Action.READ, True), + # Other users can only read published ScoreSets + PermissionTest("ScoreSet", "published", "other_user", Action.READ, True), + PermissionTest("ScoreSet", "private", "other_user", Action.READ, False, 404), + # Anonymous users can only read published ScoreSets + PermissionTest("ScoreSet", "published", "anonymous", Action.READ, True), + PermissionTest("ScoreSet", "private", "anonymous", Action.READ, False, 404), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreSet tests must have entity_state" + score_set = entity_helper.create_score_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action(user_data, score_set, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreSetUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can update any ScoreSet + PermissionTest("ScoreSet", "private", "admin", Action.UPDATE, True), + PermissionTest("ScoreSet", "published", "admin", Action.UPDATE, True), + # Owners can update any ScoreSet they own + PermissionTest("ScoreSet", "private", "owner", Action.UPDATE, True), + PermissionTest("ScoreSet", "published", "owner", Action.UPDATE, True), + # Contributors can update any ScoreSet they contribute to + PermissionTest("ScoreSet", "private", "contributor", Action.UPDATE, True), + PermissionTest("ScoreSet", "published", "contributor", Action.UPDATE, True), + # Mappers cannot update ScoreSets + PermissionTest("ScoreSet", "private", "mapper", Action.UPDATE, False, 404), + PermissionTest("ScoreSet", "published", "mapper", Action.UPDATE, False, 403), + # Other users cannot update ScoreSets + PermissionTest("ScoreSet", "private", "other_user", Action.UPDATE, False, 404), + PermissionTest("ScoreSet", "published", "other_user", Action.UPDATE, False, 403), + # Anonymous users cannot update ScoreSets + PermissionTest("ScoreSet", "private", "anonymous", Action.UPDATE, False, 404), + PermissionTest("ScoreSet", "published", "anonymous", Action.UPDATE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreSet tests must have entity_state" + score_set = entity_helper.create_score_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action(user_data, score_set, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreSetDeleteActionHandler: + """Test the _handle_delete_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can delete any ScoreSet + PermissionTest("ScoreSet", "private", "admin", Action.DELETE, True), + PermissionTest("ScoreSet", "published", "admin", Action.DELETE, True), + # Owners can only delete unpublished ScoreSets + PermissionTest("ScoreSet", "private", "owner", Action.DELETE, True), + PermissionTest("ScoreSet", "published", "owner", Action.DELETE, False, 403), + # Contributors cannot delete + PermissionTest("ScoreSet", "private", "contributor", Action.DELETE, False, 403), + PermissionTest("ScoreSet", "published", "contributor", Action.DELETE, False, 403), + # Other users cannot delete + PermissionTest("ScoreSet", "private", "other_user", Action.DELETE, False, 404), + PermissionTest("ScoreSet", "published", "other_user", Action.DELETE, False, 403), + # Anonymous users cannot delete + PermissionTest("ScoreSet", "private", "anonymous", Action.DELETE, False, 404), + PermissionTest("ScoreSet", "published", "anonymous", Action.DELETE, False, 401), + # Mappers cannot delete + PermissionTest("ScoreSet", "private", "mapper", Action.DELETE, False, 404), + PermissionTest("ScoreSet", "published", "mapper", Action.DELETE, False, 403), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_delete_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_delete_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreSet tests must have entity_state" + score_set = entity_helper.create_score_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_delete_action(user_data, score_set, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreSetSetScoresActionHandler: + """Test the _handle_set_scores_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can set scores on any ScoreSet + PermissionTest("ScoreSet", "private", "admin", Action.SET_SCORES, True), + PermissionTest("ScoreSet", "published", "admin", Action.SET_SCORES, True), + # Owners can set scores on any ScoreSet they own + PermissionTest("ScoreSet", "private", "owner", Action.SET_SCORES, True), + PermissionTest("ScoreSet", "published", "owner", Action.SET_SCORES, True), + # Contributors can set scores on any ScoreSet they contribute to + PermissionTest("ScoreSet", "private", "contributor", Action.SET_SCORES, True), + PermissionTest("ScoreSet", "published", "contributor", Action.SET_SCORES, True), + # Mappers cannot set scores on ScoreSets + PermissionTest("ScoreSet", "private", "mapper", Action.SET_SCORES, False, 404), + PermissionTest("ScoreSet", "published", "mapper", Action.SET_SCORES, False, 403), + # Other users cannot set scores on ScoreSets + PermissionTest("ScoreSet", "private", "other_user", Action.SET_SCORES, False, 404), + PermissionTest("ScoreSet", "published", "other_user", Action.SET_SCORES, False, 403), + # Anonymous users cannot set scores on ScoreSets + PermissionTest("ScoreSet", "private", "anonymous", Action.SET_SCORES, False, 404), + PermissionTest("ScoreSet", "published", "anonymous", Action.SET_SCORES, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_set_scores_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_set_scores_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreSet tests must have entity_state" + score_set = entity_helper.create_score_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_set_scores_action( + user_data, score_set, private, user_is_owner, user_is_contributor, active_roles + ) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestScoreSetPublishActionHandler: + """Test the _handle_publish_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can publish any ScoreSet + PermissionTest("ScoreSet", "private", "admin", Action.PUBLISH, True), + PermissionTest("ScoreSet", "published", "admin", Action.PUBLISH, True), + # Owners can publish any ScoreSet they own + PermissionTest("ScoreSet", "private", "owner", Action.PUBLISH, True), + PermissionTest("ScoreSet", "published", "owner", Action.PUBLISH, True), + # Contributors cannot publish ScoreSets they contribute to + PermissionTest("ScoreSet", "private", "contributor", Action.PUBLISH, False, 403), + PermissionTest("ScoreSet", "published", "contributor", Action.PUBLISH, False, 403), + # Mappers cannot publish ScoreSets + PermissionTest("ScoreSet", "private", "mapper", Action.PUBLISH, False, 404), + PermissionTest("ScoreSet", "published", "mapper", Action.PUBLISH, False, 403), + # Other users cannot publish ScoreSets + PermissionTest("ScoreSet", "private", "other_user", Action.PUBLISH, False, 404), + PermissionTest("ScoreSet", "published", "other_user", Action.PUBLISH, False, 403), + # Anonymous users cannot publish ScoreSets + PermissionTest("ScoreSet", "private", "anonymous", Action.PUBLISH, False, 404), + PermissionTest("ScoreSet", "published", "anonymous", Action.PUBLISH, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.entity_state}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_publish_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_publish_action helper function directly.""" + assert test_case.entity_state is not None, "ScoreSet tests must have entity_state" + score_set = entity_helper.create_score_set(test_case.entity_state) + user_data = entity_helper.create_user_data(test_case.user_type) + + private = test_case.entity_state == "private" + user_is_owner = test_case.user_type == "owner" + user_is_contributor = test_case.user_type == "contributor" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_publish_action(user_data, score_set, private, user_is_owner, user_is_contributor, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code diff --git a/tests/lib/permissions/test_user.py b/tests/lib/permissions/test_user.py new file mode 100644 index 00000000..b4efa876 --- /dev/null +++ b/tests/lib/permissions/test_user.py @@ -0,0 +1,237 @@ +# ruff: noqa: E402 + +"""Tests for User permissions module.""" + +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + +from typing import Callable, List +from unittest import mock + +from mavedb.lib.permissions.actions import Action +from mavedb.lib.permissions.user import ( + _handle_add_role_action, + _handle_lookup_action, + _handle_read_action, + _handle_update_action, + has_permission, +) +from mavedb.models.enums.user_role import UserRole +from tests.lib.permissions.conftest import EntityTestHelper, PermissionTest + +USER_SUPPORTED_ACTIONS: dict[Action, Callable] = { + Action.READ: _handle_read_action, + Action.UPDATE: _handle_update_action, + Action.LOOKUP: _handle_lookup_action, + Action.ADD_ROLE: _handle_add_role_action, +} + +USER_UNSUPPORTED_ACTIONS: List[Action] = [ + Action.DELETE, + Action.ADD_EXPERIMENT, + Action.ADD_SCORE_SET, + Action.ADD_BADGE, + Action.CHANGE_RANK, + Action.SET_SCORES, + Action.PUBLISH, +] + + +def test_user_handles_all_actions() -> None: + """Test that all User actions are either supported or explicitly unsupported.""" + all_actions = set(action for action in Action) + supported = set(USER_SUPPORTED_ACTIONS) + unsupported = set(USER_UNSUPPORTED_ACTIONS) + + assert ( + supported.union(unsupported) == all_actions + ), "Some actions are not categorized as supported or unsupported for users." + + +class TestUserHasPermission: + """Test the main has_permission dispatcher function for User entities.""" + + @pytest.mark.parametrize("action, handler", USER_SUPPORTED_ACTIONS.items()) + def test_supported_actions_route_to_correct_action_handler( + self, entity_helper: EntityTestHelper, action: Action, handler: Callable + ) -> None: + """Test that has_permission routes supported actions to their handlers.""" + user = entity_helper.create_user() + admin_user = entity_helper.create_user_data("admin") + + with mock.patch("mavedb.lib.permissions.user." + handler.__name__, wraps=handler) as mock_handler: + has_permission(admin_user, user, action) + mock_handler.assert_called_once_with( + admin_user, + user, + False, # admin is not viewing self + [UserRole.admin], + ) + + @pytest.mark.parametrize("action", USER_UNSUPPORTED_ACTIONS) + def test_raises_for_unsupported_actions(self, entity_helper: EntityTestHelper, action: Action) -> None: + """Test that unsupported actions raise NotImplementedError with descriptive message.""" + user = entity_helper.create_user() + admin_user = entity_helper.create_user_data("admin") + + with pytest.raises(NotImplementedError) as exc_info: + has_permission(admin_user, user, action) + + error_msg = str(exc_info.value) + assert action.value in error_msg + assert all(a.value in error_msg for a in USER_SUPPORTED_ACTIONS) + + +class TestUserReadActionHandler: + """Test the _handle_read_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can read any User profile + PermissionTest("User", None, "admin", Action.READ, True), + # Users can read their own profile + PermissionTest("User", None, "self", Action.READ, True), + # Owners cannot read other user profiles (no special privilege) + PermissionTest("User", None, "owner", Action.READ, False, 403), + # Contributors cannot read other user profiles + PermissionTest("User", None, "contributor", Action.READ, False, 403), + # Mappers cannot read other user profiles + PermissionTest("User", None, "mapper", Action.READ, False, 403), + # Other users cannot read other user profiles + PermissionTest("User", None, "other_user", Action.READ, False, 403), + # Anonymous users cannot read user profiles + PermissionTest("User", None, "anonymous", Action.READ, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_read_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_read_action helper function directly.""" + user = entity_helper.create_user() + user_data = entity_helper.create_user_data(test_case.user_type) + + # Determine user relationship to entity + user_is_self = test_case.user_type == "self" + active_roles = user_data.active_roles if user_data else [] + + # Test the helper function directly + result = _handle_read_action(user_data, user, user_is_self, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestUserUpdateActionHandler: + """Test the _handle_update_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can update any User profile + PermissionTest("User", None, "admin", Action.UPDATE, True), + # Users can update their own profile + PermissionTest("User", None, "self", Action.UPDATE, True), + # Owners cannot update other user profiles (no special privilege) + PermissionTest("User", None, "owner", Action.UPDATE, False, 403), + # Contributors cannot update other user profiles + PermissionTest("User", None, "contributor", Action.UPDATE, False, 403), + # Mappers cannot update other user profiles + PermissionTest("User", None, "mapper", Action.UPDATE, False, 403), + # Other users cannot update other user profiles + PermissionTest("User", None, "other_user", Action.UPDATE, False, 403), + # Anonymous users cannot update user profiles + PermissionTest("User", None, "anonymous", Action.UPDATE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_update_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_update_action helper function directly.""" + user = entity_helper.create_user() + user_data = entity_helper.create_user_data(test_case.user_type) + + user_is_self = test_case.user_type == "self" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_update_action(user_data, user, user_is_self, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestUserLookupActionHandler: + """Test the _handle_lookup_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can lookup any User + PermissionTest("User", None, "admin", Action.LOOKUP, True), + # Users can lookup themselves + PermissionTest("User", None, "self", Action.LOOKUP, True), + # Owners can lookup other users (authenticated user privilege) + PermissionTest("User", None, "owner", Action.LOOKUP, True), + # Contributors can lookup other users (authenticated user privilege) + PermissionTest("User", None, "contributor", Action.LOOKUP, True), + # Mappers can lookup other users (authenticated user privilege) + PermissionTest("User", None, "mapper", Action.LOOKUP, True), + # Other authenticated users can lookup other users + PermissionTest("User", None, "other_user", Action.LOOKUP, True), + # Anonymous users cannot lookup users + PermissionTest("User", None, "anonymous", Action.LOOKUP, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_lookup_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_lookup_action helper function directly.""" + user = entity_helper.create_user() + user_data = entity_helper.create_user_data(test_case.user_type) + + user_is_self = test_case.user_type == "self" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_lookup_action(user_data, user, user_is_self, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code + + +class TestUserAddRoleActionHandler: + """Test the _handle_add_role_action helper function directly.""" + + @pytest.mark.parametrize( + "test_case", + [ + # Admins can add roles to any User + PermissionTest("User", None, "admin", Action.ADD_ROLE, True), + # Users cannot add roles to themselves + PermissionTest("User", None, "self", Action.ADD_ROLE, False, 403), + # Owners cannot add roles to other users + PermissionTest("User", None, "owner", Action.ADD_ROLE, False, 403), + # Contributors cannot add roles to other users + PermissionTest("User", None, "contributor", Action.ADD_ROLE, False, 403), + # Mappers cannot add roles to other users + PermissionTest("User", None, "mapper", Action.ADD_ROLE, False, 403), + # Other users cannot add roles to other users + PermissionTest("User", None, "other_user", Action.ADD_ROLE, False, 403), + # Anonymous users cannot add roles to users + PermissionTest("User", None, "anonymous", Action.ADD_ROLE, False, 401), + ], + ids=lambda tc: f"{tc.user_type}_{tc.action.value}_{'permitted' if tc.should_be_permitted else 'denied'}", + ) + def test_handle_add_role_action(self, test_case: PermissionTest, entity_helper: EntityTestHelper) -> None: + """Test _handle_add_role_action helper function directly.""" + user = entity_helper.create_user() + user_data = entity_helper.create_user_data(test_case.user_type) + + user_is_self = test_case.user_type == "self" + active_roles = user_data.active_roles if user_data else [] + + result = _handle_add_role_action(user_data, user, user_is_self, active_roles) + + assert result.permitted == test_case.should_be_permitted + if not test_case.should_be_permitted and test_case.expected_code: + assert result.http_code == test_case.expected_code diff --git a/tests/lib/permissions/test_utils.py b/tests/lib/permissions/test_utils.py new file mode 100644 index 00000000..0cc8d76a --- /dev/null +++ b/tests/lib/permissions/test_utils.py @@ -0,0 +1,223 @@ +# ruff: noqa: E402 + +"""Tests for permissions utils module.""" + +import pytest + +pytest.importorskip("fastapi", reason="Skipping permissions tests; FastAPI is required but not installed.") + +from unittest.mock import Mock + +from mavedb.lib.permissions.utils import deny_action_for_entity, roles_permitted +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.models.enums.user_role import UserRole + + +class TestRolesPermitted: + """Test the roles_permitted utility function.""" + + def test_user_role_permission_granted(self): + """Test that permission is granted when user has a permitted role.""" + user_roles = [UserRole.admin, UserRole.mapper] + permitted_roles = [UserRole.admin] + + result = roles_permitted(user_roles, permitted_roles) + assert result is True + + def test_user_role_permission_denied(self): + """Test that permission is denied when user lacks permitted roles.""" + user_roles = [UserRole.mapper] + permitted_roles = [UserRole.admin] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_contribution_role_permission_granted(self): + """Test that permission is granted for contribution roles.""" + user_roles = [ContributionRole.admin, ContributionRole.editor] + permitted_roles = [ContributionRole.admin] + + result = roles_permitted(user_roles, permitted_roles) + assert result is True + + def test_contribution_role_permission_denied(self): + """Test that permission is denied for contribution roles.""" + user_roles = [ContributionRole.viewer] + permitted_roles = [ContributionRole.admin, ContributionRole.editor] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_empty_user_roles_permission_denied(self): + """Test that permission is denied when user has no roles.""" + user_roles = [] + permitted_roles = [UserRole.admin] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_multiple_matching_roles(self): + """Test permission when user has multiple permitted roles.""" + user_roles = [UserRole.admin, UserRole.mapper] + permitted_roles = [UserRole.admin, UserRole.mapper] + + result = roles_permitted(user_roles, permitted_roles) + assert result is True + + def test_partial_role_match(self): + """Test permission when user has some but not all permitted roles.""" + user_roles = [UserRole.mapper] + permitted_roles = [UserRole.admin, UserRole.mapper] + + result = roles_permitted(user_roles, permitted_roles) + assert result is True + + def test_no_role_overlap(self): + """Test permission when user roles don't overlap with permitted roles.""" + user_roles = [ContributionRole.viewer] + permitted_roles = [ContributionRole.admin, ContributionRole.editor] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_empty_permitted_roles(self): + """Test behavior when no roles are permitted.""" + user_roles = [UserRole.admin] + permitted_roles = [] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_both_empty_roles(self): + """Test behavior when both user and permitted roles are empty.""" + user_roles = [] + permitted_roles = [] + + result = roles_permitted(user_roles, permitted_roles) + assert result is False + + def test_consistent_role_types_allowed(self): + """Test behavior with consistent role types (should work fine).""" + user_roles = [UserRole.admin] + permitted_roles = [UserRole.admin, UserRole.mapper] + assert roles_permitted(user_roles, permitted_roles) is True + + user_roles = [ContributionRole.editor] + permitted_roles = [ContributionRole.admin, ContributionRole.editor, ContributionRole.viewer] + assert roles_permitted(user_roles, permitted_roles) is True + + def test_mixed_user_role_types_raises_error(self): + """Test that mixed role types in user_roles list raises ValueError.""" + permitted_roles = [UserRole.admin] + mixed_user_roles = [UserRole.admin, ContributionRole.editor] + + with pytest.raises(ValueError) as exc_info: + roles_permitted(mixed_user_roles, permitted_roles) + + assert "user_roles list cannot contain mixed role types" in str(exc_info.value) + + def test_mixed_permitted_role_types_raises_error(self): + """Test that mixed role types in permitted_roles list raises ValueError.""" + user_roles = [UserRole.admin] + mixed_permitted_roles = [UserRole.admin, ContributionRole.editor] + + with pytest.raises(ValueError) as exc_info: + roles_permitted(user_roles, mixed_permitted_roles) + + assert "permitted_roles list cannot contain mixed role types" in str(exc_info.value) + + def test_different_role_types_between_lists_raises_error(self): + """Test that different role types between lists raises ValueError.""" + user_roles = [UserRole.admin] + permitted_roles = [ContributionRole.admin] + + with pytest.raises(ValueError) as exc_info: + roles_permitted(user_roles, permitted_roles) + + assert "user_roles and permitted_roles must contain the same role type" in str(exc_info.value) + + def test_single_role_lists(self): + """Test with single-item role lists.""" + user_roles = [UserRole.admin] + permitted_roles = [UserRole.admin] + assert roles_permitted(user_roles, permitted_roles) is True + + user_roles = [UserRole.mapper] + permitted_roles = [UserRole.admin] + assert roles_permitted(user_roles, permitted_roles) is False + + +class TestDenyActionForEntity: + """Test the deny_action_for_entity utility function.""" + + @pytest.mark.parametrize( + "entity_is_private, user_data, user_can_view_private, expected_status", + [ + # Private entity, anonymous user + (True, None, False, 404), + # Private entity, authenticated user without permissions + (True, Mock(user=Mock(id=1)), False, 404), + # Private entity, authenticated user with permissions + (True, Mock(user=Mock(id=1)), True, 403), + # Public entity, anonymous user + (False, None, False, 401), + # Public entity, authenticated user + (False, Mock(user=Mock(id=1)), False, 403), + ], + ids=[ + "private_anonymous_not-viewer", + "private_authenticated_not-viewer", + "private_authenticated_viewer", + "public_anonymous", + "public_authenticated", + ], + ) + def test_deny_action(self, entity_is_private, user_data, user_can_view_private, expected_status): + """Test denial for various user and entity privacy scenarios.""" + + entity = Mock(urn="entity:1234") + response = deny_action_for_entity(entity, entity_is_private, user_data, user_can_view_private) + + assert response.permitted is False + assert response.http_code == expected_status + + def test_deny_action_urn_available(self): + """Test denial message includes URN when available.""" + entity = Mock(urn="entity:5678") + response = deny_action_for_entity(entity, True, None, False) + + assert "URN 'entity:5678'" in response.message + + def test_deny_action_id_available(self): + """Test denial message includes ID when URN is not available.""" + entity = Mock(urn=None, id=42) + response = deny_action_for_entity(entity, True, None, False) + + assert "ID '42'" in response.message + + def test_deny_action_no_identifier(self): + """Test denial message when neither URN nor ID is available.""" + entity = Mock(urn=None, id=None) + response = deny_action_for_entity(entity, True, None, False) + + assert "unknown" in response.message + + def test_deny_handles_undefined_attributres(self): + """Test denial message when identifier attributes are undefined.""" + entity = Mock() + del entity.urn # Remove urn attribute + del entity.id # Remove id attribute + response = deny_action_for_entity(entity, True, None, False) + + assert "unknown" in response.message + + def test_deny_action_entity_name_in_message(self): + """Test denial message includes entity class name.""" + + class CustomEntity: + pass + + entity = CustomEntity() + response = deny_action_for_entity(entity, True, None, False, "custom entity") + + assert "custom entity" in response.message diff --git a/tests/routers/test_collections.py b/tests/routers/test_collections.py index 3b3bec65..f7103a9b 100644 --- a/tests/routers/test_collections.py +++ b/tests/routers/test_collections.py @@ -14,12 +14,11 @@ from mavedb.lib.validation.urn_re import MAVEDB_COLLECTION_URN_RE from mavedb.models.enums.contribution_role import ContributionRole from mavedb.view_models.collection import Collection - from tests.helpers.constants import ( EXTRA_USER, - TEST_USER, TEST_COLLECTION, TEST_COLLECTION_RESPONSE, + TEST_USER, ) from tests.helpers.dependency_overrider import DependencyOverrider from tests.helpers.util.collection import create_collection @@ -198,7 +197,7 @@ def test_unauthorized_user_cannot_read_private_collection(session, client, setup response = client.get(f"/api/v1/collections/{collection['urn']}") assert response.status_code == 404 - assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] + assert f"collection with URN '{collection['urn']}'" in response.json()["detail"] def test_anonymous_cannot_read_private_collection(session, client, setup_router_db, anonymous_app_overrides): @@ -208,7 +207,7 @@ def test_anonymous_cannot_read_private_collection(session, client, setup_router_ response = client.get(f"/api/v1/collections/{collection['urn']}") assert response.status_code == 404 - assert f"collection with URN '{collection['urn']}' not found" in response.json()["detail"] + assert f"collection with URN '{collection['urn']}'" in response.json()["detail"] def test_anonymous_can_read_public_collection(session, client, setup_router_db, anonymous_app_overrides): @@ -360,7 +359,7 @@ def test_viewer_cannot_add_experiment_to_collection( assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions for URN '{collection['urn']}'" in response_data["detail"] + assert f"insufficient permissions on collection with URN '{collection['urn']}'" in response_data["detail"] def test_unauthorized_user_cannot_add_experiment_to_collection( @@ -544,7 +543,7 @@ def test_viewer_cannot_add_score_set_to_collection( assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions for URN '{collection['urn']}'" in response_data["detail"] + assert f"insufficient permissions on collection with URN '{collection['urn']}'" in response_data["detail"] def test_unauthorized_user_cannot_add_score_set_to_collection( diff --git a/tests/routers/test_experiments.py b/tests/routers/test_experiments.py index 9767c125..cd4a54ad 100644 --- a/tests/routers/test_experiments.py +++ b/tests/routers/test_experiments.py @@ -621,7 +621,10 @@ def test_cannot_update_other_users_public_experiment_set(session, data_provider, response = client.post("/api/v1/experiments/", json=experiment_post_payload) assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions for URN '{published_experiment_set_urn}'" in response_data["detail"] + assert ( + f"insufficient permissions on experiment set with URN '{published_experiment_set_urn}'" + in response_data["detail"] + ) def test_anonymous_cannot_update_others_user_public_experiment_set( @@ -1651,10 +1654,12 @@ def test_cannot_delete_own_published_experiment(session, data_provider, client, assert del_response.status_code == 403 del_response_data = del_response.json() - assert f"insufficient permissions for URN '{experiment_urn}'" in del_response_data["detail"] + assert f"insufficient permissions on experiment with URN '{experiment_urn}'" in del_response_data["detail"] -def test_contributor_can_delete_other_users_private_experiment(session, client, setup_router_db, admin_app_overrides): +def test_contributor_cannot_delete_other_users_private_experiment( + session, client, setup_router_db, admin_app_overrides +): experiment = create_experiment(client) change_ownership(session, experiment["urn"], ExperimentDbModel) add_contributor( @@ -1667,7 +1672,8 @@ def test_contributor_can_delete_other_users_private_experiment(session, client, ) response = client.delete(f"/api/v1/experiments/{experiment['urn']}") - assert response.status_code == 200 + assert response.status_code == 403 + assert f"insufficient permissions on experiment with URN '{experiment['urn']}'" in response.json()["detail"] def test_admin_can_delete_other_users_private_experiment(session, client, setup_router_db, admin_app_overrides): @@ -1833,4 +1839,4 @@ def test_cannot_add_experiment_to_others_public_experiment_set( response = client.post("/api/v1/experiments/", json=test_experiment) assert response.status_code == 403 response_data = response.json() - assert f"insufficient permissions for URN '{experiment_set_urn}'" in response_data["detail"] + assert f"insufficient permissions on experiment set with URN '{experiment_set_urn}'" in response_data["detail"] diff --git a/tests/routers/test_permissions.py b/tests/routers/test_permissions.py index 74405a47..b60a924e 100644 --- a/tests/routers/test_permissions.py +++ b/tests/routers/test_permissions.py @@ -131,7 +131,7 @@ def test_contributor_gets_true_permission_from_others_experiment_update_check(se assert response.json() -def test_contributor_gets_true_permission_from_others_experiment_delete_check(session, client, setup_router_db): +def test_contributor_gets_false_permission_from_others_experiment_delete_check(session, client, setup_router_db): experiment = create_experiment(client) change_ownership(session, experiment["urn"], ExperimentDbModel) add_contributor( @@ -145,7 +145,7 @@ def test_contributor_gets_true_permission_from_others_experiment_delete_check(se response = client.get(f"/api/v1/permissions/user-is-permitted/experiment/{experiment['urn']}/delete") assert response.status_code == 200 - assert response.json() + assert not response.json() def test_contributor_gets_true_permission_from_others_private_experiment_add_score_set_check( @@ -282,7 +282,7 @@ def test_contributor_gets_true_permission_from_others_score_set_update_check(ses assert response.json() -def test_contributor_gets_true_permission_from_others_score_set_delete_check(session, client, setup_router_db): +def test_contributor_gets_false_permission_from_others_score_set_delete_check(session, client, setup_router_db): experiment = create_experiment(client) score_set = create_seq_score_set(client, experiment["urn"]) change_ownership(session, score_set["urn"], ScoreSetDbModel) @@ -297,10 +297,10 @@ def test_contributor_gets_true_permission_from_others_score_set_delete_check(ses response = client.get(f"/api/v1/permissions/user-is-permitted/score-set/{score_set['urn']}/delete") assert response.status_code == 200 - assert response.json() + assert not response.json() -def test_contributor_gets_true_permission_from_others_score_set_publish_check(session, client, setup_router_db): +def test_contributor_gets_false_permission_from_others_score_set_publish_check(session, client, setup_router_db): experiment = create_experiment(client) score_set = create_seq_score_set(client, experiment["urn"]) change_ownership(session, score_set["urn"], ScoreSetDbModel) @@ -315,7 +315,7 @@ def test_contributor_gets_true_permission_from_others_score_set_publish_check(se response = client.get(f"/api/v1/permissions/user-is-permitted/score-set/{score_set['urn']}/publish") assert response.status_code == 200 - assert response.json() + assert not response.json() def test_get_false_permission_from_others_score_set_delete_check(session, client, setup_router_db): @@ -423,7 +423,7 @@ def test_contributor_gets_true_permission_from_others_investigator_provided_scor assert response.json() -def test_contributor_gets_true_permission_from_others_investigator_provided_score_calibration_delete_check( +def test_contributor_gets_false_permission_from_others_investigator_provided_score_calibration_delete_check( session, client, setup_router_db, extra_user_app_overrides ): experiment = create_experiment(client) @@ -445,10 +445,12 @@ def test_contributor_gets_true_permission_from_others_investigator_provided_scor ) assert response.status_code == 200 - assert response.json() + assert not response.json() -def test_get_false_permission_from_others_score_calibration_update_check(session, client, setup_router_db): +def test_get_true_permission_as_score_set_owner_on_others_investigator_provided_score_calibration_update_check( + session, client, setup_router_db +): experiment = create_experiment(client) score_set = create_seq_score_set(client, experiment["urn"]) score_calibration = create_test_score_calibration_in_score_set_via_client( @@ -458,6 +460,23 @@ def test_get_false_permission_from_others_score_calibration_update_check(session response = client.get(f"/api/v1/permissions/user-is-permitted/score-calibration/{score_calibration['urn']}/update") + assert response.status_code == 200 + assert response.json() + + +def test_get_false_permission_as_score_set_owner_on_others_community_score_calibration_update_check( + session, client, setup_router_db, admin_app_overrides +): + experiment = create_experiment(client) + score_set = create_seq_score_set(client, experiment["urn"]) + + with DependencyOverrider(admin_app_overrides): + score_calibration = create_test_score_calibration_in_score_set_via_client( + client, score_set["urn"], deepcamelize(TEST_MINIMAL_CALIBRATION) + ) + + response = client.get(f"/api/v1/permissions/user-is-permitted/score-calibration/{score_calibration['urn']}/update") + assert response.status_code == 200 assert not response.json() diff --git a/tests/routers/test_score_calibrations.py b/tests/routers/test_score_calibrations.py index 307394ec..5235decb 100644 --- a/tests/routers/test_score_calibrations.py +++ b/tests/routers/test_score_calibrations.py @@ -13,6 +13,14 @@ from mavedb.models.score_calibration import ScoreCalibration as CalibrationDbModel from mavedb.models.score_set import ScoreSet as ScoreSetDbModel +from tests.helpers.constants import ( + EXTRA_USER, + TEST_BIORXIV_IDENTIFIER, + TEST_BRNICH_SCORE_CALIBRATION, + TEST_PATHOGENICITY_SCORE_CALIBRATION, + TEST_PUBMED_IDENTIFIER, + VALID_CALIBRATION_URN, +) from tests.helpers.dependency_overrider import DependencyOverrider from tests.helpers.util.common import deepcamelize from tests.helpers.util.contributor import add_contributor @@ -24,15 +32,6 @@ ) from tests.helpers.util.score_set import create_seq_score_set_with_mapped_variants, publish_score_set -from tests.helpers.constants import ( - EXTRA_USER, - TEST_BIORXIV_IDENTIFIER, - TEST_BRNICH_SCORE_CALIBRATION, - TEST_PATHOGENICITY_SCORE_CALIBRATION, - TEST_PUBMED_IDENTIFIER, - VALID_CALIBRATION_URN, -) - ########################################################### # GET /score-calibrations/{calibration_urn} ########################################################### @@ -1303,7 +1302,7 @@ def test_cannot_create_score_calibration_in_public_score_set_when_score_set_not_ assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{score_set['urn']}'" in error["detail"] + assert f"insufficient permissions on score set with URN '{score_set['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -1656,7 +1655,7 @@ def test_cannot_update_score_calibration_in_published_score_set_when_score_set_n assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{score_set['urn']}'" in error["detail"] + assert f"insufficient permissions on score set with URN '{score_set['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -1736,7 +1735,7 @@ def test_cannot_update_published_score_calibration_as_score_set_owner( assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -2291,7 +2290,7 @@ def test_cannot_delete_published_score_calibration_as_owner( assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -2304,7 +2303,7 @@ def test_cannot_delete_published_score_calibration_as_owner( ], indirect=["mock_publication_fetch"], ) -def test_can_delete_investigator_score_calibration_as_score_set_contributor( +def test_cannot_delete_investigator_score_calibration_as_score_set_contributor( client, setup_router_db, mock_publication_fetch, session, data_provider, data_files, extra_user_app_overrides ): experiment = create_experiment(client) @@ -2331,11 +2330,9 @@ def test_can_delete_investigator_score_calibration_as_score_set_contributor( with DependencyOverrider(extra_user_app_overrides): response = client.delete(f"/api/v1/score-calibrations/{calibration['urn']}") - assert response.status_code == 204 - - # verify it's deleted - get_response = client.get(f"/api/v1/score-calibrations/{calibration['urn']}") - assert get_response.status_code == 404 + error = response.json() + assert response.status_code == 403 + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -2487,7 +2484,7 @@ def test_cannot_delete_primary_score_calibration( assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] ########################################################### @@ -2573,7 +2570,7 @@ def test_cannot_promote_score_calibration_when_score_calibration_not_owned_by_us assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( @@ -2910,7 +2907,7 @@ def test_cannot_promote_to_primary_with_demote_existing_flag_if_user_does_not_ha assert response.status_code == 403 promotion_response = response.json() - assert "insufficient permissions for URN" in promotion_response["detail"] + assert "insufficient permissions on score calibration with URN" in promotion_response["detail"] # verify the previous primary is still primary @@ -3002,7 +2999,7 @@ def test_cannot_demote_score_calibration_when_score_calibration_not_owned_by_use assert response.status_code == 403 error = response.json() - assert f"insufficient permissions for URN '{calibration['urn']}'" in error["detail"] + assert f"insufficient permissions on score calibration with URN '{calibration['urn']}'" in error["detail"] @pytest.mark.parametrize( diff --git a/tests/routers/test_score_set.py b/tests/routers/test_score_set.py index 86234392..09a2c25b 100644 --- a/tests/routers/test_score_set.py +++ b/tests/routers/test_score_set.py @@ -1408,7 +1408,7 @@ def test_anonymous_cannot_publish_user_private_score_set( assert "Could not validate credentials" in response_data["detail"] -def test_contributor_can_publish_other_users_score_set(session, data_provider, client, setup_router_db, data_files): +def test_contributor_cannot_publish_other_users_score_set(session, data_provider, client, setup_router_db, data_files): experiment = create_experiment(client) score_set = create_seq_score_set(client, experiment["urn"]) score_set = mock_worker_variant_insertion(client, session, data_provider, score_set, data_files / "scores.csv") @@ -1423,60 +1423,15 @@ def test_contributor_can_publish_other_users_score_set(session, data_provider, c ) with patch.object(arq.ArqRedis, "enqueue_job", return_value=None) as worker_queue: - published_score_set = publish_score_set(client, score_set["urn"]) - worker_queue.assert_called_once() - - assert published_score_set["urn"] == "urn:mavedb:00000001-a-1" - assert published_score_set["experiment"]["urn"] == "urn:mavedb:00000001-a" - - expected_response = update_expected_response_for_created_resources( - deepcopy(TEST_MINIMAL_SEQ_SCORESET_RESPONSE), published_score_set["experiment"], published_score_set - ) - expected_response["experiment"].update({"publishedDate": date.today().isoformat(), "numScoreSets": 1}) - expected_response.update( - { - "urn": published_score_set["urn"], - "publishedDate": date.today().isoformat(), - "numVariants": 3, - "private": False, - "datasetColumns": SAVED_MINIMAL_DATASET_COLUMNS, - "processingState": ProcessingState.success.name, - } - ) - expected_response["contributors"] = [ - { - "recordType": "Contributor", - "orcidId": TEST_USER["username"], - "givenName": TEST_USER["first_name"], - "familyName": TEST_USER["last_name"], - } - ] - expected_response["createdBy"] = { - "recordType": "User", - "orcidId": EXTRA_USER["username"], - "firstName": EXTRA_USER["first_name"], - "lastName": EXTRA_USER["last_name"], - } - expected_response["modifiedBy"] = { - "recordType": "User", - "orcidId": EXTRA_USER["username"], - "firstName": EXTRA_USER["first_name"], - "lastName": EXTRA_USER["last_name"], - } - assert sorted(expected_response.keys()) == sorted(published_score_set.keys()) + response = client.post(f"/api/v1/score-sets/{score_set['urn']}/publish") + assert response.status_code == 403 + worker_queue.assert_not_called() + response_data = response.json() - # refresh score set to post worker state - score_set = (client.get(f"/api/v1/score-sets/{published_score_set['urn']}")).json() - for key in expected_response: - assert (key, expected_response[key]) == (key, score_set[key]) - - score_set_variants = session.execute( - select(VariantDbModel).join(ScoreSetDbModel).where(ScoreSetDbModel.urn == score_set["urn"]) - ).scalars() - assert all([variant.urn.startswith("urn:mavedb:") for variant in score_set_variants]) + assert f"insufficient permissions on score set with URN '{score_set['urn']}'" in response_data["detail"] -def test_admin_cannot_publish_other_user_private_score_set( +def test_admin_can_publish_other_user_private_score_set( session, data_provider, client, admin_app_overrides, setup_router_db, data_files ): experiment = create_experiment(client) @@ -1488,11 +1443,8 @@ def test_admin_cannot_publish_other_user_private_score_set( patch.object(arq.ArqRedis, "enqueue_job", return_value=None) as queue, ): response = client.post(f"/api/v1/score-sets/{score_set['urn']}/publish") - assert response.status_code == 404 - queue.assert_not_called() - response_data = response.json() - - assert f"score set with URN '{score_set['urn']}' not found" in response_data["detail"] + assert response.status_code == 200 + queue.assert_called_once() ######################################################################################################################## @@ -2334,7 +2286,9 @@ def test_cannot_delete_own_published_scoreset(session, data_provider, client, se assert del_response.status_code == 403 del_response_data = del_response.json() - assert f"insufficient permissions for URN '{published_score_set['urn']}'" in del_response_data["detail"] + assert ( + f"insufficient permissions on score set with URN '{published_score_set['urn']}'" in del_response_data["detail"] + ) def test_contributor_can_delete_other_users_private_scoreset( @@ -2355,7 +2309,9 @@ def test_contributor_can_delete_other_users_private_scoreset( response = client.delete(f"/api/v1/score-sets/{score_set['urn']}") - assert response.status_code == 200 + assert response.status_code == 403 + response_data = response.json() + assert f"insufficient permissions on score set with URN '{score_set['urn']}'" in response_data["detail"] def test_admin_can_delete_other_users_private_scoreset( diff --git a/tests/routers/test_users.py b/tests/routers/test_users.py index 03b57c0b..68fa382d 100644 --- a/tests/routers/test_users.py +++ b/tests/routers/test_users.py @@ -21,14 +21,14 @@ def test_cannot_list_users_as_anonymous_user(client, setup_router_db, anonymous_ assert response.status_code == 401 response_value = response.json() - assert response_value["detail"] in "Could not validate credentials" + assert "Could not validate credentials" in response_value["detail"] def test_cannot_list_users_as_normal_user(client, setup_router_db): response = client.get("/api/v1/users/") assert response.status_code == 403 response_value = response.json() - assert response_value["detail"] in "You are not authorized to use this feature" + assert "You are not authorized to use this feature" in response_value["detail"] def test_can_list_users_as_admin_user(admin_app_overrides, setup_router_db, client): @@ -50,10 +50,7 @@ def test_cannot_get_anonymous_user(client, setup_router_db, session, anonymous_a assert response.status_code == 401 response_value = response.json() - assert response_value["detail"] in "Could not validate credentials" - - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() + assert "Could not validate credentials" in response_value["detail"] def test_get_current_user(client, setup_router_db, session): @@ -62,9 +59,6 @@ def test_get_current_user(client, setup_router_db, session): response_value = response.json() assert response_value["orcidId"] == TEST_USER["username"] - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_get_current_admin_user(client, admin_app_overrides, setup_router_db, session): with DependencyOverrider(admin_app_overrides): @@ -75,9 +69,6 @@ def test_get_current_admin_user(client, admin_app_overrides, setup_router_db, se assert response_value["orcidId"] == ADMIN_USER["username"] assert response_value["roles"] == ["admin"] - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_cannot_impersonate_admin_user_as_default_user(client, setup_router_db, session): # NOTE: We can't mock JWTBearer directly because the object is created when the `get_current_user` function is called. @@ -100,9 +91,6 @@ def test_cannot_impersonate_admin_user_as_default_user(client, setup_router_db, assert response.status_code == 403 assert response.json()["detail"] in "This user is not a member of the requested acting role." - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_cannot_fetch_single_user_as_anonymous_user(client, setup_router_db, session, anonymous_app_overrides): with DependencyOverrider(anonymous_app_overrides): @@ -111,18 +99,12 @@ def test_cannot_fetch_single_user_as_anonymous_user(client, setup_router_db, ses assert response.status_code == 401 assert response.json()["detail"] in "Could not validate credentials" - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_cannot_fetch_single_user_as_normal_user(client, setup_router_db, session): response = client.get("/api/v1/users/2") assert response.status_code == 403 assert response.json()["detail"] in "You are not authorized to use this feature" - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_can_fetch_single_user_as_admin_user(client, setup_router_db, session, admin_app_overrides): with DependencyOverrider(admin_app_overrides): @@ -132,9 +114,6 @@ def test_can_fetch_single_user_as_admin_user(client, setup_router_db, session, a response_value = response.json() assert response_value["orcidId"] == EXTRA_USER["username"] - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() - def test_fetching_nonexistent_user_as_admin_raises_exception(client, setup_router_db, session, admin_app_overrides): with DependencyOverrider(admin_app_overrides): @@ -142,10 +121,7 @@ def test_fetching_nonexistent_user_as_admin_raises_exception(client, setup_route assert response.status_code == 404 response_value = response.json() - assert "User with ID 0 not found" in response_value["detail"] - - # Some lingering db transaction holds this test open unless it is explicitly closed. - session.commit() + assert "user profile with ID 0 not found" in response_value["detail"] def test_anonymous_user_cannot_update_self(client, setup_router_db, anonymous_app_overrides): @@ -209,7 +185,7 @@ def test_admin_can_set_logged_in_property_on_self(client, setup_router_db, admin [ ("email", "updated@test.com"), ("first_name", "Updated"), - ("last_name", "User"), + ("last_name", "user profile"), ("roles", ["admin"]), ], ) @@ -223,7 +199,7 @@ def test_anonymous_user_cannot_update_other_users( assert response.status_code == 401 response_value = response.json() - assert response_value["detail"] in "Could not validate credentials" + assert "Could not validate credentials" in response_value["detail"] @pytest.mark.parametrize( @@ -231,7 +207,7 @@ def test_anonymous_user_cannot_update_other_users( [ ("email", "updated@test.com"), ("first_name", "Updated"), - ("last_name", "User"), + ("last_name", "user profile"), ("roles", ["admin"]), ], ) @@ -241,7 +217,7 @@ def test_user_cannot_update_other_users(client, setup_router_db, field_name, fie response = client.put("/api/v1/users//2", json=user_update) assert response.status_code == 403 response_value = response.json() - assert response_value["detail"] in "Insufficient permissions for user update." + assert "insufficient permissions on user profile with ID '2'" in response_value["detail"] @pytest.mark.parametrize( @@ -249,7 +225,7 @@ def test_user_cannot_update_other_users(client, setup_router_db, field_name, fie [ ("email", "updated@test.com"), ("first_name", "Updated"), - ("last_name", "User"), + ("last_name", "user profile"), ("roles", ["admin"]), ], )