From 2dc1acc86bad9c2a79f9fe83b75da6137ee80591 Mon Sep 17 00:00:00 2001 From: Justin Ryan Date: Fri, 11 Oct 2024 15:41:26 +0100 Subject: [PATCH] feat: add client scope client-specific role mappings --- src/keycloak/keycloak_admin.py | 223 ++++++++++++++++++++++++++++++++- src/keycloak/urls_patterns.py | 5 + tests/test_keycloak_admin.py | 176 ++++++++++++++++++++++++++ 3 files changed, 399 insertions(+), 5 deletions(-) diff --git a/src/keycloak/keycloak_admin.py b/src/keycloak/keycloak_admin.py index 0ec3985..ec4388e 100644 --- a/src/keycloak/keycloak_admin.py +++ b/src/keycloak/keycloak_admin.py @@ -2602,7 +2602,9 @@ class KeycloakAdmin: return raise_error_from_response(data_raw, KeycloakGetError) def assign_client_roles_to_client_scope(self, client_id, client_roles_owner_id, roles): - """Assign client roles to a client's scope. + """Assign client roles to a client's dedicated scope. + + To assign roles to a client scope, use add_client_specific_roles_to_client_scope. :param client_id: id of client (not client-id) who is assigned the roles :type client_id: str @@ -2626,7 +2628,9 @@ class KeycloakAdmin: return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) def delete_client_roles_of_client_scope(self, client_id, client_roles_owner_id, roles): - """Delete client roles of a client's scope. + """Delete client roles of a client's dedicated scope. + + To delete roles from a client scope, use remove_client_specific_roles_of_client_scope. :param client_id: id of client (not client-id) who is assigned the roles :type client_id: str @@ -2650,7 +2654,9 @@ class KeycloakAdmin: return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) def get_client_roles_of_client_scope(self, client_id, client_roles_owner_id): - """Get all client roles for a client's scope. + """Get all client roles for a client's dedicated scope. + + To get roles for a client scope, use get_client_specific_roles_of_client_scope. :param client_id: id of client (not client-id) :type client_id: str @@ -3574,6 +3580,104 @@ class KeycloakAdmin: ) return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + def add_client_specific_roles_to_client_scope( + self, client_scope_id, client_roles_owner_id, roles + ): + """Assign client roles to a client scope. + + To assign roles to a client's dedicated scope, use assign_client_roles_to_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation, must include id and name) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = self.connection.raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + def remove_client_specific_roles_of_client_scope( + self, client_scope_id, client_roles_owner_id, roles + ): + """Delete client roles of a client scope. + + To delete roles from a client's dedicated scope, use delete_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation, must include id and name) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = self.connection.raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + def get_client_specific_roles_of_client_scope(self, client_scope_id, client_roles_owner_id): + """Get client roles for a client scope, for a specific client. + + To get roles for a client's dedicated scope, use get_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + def get_all_roles_of_client_scope(self, client_scope_id): + """Get all client roles for a client scope. + + To get roles for a client's dedicated scope, + use get_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + def get_mappers_from_client(self, client_id): """List of all client mappers. @@ -6816,7 +6920,9 @@ class KeycloakAdmin: return raise_error_from_response(data_raw, KeycloakGetError) async def a_assign_client_roles_to_client_scope(self, client_id, client_roles_owner_id, roles): - """Assign client roles to a client's scope asynchronously. + """Assign client roles to a client's dedicated scope asynchronously. + + To assign roles to a client scope, use a_add_client_specific_roles_to_client_scope. :param client_id: id of client (not client-id) who is assigned the roles :type client_id: str @@ -6840,7 +6946,9 @@ class KeycloakAdmin: return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) async def a_delete_client_roles_of_client_scope(self, client_id, client_roles_owner_id, roles): - """Delete client roles of a client's scope asynchronously. + """Delete client roles of a client's dedicated scope asynchronously. + + To remove roles from a client scope, use a_remove_client_specific_roles_of_client_scope. :param client_id: id of client (not client-id) who is assigned the roles :type client_id: str @@ -6866,6 +6974,8 @@ class KeycloakAdmin: async def a_get_client_roles_of_client_scope(self, client_id, client_roles_owner_id): """Get all client roles for a client's scope asynchronously. + To get roles from a client scope, use a_get_client_roles_of_client_scope. + :param client_id: id of client (not client-id) :type client_id: str :param client_roles_owner_id: id of client (not client-id) who has the roles @@ -7794,6 +7904,109 @@ class KeycloakAdmin: ) return raise_error_from_response(data_raw, KeycloakPutError, expected_codes=[204]) + async def a_add_client_specific_roles_to_client_scope( + self, client_scope_id, client_roles_owner_id, roles + ): + """Assign client roles to a client scope asynchronously. + + To assign roles to a client's dedicated scope, use + a_assign_client_roles_to_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation, must include id and name) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = await self.connection.a_raw_post( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakPostError, expected_codes=[204]) + + async def a_remove_client_specific_roles_of_client_scope( + self, client_scope_id, client_roles_owner_id, roles + ): + """Delete client roles of a client scope asynchronously. + + To delete roles from a client's dedicated scope, + use a_delete_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :param roles: roles list or role (use RoleRepresentation, must include id and name) + :type roles: list + :return: Keycloak server response + :rtype: dict + """ + payload = roles if isinstance(roles, list) else [roles] + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = await self.connection.a_raw_delete( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path), + data=json.dumps(payload), + ) + return raise_error_from_response(data_raw, KeycloakDeleteError, expected_codes=[204]) + + async def a_get_client_specific_roles_of_client_scope( + self, client_scope_id, client_roles_owner_id + ): + """Get all client roles for a client scope asynchronously. + + To get roles for a client's dedicated scope, + use a_get_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :param client_roles_owner_id: id of client (not client-id) who has the roles + :type client_roles_owner_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + "client-id": client_roles_owner_id, + } + data_raw = await self.connection.a_raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + + async def a_get_all_roles_of_client_scope(self, client_scope_id): + """Get all client roles for a client scope. + + To get roles for a client's dedicated scope, + use a_get_client_roles_of_client_scope. + + :param client_scope_id: client scope id + :type client_scope_id: str + :return: Keycloak server response (array RoleRepresentation) + :rtype: dict + """ + params_path = { + "realm-name": self.connection.realm_name, + "scope-id": client_scope_id, + } + data_raw = self.connection.raw_get( + urls_patterns.URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS.format(**params_path) + ) + return raise_error_from_response(data_raw, KeycloakGetError) + async def a_get_mappers_from_client(self, client_id): """List of all client mappers asynchronously. diff --git a/src/keycloak/urls_patterns.py b/src/keycloak/urls_patterns.py index a183183..9b97f21 100644 --- a/src/keycloak/urls_patterns.py +++ b/src/keycloak/urls_patterns.py @@ -148,6 +148,11 @@ URL_ADMIN_CLIENT_SCOPES = "admin/realms/{realm-name}/client-scopes" URL_ADMIN_CLIENT_SCOPE = URL_ADMIN_CLIENT_SCOPES + "/{scope-id}" URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER = URL_ADMIN_CLIENT_SCOPE + "/protocol-mappers/models" URL_ADMIN_CLIENT_SCOPES_MAPPERS = URL_ADMIN_CLIENT_SCOPES_ADD_MAPPER + "/{protocol-mapper-id}" +URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS = URL_ADMIN_CLIENT_SCOPE + "/scope-mappings" +URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_REALM = URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS + "/realm" +URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS_CLIENT = ( + URL_ADMIN_CLIENT_SCOPE_ROLE_MAPPINGS + "/clients/{client-id}" +) URL_ADMIN_REALM_ROLES = "admin/realms/{realm-name}/roles" URL_ADMIN_REALM_ROLES_SEARCH = URL_ADMIN_REALM_ROLES + "?search={search-text}" diff --git a/tests/test_keycloak_admin.py b/tests/test_keycloak_admin.py index f73a51c..e33614b 100644 --- a/tests/test_keycloak_admin.py +++ b/tests/test_keycloak_admin.py @@ -1683,6 +1683,93 @@ def test_client_scope_client_roles(admin: KeycloakAdmin, realm: str, client: str assert len(roles) == 0 +def test_client_scope_mapping_client_roles(admin: KeycloakAdmin, realm: str, client: str): + """Test client scope assignment of client roles. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client owning roles + :type client: str + """ + CLIENT_ROLE_NAME = "some-client-role" + + admin.change_current_realm(realm) + + client_name = admin.get_client(client)["name"] + + client_scope = { + "name": "test_client_scope", + "description": "Test Client Scope", + "protocol": "openid-connect", + "attributes": {}, + } + client_scope_id = admin.create_client_scope(client_scope, skip_exists=False) + + # Test get client roles + client_specific_roles = admin.get_client_specific_roles_of_client_scope( + client_scope_id, client + ) + assert len(client_specific_roles) == 0, client_specific_roles + all_roles = admin.get_all_roles_of_client_scope(client_scope_id) + assert len(all_roles) == 0, all_roles + + # create client role for test + client_role_name = admin.create_client_role( + client_role_id=client, payload={"name": CLIENT_ROLE_NAME}, skip_exists=True + ) + assert client_role_name, client_role_name + + # Test client role assignment to other client + with pytest.raises(KeycloakPostError) as err: + admin.add_client_specific_roles_to_client_scope( + client_scope_id=client_scope_id, client_roles_owner_id=client, roles=["bad"] + ) + assert err.match(UNKOWN_ERROR_REGEX), err + + res = admin.add_client_specific_roles_to_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=[admin.get_client_role(client_id=client, role_name=CLIENT_ROLE_NAME)], + ) + assert res == dict(), res + + # Test when getting roles for the specific owner client + client_specific_roles = admin.get_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, client_roles_owner_id=client + ) + assert len(client_specific_roles) == 1 + client_role_names = [x["name"] for x in client_specific_roles] + assert CLIENT_ROLE_NAME in client_role_names, client_role_names + + # Test when getting all roles for the client scope + all_roles = admin.get_all_roles_of_client_scope(client_scope_id=client_scope_id) + assert "clientMappings" in all_roles, all_roles + all_roles_clients = all_roles["clientMappings"] + assert client_name in all_roles_clients, all_roles_clients + mappings = all_roles_clients[client_name]["mappings"] + client_role_names = [x["name"] for x in mappings] + assert CLIENT_ROLE_NAME in client_role_names, client_role_names + + # Test remove realm role of client + with pytest.raises(KeycloakDeleteError) as err: + admin.remove_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, client_roles_owner_id=client, roles=["bad"] + ) + assert err.match(UNKOWN_ERROR_REGEX), err + + res = admin.remove_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=[admin.get_client_role(client_id=client, role_name=CLIENT_ROLE_NAME)], + ) + assert res == dict(), res + + all_roles = admin.get_all_roles_of_client_scope(client_scope_id=client_scope_id) + assert len(all_roles) == 0 + + def test_client_default_client_scopes(admin: KeycloakAdmin, realm: str, client: str): """Test client assignment of default client scopes. @@ -4729,6 +4816,95 @@ async def test_a_client_scope_client_roles(admin: KeycloakAdmin, realm: str, cli assert len(roles) == 0 +@pytest.mark.asyncio +async def test_a_client_scope_mapping_client_roles(admin: KeycloakAdmin, realm: str, client: str): + """Test client scope assignment of client roles. + + :param admin: Keycloak admin + :type admin: KeycloakAdmin + :param realm: Keycloak realm + :type realm: str + :param client: Keycloak client owning roles + :type client: str + """ + CLIENT_ROLE_NAME = "some-client-role" + + await admin.a_change_current_realm(realm) + + client_obj = await admin.a_get_client(client) + client_name = client_obj["name"] + + client_scope = { + "name": "test_client_scope", + "description": "Test Client Scope", + "protocol": "openid-connect", + "attributes": {}, + } + client_scope_id = await admin.a_create_client_scope(client_scope, skip_exists=False) + + # Test get client roles + client_specific_roles = await admin.a_get_client_specific_roles_of_client_scope( + client_scope_id, client + ) + assert len(client_specific_roles) == 0, client_specific_roles + all_roles = await admin.a_get_all_roles_of_client_scope(client_scope_id) + assert len(all_roles) == 0, all_roles + + # create client role for test + client_role_name = await admin.a_create_client_role( + client_role_id=client, payload={"name": CLIENT_ROLE_NAME}, skip_exists=True + ) + assert client_role_name, client_role_name + + # Test client role assignment to other client + with pytest.raises(KeycloakPostError) as err: + await admin.a_add_client_specific_roles_to_client_scope( + client_scope_id=client_scope_id, client_roles_owner_id=client, roles=["bad"] + ) + assert err.match(UNKOWN_ERROR_REGEX), err + + res = await admin.a_add_client_specific_roles_to_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=[await admin.a_get_client_role(client_id=client, role_name=CLIENT_ROLE_NAME)], + ) + assert res == dict(), res + + # Test when getting roles for the specific owner client + client_specific_roles = await admin.a_get_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, client_roles_owner_id=client + ) + assert len(client_specific_roles) == 1 + client_role_names = [x["name"] for x in client_specific_roles] + assert CLIENT_ROLE_NAME in client_role_names, client_role_names + + # Test when getting all roles for the client scope + all_roles = await admin.a_get_all_roles_of_client_scope(client_scope_id=client_scope_id) + assert "clientMappings" in all_roles, all_roles + all_roles_clients = all_roles["clientMappings"] + assert client_name in all_roles_clients, all_roles_clients + mappings = all_roles_clients[client_name]["mappings"] + client_role_names = [x["name"] for x in mappings] + assert CLIENT_ROLE_NAME in client_role_names, client_role_names + + # Test remove realm role of client + with pytest.raises(KeycloakDeleteError) as err: + await admin.a_remove_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, client_roles_owner_id=client, roles=["bad"] + ) + assert err.match(UNKOWN_ERROR_REGEX), err + + res = await admin.a_remove_client_specific_roles_of_client_scope( + client_scope_id=client_scope_id, + client_roles_owner_id=client, + roles=[await admin.a_get_client_role(client_id=client, role_name=CLIENT_ROLE_NAME)], + ) + assert res == dict(), res + + all_roles = await admin.a_get_all_roles_of_client_scope(client_scope_id=client_scope_id) + assert len(all_roles) == 0 + + @pytest.mark.asyncio async def test_a_client_default_client_scopes(admin: KeycloakAdmin, realm: str, client: str): """Test client assignment of default client scopes.