From b15826eab415868eb77d4730b421442d512124cb Mon Sep 17 00:00:00 2001 From: Abhishek Gaikwad Date: Fri, 9 Aug 2024 17:29:04 -0700 Subject: [PATCH] python/authn: Implement Roles API This commit adds the Roles API to the authentication module, enabling role-based access control features. - Added APIs to create, update, delete, get, and list roles. - Added Unit and Integration tests. Signed-off-by: Ryan Koo Co-authored-by: Ryan Koo Co-authored-by: Abhishek Gaikwad --- python/aistore/sdk/authn/access_attr.py | 52 ++++ python/aistore/sdk/authn/authn_client.py | 12 +- python/aistore/sdk/authn/authn_types.py | 97 ------- python/aistore/sdk/authn/cluster_manager.py | 2 +- python/aistore/sdk/authn/role_manager.py | 254 ++++++++++++++++++ python/aistore/sdk/authn/types.py | 172 ++++++++++++ python/aistore/sdk/const.py | 1 + ...st_auth_client.py => test_authn_client.py} | 2 +- ...sters.py => test_authn_cluster_manager.py} | 4 +- .../sdk/authn/test_authn_role_manager.py | 152 +++++++++++ .../unit/sdk/authn/test_authn_access_attr.py | 90 +++++++ .../tests/unit/sdk/authn/test_authn_client.py | 2 +- ...sters.py => test_authn_cluster_manager.py} | 4 +- .../unit/sdk/authn/test_authn_role_manager.py | 181 +++++++++++++ 14 files changed, 920 insertions(+), 105 deletions(-) create mode 100644 python/aistore/sdk/authn/access_attr.py delete mode 100644 python/aistore/sdk/authn/authn_types.py create mode 100644 python/aistore/sdk/authn/role_manager.py create mode 100644 python/aistore/sdk/authn/types.py rename python/tests/integration/sdk/authn/{test_auth_client.py => test_authn_client.py} (97%) rename python/tests/integration/sdk/authn/{test_authn_clusters.py => test_authn_cluster_manager.py} (96%) create mode 100644 python/tests/integration/sdk/authn/test_authn_role_manager.py create mode 100644 python/tests/unit/sdk/authn/test_authn_access_attr.py rename python/tests/unit/sdk/authn/{test_authn_clusters.py => test_authn_cluster_manager.py} (97%) create mode 100644 python/tests/unit/sdk/authn/test_authn_role_manager.py diff --git a/python/aistore/sdk/authn/access_attr.py b/python/aistore/sdk/authn/access_attr.py new file mode 100644 index 00000000000..2b8b03b6e50 --- /dev/null +++ b/python/aistore/sdk/authn/access_attr.py @@ -0,0 +1,52 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# + +from enum import IntFlag + + +class AccessAttr(IntFlag): + """ + AccessAttr defines permissions as bitwise flags for access control (for more details, refer to the Go API). + """ + + GET = 1 << 0 + OBJ_HEAD = 1 << 1 + PUT = 1 << 2 + APPEND = 1 << 3 + OBJ_DELETE = 1 << 4 + OBJ_MOVE = 1 << 5 + PROMOTE = 1 << 6 + OBJ_UPDATE = 1 << 7 + BCK_HEAD = 1 << 8 + OBJ_LIST = 1 << 9 + PATCH = 1 << 10 + BCK_SET_ACL = 1 << 11 + LIST_BUCKETS = 1 << 12 + SHOW_CLUSTER = 1 << 13 + CREATE_BUCKET = 1 << 14 + DESTROY_BUCKET = 1 << 15 + MOVE_BUCKET = 1 << 16 + ADMIN = 1 << 17 + + ACCESS_RO = GET | OBJ_HEAD | LIST_BUCKETS | BCK_HEAD | OBJ_LIST + ACCESS_RW = ACCESS_RO | PUT | APPEND | OBJ_DELETE | OBJ_MOVE + ACCESS_CLUSTER = LIST_BUCKETS | CREATE_BUCKET | DESTROY_BUCKET | MOVE_BUCKET | ADMIN + ACCESS_ALL = ( + ACCESS_RW + | ACCESS_CLUSTER + | PROMOTE + | OBJ_UPDATE + | PATCH + | BCK_SET_ACL + | SHOW_CLUSTER + ) + ACCESS_NONE = 0 + + @staticmethod + def describe(perms: int) -> str: + """ + Returns a comma-separated string describing the permissions based on the provided bitwise flags. + """ + access_op = {v.value: v.name for v in AccessAttr} + return ",".join(op for perm, op in access_op.items() if perms & perm) diff --git a/python/aistore/sdk/authn/authn_client.py b/python/aistore/sdk/authn/authn_client.py index 41cccc6d41e..bcef5a9c3e0 100644 --- a/python/aistore/sdk/authn/authn_client.py +++ b/python/aistore/sdk/authn/authn_client.py @@ -9,8 +9,9 @@ HTTP_METHOD_POST, URL_PATH_AUTHN_USERS, ) -from aistore.sdk.authn.authn_types import TokenMsg, LoginMsg +from aistore.sdk.authn.types import TokenMsg, LoginMsg from aistore.sdk.authn.cluster_manager import ClusterManager +from aistore.sdk.authn.role_manager import RoleManager # logging logging.basicConfig(level=logging.INFO) @@ -109,3 +110,12 @@ def cluster_manager(self) -> ClusterManager: ClusterManager: An instance to manage cluster operations. """ return ClusterManager(client=self._request_client) + + def role_manager(self) -> RoleManager: + """ + Factory method to create a RoleManager instance. + + Returns: + RoleManager: An instance to manage role operations. + """ + return RoleManager(client=self._request_client) diff --git a/python/aistore/sdk/authn/authn_types.py b/python/aistore/sdk/authn/authn_types.py deleted file mode 100644 index 7c568508959..00000000000 --- a/python/aistore/sdk/authn/authn_types.py +++ /dev/null @@ -1,97 +0,0 @@ -# -# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. -# - -from __future__ import annotations -from typing import Optional, Union, Dict, List -from pydantic import BaseModel, Field -from aistore.sdk.const import NANOSECONDS_IN_SECOND - - -class LoginMsg(BaseModel): - """ - Represents a login message with a password and optional expiration duration. - - Attributes: - password (str): The password string. - expires_in (Optional[Union[int, float]]): The expiration duration in seconds. - """ - - password: str - expires_in: Optional[Union[int, float]] = Field( - None, description="Expiration duration in seconds." - ) - - def as_dict(self): - """ - Converts the instance to a dict, converting the expiration duration to nanoseconds if specified. - - Returns: - Dict[str, Union[str, int]]: The dict representation of the login message. - """ - - data = self.dict() - if self.expires_in is not None: - data["expires_in"] = int( - self.expires_in * NANOSECONDS_IN_SECOND - ) # Convert seconds to nanoseconds - return data - - -class TokenMsg(BaseModel): - """ - Represents a message containing a token. - - Attributes: - token (str): The token string. - """ - - token: str - - -class ClusterInfo(BaseModel): - """ - Represents information about a cluster. - - Attributes: - id (str): The unique identifier of the cluster. - alias (Optional[str]): The alias name of the cluster. Defaults to None. - urls (List[str]): A list of URLs associated with the cluster. - """ - - id: str - alias: Optional[str] = None - urls: List[str] = [] - - def as_dict(self) -> Dict[str, Optional[str] | List[str]]: - """ - Converts the ClusterInfo object to a dictionary. - - Returns: - Dict[str, Optional[str] | List[str]]: A dictionary representation of the ClusterInfo object. - """ - return { - "id": self.id, - "alias": self.alias, - "urls": self.urls, - } - - -class ClusterList(BaseModel): - """ - Represents a list of clusters. - - Attributes: - clusters (Dict[str, ClusterInfo]): A dictionary of cluster IDs to ClusterInfo objects. - """ - - clusters: Dict[str, ClusterInfo] = {} - - def as_dict(self) -> Dict[str, ClusterInfo]: - """ - Converts the ClusterList object to a dictionary. - - Returns: - Dict[str, ClusterInfo]: A dictionary representation of the ClusterList object. - """ - return self.clusters diff --git a/python/aistore/sdk/authn/cluster_manager.py b/python/aistore/sdk/authn/cluster_manager.py index f5f91e42222..3a362a810bb 100644 --- a/python/aistore/sdk/authn/cluster_manager.py +++ b/python/aistore/sdk/authn/cluster_manager.py @@ -13,7 +13,7 @@ HTTP_METHOD_DELETE, URL_PATH_AUTHN_CLUSTERS, ) -from aistore.sdk.authn.authn_types import ClusterInfo, ClusterList +from aistore.sdk.authn.types import ClusterInfo, ClusterList # logging logging.basicConfig(level=logging.INFO) diff --git a/python/aistore/sdk/authn/role_manager.py b/python/aistore/sdk/authn/role_manager.py new file mode 100644 index 00000000000..116beaad31f --- /dev/null +++ b/python/aistore/sdk/authn/role_manager.py @@ -0,0 +1,254 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# + +# pylint: disable=too-many-arguments, duplicate-code + +import logging +from typing import List +from aistore.sdk.request_client import RequestClient +from aistore.sdk.const import ( + HTTP_METHOD_GET, + HTTP_METHOD_POST, + HTTP_METHOD_PUT, + HTTP_METHOD_DELETE, + URL_PATH_AUTHN_ROLES, +) +from aistore.sdk.authn.types import ( + RoleInfo, + RolesList, + BucketPermission, + ClusterPermission, +) +from aistore.sdk.authn.access_attr import AccessAttr +from aistore.sdk.authn.cluster_manager import ClusterManager +from aistore.sdk.types import BucketModel +from aistore.sdk.namespace import Namespace +from aistore.sdk.errors import AISError + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class RoleManager: + """ + Manages role-related operations. + + This class provides methods to interact with roles, including + retrieving, creating, updating, and deleting role information. + + Args: + client (RequestClient): The client used to make HTTP requests. + """ + + def __init__(self, client: RequestClient): + self._client = client + + @property + def client(self) -> RequestClient: + """Returns the RequestClient instance used by this RoleManager.""" + return self._client + + def list(self) -> RolesList: + """ + Retrieves information about all roles. + + Returns: + RoleList: A list containing information about all roles. + + Raises: + aistore.sdk.errors.AISError: All other types of errors with AIStore. + requests.RequestException: If the HTTP request fails. + """ + logger.info("Listing all roles") + return self.client.request_deserialize( + HTTP_METHOD_GET, + path=URL_PATH_AUTHN_ROLES, + res_model=RolesList, + ) + + def get(self, role_name: str) -> RoleInfo: + """ + Retrieves information about a specific role. + + Args: + role_name (str): The name of the role to retrieve. + + Returns: + RoleInfo: Information about the specified role. + + Raises: + aistore.sdk.errors.AISError: All other types of errors with AIStore. + requests.RequestException: If the HTTP request fails. + """ + logger.info("Getting role with name: %s", role_name) + return self.client.request_deserialize( + HTTP_METHOD_GET, + path=f"{URL_PATH_AUTHN_ROLES}/{role_name}", + res_model=RoleInfo, + ) + + def create( + self, + name: str, + desc: str, + cluster_alias: str, + perms: List[AccessAttr], + bucket_name: str = None, + ) -> RoleInfo: + """ + Creates a new role. + + Args: + name (str): The name of the role. + desc (str): A description of the role. + cluster_alias (str): The alias of the cluster this role will have access to. + perms (List[AccessAttr]): A list of permissions to be granted for this role. + bucket_name (str, optional): The name of the bucket this role will have access to. + + Returns: + RoleInfo: Information about the newly created role. + + Raises: + aistore.sdk.errors.AISError: All other types of errors with AIStore. + requests.RequestException: If the HTTP request fails. + """ + # Convert the list of AccessAttr to an integer representing the permissions + perm_value = sum(perm.value for perm in perms) + + cluster_uuid = ClusterManager(self.client).get(cluster_alias=cluster_alias).id + role_info = RoleInfo(name=name, desc=desc) + if bucket_name: + role_info.buckets = [ + BucketPermission( + bck=BucketModel( + name=bucket_name, + provider="ais", + namespace=Namespace(uuid=cluster_uuid), + ), + perm=perm_value, + ) + ] + else: + role_info.clusters = [ClusterPermission(id=cluster_uuid, perm=perm_value)] + + logger.info("Creating role with name: %s", name) + self.client.request( + HTTP_METHOD_POST, + path=URL_PATH_AUTHN_ROLES, + json=role_info.dict(), + ) + + return self.get(role_name=name) + + def update( + self, + name: str, + desc: str = None, + cluster_alias: str = None, + perms: List[AccessAttr] = None, + bucket_name: str = None, + ) -> RoleInfo: + """ + Updates an existing role. + + Args: + name (str): The name of the role. + desc (str, optional): An updated description of the role. + cluster_alias (str, optional): The alias of the cluster this role will have access to. + perms (List[AccessAttr], optional): A list of updated permissions to be granted for this role. + bucket_name (str, optional): The name of the bucket this role will have access to. + + Raises: + aistore.sdk.errors.AISError: All other types of errors with AIStore. + requests.RequestException: If the HTTP request fails. + ValueError: If the role does not exist or if invalid parameters are provided. + """ + + if not (desc or cluster_alias or perms or bucket_name): + raise ValueError( + "You must change either the description or permissions for a bucket or cluster." + ) + + if perms and not cluster_alias: + raise ValueError( + "Cluster alias must be provided when permissions are specified." + ) + + if bucket_name and not cluster_alias: + raise ValueError( + "Cluster alias must be provided when bucket_name is specified." + ) + + if (cluster_alias or bucket_name) and not perms: + raise ValueError( + "Permissions must be provided when cluster alias or bucket name is specified." + ) + + try: + role_info = self.get(role_name=name) + except AISError as error: + raise ValueError(f"Role {name} does not exist") from error + + if desc: + role_info.desc = desc + + if cluster_alias: + cluster_uuid = ( + ClusterManager(self.client).get(cluster_alias=cluster_alias).id + ) + perm_value = sum(perm.value for perm in perms) + + if bucket_name: + logger.info( + "Preparing bucket-specific permissions for bucket: %s", bucket_name + ) + role_info.buckets = [ + BucketPermission( + bck=BucketModel( + name=bucket_name, + provider="ais", + namespace=Namespace(uuid=cluster_uuid), + ), + perm=perm_value, + ) + ] + else: + logger.info( + "Preparing cluster-wide permissions for cluster alias: %s", + cluster_alias, + ) + role_info.clusters = [ + ClusterPermission(id=cluster_uuid, perm=perm_value) + ] + + logger.info("Updating role with name: %s", name) + self.client.request( + HTTP_METHOD_PUT, + path=f"{URL_PATH_AUTHN_ROLES}/{name}", + json=role_info.dict(), + ) + + def delete(self, name: str) -> None: + """ + Deletes a role. + + Args: + name (str): The name of the role to delete. + + Raises: + aistore.sdk.errors.AISError: All other types of errors with AIStore. + requests.RequestException: If the HTTP request fails. + ValueError: If the role does not exist. + """ + try: + self.get(role_name=name) + except AISError as error: + raise ValueError(f"Role {name} does not exist") from error + + logger.info("Deleting role with name: %s", name) + + self.client.request( + HTTP_METHOD_DELETE, + path=f"{URL_PATH_AUTHN_ROLES}/{name}", + ) diff --git a/python/aistore/sdk/authn/types.py b/python/aistore/sdk/authn/types.py new file mode 100644 index 00000000000..9756c5e7ce0 --- /dev/null +++ b/python/aistore/sdk/authn/types.py @@ -0,0 +1,172 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# + +from __future__ import annotations +from typing import Optional, Union, Dict, List +from pydantic import BaseModel, Field +from aistore.sdk.authn.access_attr import AccessAttr +from aistore.sdk.const import NANOSECONDS_IN_SECOND +from aistore.sdk.types import BucketModel + + +# pylint: disable=missing-class-docstring, missing-function-docstring + + +class LoginMsg(BaseModel): + """ + Represents a login message with a password and optional expiration duration. + + Attributes: + password (str): The password string. + expires_in (Optional[Union[int, float]]): The expiration duration in seconds. + """ + + password: str + expires_in: Optional[Union[int, float]] = Field( + None, description="Expiration duration in seconds." + ) + + def as_dict(self): + """ + Converts the instance to a dict, converting the expiration duration to nanoseconds if specified. + + Returns: + Dict[str, Union[str, int]]: The dict representation of the login message. + """ + + data = self.dict() + if self.expires_in is not None: + data["expires_in"] = int( + self.expires_in * NANOSECONDS_IN_SECOND + ) # Convert seconds to nanoseconds + return data + + +class TokenMsg(BaseModel): + """ + Represents a message containing a token. + + Attributes: + token (str): The token string. + """ + + token: str + + +class ClusterInfo(BaseModel): + """ + Represents information about a cluster. + + Attributes: + id (str): The unique identifier of the cluster. + alias (Optional[str]): The alias name of the cluster. Defaults to None. + urls (List[str]): A list of URLs associated with the cluster. + """ + + id: str + alias: Optional[str] = None + urls: List[str] = [] + + +class ClusterList(BaseModel): + """ + Represents a list of clusters. + + Attributes: + clusters (Dict[str, ClusterInfo]): A dictionary of cluster IDs to ClusterInfo objects. + """ + + clusters: Dict[str, ClusterInfo] = {} + + +class ClusterPermission(BaseModel): + """ + Represents a cluster with its associated permissions. + """ + + id: str + perm: str + + def describe(self) -> str: + return AccessAttr.describe(int(self.perm)) + + def __str__(self) -> str: + return f"ClusterPermission(id={self.id}, perm={self.describe()})" + + +class BucketPermission(BaseModel): + """ + Represents a bucket with its associated permissions. + """ + + bck: BucketModel + perm: str + + def describe(self) -> str: + return AccessAttr.describe(int(self.perm)) + + def __str__(self) -> str: + return f"BucketPermission(bck={self.bck}, perm={self.describe()})" + + +class RoleInfo(BaseModel): + """ + Represents role information including permissions for clusters and buckets. + + Attributes: + name (str): Name of the role. + desc (str): Description of the role. + clusters (Optional[List[ClusterPermission]]): List of cluster permissions. + buckets (Optional[List[BucketPermission]]): List of bucket permissions. + admin (bool): Whether the role has admin privileges. + """ + + name: str + desc: str + clusters: List[ClusterPermission] = None + buckets: List[BucketPermission] = None + admin: bool = False + + def __str__(self) -> str: + if self.clusters is None: + clusters_str = "None" + else: + clusters_str = ", ".join(str(cluster) for cluster in self.clusters) + + if self.buckets is None: + buckets_str = "None" + else: + buckets_str = ", ".join(str(bucket) for bucket in self.buckets) + + return ( + f"RoleInfo(name={self.name}, desc={self.desc}, " + f"clusters=[{clusters_str}], buckets=[{buckets_str}], admin={self.admin})" + ) + + +class RolesList(BaseModel): + """ + Represents a list of roles. + + Attributes: + __root__ (List[RoleInfo]): List of roles. + """ + + __root__: List[RoleInfo] + + def __iter__(self): + return iter(self.__root__) + + def __getitem__(self, item): + return self.__root__[item] + + def __len__(self): + return len(self.__root__) + + @property + def roles(self): + return self.__root__ + + def __str__(self) -> str: + return "\n".join(str(role) for role in self.__root__) diff --git a/python/aistore/sdk/const.py b/python/aistore/sdk/const.py index 252fcdeded9..a2ba8f9bf49 100644 --- a/python/aistore/sdk/const.py +++ b/python/aistore/sdk/const.py @@ -85,6 +85,7 @@ # AuthN URL_PATH_AUTHN_USERS = "users" URL_PATH_AUTHN_CLUSTERS = "clusters" +URL_PATH_AUTHN_ROLES = "roles" # Bucket providers # See api/apc/provider.go diff --git a/python/tests/integration/sdk/authn/test_auth_client.py b/python/tests/integration/sdk/authn/test_authn_client.py similarity index 97% rename from python/tests/integration/sdk/authn/test_auth_client.py rename to python/tests/integration/sdk/authn/test_authn_client.py index 1199be1665e..f6fc256ce00 100644 --- a/python/tests/integration/sdk/authn/test_auth_client.py +++ b/python/tests/integration/sdk/authn/test_authn_client.py @@ -21,7 +21,7 @@ # pylint: disable=duplicate-code -class TestAuthCLient(unittest.TestCase): +class TestAuthNClient(unittest.TestCase): def setUp(self) -> None: # AIStore Client self.bck_name = random_string() diff --git a/python/tests/integration/sdk/authn/test_authn_clusters.py b/python/tests/integration/sdk/authn/test_authn_cluster_manager.py similarity index 96% rename from python/tests/integration/sdk/authn/test_authn_clusters.py rename to python/tests/integration/sdk/authn/test_authn_cluster_manager.py index 11bf03ade8c..9e481353681 100644 --- a/python/tests/integration/sdk/authn/test_authn_clusters.py +++ b/python/tests/integration/sdk/authn/test_authn_cluster_manager.py @@ -10,7 +10,7 @@ from aistore.sdk.authn.authn_client import AuthNClient from aistore.sdk.client import Client -from aistore.sdk.authn.authn_types import ClusterInfo, ClusterList +from aistore.sdk.authn.types import ClusterInfo, ClusterList from aistore.sdk.errors import AISError from tests.integration import ( AIS_AUTHN_SU_NAME, @@ -20,7 +20,7 @@ ) -class TestClusterManager( +class TestAuthNClusterManager( unittest.TestCase ): # pylint: disable=too-many-instance-attributes def setUp(self) -> None: diff --git a/python/tests/integration/sdk/authn/test_authn_role_manager.py b/python/tests/integration/sdk/authn/test_authn_role_manager.py new file mode 100644 index 00000000000..3a9455ee126 --- /dev/null +++ b/python/tests/integration/sdk/authn/test_authn_role_manager.py @@ -0,0 +1,152 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# + +import unittest + +import pytest + +from aistore.sdk.authn.authn_client import AuthNClient +from aistore.sdk.authn.access_attr import AccessAttr +from aistore.sdk.client import Client +from aistore.sdk.errors import AISError +from tests.integration import ( + AIS_AUTHN_SU_NAME, + AIS_AUTHN_SU_PASS, + AUTHN_ENDPOINT, + CLUSTER_ENDPOINT, +) + +from tests.utils import random_string + + +class TestAuthNRoleManager( + unittest.TestCase +): # pylint: disable=too-many-instance-attributes + def setUp(self) -> None: + self.authn_client = AuthNClient(AUTHN_ENDPOINT) + self.authn_client.login(AIS_AUTHN_SU_NAME, AIS_AUTHN_SU_PASS) + self.cluster_manager = self.authn_client.cluster_manager() + self.cluster_alias = "Test-Cluster-" + random_string() + self.cluster_info = self.cluster_manager.register( + self.cluster_alias, [CLUSTER_ENDPOINT] + ) + self.role_manager = self.authn_client.role_manager() + self.role = self._create_role() + self.ais_client = Client(CLUSTER_ENDPOINT, token=self.authn_client.client.token) + self.uuid = self.ais_client.cluster().get_uuid() + + def tearDown(self) -> None: + self.cluster_manager.delete(cluster_id=self.uuid) + try: + self.role_manager.delete(name=self.role.name) + except ValueError: + pass + + def _create_role(self): + return self.role_manager.create( + name="Test-Role-" + random_string(), + desc="Test Description", + cluster_alias=self.cluster_info.alias, + perms=[AccessAttr.GET], + ) + + @pytest.mark.authn + def test_role_get(self): + self.assertTrue(self.role == self.role_manager.get(role_name=self.role.name)) + + @pytest.mark.authn + def test_role_list(self): + self.assertIn(self.role, self.role_manager.list()) + self.role_manager.delete(name=self.role.name) + self.assertNotIn(self.role, self.role_manager.list()) + + @pytest.mark.authn + def test_role_invalid_delete(self): + with self.assertRaises(ValueError): + self.role_manager.delete(name="invalid-name") + + @pytest.mark.authn + def test_role_delete(self): + self.role_manager.delete(name=self.role.name) + with self.assertRaises(AISError): + self.role_manager.get(role_name=self.role.name) + + @pytest.mark.authn + def test_role_invalid_update(self): + with self.assertRaises(ValueError): + self.role_manager.update(name=self.role.name) + with self.assertRaises(ValueError): + self.role_manager.update(name="invalid-name") + with self.assertRaises(ValueError): + self.role_manager.update(name=self.role.name, perms=[AccessAttr.GET]) + with self.assertRaises(ValueError): + self.role_manager.update( + name=self.role.name, cluster_alias=self.cluster_alias + ) + with self.assertRaises(ValueError): + self.role_manager.update( + name=self.role.name, + cluster_alias=self.cluster_alias, + bucket_name="bucket", + ) + with self.assertRaises(ValueError): + self.role_manager.update(name=self.role.name, bucket_name="bucket") + with self.assertRaises(ValueError): + self.role_manager.update( + name=self.role.name, bucket_name="bucket", perms=[AccessAttr.GET] + ) + + @pytest.mark.authn + def test_role_desc_update(self): + updated_description = "New Test Description" + self.role_manager.update(name=self.role.name, desc=updated_description) + self.assertTrue( + updated_description == self.role_manager.get(role_name=self.role.name).desc + ) + + @pytest.mark.authn + def test_role_perms_bucket_update(self): + self.role_manager.update( + name=self.role.name, + bucket_name="test-bucket", + cluster_alias=self.cluster_alias, + perms=[AccessAttr.ACCESS_RO], + ) + + buckets = self.role_manager.get(role_name=self.role.name).buckets + combined_perms = ( + AccessAttr.GET.value + | AccessAttr.OBJ_HEAD.value + | AccessAttr.LIST_BUCKETS.value + | AccessAttr.BCK_HEAD.value + | AccessAttr.OBJ_LIST.value + ) + + self.assertTrue( + buckets[0].bck.name == "test-bucket" + and int(buckets[0].perm) == combined_perms == AccessAttr.ACCESS_RO + ) + + @pytest.mark.authn + def test_role_perms_cluster_update(self): + self.role_manager.update( + name=self.role.name, + cluster_alias=self.cluster_alias, + perms=[ + AccessAttr.LIST_BUCKETS, + AccessAttr.CREATE_BUCKET, + AccessAttr.DESTROY_BUCKET, + ], + ) + + clusters = self.role_manager.get(role_name=self.role.name).clusters + combined_perms = ( + AccessAttr.LIST_BUCKETS.value + | AccessAttr.CREATE_BUCKET.value + | AccessAttr.DESTROY_BUCKET.value + ) + + self.assertTrue( + clusters[0].id == self.uuid and int(clusters[0].perm) == combined_perms + ) diff --git a/python/tests/unit/sdk/authn/test_authn_access_attr.py b/python/tests/unit/sdk/authn/test_authn_access_attr.py new file mode 100644 index 00000000000..d967702308e --- /dev/null +++ b/python/tests/unit/sdk/authn/test_authn_access_attr.py @@ -0,0 +1,90 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# + +import unittest +from aistore.sdk.authn.access_attr import AccessAttr + + +class TestAuthNAccessAttr(unittest.TestCase): + """ + Unit tests for AccessAttr, verifying bitwise flag combinations, inclusion, and descriptions. + """ + + def test_access_none(self): + self.assertFalse(AccessAttr.ACCESS_NONE & AccessAttr.GET) + self.assertFalse(AccessAttr.ACCESS_NONE & AccessAttr.PUT) + self.assertFalse(AccessAttr.ACCESS_NONE & AccessAttr.ACCESS_RO) + self.assertFalse(AccessAttr.ACCESS_NONE & AccessAttr.ACCESS_RW) + self.assertEqual(AccessAttr.ACCESS_NONE, AccessAttr(0)) + + def test_simple_combination_of_access_attrs(self): + combined = AccessAttr.GET | AccessAttr.PUT | AccessAttr.OBJ_DELETE + self.assertTrue(combined & AccessAttr.GET) + self.assertTrue(combined & AccessAttr.PUT) + self.assertTrue(combined & AccessAttr.OBJ_DELETE) + self.assertFalse(combined & AccessAttr.ADMIN) + + def test_access_all(self): + self.assertTrue(AccessAttr.ACCESS_ALL & AccessAttr.GET) + self.assertTrue(AccessAttr.ACCESS_ALL & AccessAttr.ADMIN) + self.assertEqual(AccessAttr.ACCESS_ALL & AccessAttr.ACCESS_NONE, 0) + + def test_describe_combined_access(self): + combined = AccessAttr.GET | AccessAttr.PUT | AccessAttr.OBJ_DELETE + description = AccessAttr.describe(combined) + self.assertIn("GET", description) + self.assertIn("PUT", description) + self.assertIn("DELETE", description) + self.assertNotIn("ADMIN", description) + + def test_describe_derived_access(self): + description = AccessAttr.describe(AccessAttr.ACCESS_RO) + self.assertIn("GET", description) + self.assertIn("OBJ_HEAD", description) + self.assertIn("LIST_BUCKETS", description) + self.assertIn("BCK_HEAD", description) + self.assertIn("OBJ_LIST", description) + self.assertNotIn("PUT", description) + self.assertNotIn("ADMIN", description) + + description = AccessAttr.describe(AccessAttr.ACCESS_RW) + self.assertIn("GET", description) + self.assertIn("OBJ_HEAD", description) + self.assertIn("LIST_BUCKETS", description) + self.assertIn("BCK_HEAD", description) + self.assertIn("OBJ_LIST", description) + self.assertIn("PUT", description) + self.assertIn("APPEND", description) + self.assertIn("OBJ_DELETE", description) + self.assertIn("OBJ_MOVE", description) + self.assertNotIn("ADMIN", description) + self.assertNotIn("PROMOTE", description) + + def test_access_ro(self): + self.assertTrue(AccessAttr.ACCESS_RO & AccessAttr.GET) + self.assertTrue(AccessAttr.ACCESS_RO & AccessAttr.OBJ_HEAD) + self.assertFalse(AccessAttr.ACCESS_RO & AccessAttr.PUT) + self.assertFalse(AccessAttr.ACCESS_RO & AccessAttr.ADMIN) + + def test_access_rw(self): + self.assertTrue(AccessAttr.ACCESS_RW & AccessAttr.GET) + self.assertTrue(AccessAttr.ACCESS_RW & AccessAttr.PUT) + self.assertTrue(AccessAttr.ACCESS_RW & AccessAttr.OBJ_DELETE) + self.assertFalse(AccessAttr.ACCESS_RW & AccessAttr.ADMIN) + + def test_access_cluster(self): + self.assertTrue(AccessAttr.ACCESS_CLUSTER & AccessAttr.LIST_BUCKETS) + self.assertTrue(AccessAttr.ACCESS_CLUSTER & AccessAttr.CREATE_BUCKET) + self.assertTrue(AccessAttr.ACCESS_CLUSTER & AccessAttr.ADMIN) + self.assertFalse(AccessAttr.ACCESS_CLUSTER & AccessAttr.GET) + + def test_access_all_includes_all_derived_roles(self): + self.assertTrue(AccessAttr.ACCESS_ALL & AccessAttr.ACCESS_RW) + self.assertTrue(AccessAttr.ACCESS_ALL & AccessAttr.ACCESS_CLUSTER) + self.assertTrue(AccessAttr.ACCESS_ALL & AccessAttr.PROMOTE) + self.assertFalse(AccessAttr.ACCESS_ALL & AccessAttr.ACCESS_NONE) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/unit/sdk/authn/test_authn_client.py b/python/tests/unit/sdk/authn/test_authn_client.py index 1f916f1e593..509b7d4b24c 100644 --- a/python/tests/unit/sdk/authn/test_authn_client.py +++ b/python/tests/unit/sdk/authn/test_authn_client.py @@ -6,7 +6,7 @@ from unittest.mock import patch, Mock from aistore.sdk.authn import AuthNClient -from aistore.sdk.authn.authn_types import TokenMsg, LoginMsg +from aistore.sdk.authn.types import TokenMsg, LoginMsg from tests.utils import test_cases diff --git a/python/tests/unit/sdk/authn/test_authn_clusters.py b/python/tests/unit/sdk/authn/test_authn_cluster_manager.py similarity index 97% rename from python/tests/unit/sdk/authn/test_authn_clusters.py rename to python/tests/unit/sdk/authn/test_authn_cluster_manager.py index f2d6f302030..416201e39da 100644 --- a/python/tests/unit/sdk/authn/test_authn_clusters.py +++ b/python/tests/unit/sdk/authn/test_authn_cluster_manager.py @@ -6,7 +6,7 @@ from unittest.mock import patch, Mock from aistore.sdk.request_client import RequestClient -from aistore.sdk.authn.authn_types import ClusterInfo, ClusterList +from aistore.sdk.authn.types import ClusterInfo, ClusterList from aistore.sdk.authn.cluster_manager import ClusterManager from aistore.sdk.const import ( HTTP_METHOD_GET, @@ -17,7 +17,7 @@ ) -class TestClusterManager(unittest.TestCase): +class TestAuthNClusterManager(unittest.TestCase): def setUp(self) -> None: self.endpoint = "http://authn-endpoint" self.mock_client = Mock(RequestClient) diff --git a/python/tests/unit/sdk/authn/test_authn_role_manager.py b/python/tests/unit/sdk/authn/test_authn_role_manager.py new file mode 100644 index 00000000000..e24d0b2fa82 --- /dev/null +++ b/python/tests/unit/sdk/authn/test_authn_role_manager.py @@ -0,0 +1,181 @@ +# +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. +# + +# pylint: disable=duplicate-code + +import unittest +from unittest.mock import patch, Mock + +from aistore.sdk.request_client import RequestClient +from aistore.sdk.authn.types import ( + BucketModel, + BucketPermission, + ClusterInfo, + ClusterPermission, + ClusterList, + RoleInfo, + RolesList, +) +from aistore.sdk.authn.access_attr import AccessAttr +from aistore.sdk.authn.role_manager import RoleManager +from aistore.sdk.const import ( + HTTP_METHOD_GET, + HTTP_METHOD_DELETE, + HTTP_METHOD_PUT, + HTTP_METHOD_POST, + URL_PATH_AUTHN_ROLES, +) + + +class TestAuthNRoleManager(unittest.TestCase): + def setUp(self) -> None: + self.mock_client = Mock(RequestClient) + self.role_manager = RoleManager(self.mock_client) + + def test_role_delete(self): + self.role_manager.delete(name="test-role") + self.mock_client.request.assert_called_once_with( + HTTP_METHOD_DELETE, + path=f"{URL_PATH_AUTHN_ROLES}/test-role", + ) + + def test_role_list(self): + mock_roles_list = RolesList( + __root__=[ + RoleInfo( + name="role1", + desc="Description for role1", + clusters=[ + ClusterPermission(id="cluster1", perm=str(AccessAttr.GET.value)) + ], + buckets=[ + BucketPermission( + bck=BucketModel(name="bucket1"), + perm=str(AccessAttr.GET.value), + ) + ], + admin=False, + ), + RoleInfo( + name="role2", + desc="Description for role2", + clusters=[ + ClusterPermission(id="cluster2", perm=str(AccessAttr.PUT.value)) + ], + buckets=[ + BucketPermission( + bck=BucketModel(name="bucket2"), + perm=str(AccessAttr.PUT.value), + ) + ], + admin=True, + ), + ] + ) + self.mock_client.request_deserialize.return_value = mock_roles_list + + roles_list = self.role_manager.list() + self.assertEqual(roles_list, mock_roles_list) + + self.mock_client.request_deserialize.assert_called_once_with( + HTTP_METHOD_GET, + path=URL_PATH_AUTHN_ROLES, + res_model=RolesList, + ) + + def test_role_get(self): + mock_role_info = RoleInfo( + name="test-role", + desc="Test Description", + clusters=[], + buckets=[], + admin=False, + ) + self.mock_client.request_deserialize.return_value = mock_role_info + + role_info = self.role_manager.get(role_name="test-role") + self.assertEqual(role_info, mock_role_info) + + self.mock_client.request_deserialize.assert_called_once_with( + HTTP_METHOD_GET, + path=f"{URL_PATH_AUTHN_ROLES}/test-role", + res_model=RoleInfo, + ) + + def test_role_create(self): + cluster_id = "cluster_id" + cluster_alias = "test-cluster" + urls = ["http://new-cluster-url"] + mock_cluster_permission = ClusterPermission( + id=cluster_id, perm=str(AccessAttr.GET.value) + ) + mock_cluster_list = ClusterList( + clusters={ + cluster_id: ClusterInfo(id=cluster_id, alias=cluster_alias, urls=urls) + } + ) + mock_role_info = RoleInfo( + name="test-role", + desc="Test Description", + clusters=[mock_cluster_permission], + admin=False, + ) + self.mock_client.request_deserialize.side_effect = [ + mock_cluster_list, + mock_role_info, + ] + + role_info = self.role_manager.create( + name="test-role", + desc="Test Description", + cluster_alias=cluster_alias, + perms=[AccessAttr.GET], + ) + + self.assertEqual(role_info, mock_role_info) + + self.mock_client.request.assert_called_once_with( + HTTP_METHOD_POST, + path=URL_PATH_AUTHN_ROLES, + json=role_info.dict(), + ) + + @patch("aistore.sdk.authn.cluster_manager.ClusterManager.get") + def test_update_role(self, mock_cluster_manager_get): + cluster_id = "cluster-id" + role_name = "test-role" + new_desc = "Updated Description" + cluster_alias = "test-cluster" + perms = [AccessAttr.GET] + + mock_role_info = RoleInfo( + name=role_name, + desc="Original Description", + clusters=[ClusterPermission(id=cluster_id, perm=str(AccessAttr.PUT.value))], + ) + self.mock_client.request_deserialize.return_value = mock_role_info + + mock_cluster_info = ClusterInfo( + id=cluster_id, alias=cluster_alias, urls=["http://example.com"] + ) + mock_cluster_manager_get.return_value = mock_cluster_info + + self.role_manager.update( + name=role_name, + desc=new_desc, + cluster_alias=cluster_alias, + perms=perms, + ) + + expected_updated_role_info = RoleInfo( + name=role_name, + desc=new_desc, + clusters=[ClusterPermission(id=cluster_id, perm=str(AccessAttr.GET.value))], + ) + + self.mock_client.request.assert_called_once_with( + HTTP_METHOD_PUT, + path=f"{URL_PATH_AUTHN_ROLES}/{role_name}", + json=expected_updated_role_info.dict(), + )