Skip to content

Commit 49f601f

Browse files
authored
Tt 98 toogles backend (#250)
* TT-98 feat: add feature flags * TT-98 fix: refactor feature toggle manager * TT-98 feat: Add test to feature toggle manager file * TT-98 fix: refactor code in feature toggles file * TT-98 fix: upgrate version of azure core * TT-98 fix: refactor comments made by Roberto * TT-98 fix: remove import in time_entries_namespaces
1 parent 508171b commit 49f601f

File tree

7 files changed

+184
-11
lines changed

7 files changed

+184
-11
lines changed

.env.template

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ export MS_CLIENT_ID=
2020
export MS_SCOPE=
2121
export MS_SECRET=
2222
export MS_ENDPOINT=
23+
export AZURE_APP_CONFIGURATION_CONNECTION_STRING=

commons/feature_toggles/__init__.py

Whitespace-only changes.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import os
2+
import json
3+
from time_tracker_api.security import current_user_email
4+
from azure.appconfiguration import AzureAppConfigurationClient
5+
6+
7+
class FeatureToggleConfig:
8+
def check_variables_are_defined():
9+
azure_app_variable = 'AZURE_APP_CONFIGURATION_CONNECTION_STRING'
10+
if azure_app_variable not in os.environ:
11+
raise EnvironmentError(
12+
"{} is not defined in the environment".format(
13+
azure_app_variable
14+
)
15+
)
16+
17+
check_variables_are_defined()
18+
AZURE_APP_CONFIGURATION_CONNECTION_STRING = os.environ.get(
19+
'AZURE_APP_CONFIGURATION_CONNECTION_STRING'
20+
)
21+
22+
23+
class FeatureToggleManager:
24+
def __init__(
25+
self, key: str, label: str = None, config=FeatureToggleConfig
26+
):
27+
self.key = key
28+
self.label = label
29+
self.config = config
30+
self.client = self.get_azure_app_configuration_client()
31+
32+
def get_azure_app_configuration_client(self):
33+
connection_str = self.config.AZURE_APP_CONFIGURATION_CONNECTION_STRING
34+
client = AzureAppConfigurationClient.from_connection_string(
35+
connection_str
36+
)
37+
38+
return client
39+
40+
def get_configuration(self, key: str, label: str):
41+
configuration = self.client.get_configuration_setting(
42+
key=f".appconfig.featureflag/{key}", label=label
43+
)
44+
45+
return configuration
46+
47+
def get_data_configuration(self):
48+
feature_data_configuration = self.get_configuration(
49+
self.key, self.label
50+
)
51+
result = json.loads(feature_data_configuration.value)
52+
53+
return result
54+
55+
def is_toggle_enabled(self):
56+
data = self.get_data_configuration()
57+
result = data["enabled"]
58+
59+
return result
60+
61+
def get_list_users(self):
62+
data = self.get_data_configuration()
63+
client_filters = data["conditions"]["client_filters"]
64+
first_client = client_filters[0]
65+
list_users = first_client["parameters"]["Audience"]["Users"]
66+
67+
return list_users
68+
69+
def is_toggle_enabled_for_user(self):
70+
list_users = self.get_list_users()
71+
current_user = current_user_email()
72+
73+
return current_user in list_users and self.is_toggle_enabled()

requirements/azure_cosmos.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# For Cosmos DB
44

55
# Azure Cosmos DB official library
6-
azure-core==1.1.1
6+
azure-core==1.9.0
77
azure-cosmos==4.0.0b6
88
certifi==2019.11.28
99
chardet==3.0.4

requirements/commons.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,7 @@
66
requests==2.23.0
77

88
# To create sample content in tests and API documentation
9-
Faker==4.0.2
9+
Faker==4.0.2
10+
11+
# For feature toggles
12+
azure-appconfiguration==1.1.1
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from commons.feature_toggles.feature_toggle_manager import FeatureToggleManager
2+
from unittest.mock import Mock, patch
3+
from pytest import mark
4+
5+
6+
def mock_feature_toggle_config_response(enabled, user):
7+
return {
8+
"id": "test-feature-toggle",
9+
"description": "Feature Toggle test Backend",
10+
"enabled": enabled,
11+
"conditions": {
12+
"client_filters": [
13+
{
14+
"name": "Microsoft.Targeting",
15+
"parameters": {
16+
"Audience": {
17+
"Users": [user],
18+
"Groups": [],
19+
"DefaultRolloutPercentage": 50,
20+
}
21+
},
22+
}
23+
]
24+
},
25+
}
26+
27+
28+
@patch(
29+
'azure.appconfiguration.AzureAppConfigurationClient.from_connection_string',
30+
new_callable=Mock,
31+
)
32+
@patch(
33+
'commons.feature_toggles.feature_toggle_manager.FeatureToggleManager.get_data_configuration',
34+
new_callable=Mock,
35+
)
36+
@patch('commons.feature_toggles.feature_toggle_manager.current_user_email')
37+
@mark.parametrize(
38+
'user_email_enabled,currrent_user_email,is_toggle_enabled,expected_result',
39+
[
40+
41+
('[email protected]', '[email protected]', False, False),
42+
43+
('[email protected]', '[email protected]', False, False),
44+
],
45+
)
46+
def test_if_is_toggle_enabled_for_user(
47+
current_user_email_mock,
48+
get_data_configuration_mock,
49+
from_connection_string_mock,
50+
user_email_enabled,
51+
currrent_user_email,
52+
is_toggle_enabled,
53+
expected_result,
54+
):
55+
current_user_email_mock.return_value = currrent_user_email
56+
feature_toggle_manager = FeatureToggleManager("test-feature-toggle")
57+
feature_toggle_manager.get_data_configuration.return_value = (
58+
mock_feature_toggle_config_response(
59+
is_toggle_enabled, user_email_enabled
60+
)
61+
)
62+
63+
assert (
64+
feature_toggle_manager.is_toggle_enabled_for_user() == expected_result
65+
)

time_tracker_api/security.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,43 @@
2525
}
2626

2727
# For matching UUIDs
28-
UUID_REGEX = '[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12}'
28+
UUID_REGEX = (
29+
'[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12}'
30+
)
2931

30-
iss_claim_pattern = re.compile(r"(.*).b2clogin.com/(?P<tenant_id>%s)" % UUID_REGEX)
32+
iss_claim_pattern = re.compile(
33+
r"(.*).b2clogin.com/(?P<tenant_id>%s)" % UUID_REGEX
34+
)
3135

3236
roles = {
3337
"admin": {"name": "time-tracker-admin"},
34-
"client": {"name": "client-role"}
38+
"client": {"name": "client-role"},
3539
}
3640

3741

3842
def current_user_id() -> str:
3943
oid_claim = get_token_json().get("oid")
4044
if oid_claim is None:
41-
abort(message='The claim "oid" is missing in the JWT', code=HTTPStatus.UNAUTHORIZED)
45+
abort(
46+
message='The claim "oid" is missing in the JWT',
47+
code=HTTPStatus.UNAUTHORIZED,
48+
)
4249

4350
return oid_claim
4451

4552

53+
def current_user_email() -> str:
54+
email_list_claim = get_token_json().get("emails")
55+
if email_list_claim is None:
56+
abort(
57+
message='The claim "emails" is missing in the JWT',
58+
code=HTTPStatus.UNAUTHORIZED,
59+
)
60+
61+
email_user = email_list_claim[0]
62+
return email_user
63+
64+
4665
def current_role_user() -> str:
4766
role_user = get_token_json().get("extension_role", None)
4867
return role_user if role_user else roles.get("client").get("name")
@@ -51,13 +70,18 @@ def current_role_user() -> str:
5170
def current_user_tenant_id() -> str:
5271
iss_claim = get_token_json().get("iss")
5372
if iss_claim is None:
54-
abort(message='The claim "iss" is missing in the JWT', code=HTTPStatus.UNAUTHORIZED)
73+
abort(
74+
message='The claim "iss" is missing in the JWT',
75+
code=HTTPStatus.UNAUTHORIZED,
76+
)
5577

5678
tenant_id = parse_tenant_id_from_iss_claim(iss_claim)
5779
if tenant_id is None:
58-
abort(message='The format of the claim "iss" cannot be understood. '
59-
'Please contact the development team.',
60-
code=HTTPStatus.UNAUTHORIZED)
80+
abort(
81+
message='The format of the claim "iss" cannot be understood. '
82+
'Please contact the development team.',
83+
code=HTTPStatus.UNAUTHORIZED,
84+
)
6185

6286
return tenant_id
6387

@@ -66,11 +90,18 @@ def get_or_generate_dev_secret_key():
6690
global dev_secret_key
6791
if dev_secret_key is None:
6892
from time_tracker_api import flask_app as app
93+
6994
"""
7095
Generates a security key for development purposes
7196
:return: str
7297
"""
73-
dev_secret_key = fake.password(length=16, special_chars=True, digits=True, upper_case=True, lower_case=True)
98+
dev_secret_key = fake.password(
99+
length=16,
100+
special_chars=True,
101+
digits=True,
102+
upper_case=True,
103+
lower_case=True,
104+
)
74105
if app.config.get("FLASK_DEBUG", False): # pragma: no cover
75106
print('*********************************************************')
76107
print("The generated secret is \"%s\"" % dev_secret_key)

0 commit comments

Comments
 (0)