diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52a7bf1c..eafd6e2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: - name: Install dependencies run: | + sudo apt-get install -y libkrb5-dev pip install -U setuptools pip wheel pip install -e .[cpphash,redis,uwsgi] diff --git a/setup.py b/setup.py index 907886f6..ebc484dd 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,8 @@ 'attrs==22.1.0', 'pytest-asyncio==0.21.0', 'aiohttp>=3.8.4', - 'aiofiles>=23.1.0' + 'aiofiles>=23.1.0', + 'requests-kerberos>=0.14.0' ] INSTALL_REQUIRES = [ @@ -46,7 +47,8 @@ 'redis': ['redis>=2.10.5'], 'uwsgi': ['uwsgi>=2.0.0'], 'cpphash': ['mmh3cffi==0.2.1'], - 'asyncio': ['aiohttp>=3.8.4', 'aiofiles>=23.1.0'] + 'asyncio': ['aiohttp>=3.8.4', 'aiofiles>=23.1.0'], + 'kerberos': ['requests-kerberos>=0.14.0'] }, setup_requires=['pytest-runner', 'pluggy==1.0.0;python_version<"3.8"'], classifiers=[ diff --git a/splitio/api/client.py b/splitio/api/client.py index 1f9df88c..b255baff 100644 --- a/splitio/api/client.py +++ b/splitio/api/client.py @@ -5,7 +5,9 @@ import abc import logging import json +from splitio.optional.loaders import HTTPKerberosAuth, OPTIONAL +from splitio.client.config import AuthenticateScheme from splitio.optional.loaders import aiohttp from splitio.util.time import get_current_epoch_time_ms @@ -95,7 +97,7 @@ def set_telemetry_data(self, metric_name, telemetry_runtime_producer): class HttpClient(HttpClientBase): """HttpClient wrapper.""" - def __init__(self, timeout=None, sdk_url=None, events_url=None, auth_url=None, telemetry_url=None): + def __init__(self, timeout=None, sdk_url=None, events_url=None, auth_url=None, telemetry_url=None, authentication_scheme=None, authentication_params=None): """ Class constructor. @@ -111,6 +113,8 @@ def __init__(self, timeout=None, sdk_url=None, events_url=None, auth_url=None, t :type telemetry_url: str """ self._timeout = timeout/1000 if timeout else None # Convert ms to seconds. + self._authentication_scheme = authentication_scheme + self._authentication_params = authentication_params self._urls = _construct_urls(sdk_url, events_url, auth_url, telemetry_url) def get(self, server, path, sdk_key, query=None, extra_headers=None): # pylint: disable=too-many-arguments @@ -135,13 +139,15 @@ def get(self, server, path, sdk_key, query=None, extra_headers=None): # pylint: if extra_headers is not None: headers.update(extra_headers) + authentication = self._get_authentication() start = get_current_epoch_time_ms() try: response = requests.get( _build_url(server, path, self._urls), params=query, headers=headers, - timeout=self._timeout + timeout=self._timeout, + auth=authentication ) self._record_telemetry(response.status_code, get_current_epoch_time_ms() - start) return HttpResponse(response.status_code, response.text, response.headers) @@ -174,6 +180,7 @@ def post(self, server, path, sdk_key, body, query=None, extra_headers=None): # if extra_headers is not None: headers.update(extra_headers) + authentication = self._get_authentication() start = get_current_epoch_time_ms() try: response = requests.post( @@ -181,7 +188,8 @@ def post(self, server, path, sdk_key, body, query=None, extra_headers=None): # json=body, params=query, headers=headers, - timeout=self._timeout + timeout=self._timeout, + auth=authentication ) self._record_telemetry(response.status_code, get_current_epoch_time_ms() - start) return HttpResponse(response.status_code, response.text, response.headers) @@ -189,6 +197,15 @@ def post(self, server, path, sdk_key, body, query=None, extra_headers=None): # except Exception as exc: # pylint: disable=broad-except raise HttpClientException('requests library is throwing exceptions') from exc + def _get_authentication(self): + authentication = None + if self._authentication_scheme == AuthenticateScheme.KERBEROS: + if self._authentication_params is not None: + authentication = HTTPKerberosAuth(principal=self._authentication_params[0], password=self._authentication_params[1], mutual_authentication=OPTIONAL) + else: + authentication = HTTPKerberosAuth(mutual_authentication=OPTIONAL) + return authentication + def _record_telemetry(self, status_code, elapsed): """ Record Telemetry info @@ -333,4 +350,4 @@ async def _record_telemetry(self, status_code, elapsed): async def close_session(self): if not self._session.closed: - await self._session.close() \ No newline at end of file + await self._session.close() diff --git a/splitio/client/config.py b/splitio/client/config.py index 392f8fa9..60643a37 100644 --- a/splitio/client/config.py +++ b/splitio/client/config.py @@ -1,6 +1,7 @@ """Default settings for the Split.IO SDK Python client.""" import os.path import logging +from enum import Enum from splitio.engine.impressions import ImpressionsMode from splitio.client.input_validator import validate_flag_sets @@ -8,6 +9,12 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_DATA_SAMPLING = 1 +class AuthenticateScheme(Enum): + """Authentication Scheme.""" + NONE = 'NONE' + KERBEROS = 'KERBEROS' + + DEFAULT_CONFIG = { 'operationMode': 'standalone', 'connectionTimeout': 1500, @@ -59,7 +66,10 @@ 'storageWrapper': None, 'storagePrefix': None, 'storageType': None, - 'flagSetsFilter': None + 'flagSetsFilter': None, + 'httpAuthenticateScheme': AuthenticateScheme.NONE, + 'kerberosPrincipalUser': None, + 'kerberosPrincipalPassword': None } def _parse_operation_mode(sdk_key, config): @@ -148,4 +158,14 @@ def sanitize(sdk_key, config): else: processed['flagSetsFilter'] = sorted(validate_flag_sets(processed['flagSetsFilter'], 'SDK Config')) if processed['flagSetsFilter'] is not None else None + if config.get('httpAuthenticateScheme') is not None: + try: + authenticate_scheme = AuthenticateScheme(config['httpAuthenticateScheme'].upper()) + except (ValueError, AttributeError): + authenticate_scheme = AuthenticateScheme.NONE + _LOGGER.warning('You passed an invalid HttpAuthenticationScheme, HttpAuthenticationScheme should be ' \ + 'one of the following values: `none` or `kerberos`. ' + ' Defaulting to `none` mode.') + processed["httpAuthenticateScheme"] = authenticate_scheme + return processed diff --git a/splitio/client/factory.py b/splitio/client/factory.py index 18f4e8eb..27938ecd 100644 --- a/splitio/client/factory.py +++ b/splitio/client/factory.py @@ -7,8 +7,8 @@ from splitio.optional.loaders import asyncio from splitio.client.client import Client, ClientAsync from splitio.client import input_validator +from splitio.client.config import sanitize as sanitize_config, DEFAULT_DATA_SAMPLING, AuthenticateScheme from splitio.client.manager import SplitManager, SplitManagerAsync -from splitio.client.config import sanitize as sanitize_config, DEFAULT_DATA_SAMPLING from splitio.client import util from splitio.client.listener import ImpressionListenerWrapper, ImpressionListenerWrapperAsync from splitio.engine.impressions.impressions import Manager as ImpressionsManager @@ -508,12 +508,19 @@ def _build_in_memory_factory(api_key, cfg, sdk_url=None, events_url=None, # pyl telemetry_evaluation_producer = telemetry_producer.get_telemetry_evaluation_producer() telemetry_init_producer = telemetry_producer.get_telemetry_init_producer() + authentication_params = None + if cfg.get("httpAuthenticateScheme") == AuthenticateScheme.KERBEROS: + authentication_params = [cfg.get("kerberosPrincipalUser"), + cfg.get("kerberosPrincipalPassword")] + http_client = HttpClient( sdk_url=sdk_url, events_url=events_url, auth_url=auth_api_base_url, telemetry_url=telemetry_api_base_url, - timeout=cfg.get('connectionTimeout') + timeout=cfg.get('connectionTimeout'), + authentication_scheme = cfg.get("httpAuthenticateScheme"), + authentication_params = authentication_params ) sdk_metadata = util.get_metadata(cfg) diff --git a/splitio/optional/loaders.py b/splitio/optional/loaders.py index 4c2e02d9..b5f11621 100644 --- a/splitio/optional/loaders.py +++ b/splitio/optional/loaders.py @@ -14,5 +14,17 @@ def missing_asyncio_dependencies(*_, **__): asyncio = missing_asyncio_dependencies aiofiles = missing_asyncio_dependencies +try: + from requests_kerberos import HTTPKerberosAuth, OPTIONAL +except ImportError: + def missing_auth_dependencies(*_, **__): + """Fail if missing dependencies are used.""" + raise NotImplementedError( + 'Missing kerberos auth dependency. ' + 'Please use `pip install splitio_client[kerberos]` to install the sdk with kerberos auth support' + ) + HTTPKerberosAuth = missing_auth_dependencies + OPTIONAL = missing_auth_dependencies + async def _anext(it): return await it.__anext__() diff --git a/splitio/version.py b/splitio/version.py index ffcd3342..a671925d 100644 --- a/splitio/version.py +++ b/splitio/version.py @@ -1 +1 @@ -__version__ = '10.0.1' \ No newline at end of file +__version__ = '10.1.0rc1' \ No newline at end of file diff --git a/tests/api/test_httpclient.py b/tests/api/test_httpclient.py index 3755190d..c0530854 100644 --- a/tests/api/test_httpclient.py +++ b/tests/api/test_httpclient.py @@ -1,7 +1,9 @@ """HTTPClient test module.""" +from requests_kerberos import HTTPKerberosAuth, OPTIONAL import pytest import unittest.mock as mock +from splitio.client.config import AuthenticateScheme from splitio.api import client from splitio.engine.telemetry import TelemetryStorageProducer, TelemetryStorageProducerAsync from splitio.storage.inmemmory import InMemoryTelemetryStorage, InMemoryTelemetryStorageAsync @@ -25,7 +27,8 @@ def test_get(self, mocker): client.SDK_URL + '/test1', headers={'Authorization': 'Bearer some_api_key', 'h1': 'abc', 'Content-Type': 'application/json'}, params={'param1': 123}, - timeout=None + timeout=None, + auth=None ) assert response.status_code == 200 assert response.body == 'ok' @@ -37,7 +40,8 @@ def test_get(self, mocker): client.EVENTS_URL + '/test1', headers={'Authorization': 'Bearer some_api_key', 'h1': 'abc', 'Content-Type': 'application/json'}, params={'param1': 123}, - timeout=None + timeout=None, + auth=None ) assert get_mock.mock_calls == [call] assert response.status_code == 200 @@ -59,7 +63,8 @@ def test_get_custom_urls(self, mocker): 'https://sdk.com/test1', headers={'Authorization': 'Bearer some_api_key', 'h1': 'abc', 'Content-Type': 'application/json'}, params={'param1': 123}, - timeout=None + timeout=None, + auth=None ) assert get_mock.mock_calls == [call] assert response.status_code == 200 @@ -71,7 +76,8 @@ def test_get_custom_urls(self, mocker): 'https://events.com/test1', headers={'Authorization': 'Bearer some_api_key', 'h1': 'abc', 'Content-Type': 'application/json'}, params={'param1': 123}, - timeout=None + timeout=None, + auth=None ) assert response.status_code == 200 assert response.body == 'ok' @@ -95,7 +101,8 @@ def test_post(self, mocker): json={'p1': 'a'}, headers={'Authorization': 'Bearer some_api_key', 'h1': 'abc', 'Content-Type': 'application/json'}, params={'param1': 123}, - timeout=None + timeout=None, + auth=None ) assert response.status_code == 200 assert response.body == 'ok' @@ -108,7 +115,8 @@ def test_post(self, mocker): json={'p1': 'a'}, headers={'Authorization': 'Bearer some_api_key', 'h1': 'abc', 'Content-Type': 'application/json'}, params={'param1': 123}, - timeout=None + timeout=None, + auth=None ) assert response.status_code == 200 assert response.body == 'ok' @@ -131,7 +139,8 @@ def test_post_custom_urls(self, mocker): json={'p1': 'a'}, headers={'Authorization': 'Bearer some_api_key', 'h1': 'abc', 'Content-Type': 'application/json'}, params={'param1': 123}, - timeout=None + timeout=None, + auth=None ) assert response.status_code == 200 assert response.body == 'ok' @@ -144,12 +153,35 @@ def test_post_custom_urls(self, mocker): json={'p1': 'a'}, headers={'Authorization': 'Bearer some_api_key', 'h1': 'abc', 'Content-Type': 'application/json'}, params={'param1': 123}, - timeout=None + timeout=None, + auth=None ) assert response.status_code == 200 assert response.body == 'ok' assert get_mock.mock_calls == [call] + def test_authentication_scheme(self, mocker): + response_mock = mocker.Mock() + response_mock.status_code = 200 + response_mock.text = 'ok' + get_mock = mocker.Mock() + get_mock.return_value = response_mock + mocker.patch('splitio.api.client.requests.get', new=get_mock) + httpclient = client.HttpClient(sdk_url='https://sdk.com', authentication_scheme=AuthenticateScheme.KERBEROS) + httpclient.set_telemetry_data("metric", mocker.Mock()) + response = httpclient.get('sdk', '/test1', 'some_api_key', {'param1': 123}, {'h1': 'abc'}) + call = mocker.call( + 'https://sdk.com/test1', + headers={'Authorization': 'Bearer some_api_key', 'h1': 'abc', 'Content-Type': 'application/json'}, + params={'param1': 123}, + timeout=None, + auth=HTTPKerberosAuth(mutual_authentication=OPTIONAL) + ) + + httpclient = client.HttpClient(sdk_url='https://sdk.com', authentication_scheme=AuthenticateScheme.KERBEROS, authentication_params=['bilal', 'split']) + httpclient.set_telemetry_data("metric", mocker.Mock()) + response = httpclient.get('sdk', '/test1', 'some_api_key', {'param1': 123}, {'h1': 'abc'}) + def test_telemetry(self, mocker): telemetry_storage = InMemoryTelemetryStorage() telemetry_producer = TelemetryStorageProducer(telemetry_storage) diff --git a/tests/client/test_config.py b/tests/client/test_config.py index 3b79a02c..ddfd85b0 100644 --- a/tests/client/test_config.py +++ b/tests/client/test_config.py @@ -68,9 +68,19 @@ def test_sanitize(self): processed = config.sanitize('some', configs) assert processed['redisLocalCacheEnabled'] # check default is True assert processed['flagSetsFilter'] is None + assert processed['httpAuthenticateScheme'] is config.AuthenticateScheme.NONE processed = config.sanitize('some', {'redisHost': 'x', 'flagSetsFilter': ['set']}) assert processed['flagSetsFilter'] is None processed = config.sanitize('some', {'storageType': 'pluggable', 'flagSetsFilter': ['set']}) assert processed['flagSetsFilter'] is None + + processed = config.sanitize('some', {'httpAuthenticateScheme': 'KERBEROS'}) + assert processed['httpAuthenticateScheme'] is config.AuthenticateScheme.KERBEROS + + processed = config.sanitize('some', {'httpAuthenticateScheme': 'anything'}) + assert processed['httpAuthenticateScheme'] is config.AuthenticateScheme.NONE + + processed = config.sanitize('some', {'httpAuthenticateScheme': 'NONE'}) + assert processed['httpAuthenticateScheme'] is config.AuthenticateScheme.NONE