Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
69 changes: 69 additions & 0 deletions commons/feature_toggles/feature_toggle_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import os
import json
from time_tracker_api.security import current_user_email
from azure.appconfiguration import AzureAppConfigurationClient


class MSConfig:
def check_variables_are_defined():
auth_variables = ['AZURE_APP_CONFIGURATION_CONNECTION_STRING']
for var in auth_variables:
if var not in os.environ:
raise EnvironmentError(
"{} is not defined in the environment".format(var)
)

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=MSConfig):
self.key = key
self.label = label
self.config = config
self.client = self.get_azure_app_configuration_client()
self.configuration = {}

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/{self.key}", label=self.label
)

return configuration

def get_data_configuration(self):
self.configuration = self.get_configuration(self.key, self.label)
result = json.loads(self.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()
5 changes: 4 additions & 1 deletion requirements/commons.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@
requests==2.23.0

# To create sample content in tests and API documentation
Faker==4.0.2
Faker==4.0.2

# For feature toggles
azure-appconfiguration==1.1.1
63 changes: 63 additions & 0 deletions tests/commons/feature_toggles/feature_toggles_manager_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from commons.feature_toggles.feature_toggle_manager import FeatureToggleManager
from unittest.mock import Mock, patch
from pytest import mark


def mock_payload(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',
[
('[email protected]', '[email protected]', True, True),
('[email protected]', '[email protected]', False, False),
('[email protected]', '[email protected]', True, False),
('[email protected]', '[email protected]', 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_payload(
is_toggle_enabled, user_email_enabled
)

assert (
feature_toggle_manager.is_toggle_enabled_for_user() == expected_result
)
49 changes: 40 additions & 9 deletions time_tracker_api/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<tenant_id>%s)" % UUID_REGEX)
iss_claim_pattern = re.compile(
r"(.*).b2clogin.com/(?P<tenant_id>%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")
Expand All @@ -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

Expand All @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions time_tracker_api/time_entries/time_entries_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
remove_required_constraint,
)
from time_tracker_api.time_entries.time_entries_dao import create_dao
from commons.feature_toggles.feature_toggle_manager import FeatureToggleManager

faker = Faker()

Expand Down Expand Up @@ -267,6 +268,24 @@ def get(self):
return time_entries_dao.get_lastest_entries_by_project(conditions={})


@ns.route('/feature-toggles')
class FeaturesToggles(Resource):
@ns.doc('feature_toggle_tests')
@ns.marshal_list_with(time_entry)
@ns.response(HTTPStatus.NOT_FOUND, 'No time entries found')
def get(self):
"""Test Feature Toggles"""
featureTest = FeatureToggleManager("ui-list-test-users")
test = featureTest.is_toggle_enabled_for_user()
if test:
return time_entries_dao.get_lastest_entries_by_project(
conditions={}
)
else:
conditions = attributes_filter.parse_args()
return time_entries_dao.get_all(conditions=conditions)


@ns.route('/<string:id>')
@ns.response(HTTPStatus.NOT_FOUND, 'This time entry does not exist')
@ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, 'The id has an invalid format')
Expand Down