Skip to content

Commit 3646c12

Browse files
author
EliuX
committed
fix: Close #103 Filter running time entry by owner_id
1 parent ef1ffe7 commit 3646c12

File tree

10 files changed

+106
-35
lines changed

10 files changed

+106
-35
lines changed

README.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,31 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well.
6666
- Open `http://127.0.0.1:5000/` in a browser. You will find in the presented UI
6767
a link to the swagger.json with the definition of the api.
6868
69+
### Security
70+
In this API we are requiring authenticated users using JWT. To do so, we are using the library
71+
[PyJWT](https://pypi.org/project/PyJWT/), so in every request to the API we expect a header `Authorization` with a format
72+
like:
73+
74+
>Bearer <JWT>
75+
76+
In the Swagger UI, you will now see a new button called "Authorize":
77+
![image](https://user-images.githubusercontent.com/6514740/80011459-841f7580-8491-11ea-9c23-5bfb8822afe6.png)
78+
79+
when you click it then you will be notified that you must enter the content of the Authorization header, as mentioned
80+
before:
81+
![image](https://user-images.githubusercontent.com/6514740/80011702-d95b8700-8491-11ea-973a-8aaf3cdadb00.png)
82+
83+
Click "Authorize" and then close that dialog. From that moment forward you will not have to do it anymore because the
84+
Swagger UI will use that JWT in every call, e.g.
85+
![image](https://user-images.githubusercontent.com/6514740/80011852-0e67d980-8492-11ea-9dd3-2b1efeaa57d8.png)
86+
87+
If you want to check out the data (claims) that your JWT contains, you can also use the CLI of
88+
[PyJWT](https://pypi.org/project/PyJWT/):
89+
```
90+
pyjwt decode --no-verify "<JWT>"
91+
```
92+
93+
Bear in mind that this API is not in charge of verifying the authenticity of the JWT, but the API Management.
6994
7095
### Important notes
7196
Due to the used technology and particularities on the implementation of this API, it is important that you respect the
@@ -164,13 +189,17 @@ python cli.py gen_swagger_json -f ~/Downloads/swagger.json
164189
## Semantic versioning
165190

166191
### Style
167-
We use [angular commit message style](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits) as the standard commit message style.
192+
We use [angular commit message style](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits) as the
193+
standard commit message style.
168194

169195
### Release
170-
1. The release is automatically done by the [TimeTracker CI](https://dev.azure.com/IOET-DevOps/TimeTracker-API/_build?definitionId=1&_a=summary) although can also be done manually. The variable `GH_TOKEN` is required to post releases to Github. The `GH_TOKEN` can be generated following [these steps](https://help.github.com/es/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line).
196+
1. The release is automatically done by the [TimeTracker CI](https://dev.azure.com/IOET-DevOps/TimeTracker-API/_build?definitionId=1&_a=summary)
197+
although can also be done manually. The variable `GH_TOKEN` is required to post releases to Github. The `GH_TOKEN` can
198+
be generated following [these steps](https://help.github.com/es/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line).
171199

172200
2. We use the command `semantic-release publish` after a successful PR to make a release. Check the library
173-
[python-semantic-release](https://python-semantic-release.readthedocs.io/en/latest/commands.html#publish) for details of underlying operations.
201+
[python-semantic-release](https://python-semantic-release.readthedocs.io/en/latest/commands.html#publish) for details of
202+
underlying operations.
174203

175204
## Run as docker container
176205
1. Build image

commons/data_access_layer/cosmos_db.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def from_flask_config(cls, app: Flask):
3737
raise EnvironmentError("DATABASE_MASTER_KEY is not defined in the environment")
3838

3939
client = cosmos_client.CosmosClient(account_uri, {'masterKey': master_key},
40-
user_agent="CosmosDBDotnetQuickstart",
40+
user_agent="TimeTrackerAPI",
4141
user_agent_overwrite=True)
4242
else:
4343
client = cosmos_client.CosmosClient.from_connection_string(db_uri)
@@ -108,7 +108,7 @@ def create_sql_condition_for_visibility(visible_only: bool, container_name='c')
108108
def create_sql_where_conditions(conditions: dict, container_name='c') -> str:
109109
where_conditions = []
110110
for k in conditions.keys():
111-
where_conditions.append('{c}.{var} = @{var}'.format(c=container_name, var=k))
111+
where_conditions.append(f'{container_name}.{k} = @{k}')
112112

113113
if len(where_conditions) > 0:
114114
return "AND {where_conditions_clause}".format(
@@ -117,14 +117,15 @@ def create_sql_where_conditions(conditions: dict, container_name='c') -> str:
117117
return ""
118118

119119
@staticmethod
120-
def append_conditions_values(params: list, conditions: dict) -> dict:
120+
def generate_condition_values(conditions: dict) -> dict:
121+
result = []
121122
for k, v in conditions.items():
122-
params.append({
123+
result.append({
123124
"name": "@%s" % k,
124125
"value": v
125126
})
126127

127-
return params
128+
return result
128129

129130
@staticmethod
130131
def check_visibility(item, throw_not_found_if_deleted):
@@ -152,11 +153,12 @@ def find_all(self, partition_key_value: str, conditions: dict = {}, max_count=No
152153
visible_only=True, mapper: Callable = None):
153154
# TODO Use the tenant_id param and change container alias
154155
max_count = self.get_page_size_or(max_count)
155-
params = self.append_conditions_values([
156+
params = [
156157
{"name": "@partition_key_value", "value": partition_key_value},
157158
{"name": "@offset", "value": offset},
158159
{"name": "@max_count", "value": max_count},
159-
], conditions)
160+
]
161+
params.extend(self.generate_condition_values(conditions))
160162
result = self.container.query_items(query="""
161163
SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value
162164
{conditions_clause} {visibility_condition} {order_clause}

tests/commons/data_access_layer/cosmos_db_test.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from dataclasses import dataclass
2+
from datetime import timedelta
23
from typing import Callable
34

45
import pytest
@@ -7,7 +8,8 @@
78
from flask_restplus._http import HTTPStatus
89
from pytest import fail
910

10-
from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBModel, CustomError
11+
from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBModel, CustomError, current_datetime, \
12+
datetime_str
1113

1214
fake = Faker()
1315
Faker.seed()
@@ -557,7 +559,7 @@ def test_repository_create_sql_where_conditions_with_no_values(cosmos_db_reposit
557559

558560

559561
def test_repository_append_conditions_values(cosmos_db_repository: CosmosDBRepository):
560-
result = cosmos_db_repository.append_conditions_values([], {'owner_id': 'mark', 'customer_id': 'ioet'})
562+
result = cosmos_db_repository.generate_condition_values({'owner_id': 'mark', 'customer_id': 'ioet'})
561563

562564
assert result is not None
563565
assert result == [{'name': '@owner_id', 'value': 'mark'},
@@ -586,3 +588,17 @@ def raise_bad_request_if_name_diff_the_one_from_sample_item(data: dict):
586588
except Exception as e:
587589
assert e.code == HTTPStatus.BAD_REQUEST
588590
assert e.description == "Anything"
591+
592+
593+
def test_datetime_str_comparison():
594+
now = current_datetime()
595+
now_str = datetime_str(now)
596+
597+
assert now_str > datetime_str(now - timedelta(microseconds=1))
598+
assert now_str < datetime_str(now + timedelta(microseconds=1))
599+
600+
assert now_str > datetime_str(now - timedelta(seconds=1))
601+
assert now_str < datetime_str(now + timedelta(seconds=1))
602+
603+
assert now_str > datetime_str(now - timedelta(days=1))
604+
assert now_str < datetime_str(now + timedelta(days=1))

tests/commons/data_access_layer/sql_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ def test_find_all_that_contains_property_with_string_case_insensitive(sql_reposi
5656
existing_elements_registry.append(new_element)
5757

5858
search_snow_result = sql_repository.find_all_contain_str('name', 'Snow')
59-
assert len(search_snow_result) == 2
59+
assert 2 == len(search_snow_result)
6060

6161
search_jon_result = sql_repository.find_all_contain_str('name', 'Jon')
62-
assert len(search_jon_result) == 1
62+
assert 1 == len(search_jon_result)
6363

6464
search_ram_result = sql_repository.find_all_contain_str('name', fake_name)
6565
assert search_ram_result[0].name == new_element['name']

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository,
167167
def valid_jwt(app: Flask, tenant_id: str, owner_id: str) -> str:
168168
expiration_time = datetime.utcnow() + timedelta(seconds=3600)
169169
return jwt.encode({
170-
"iss": "https://securityioet.b2clogin.com/%s/v2.0/" % tenant_id,
170+
"iss": "https://ioetec.b2clogin.com/%s/v2.0/" % tenant_id,
171171
"oid": owner_id,
172172
'exp': expiration_time
173173
}, key=get_or_generate_dev_secret_key()).decode("UTF-8")

tests/time_tracker_api/security_test.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import pytest
2+
13
from time_tracker_api.security import parse_jwt, parse_tenant_id_from_iss_claim
24

35

@@ -14,8 +16,11 @@ def test_parse_jwt_with_invalid_input():
1416
assert result is None
1517

1618

17-
def test_parse_tenant_id_from_iss_claim_with_valid_input():
18-
valid_iss_claim = "https://securityioet.b2clogin.com/b21c4e98-c4bf-420f-9d76-e51c2515c7a4/v2.0/"
19+
@pytest.mark.parametrize(
20+
'domain_prefix', ['securityioet', 'ioetec', 'anything-else']
21+
)
22+
def test_parse_tenant_id_from_iss_claim_with_valid_input(domain_prefix):
23+
valid_iss_claim = f'https://{domain_prefix}.b2clogin.com/b21c4e98-c4bf-420f-9d76-e51c2515c7a4/v2.0/'
1924

2025
result = parse_tenant_id_from_iss_claim(valid_iss_claim)
2126

tests/time_tracker_api/time_entries/time_entries_model_test.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,21 @@ def test_find_interception_should_ignore_id_of_existing_item(owner_id: str,
8181

8282

8383
def test_find_running_should_return_running_time_entry(running_time_entry,
84+
owner_id: str,
8485
time_entry_repository: TimeEntryCosmosDBRepository):
85-
found_time_entry = time_entry_repository.find_running(partition_key_value=running_time_entry.tenant_id)
86+
found_time_entry = time_entry_repository.find_running(partition_key_value=running_time_entry.tenant_id,
87+
owner_id=owner_id)
8688

87-
assert found_time_entry is not None
88-
assert found_time_entry.id == running_time_entry.id
89+
assert found_time_entry is not None
90+
assert found_time_entry.id == running_time_entry.id
91+
assert found_time_entry.owner_id == running_time_entry.owner_id
8992

9093

9194
def test_find_running_should_not_find_any_item(tenant_id: str,
95+
owner_id: str,
9296
time_entry_repository: TimeEntryCosmosDBRepository):
9397
try:
94-
time_entry_repository.find_running(partition_key_value=tenant_id)
98+
time_entry_repository.find_running(partition_key_value=tenant_id,
99+
owner_id=owner_id)
95100
except Exception as e:
96101
assert type(e) is StopIteration

tests/time_tracker_api/time_entries/time_entries_namespace_test.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient,
390390
def test_get_running_should_call_find_running(client: FlaskClient,
391391
mocker: MockFixture,
392392
valid_header: dict,
393+
owner_id: str,
393394
tenant_id: str):
394395
from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao
395396
repository_update_mock = mocker.patch.object(time_entries_dao.repository,
@@ -402,12 +403,14 @@ def test_get_running_should_call_find_running(client: FlaskClient,
402403

403404
assert HTTPStatus.OK == response.status_code
404405
assert json.loads(response.data) is not None
405-
repository_update_mock.assert_called_once_with(partition_key_value=tenant_id)
406+
repository_update_mock.assert_called_once_with(partition_key_value=tenant_id,
407+
owner_id=owner_id)
406408

407409

408410
def test_get_running_should_return_not_found_if_find_running_throws_StopIteration(client: FlaskClient,
409411
mocker: MockFixture,
410412
valid_header: dict,
413+
owner_id: str,
411414
tenant_id: str):
412415
from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao
413416
repository_update_mock = mocker.patch.object(time_entries_dao.repository,
@@ -419,4 +422,5 @@ def test_get_running_should_return_not_found_if_find_running_throws_StopIteratio
419422
follow_redirects=True)
420423

421424
assert HTTPStatus.NOT_FOUND == response.status_code
422-
repository_update_mock.assert_called_once_with(partition_key_value=tenant_id)
425+
repository_update_mock.assert_called_once_with(partition_key_value=tenant_id,
426+
owner_id=owner_id)

time_tracker_api/security.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
}
2626

2727
iss_claim_pattern = re.compile(
28-
r"securityioet.b2clogin.com/(?P<tenant_id>[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12})")
28+
r"(.*).b2clogin.com/(?P<tenant_id>[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12})")
2929

3030

3131
def current_user_id() -> str:

time_tracker_api/time_entries/time_entries_model.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,20 +88,22 @@ def on_update(self, updated_item_data: dict):
8888

8989
def find_interception_with_date_range(self, start_date, end_date, owner_id, partition_key_value,
9090
ignore_id=None, visible_only=True, mapper: Callable = None):
91-
conditions = {"owner_id": owner_id}
92-
params = self.append_conditions_values([
93-
{"name": "@partition_key_value", "value": partition_key_value},
91+
conditions = {
92+
"owner_id": owner_id,
93+
"tenant_id": partition_key_value,
94+
}
95+
params = [
9496
{"name": "@start_date", "value": start_date},
9597
{"name": "@end_date", "value": end_date or current_datetime_str()},
9698
{"name": "@ignore_id", "value": ignore_id},
97-
], conditions)
99+
]
100+
params.extend(self.generate_condition_values(conditions))
98101
result = self.container.query_items(
99102
query="""
100-
SELECT * FROM c WHERE c.tenant_id=@partition_key_value
101-
AND ((c.start_date BETWEEN @start_date AND @end_date) OR (c.end_date BETWEEN @start_date AND @end_date))
103+
SELECT * FROM c WHERE ((c.start_date BETWEEN @start_date AND @end_date)
104+
OR (c.end_date BETWEEN @start_date AND @end_date))
102105
{conditions_clause} {ignore_id_condition} {visibility_condition} {order_clause}
103-
""".format(partition_key_attribute=self.partition_key_attribute,
104-
ignore_id_condition=self.create_sql_ignore_id_condition(ignore_id),
106+
""".format(ignore_id_condition=self.create_sql_ignore_id_condition(ignore_id),
105107
visibility_condition=self.create_sql_condition_for_visibility(visible_only),
106108
conditions_clause=self.create_sql_where_conditions(conditions),
107109
order_clause=self.create_sql_order_clause()),
@@ -111,15 +113,22 @@ def find_interception_with_date_range(self, start_date, end_date, owner_id, part
111113
function_mapper = self.get_mapper_or_dict(mapper)
112114
return list(map(function_mapper, result))
113115

114-
def find_running(self, partition_key_value: str, mapper: Callable = None):
116+
def find_running(self, partition_key_value: str, owner_id: str, mapper: Callable = None):
117+
conditions = {
118+
"owner_id": owner_id,
119+
"tenant_id": partition_key_value,
120+
}
115121
result = self.container.query_items(
116122
query="""
117123
SELECT * from c
118-
WHERE (NOT IS_DEFINED(c.end_date) OR c.end_date = null) {visibility_condition}
124+
WHERE (NOT IS_DEFINED(c.end_date) OR c.end_date = null)
125+
{conditions_clause} {visibility_condition}
119126
OFFSET 0 LIMIT 1
120127
""".format(
121128
visibility_condition=self.create_sql_condition_for_visibility(True),
129+
conditions_clause=self.create_sql_where_conditions(conditions),
122130
),
131+
parameters=self.generate_condition_values(conditions),
123132
partition_key=partition_key_value,
124133
max_item_count=1)
125134

@@ -183,7 +192,8 @@ def delete(self, id):
183192
peeker=self.check_whether_current_user_owns_item)
184193

185194
def find_running(self):
186-
return self.repository.find_running(partition_key_value=self.partition_key_value)
195+
return self.repository.find_running(partition_key_value=self.partition_key_value,
196+
owner_id=self.current_user_id())
187197

188198

189199
def create_dao() -> TimeEntriesDao:

0 commit comments

Comments
 (0)