From a3d8d430a92825ff1291cdbfe2c8801d9dc7d229 Mon Sep 17 00:00:00 2001 From: Rasmus Welander Date: Mon, 8 Dec 2025 17:20:07 +0100 Subject: [PATCH] implement client for groups --- README.md | 19 ++++ cs3client/cs3client.py | 2 + cs3client/group.py | 124 +++++++++++++++++++++ examples/group_api_example.py | 67 ++++++++++++ tests/test_group.py | 195 ++++++++++++++++++++++++++++++++++ 5 files changed, 407 insertions(+) create mode 100644 cs3client/group.py create mode 100644 examples/group_api_example.py create mode 100644 tests/test_group.py diff --git a/README.md b/README.md index 2219c61..065bdf4 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - Support for common lock operations (set lock, get lock, unlock, ...). - Support for common share operations (create share, update share, delete share, ...). - Support for common user operations (get user, find users, get user groups, ...). +- support for common group operations (get group, find group, has member, ...). - Support for restoring files through checkpoints (restore file version, list checkpoints). - Support for applications (open in app, list app providers). - Authentication and authorization handling. @@ -302,6 +303,24 @@ res = client.user.get_user_by_claim("username", "rwelande") ``` +### Group example +```python +# get_group_by_claim (username) +res = client.group.get_group_by_claim(client.auth.get_token(), "username", "rwelande") + +# get_group +res = client.group.get_group(client.auth.get_token(), "https://auth.cern.ch/auth/realms/cern", "asdoiqwe") + +# has_member +res = client.group.has_member(client.auth.get_token(), "somegroup", "rwelande", "https://auth.cern.ch/auth/realms/cern") + +# get_members +res = client.group.get_members(client.auth.get_token(), "somegroup", "https://auth.cern.ch/auth/realms/cern") + +# find_groups +res = client.group.find_groups(client.auth.get_token(), "rwel") +``` + ### App Example ```python # list_app_providers diff --git a/cs3client/cs3client.py b/cs3client/cs3client.py index 79cb608..8554b44 100644 --- a/cs3client/cs3client.py +++ b/cs3client/cs3client.py @@ -13,6 +13,7 @@ from .file import File from .user import User +from .group import Group from .share import Share from .statuscodehandler import StatusCodeHandler from .app import App @@ -48,6 +49,7 @@ def __init__(self, config: ConfigParser, config_category: str, log: logging.Logg self.file: File = File(self._config, self._log, self._gateway, self._status_code_handler) self.user: User = User(self._config, self._log, self._gateway, self._status_code_handler) self.app: App = App(self._config, self._log, self._gateway, self._status_code_handler) + self.group: Group = Group(self._config, self._log, self._gateway, self._status_code_handler) self.checkpoint: Checkpoint = Checkpoint( self._config, self._log, self._gateway, self._status_code_handler ) diff --git a/cs3client/group.py b/cs3client/group.py new file mode 100644 index 0000000..5015268 --- /dev/null +++ b/cs3client/group.py @@ -0,0 +1,124 @@ +""" +group.py + +Authors: Rasmus Welander, Diogo Castro, Giuseppe Lo Presti. +Emails: rasmus.oscar.welander@cern.ch, diogo.castro@cern.ch, giuseppe.lopresti@cern.ch +Last updated: 08/12/2025 +""" + +import logging +import cs3.identity.group.v1beta1.resources_pb2 as cs3igr +import cs3.identity.group.v1beta1.group_api_pb2 as cs3ig +import cs3.identity.user.v1beta1.resources_pb2 as cs3iur +from cs3.gateway.v1beta1.gateway_api_pb2_grpc import GatewayAPIStub + +from .config import Config +from .statuscodehandler import StatusCodeHandler + + +class Group: + """ + Group class to handle group related API calls with CS3 Gateway API. + """ + + def __init__( + self, + config: Config, + log: logging.Logger, + gateway: GatewayAPIStub, + status_code_handler: StatusCodeHandler, + ) -> None: + """ + Initializes the Group class with logger, auth, and gateway stub, + + :param log: Logger instance for logging. + :param gateway: GatewayAPIStub instance for interacting with CS3 Gateway. + :param auth: An instance of the auth class. + """ + self._log: logging.Logger = log + self._gateway: GatewayAPIStub = gateway + self._config: Config = config + self._status_code_handler: StatusCodeHandler = status_code_handler + + def get_group(self, auth_token: tuple, opaque_id, idp) -> cs3igr.Group: + """ + Get the group information provided the opaque_id. + + :param opaque_id: Opaque group id. + :return: Group information. + :raises: return NotFoundException (Group not found) + :raises: AuthenticationException (Operation not permitted) + :raises: UnknownException (Unknown error) + """ + req = cs3ig.GetGroupRequest(group_id=cs3igr.GroupId(opaque_id=opaque_id, idp=idp)) + res = self._gateway.GetGroup(request=req, metadata=[auth_token]) + self._status_code_handler.handle_errors(res.status, "get group", f'opaque_id="{opaque_id}"') + self._log.debug(f'msg="Invoked GetGroup" opaque_id="{res.group.id.opaque_id}" trace="{res.status.trace}"') + return res.group + + def get_group_by_claim(self, auth_token: tuple, claim, value) -> cs3igr.Group: + """ + Get the group information provided the claim and value. + + :param claim: Claim to search for. + :param value: Value to search for. + :return: Group information. + :raises: NotFoundException (Group not found) + :raises: AuthenticationException (Operation not permitted) + :raises: UnknownException (Unknown error) + """ + req = cs3ig.GetGroupByClaimRequest(claim=claim, value=value, skip_fetching_members=False) + res = self._gateway.GetGroupByClaim(request=req, metadata=[auth_token]) + self._status_code_handler.handle_errors(res.status, "get group by claim", f'claim="{claim}" value="{value}"') + self._log.debug(f'msg="Invoked GetGroupByClaim" opaque_id="{res.group.id.opaque_id}" trace="{res.status.trace}"') + return res.group + + def has_member(self, auth_token: tuple, group_opaque_id, user_opaque_id, idp) -> bool: + """ + Check if a user is a member of a group. + + :param group_opaque_id: Group opaque id. + :param user_opaque_id: User opaque id. + :return: True if the user is a member of the group, False otherwise. + :raises: NotFoundException (Group not found) + :raises: AuthenticationException (Operation not permitted) + :raises: UnknownException (Unknown error) + """ + req = cs3ig.HasMemberRequest(group_id=cs3igr.GroupId(opaque_id=group_opaque_id, idp=idp), user_id=cs3iur.UserId(opaque_id=user_opaque_id, idp=idp)) + res = self._gateway.HasMember(request=req, metadata=[auth_token]) + self._status_code_handler.handle_errors(res.status, "has member", f'group_id="{group_opaque_id}" user_id="{user_opaque_id}"') + self._log.debug(f'msg="Invoked HasMember" group_id="{group_opaque_id}" user_id="{user_opaque_id}" trace="{res.status.trace}"') + return res.ok + + def get_members(self, auth_token: tuple, opaque_id, idp) -> list[str]: + """ + Get the groups the user is a part of. + + :param opaque_id: Opaque group id. + :return: A list of the groups the user is part of. + :raises: NotFoundException (User not found) + :raises: AuthenticationException (Operation not permitted) + :raises: UnknownException (Unknown error) + """ + req = cs3ig.GetMembersRequest(group_id=cs3igr.GroupId(idp=idp, opaque_id=opaque_id)) + res = self._gateway.GetUserGroups(request=req, metadata=[auth_token]) + self._status_code_handler.handle_errors(res.status, "get user groups", f'opaque_id="{opaque_id}"') + self._log.debug(f'msg="Invoked GetUserGroups" opaque_id="{opaque_id}" trace="{res.status.trace}"') + return res.groups + + def find_groups(self, auth_token: tuple, filters) -> list[cs3igr.Group]: + """ + Find a group based on a filter. + + :param auth_token: tuple in the form ('x-access-token', ) (see auth.get_token/auth.check_token) + :param filters: Filters to search for. + :return: a list of group(s). + :raises: NotFoundException (Group not found) + :raises: AuthenticationException (Operation not permitted) + :raises: UnknownException (Unknown error) + """ + req = cs3ig.FindGroupsRequest(filters=filters) + res = self._gateway.FindGroups(request=req, metadata=[auth_token]) + self._status_code_handler.handle_errors(res.status, "find groups") + self._log.debug(f'msg="Invoked FindGroups" filter="{filter}" trace="{res.status.trace}"') + return res.groups diff --git a/examples/group_api_example.py b/examples/group_api_example.py new file mode 100644 index 0000000..2d5ddeb --- /dev/null +++ b/examples/group_api_example.py @@ -0,0 +1,67 @@ +""" +group_api_example.py + +Example script to demonstrate the usage of the CS3Client class. + + +Authors: Rasmus Welander, Diogo Castro, Giuseppe Lo Presti. +Emails: rasmus.oscar.welander@cern.ch, diogo.castro@cern.ch, giuseppe.lopresti@cern.ch +Last updated: 08/12/2025 +""" + +import logging +import configparser +from cs3client.cs3client import CS3Client +from cs3client.auth import Auth + +config = configparser.ConfigParser() +with open("default.conf") as fdef: + config.read_file(fdef) +log = logging.getLogger(__name__) + +client = CS3Client(config, "cs3client", log) +auth = Auth(client) +# Set the client id (can also be set in the config) +auth.set_client_id("") +# Set client secret (can also be set in config) +auth.set_client_secret("") +# Checks if token is expired if not return ('x-access-token', ) +# if expired, request a new token from reva +auth_token = auth.get_token() + +# OR if you already have a reva token +# Checks if token is expired if not return (x-access-token', ) +# if expired, throws an AuthenticationException (so you can refresh your reva token) +token = "" +auth_token = Auth.check_token(token) + + +res = None + + +# get_group_by_claim (username) +res = client.group.get_group_by_claim(client.auth.get_token(), "username", "rwelande") +if res is not None: + print(res) + +# get_group +res = client.group.get_group(client.auth.get_token(), "https://auth.cern.ch/auth/realms/cern", "asdoiqwe") + +if res is not None: + print(res) + +# has_member +res = client.group.has_member(client.auth.get_token(), "somegroup", "rwelande", "https://auth.cern.ch/auth/realms/cern") + +if res is not None: + print(res) + +# get_members +res = client.group.get_members(client.auth.get_token(), "somegroup", "https://auth.cern.ch/auth/realms/cern") +if res is not None: + print(res) + +# find_groups +res = client.group.find_groups(client.auth.get_token(), "rwel") +if res is not None: + print(res) diff --git a/tests/test_group.py b/tests/test_group.py new file mode 100644 index 0000000..983cc68 --- /dev/null +++ b/tests/test_group.py @@ -0,0 +1,195 @@ +""" +test_group.py + +Tests that the Group class methods work as expected. + +Authors: Rasmus Welander, Diogo Castro, Giuseppe Lo Presti. +Emails: rasmus.oscar.welander@cern.ch, diogo.castro@cern.ch, giuseppe.lopresti@cern.ch +Last updated: 08/12/2025 +""" + +import pytest +from unittest.mock import Mock, patch +import cs3.rpc.v1beta1.code_pb2 as cs3code +import cs3.identity.group.v1beta1.group_api_pb2 as cs3ig +import cs3.identity.group.v1beta1.resources_pb2 as cs3igr + +from cs3client.exceptions import ( + AuthenticationException, + NotFoundException, + UnknownException, +) +from .fixtures import ( # noqa: F401 (they are used, the framework is not detecting it) + mock_config, + mock_logger, + mock_gateway, + mock_status_code_handler, +) + + +@pytest.fixture +def group_instance(mock_config, mock_logger, mock_gateway, mock_status_code_handler): # noqa: F811 + """ + Fixture for creating a Group instance with mocked dependencies. + """ + from cs3client.group import Group + + return Group(mock_config, mock_logger, mock_gateway, mock_status_code_handler) + + +# Test cases for the Group class + + +@pytest.mark.parametrize( + "status_code, status_message, expected_exception, group_data", + [ + (cs3code.CODE_OK, None, None, Mock(id=Mock(idp="idp", opaque_id="opaque_id"))), + (cs3code.CODE_NOT_FOUND, "error", NotFoundException, None), + (-2, "error", UnknownException, None), + ], +) +def test_get_group( + group_instance, status_code, status_message, expected_exception, group_data # noqa: F811 (not a redefinition) +): + idp = "idp" + opaque_id = "opaque_id" + + mock_response = Mock() + mock_response.status.code = status_code + mock_response.status.message = status_message + mock_response.group = group_data + auth_token = ('x-access-token', "some_token") + + + with patch.object(group_instance._gateway, "GetGroup", return_value=mock_response): + if expected_exception: + with pytest.raises(expected_exception): + group_instance.get_group(auth_token, opaque_id, idp) + else: + result = group_instance.get_group(auth_token, opaque_id, idp) + assert result == group_data + + +@pytest.mark.parametrize( + "status_code, status_message, expected_exception, has_member", + [ + (cs3code.CODE_OK, None, None, True), + (cs3code.CODE_OK, None, None, False), + (cs3code.CODE_NOT_FOUND, "error", NotFoundException, None), + (-2, "error", UnknownException, None), + ], +) +def test_has_member( + group_instance, status_code, status_message, expected_exception, has_member # noqa: F811 (not a redefinition) +): + group_opaque_id = "group_opaque_id" + user_opaque_id = "user_opaque_id" + user_idp = "user_idp" + + mock_response = Mock() + mock_response.status.code = status_code + mock_response.status.message = status_message + mock_response.ok = has_member + auth_token = ('x-access-token', "some_token") + + + with patch.object(group_instance._gateway, "HasMember", return_value=mock_response): + if expected_exception: + with pytest.raises(expected_exception): + group_instance.has_member(auth_token, group_opaque_id, user_opaque_id, user_idp) + else: + result = group_instance.has_member(auth_token, group_opaque_id, user_opaque_id, user_idp) + assert result == has_member + + +@pytest.mark.parametrize( + "status_code, status_message, expected_exception, groups", + [ + (cs3code.CODE_OK, None, None, ["member1", "member2"]), + (cs3code.CODE_NOT_FOUND, "error", NotFoundException, None), + (-2, "error", UnknownException, None), + ], +) +def test_get_members( + group_instance, status_code, status_message, expected_exception, groups # noqa: F811 (not a redefinition) +): + idp = "idp" + opaque_id = "opaque_id" + + mock_response = Mock() + mock_response.status.code = status_code + mock_response.status.message = status_message + mock_response.groups = groups + auth_token = ('x-access-token', "some_token") + + + with patch.object(group_instance._gateway, "GetUserGroups", return_value=mock_response): + if expected_exception: + with pytest.raises(expected_exception): + group_instance.get_members(auth_token, opaque_id, idp) + else: + result = group_instance.get_members(auth_token, opaque_id, idp) + assert result == groups + + +@pytest.mark.parametrize( + "status_code, status_message, expected_exception, groups", + [ + (cs3code.CODE_OK, None, None, [Mock(), Mock()]), + (cs3code.CODE_NOT_FOUND, "error", NotFoundException, None), + (cs3code.CODE_UNAUTHENTICATED, "error", AuthenticationException, None), + (-2, "error", UnknownException, None), + ], +) +def test_find_groups( + group_instance, status_code, status_message, expected_exception, groups # noqa: F811 (not a redefinition) +): + filters = [ + cs3ig.Filter( + type=cs3igr.GroupType.GROUP_TYPE_FEDERATED, + ) + ] + + mock_response = Mock() + mock_response.status.code = status_code + mock_response.status.message = status_message + mock_response.groups = groups + auth_token = ('x-access-token', "some_token") + + with patch.object(group_instance._gateway, "FindGroups", return_value=mock_response): + if expected_exception: + with pytest.raises(expected_exception): + group_instance.find_groups(auth_token, filters) + else: + result = group_instance.find_groups(auth_token, filters) + assert result == groups + + + +@pytest.mark.parametrize( + "status_code, status_message, expected_exception, group_data", + [ + (cs3code.CODE_OK, None, None, Mock(idp="idp", opaque_id="opaque_id")), + (cs3code.CODE_NOT_FOUND, "error", NotFoundException, None), + (-2, "error", UnknownException, None), + ], +) +def test_get_group_by_claim( + group_instance, status_code, status_message, expected_exception, group_data # noqa: F811 (not a redefinition) +): + claim = "claim" + value = "value" + + mock_response = Mock() + mock_response.status.code = status_code + mock_response.status.message = status_message + mock_response.group = group_data + auth_token = ('x-access-token', "some_token") + + with patch.object(group_instance._gateway, "GetGroupByClaim", return_value=mock_response): + if expected_exception: + with pytest.raises(expected_exception): + group_instance.get_group_by_claim(auth_token, claim, value) + else: + result = group_instance.get_group_by_claim(auth_token, claim, value) + assert result == group_data \ No newline at end of file