diff --git a/.env.template b/.env.template index d5bfb21c..8a2d9502 100644 --- a/.env.template +++ b/.env.template @@ -20,3 +20,4 @@ export MS_CLIENT_ID= export MS_SCOPE= export MS_SECRET= export MS_ENDPOINT= +export AZURE_APP_CONFIGURATION_CONNECTION_STRING= \ No newline at end of file diff --git a/commons/feature_toggles/__init__.py b/commons/feature_toggles/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commons/feature_toggles/feature_toggle_manager.py b/commons/feature_toggles/feature_toggle_manager.py new file mode 100644 index 00000000..9d240b49 --- /dev/null +++ b/commons/feature_toggles/feature_toggle_manager.py @@ -0,0 +1,73 @@ +import os +import json +from time_tracker_api.security import current_user_email +from azure.appconfiguration import AzureAppConfigurationClient + + +class FeatureToggleConfig: + def check_variables_are_defined(): + azure_app_variable = 'AZURE_APP_CONFIGURATION_CONNECTION_STRING' + if azure_app_variable not in os.environ: + raise EnvironmentError( + "{} is not defined in the environment".format( + azure_app_variable + ) + ) + + check_variables_are_defined() + AZURE_APP_CONFIGURATION_CONNECTION_STRING = os.environ.get( + 'AZURE_APP_CONFIGURATION_CONNECTION_STRING' + ) + + +class FeatureToggleManager: + def __init__( + self, key: str, label: str = None, config=FeatureToggleConfig + ): + self.key = key + self.label = label + self.config = config + self.client = self.get_azure_app_configuration_client() + + def get_azure_app_configuration_client(self): + connection_str = self.config.AZURE_APP_CONFIGURATION_CONNECTION_STRING + client = AzureAppConfigurationClient.from_connection_string( + connection_str + ) + + return client + + def get_configuration(self, key: str, label: str): + configuration = self.client.get_configuration_setting( + key=f".appconfig.featureflag/{key}", label=label + ) + + return configuration + + def get_data_configuration(self): + feature_data_configuration = self.get_configuration( + self.key, self.label + ) + result = json.loads(feature_data_configuration.value) + + return result + + def is_toggle_enabled(self): + data = self.get_data_configuration() + result = data["enabled"] + + return result + + def get_list_users(self): + data = self.get_data_configuration() + client_filters = data["conditions"]["client_filters"] + first_client = client_filters[0] + list_users = first_client["parameters"]["Audience"]["Users"] + + return list_users + + def is_toggle_enabled_for_user(self): + list_users = self.get_list_users() + current_user = current_user_email() + + return current_user in list_users and self.is_toggle_enabled() diff --git a/requirements/azure_cosmos.txt b/requirements/azure_cosmos.txt index ed253c84..70e4132a 100644 --- a/requirements/azure_cosmos.txt +++ b/requirements/azure_cosmos.txt @@ -3,7 +3,7 @@ # For Cosmos DB # Azure Cosmos DB official library -azure-core==1.1.1 +azure-core==1.9.0 azure-cosmos==4.0.0b6 certifi==2019.11.28 chardet==3.0.4 diff --git a/requirements/commons.txt b/requirements/commons.txt index d6e85a2e..9b5d811c 100644 --- a/requirements/commons.txt +++ b/requirements/commons.txt @@ -6,4 +6,7 @@ requests==2.23.0 # To create sample content in tests and API documentation -Faker==4.0.2 \ No newline at end of file +Faker==4.0.2 + +# For feature toggles +azure-appconfiguration==1.1.1 \ No newline at end of file diff --git a/tests/commons/feature_toggles/feature_toggles_manager_test.py b/tests/commons/feature_toggles/feature_toggles_manager_test.py new file mode 100644 index 00000000..42633391 --- /dev/null +++ b/tests/commons/feature_toggles/feature_toggles_manager_test.py @@ -0,0 +1,65 @@ +from commons.feature_toggles.feature_toggle_manager import FeatureToggleManager +from unittest.mock import Mock, patch +from pytest import mark + + +def mock_feature_toggle_config_response(enabled, user): + return { + "id": "test-feature-toggle", + "description": "Feature Toggle test Backend", + "enabled": enabled, + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": [user], + "Groups": [], + "DefaultRolloutPercentage": 50, + } + }, + } + ] + }, + } + + +@patch( + 'azure.appconfiguration.AzureAppConfigurationClient.from_connection_string', + new_callable=Mock, +) +@patch( + 'commons.feature_toggles.feature_toggle_manager.FeatureToggleManager.get_data_configuration', + new_callable=Mock, +) +@patch('commons.feature_toggles.feature_toggle_manager.current_user_email') +@mark.parametrize( + 'user_email_enabled,currrent_user_email,is_toggle_enabled,expected_result', + [ + ('testUser@ioet.com', 'testUser@ioet.com', True, True), + ('testUser@ioet.com', 'testUser@ioet.com', False, False), + ('testUser@ioet.com', 'testWrongUser@ioet.com', True, False), + ('testUser@ioet.com', 'testWrongUser@ioet.com', False, False), + ], +) +def test_if_is_toggle_enabled_for_user( + current_user_email_mock, + get_data_configuration_mock, + from_connection_string_mock, + user_email_enabled, + currrent_user_email, + is_toggle_enabled, + expected_result, +): + current_user_email_mock.return_value = currrent_user_email + feature_toggle_manager = FeatureToggleManager("test-feature-toggle") + feature_toggle_manager.get_data_configuration.return_value = ( + mock_feature_toggle_config_response( + is_toggle_enabled, user_email_enabled + ) + ) + + assert ( + feature_toggle_manager.is_toggle_enabled_for_user() == expected_result + ) diff --git a/time_tracker_api/security.py b/time_tracker_api/security.py index 232c0ff2..40d3ea7d 100644 --- a/time_tracker_api/security.py +++ b/time_tracker_api/security.py @@ -25,24 +25,43 @@ } # For matching UUIDs -UUID_REGEX = '[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12}' +UUID_REGEX = ( + '[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12}' +) -iss_claim_pattern = re.compile(r"(.*).b2clogin.com/(?P%s)" % UUID_REGEX) +iss_claim_pattern = re.compile( + r"(.*).b2clogin.com/(?P%s)" % UUID_REGEX +) roles = { "admin": {"name": "time-tracker-admin"}, - "client": {"name": "client-role"} + "client": {"name": "client-role"}, } def current_user_id() -> str: oid_claim = get_token_json().get("oid") if oid_claim is None: - abort(message='The claim "oid" is missing in the JWT', code=HTTPStatus.UNAUTHORIZED) + abort( + message='The claim "oid" is missing in the JWT', + code=HTTPStatus.UNAUTHORIZED, + ) return oid_claim +def current_user_email() -> str: + email_list_claim = get_token_json().get("emails") + if email_list_claim is None: + abort( + message='The claim "emails" is missing in the JWT', + code=HTTPStatus.UNAUTHORIZED, + ) + + email_user = email_list_claim[0] + return email_user + + def current_role_user() -> str: role_user = get_token_json().get("extension_role", None) return role_user if role_user else roles.get("client").get("name") @@ -51,13 +70,18 @@ def current_role_user() -> str: def current_user_tenant_id() -> str: iss_claim = get_token_json().get("iss") if iss_claim is None: - abort(message='The claim "iss" is missing in the JWT', code=HTTPStatus.UNAUTHORIZED) + abort( + message='The claim "iss" is missing in the JWT', + code=HTTPStatus.UNAUTHORIZED, + ) tenant_id = parse_tenant_id_from_iss_claim(iss_claim) if tenant_id is None: - abort(message='The format of the claim "iss" cannot be understood. ' - 'Please contact the development team.', - code=HTTPStatus.UNAUTHORIZED) + abort( + message='The format of the claim "iss" cannot be understood. ' + 'Please contact the development team.', + code=HTTPStatus.UNAUTHORIZED, + ) return tenant_id @@ -66,11 +90,18 @@ def get_or_generate_dev_secret_key(): global dev_secret_key if dev_secret_key is None: from time_tracker_api import flask_app as app + """ Generates a security key for development purposes :return: str """ - dev_secret_key = fake.password(length=16, special_chars=True, digits=True, upper_case=True, lower_case=True) + dev_secret_key = fake.password( + length=16, + special_chars=True, + digits=True, + upper_case=True, + lower_case=True, + ) if app.config.get("FLASK_DEBUG", False): # pragma: no cover print('*********************************************************') print("The generated secret is \"%s\"" % dev_secret_key)