From d54dc509ea17ef05d06bc6e4988b474ad12034ad Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 28 May 2024 12:35:11 +0100 Subject: [PATCH] Filter users and groups a user can share an app with based on permissions (#295) * temp * fix users and groups filtering * remove unused imports * fix undo user name * add entity filtering test * add test for groups * move has_scope import within function * skip test if not jupyterhub 5 * add note for each test * move expanded scoeps out of loop * update assert 'scopes' in self.token_json --- jhub_apps/hub_client/hub_client.py | 37 +++++++++--- ...st_filter_users__groups_based_on_scopes.py | 58 +++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 jhub_apps/tests/tests_unit/test_filter_users__groups_based_on_scopes.py diff --git a/jhub_apps/hub_client/hub_client.py b/jhub_apps/hub_client/hub_client.py index 91dca17e..cc79d72b 100644 --- a/jhub_apps/hub_client/hub_client.py +++ b/jhub_apps/hub_client/hub_client.py @@ -24,7 +24,8 @@ def requires_user_token(func): """ @wraps(func) def wrapper(self, *args, **kwargs): - token_id = self._create_token_for_user() + response_json = self._create_token_for_user() + token_id = response_json["id"] try: original_method_return = func(self, *args, **kwargs) except Exception as e: @@ -39,6 +40,7 @@ class HubClient: def __init__(self, username=None): self.username = username self.tokens = [JUPYTERHUB_API_TOKEN] + self.token_json = None self.jhub_apps_request_id = None self._set_request_id() @@ -74,9 +76,10 @@ def _create_token_for_user(self): # for e.g. When func_a calls func_b and both have the decorator "requires_user_token" # The func_a on completing execution will only clear the token, which the decorator # requires_user_token created for it, not the token created for func_a + self.token_json = rjson self.tokens.append(rjson["token"]) logger.info(f"Created token: {rjson['id']}") - return rjson["id"] + return rjson def _revoke_token(self, token_id): assert self.username @@ -298,17 +301,37 @@ def get_groups(self): r.raise_for_status() return r.json() + @requires_user_token + def get_user_scopes(self): + assert self.token_json + assert "scopes" in self.token_json + return self.token_json["scopes"] + def get_users_and_group_allowed_to_share_with(user): """Returns a list of users and groups""" - hclient = HubClient() + hclient = HubClient(username=user.name) users = hclient.get_users() user_names = [u["name"] for u in users if u["name"] != user.name] groups = hclient.get_groups() group_names = [group['name'] for group in groups] - # TODO: Filter users and groups based on what the user has access to share with - # parsed_scopes = parse_scopes(scopes) + user_scopes = hclient.get_user_scopes() return { - "users": user_names, - "groups": group_names + "users": filter_entity_based_on_scopes( + scopes=user_scopes, entities=user_names + ), + "groups": filter_entity_based_on_scopes( + scopes=user_scopes, entities=group_names, entity_key="group" + ) } + + +def filter_entity_based_on_scopes(scopes, entities, entity_key="user"): + # only available in JupyterHub>=5 + from jupyterhub.scopes import has_scope, expand_scopes + allowed_entities_to_read = set() + expanded_scopes = expand_scopes(scopes) + for entity in entities: + if has_scope(f'read:{entity_key}s:name!{entity_key}={entity}', expanded_scopes): + allowed_entities_to_read.add(entity) + return list(allowed_entities_to_read) diff --git a/jhub_apps/tests/tests_unit/test_filter_users__groups_based_on_scopes.py b/jhub_apps/tests/tests_unit/test_filter_users__groups_based_on_scopes.py new file mode 100644 index 00000000..c92042f3 --- /dev/null +++ b/jhub_apps/tests/tests_unit/test_filter_users__groups_based_on_scopes.py @@ -0,0 +1,58 @@ +import pytest + +from jhub_apps.hub_client.hub_client import filter_entity_based_on_scopes +from jhub_apps.hub_client.utils import is_jupyterhub_5 + + +@pytest.mark.skipif(not is_jupyterhub_5(), reason="requires jupyterhub>=5") +@pytest.mark.parametrize("name,entity_key,scopes,entities,expected_entities", [ + ( + "permissions-for-some-users", "user", + [ + "read:users:name!user=user_b", + "read:users:name!user=user_c", + "read:users:name!user=user_d", + "read:users:name!user=user_f", + ], + ["user_a", "user_c", "user_d", "user_e"], + ["user_c", "user_d"] + ), + ( + "generic-permission-for-all-users", "user", + [ + "read:users:name", + ], + ["user_c", "user_a", "user_e", "user_d"], + ["user_c", "user_a", "user_e", "user_d"] + ), + ( + "no-permissions-for-users", "user", + [], + ["user_a", "user_b"], + [] + ), + ( + "permissions-for-some-groups", "group", + [ + "read:groups:name!group=group-x", + "read:groups:name!group=group-b", + "read:groups:name!group=group-c", + "read:groups:name!group=group-y", + ], + ["group-a", "group-b", "group-c", "group-d"], + ["group-b", "group-c"] + ), + ( + "permissions-for-no-groups", "group", + [], + ["group-a", "group-b", "group-c", "group-d"], + [] + ), +]) +def test_filter_users_based_on_scopes(name, entity_key, scopes, entities, expected_entities): + filtered_entities = filter_entity_based_on_scopes( + scopes=scopes, + entities=entities, + entity_key=entity_key + ) + assert set(filtered_entities) == set(expected_entities)