Skip to content

Commit 666ab05

Browse files
authored
feat(auth): store actual user ID on task actions (#94)
* feat: add auth logic for all the APIs * refactor: serializer and repositories tests * refactor: integration test for auth logic * refactor: unit tests for auth logic * fix: remove csrf disable middleware * refactor: move user setup and auth cookies to shared test utility * fix: add createdBy validator
1 parent d95b7bf commit 666ab05

23 files changed

+348
-242
lines changed

todo/constants/messages.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ class ApiErrors:
4141
TOKEN_REFRESH_FAILED = "Token refresh failed: {0}"
4242
LOGOUT_FAILED = "Logout failed: {0}"
4343
STATE_CONFLICT_TITLE = "State Conflict"
44+
UNAUTHORIZED_TITLE = "You are not authorized to perform this action"
45+
USER_NOT_FOUND = "User with ID {0} not found."
46+
USER_NOT_FOUND_GENERIC = "User not found."
4447

4548

4649
# Validation error messages

todo/dto/task_dto.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from datetime import datetime
22
from typing import List
3+
from bson import ObjectId
34
from pydantic import BaseModel, field_validator
45

6+
from todo.constants.messages import ValidationErrors
57
from todo.constants.task import TaskPriority, TaskStatus
68
from todo.dto.deferred_details_dto import DeferredDetailsDTO
79
from todo.dto.label_dto import LabelDTO
@@ -38,6 +40,7 @@ class CreateTaskDTO(BaseModel):
3840
assignee: str | None = None
3941
labels: List[str] = []
4042
dueAt: datetime | None = None
43+
createdBy: str
4144

4245
@field_validator("priority", mode="before")
4346
def parse_priority(cls, value):
@@ -50,3 +53,9 @@ def parse_status(cls, value):
5053
if isinstance(value, str):
5154
return TaskStatus[value]
5255
return value
56+
57+
@field_validator("createdBy")
58+
def validate_created_by(cls, value: str) -> str:
59+
if not ObjectId.is_valid(value):
60+
raise ValueError(ValidationErrors.INVALID_OBJECT_ID.format(value))
61+
return value

todo/exceptions/exception_handler.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
UnprocessableEntityException,
1515
TaskStateConflictException,
1616
)
17+
from todo.exceptions.user_exceptions import UserNotFoundException
1718
from .auth_exceptions import TokenExpiredError, TokenMissingError, TokenInvalidError
1819
from .google_auth_exceptions import (
1920
GoogleAuthException,
@@ -48,6 +49,7 @@ def format_validation_errors(errors) -> List[ApiErrorDetail]:
4849
def handle_exception(exc, context):
4950
response = drf_exception_handler(exc, context)
5051
task_id = context.get("kwargs", {}).get("task_id")
52+
user_id = context.get("kwargs", {}).get("user_id")
5153

5254
error_list = []
5355
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -187,6 +189,26 @@ def handle_exception(exc, context):
187189
detail=str(exc),
188190
)
189191
)
192+
193+
elif isinstance(exc, UserNotFoundException):
194+
status_code = status.HTTP_404_NOT_FOUND
195+
error_list.append(
196+
ApiErrorDetail(
197+
source={ApiErrorSource.PATH: "user_id"} if user_id else None,
198+
title=ApiErrors.RESOURCE_NOT_FOUND_TITLE,
199+
detail=str(exc),
200+
)
201+
)
202+
203+
elif isinstance(exc, PermissionError):
204+
status_code = status.HTTP_403_FORBIDDEN
205+
error_list.append(
206+
ApiErrorDetail(
207+
title=ApiErrors.UNAUTHORIZED_TITLE if hasattr(ApiErrors, "UNAUTHORIZED_TITLE") else "Permission Denied",
208+
detail=str(exc),
209+
)
210+
)
211+
190212
elif isinstance(exc, UnprocessableEntityException):
191213
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
192214
determined_message = str(exc)

todo/exceptions/user_exceptions.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from todo.constants.messages import ApiErrors
2+
3+
4+
class UserNotFoundException(Exception):
5+
def __init__(self, user_id: str | None = None, message_template: str = ApiErrors.USER_NOT_FOUND):
6+
if user_id:
7+
try:
8+
self.message = message_template.format(user_id)
9+
except (KeyError, ValueError):
10+
self.message = f"{message_template} (ID: {user_id})"
11+
else:
12+
self.message = ApiErrors.USER_NOT_FOUND_GENERIC
13+
super().__init__(self.message)

todo/repositories/task_repository.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from bson import ObjectId
44
from pymongo import ReturnDocument
55

6+
from todo.exceptions.task_exceptions import TaskNotFoundException
67
from todo.models.task import TaskModel
78
from todo.repositories.common.mongo_repository import MongoRepository
8-
from todo.constants.messages import RepositoryErrors
9+
from todo.constants.messages import ApiErrors, RepositoryErrors
910

1011

1112
class TaskRepository(MongoRepository):
@@ -32,6 +33,7 @@ def get_all(cls) -> List[TaskModel]:
3233
"""
3334
tasks_collection = cls.get_collection()
3435
tasks_cursor = tasks_collection.find()
36+
3537
return [TaskModel(**task) for task in tasks_cursor]
3638

3739
@classmethod
@@ -85,17 +87,30 @@ def get_by_id(cls, task_id: str) -> TaskModel | None:
8587
return None
8688

8789
@classmethod
88-
def delete_by_id(cls, task_id: str) -> TaskModel | None:
90+
def delete_by_id(cls, task_id: ObjectId, user_id: str) -> TaskModel | None:
8991
tasks_collection = cls.get_collection()
9092

93+
task = tasks_collection.find_one({"_id": task_id, "isDeleted": False})
94+
if not task:
95+
raise TaskNotFoundException(task_id)
96+
97+
assignee_id = task.get("assignee")
98+
99+
if assignee_id:
100+
if assignee_id != user_id:
101+
raise PermissionError(ApiErrors.UNAUTHORIZED_TITLE)
102+
else:
103+
if user_id != task.get("createdBy"):
104+
raise PermissionError(ApiErrors.UNAUTHORIZED_TITLE)
105+
91106
deleted_task_data = tasks_collection.find_one_and_update(
92-
{"_id": task_id, "isDeleted": False},
107+
{"_id": task_id},
93108
{
94109
"$set": {
95110
"isDeleted": True,
96111
"updatedAt": datetime.now(timezone.utc),
97-
"updatedBy": "system",
98-
} # TODO: modify to use actual user after auth implementation,
112+
"updatedBy": user_id,
113+
}
99114
},
100115
return_document=ReturnDocument.AFTER,
101116
)

todo/serializers/create_task_serializer.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ def validate_dueAt(self, value):
4545
return value
4646

4747
def validate_assignee(self, value):
48-
if isinstance(value, str) and not value.strip():
48+
if not value or not value.strip():
4949
return None
50+
if not ObjectId.is_valid(value):
51+
raise serializers.ValidationError(ValidationErrors.INVALID_OBJECT_ID.format(value))
5052
return value

todo/serializers/update_task_serializer.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ def validate_startedAt(self, value):
6666
return value
6767

6868
def validate_assignee(self, value):
69-
if isinstance(value, str) and not value.strip():
69+
if not value or not value.strip():
7070
return None
71+
if not ObjectId.is_valid(value):
72+
raise serializers.ValidationError(ValidationErrors.INVALID_OBJECT_ID.format(value))
7173
return value

todo/services/task_service.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from todo.dto.responses.create_task_response import CreateTaskResponse
1515
from todo.dto.responses.error_response import ApiErrorResponse, ApiErrorDetail, ApiErrorSource
1616
from todo.dto.responses.paginated_response import LinksData
17+
from todo.exceptions.user_exceptions import UserNotFoundException
1718
from todo.models.task import TaskModel, DeferredDetailsModel
1819
from todo.models.common.pyobjectid import PyObjectId
1920
from todo.repositories.task_repository import TaskRepository
@@ -28,6 +29,8 @@
2829
)
2930
from bson.errors import InvalidId as BsonInvalidId
3031

32+
from todo.repositories.user_repository import UserRepository
33+
3134

3235
@dataclass
3336
class PaginationConfig:
@@ -112,9 +115,8 @@ def build_page_url(cls, page: int, limit: int) -> str:
112115
@classmethod
113116
def prepare_task_dto(cls, task_model: TaskModel) -> TaskDTO:
114117
label_dtos = cls._prepare_label_dtos(task_model.labels) if task_model.labels else []
115-
116118
assignee = cls.prepare_user_dto(task_model.assignee) if task_model.assignee else None
117-
created_by = cls.prepare_user_dto(task_model.createdBy)
119+
created_by = cls.prepare_user_dto(task_model.createdBy) if task_model.createdBy else None
118120
updated_by = cls.prepare_user_dto(task_model.updatedBy) if task_model.updatedBy else None
119121
deferred_details = (
120122
cls.prepare_deferred_details_dto(task_model.deferredDetails) if task_model.deferredDetails else None
@@ -172,7 +174,10 @@ def prepare_deferred_details_dto(cls, deferred_details_model: DeferredDetailsMod
172174

173175
@classmethod
174176
def prepare_user_dto(cls, user_id: str) -> UserDTO:
175-
return UserDTO(id=user_id, name="SYSTEM")
177+
user = UserRepository.get_by_id(user_id)
178+
if user:
179+
return UserDTO(id=str(user_id), name=user.name)
180+
raise UserNotFoundException(user_id)
176181

177182
@classmethod
178183
def get_task_by_id(cls, task_id: str) -> TaskDTO:
@@ -208,11 +213,23 @@ def _process_enum_for_update(cls, enum_type: type, value: str | None) -> str | N
208213
return enum_type[value].value
209214

210215
@classmethod
211-
def update_task(cls, task_id: str, validated_data: dict, user_id: str = "system") -> TaskDTO:
216+
def update_task(cls, task_id: str, validated_data: dict, user_id: str) -> TaskDTO:
212217
current_task = TaskRepository.get_by_id(task_id)
218+
213219
if not current_task:
214220
raise TaskNotFoundException(task_id)
215221

222+
if current_task.assignee and current_task.assignee != user_id:
223+
raise PermissionError(ApiErrors.UNAUTHORIZED_TITLE)
224+
225+
if not current_task.assignee and current_task.createdBy != user_id:
226+
raise PermissionError(ApiErrors.UNAUTHORIZED_TITLE)
227+
228+
if validated_data.get("assignee"):
229+
assignee_data = UserRepository.get_by_id(validated_data["assignee"])
230+
if not assignee_data:
231+
raise UserNotFoundException(validated_data["assignee"])
232+
216233
update_payload = {}
217234
enum_fields = {"priority": TaskPriority, "status": TaskStatus}
218235

@@ -238,9 +255,16 @@ def update_task(cls, task_id: str, validated_data: dict, user_id: str = "system"
238255
@classmethod
239256
def defer_task(cls, task_id: str, deferred_till: datetime, user_id: str) -> TaskDTO:
240257
current_task = TaskRepository.get_by_id(task_id)
258+
241259
if not current_task:
242260
raise TaskNotFoundException(task_id)
243261

262+
if current_task.assignee and current_task.assignee != user_id:
263+
raise PermissionError(ApiErrors.UNAUTHORIZED_TITLE)
264+
265+
if not current_task.assignee and current_task.createdBy != user_id:
266+
raise PermissionError(ApiErrors.UNAUTHORIZED_TITLE)
267+
244268
if current_task.status == TaskStatus.DONE:
245269
raise TaskStateConflictException(ValidationErrors.CANNOT_DEFER_A_DONE_TASK)
246270

@@ -284,6 +308,11 @@ def create_task(cls, dto: CreateTaskDTO) -> CreateTaskResponse:
284308
now = datetime.now(timezone.utc)
285309
started_at = now if dto.status == TaskStatus.IN_PROGRESS else None
286310

311+
if dto.assignee:
312+
assignee = UserRepository.get_by_id(dto.assignee)
313+
if not assignee:
314+
raise UserNotFoundException(dto.assignee)
315+
287316
if dto.labels:
288317
existing_labels = LabelRepository.list_by_ids(dto.labels)
289318
if len(existing_labels) != len(dto.labels):
@@ -317,7 +346,7 @@ def create_task(cls, dto: CreateTaskDTO) -> CreateTaskResponse:
317346
createdAt=now,
318347
isAcknowledged=False,
319348
isDeleted=False,
320-
createdBy="system", # placeholder, will be user_id when auth is in place
349+
createdBy=dto.createdBy, # placeholder, will be user_id when auth is in place
321350
)
322351

323352
try:
@@ -356,8 +385,8 @@ def create_task(cls, dto: CreateTaskDTO) -> CreateTaskResponse:
356385
)
357386

358387
@classmethod
359-
def delete_task(cls, task_id: str) -> None:
360-
deleted_task_model = TaskRepository.delete_by_id(task_id)
388+
def delete_task(cls, task_id: str, user_id: str) -> None:
389+
deleted_task_model = TaskRepository.delete_by_id(task_id, user_id)
361390
if deleted_task_model is None:
362391
raise TaskNotFoundException(task_id)
363392
return None

todo/tests/fixtures/label.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
"name": "Label 1",
99
"color": "#fa1e4e",
1010
"createdAt": "2024-11-08T10:14:35",
11-
"createdBy": "qMbT6M2GB65W7UHgJS4g",
11+
"createdBy": str(ObjectId()),
1212
},
1313
{
1414
"_id": ObjectId("67588c1ac2195684a575840c"),
1515
"name": "Label 2",
1616
"color": "#ea1e4e",
1717
"createdAt": "2024-11-08T10:14:35",
18-
"createdBy": "qMbT6M2GB65W7UHgJS4g",
18+
"createdBy": str(ObjectId()),
1919
},
2020
]
2121

todo/tests/fixtures/user.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from datetime import datetime, timezone
22

3+
from bson import ObjectId
4+
35
users_db_data = [
46
{
57
"google_id": "123456789",
@@ -16,3 +18,10 @@
1618
"updated_at": datetime.now(timezone.utc),
1719
},
1820
]
21+
22+
google_auth_user_payload = {
23+
"user_id": str(ObjectId()),
24+
"google_id": "test_google_id",
25+
"email": "[email protected]",
26+
"name": "Test User",
27+
}

0 commit comments

Comments
 (0)