Skip to content

Commit ec4b4de

Browse files
authored
Merge pull request #83 from ioet/feature/Make-sure-users-access-their-time-entries#82
fix: Close #82 Enforce users manipulate only their time entries
2 parents 699d5bb + 3c2e9da commit ec4b4de

File tree

17 files changed

+268
-183
lines changed

17 files changed

+268
-183
lines changed

.env.template

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export FLASK_APP=time_tracker_api
99
## For Azure Cosmos DB
1010
export DATABASE_ACCOUNT_URI=https://<project_db_name>.documents.azure.com:443
1111
export DATABASE_MASTER_KEY=<db_master_key>
12-
export DATABASE_NAME=<db_name>
1312
### or
1413
# export COSMOS_DATABASE_URI=AccountEndpoint=<ACCOUNT_URI>;AccountKey=<ACCOUNT_KEY>
14+
## Also specify the database name
15+
export DATABASE_NAME=<db_name>

commons/data_access_layer/cosmos_db.py

Lines changed: 50 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from werkzeug.exceptions import HTTPException
1212

1313
from commons.data_access_layer.database import CRUDDao
14-
from time_tracker_api.security import current_user_tenant_id, current_user_id
14+
from time_tracker_api.security import current_user_tenant_id
1515

1616

1717
class CosmosDBFacade:
@@ -105,10 +105,26 @@ def create_sql_condition_for_visibility(visible_only: bool, container_name='c')
105105
return ''
106106

107107
@staticmethod
108-
def create_sql_condition_for_owner_id(owner_id: str, container_name='c') -> str:
109-
if owner_id:
110-
return 'AND %s.owner_id=@owner_id' % container_name
111-
return ''
108+
def create_sql_where_conditions(conditions: dict, container_name='c') -> str:
109+
where_conditions = []
110+
for k in conditions.keys():
111+
where_conditions.append('{c}.{var} = @{var}'.format(c=container_name, var=k))
112+
113+
if len(where_conditions) > 0:
114+
return "AND {where_conditions_clause}".format(
115+
where_conditions_clause=" AND ".join(where_conditions))
116+
else:
117+
return ""
118+
119+
@staticmethod
120+
def append_conditions_values(params: list, conditions: dict) -> dict:
121+
for k, v in conditions.items():
122+
params.append({
123+
"name": "@%s" % k,
124+
"value": v
125+
})
126+
127+
return params
112128

113129
@staticmethod
114130
def check_visibility(item, throw_not_found_if_deleted):
@@ -123,39 +139,41 @@ def create(self, data: dict, mapper: Callable = None):
123139
function_mapper = self.get_mapper_or_dict(mapper)
124140
return function_mapper(self.container.create_item(body=data))
125141

126-
def find(self, id: str, partition_key_value, visible_only=True, mapper: Callable = None):
142+
def find(self, id: str, partition_key_value, peeker: 'function' = None, visible_only=True, mapper: Callable = None):
127143
found_item = self.container.read_item(id, partition_key_value)
144+
if peeker:
145+
peeker(found_item)
146+
128147
function_mapper = self.get_mapper_or_dict(mapper)
129148
return function_mapper(self.check_visibility(found_item, visible_only))
130149

131-
def find_all(self, partition_key_value: str, owner_id=None, max_count=None, offset=0,
150+
def find_all(self, partition_key_value: str, conditions: dict = {}, max_count=None, offset=0,
132151
visible_only=True, mapper: Callable = None):
133152
# TODO Use the tenant_id param and change container alias
134153
max_count = self.get_page_size_or(max_count)
135-
result = self.container.query_items(
136-
query="""
154+
params = self.append_conditions_values([
155+
{"name": "@partition_key_value", "value": partition_key_value},
156+
{"name": "@offset", "value": offset},
157+
{"name": "@max_count", "value": max_count},
158+
], conditions)
159+
result = self.container.query_items(query="""
137160
SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value
138-
{owner_condition} {visibility_condition} {order_clause}
161+
{conditions_clause} {visibility_condition} {order_clause}
139162
OFFSET @offset LIMIT @max_count
140163
""".format(partition_key_attribute=self.partition_key_attribute,
141164
visibility_condition=self.create_sql_condition_for_visibility(visible_only),
142-
owner_condition=self.create_sql_condition_for_owner_id(owner_id),
165+
conditions_clause=self.create_sql_where_conditions(conditions),
143166
order_clause=self.create_sql_order_clause()),
144-
parameters=[
145-
{"name": "@partition_key_value", "value": partition_key_value},
146-
{"name": "@offset", "value": offset},
147-
{"name": "@max_count", "value": max_count},
148-
{"name": "@owner_id", "value": owner_id},
149-
],
150-
partition_key=partition_key_value,
151-
max_item_count=max_count)
167+
parameters=params,
168+
partition_key=partition_key_value,
169+
max_item_count=max_count)
152170

153171
function_mapper = self.get_mapper_or_dict(mapper)
154172
return list(map(function_mapper, result))
155173

156174
def partial_update(self, id: str, changes: dict, partition_key_value: str,
157-
visible_only=True, mapper: Callable = None):
158-
item_data = self.find(id, partition_key_value, visible_only=visible_only, mapper=dict)
175+
peeker: 'function' = None, visible_only=True, mapper: Callable = None):
176+
item_data = self.find(id, partition_key_value, peeker=peeker, visible_only=visible_only, mapper=dict)
159177
item_data.update(changes)
160178
return self.update(id, item_data, mapper=mapper)
161179

@@ -164,10 +182,11 @@ def update(self, id: str, item_data: dict, mapper: Callable = None):
164182
function_mapper = self.get_mapper_or_dict(mapper)
165183
return function_mapper(self.container.replace_item(id, body=item_data))
166184

167-
def delete(self, id: str, partition_key_value: str, mapper: Callable = None):
185+
def delete(self, id: str, partition_key_value: str,
186+
peeker: 'function' = None, mapper: Callable = None):
168187
return self.partial_update(id, {
169188
'deleted': str(uuid.uuid4())
170-
}, partition_key_value, visible_only=True, mapper=mapper)
189+
}, partition_key_value, peeker=peeker, visible_only=True, mapper=mapper)
171190

172191
def delete_permanently(self, id: str, partition_key_value: str) -> None:
173192
self.container.delete_item(id, partition_key_value)
@@ -190,30 +209,21 @@ def on_update(self, update_item_data: dict):
190209
def create_sql_order_clause(self):
191210
if len(self.order_fields) > 0:
192211
return "ORDER BY c.{}".format(", c.".join(self.order_fields))
193-
else:
194-
return ""
212+
return ""
195213

196214

197215
class CosmosDBDao(CRUDDao):
198216
def __init__(self, repository: CosmosDBRepository):
199217
self.repository = repository
200218

201-
@property
202-
def partition_key_value(self):
203-
return current_user_tenant_id()
204-
205219
def get_all(self) -> list:
206-
tenant_id: str = self.partition_key_value
207-
owner_id = current_user_id()
208-
return self.repository.find_all(partition_key_value=tenant_id, owner_id=owner_id)
220+
return self.repository.find_all(partition_key_value=self.partition_key_value)
209221

210222
def get(self, id):
211-
tenant_id: str = self.partition_key_value
212-
return self.repository.find(id, partition_key_value=tenant_id)
223+
return self.repository.find(id, partition_key_value=self.partition_key_value)
213224

214225
def create(self, data: dict):
215226
data[self.repository.partition_key_attribute] = self.partition_key_value
216-
data['owner_id'] = current_user_id()
217227
return self.repository.create(data)
218228

219229
def update(self, id, data: dict):
@@ -222,8 +232,11 @@ def update(self, id, data: dict):
222232
partition_key_value=self.partition_key_value)
223233

224234
def delete(self, id):
225-
tenant_id: str = current_user_tenant_id()
226-
self.repository.delete(id, partition_key_value=tenant_id)
235+
self.repository.delete(id, partition_key_value=self.partition_key_value)
236+
237+
@property
238+
def partition_key_value(self):
239+
return current_user_tenant_id()
227240

228241

229242
class CustomError(HTTPException):

tests/commons/data_access_layer/cosmos_db_test.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import pytest
55
from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError
66
from faker import Faker
7+
from flask_restplus._http import HTTPStatus
78
from pytest import fail
89

9-
from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBModel
10+
from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBModel, CustomError
1011

1112
fake = Faker()
1213
Faker.seed()
@@ -459,7 +460,7 @@ def test_find_all_can_find_deleted_items_only_if_visibile_only_is_true(
459460
assert deleted_item is not None
460461
assert deleted_item['deleted'] is not None
461462

462-
visible_items = cosmos_db_repository.find_all(sample_item['tenant_id'])
463+
visible_items = cosmos_db_repository.find_all(partition_key_value=sample_item.get('tenant_id'))
463464

464465
assert visible_items is not None
465466
assert any(item['id'] == sample_item['id'] for item in visible_items) == False, \
@@ -536,3 +537,52 @@ def test_delete_permanently_with_valid_id_should_succeed(
536537
except Exception as e:
537538
assert type(e) is CosmosResourceNotFoundError
538539
assert e.status_code == 404
540+
541+
542+
def test_repository_create_sql_where_conditions_with_multiple_values(cosmos_db_repository: CosmosDBRepository):
543+
result = cosmos_db_repository.create_sql_where_conditions({
544+
'owner_id': 'mark',
545+
'customer_id': 'me'
546+
}, "c")
547+
548+
assert result is not None
549+
assert result == "AND c.owner_id = @owner_id AND c.customer_id = @customer_id"
550+
551+
552+
def test_repository_create_sql_where_conditions_with_no_values(cosmos_db_repository: CosmosDBRepository):
553+
result = cosmos_db_repository.create_sql_where_conditions({}, "c")
554+
555+
assert result is not None
556+
assert result == ""
557+
558+
559+
def test_repository_append_conditions_values(cosmos_db_repository: CosmosDBRepository):
560+
result = cosmos_db_repository.append_conditions_values([], {'owner_id': 'mark', 'customer_id': 'ioet'})
561+
562+
assert result is not None
563+
assert result == [{'name': '@owner_id', 'value': 'mark'},
564+
{'name': '@customer_id', 'value': 'ioet'}]
565+
566+
567+
def test_find_should_call_picker_if_it_was_specified(cosmos_db_repository: CosmosDBRepository,
568+
sample_item: dict,
569+
another_item: dict):
570+
def raise_bad_request_if_name_diff_the_one_from_sample_item(data: dict):
571+
if sample_item['name'] != data['name']:
572+
raise CustomError(HTTPStatus.BAD_REQUEST, "Anything")
573+
574+
found_item = cosmos_db_repository.find(sample_item['id'],
575+
partition_key_value=sample_item['tenant_id'])
576+
577+
assert found_item is not None
578+
assert found_item['id'] == sample_item['id']
579+
580+
try:
581+
cosmos_db_repository.find(another_item['id'],
582+
partition_key_value=another_item['tenant_id'],
583+
peeker=raise_bad_request_if_name_diff_the_one_from_sample_item)
584+
585+
fail('It should have not found any item because of condition')
586+
except Exception as e:
587+
assert e.code == HTTPStatus.BAD_REQUEST
588+
assert e.description == "Anything"

tests/conftest.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,17 @@ def sample_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> dic
117117
return cosmos_db_repository.create(sample_item_data)
118118

119119

120+
@pytest.fixture(scope="function")
121+
def another_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> dict:
122+
sample_item_data = dict(id=fake.uuid4(),
123+
name=fake.name(),
124+
email=fake.safe_email(),
125+
age=fake.pyint(min_value=10, max_value=80),
126+
tenant_id=tenant_id)
127+
128+
return cosmos_db_repository.create(sample_item_data)
129+
130+
120131
@pytest.yield_fixture(scope="module")
121-
def time_entry_repository(cosmos_db_repository: CosmosDBRepository) -> TimeEntryCosmosDBRepository:
132+
def time_entry_repository() -> TimeEntryCosmosDBRepository:
122133
return TimeEntryCosmosDBRepository()

tests/time_tracker_api/time_entries/time_entries_model_test.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def test_find_interception_with_date_range_should_find(start_date: datetime,
5353
finally:
5454
time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id)
5555

56+
5657
def test_find_interception_should_ignore_id_of_existing_item(owner_id: str,
5758
tenant_id: str,
5859
time_entry_repository: TimeEntryCosmosDBRepository):
@@ -62,13 +63,13 @@ def test_find_interception_should_ignore_id_of_existing_item(owner_id: str,
6263
try:
6364

6465
colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date,
65-
owner_id=owner_id,
66-
partition_key_value=tenant_id)
66+
owner_id=owner_id,
67+
partition_key_value=tenant_id)
6768

6869
non_colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date,
69-
owner_id=owner_id,
70-
partition_key_value=tenant_id,
71-
ignore_id=existing_item.id)
70+
owner_id=owner_id,
71+
partition_key_value=tenant_id,
72+
ignore_id=existing_item.id)
7273

7374
colliding_result is not None
7475
assert any([existing_item.id == item.id for item in colliding_result])
@@ -77,4 +78,3 @@ def test_find_interception_should_ignore_id_of_existing_item(owner_id: str,
7778
assert not any([existing_item.id == item.id for item in non_colliding_result])
7879
finally:
7980
time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id)
80-

0 commit comments

Comments
 (0)