From 017d442da9a7193c48e34d62e4469ba9037b372b Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Thu, 10 Oct 2024 10:29:41 -0500 Subject: [PATCH 01/13] Add delete_user endpoint. --- .../xfd_django/xfd_api/api_methods/user.py | 21 +++++++++++++++++++ backend/src/xfd_django/xfd_api/views.py | 21 ++++++++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index fdb0f07ac..55b3c3b2b 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -30,3 +30,24 @@ def get_users(regionId): return [UserSchema.from_orm(user) for user in users] except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +def delete_user(user_id: str): + """ + Delete a user by ID. + + Args: + user_id : The ID of the user to delete. + Raises: + HTTPException: If the user is not authorized or the user is not found. + + Returns: + User: The user that was deleted. + """ + + try: + user = User.objects.get(id=user_id) + user.delete() + return UserSchema.from_orm(user) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index 54a9a8878..85a2fd880 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -36,7 +36,7 @@ from .api_methods.cve import get_cves_by_id, get_cves_by_name from .api_methods.domain import export_domains, get_domain_by_id, search_domains from .api_methods.organization import get_organizations, read_orgs -from .api_methods.user import get_users +from .api_methods.user import delete_user, get_users from .api_methods.vulnerability import get_vulnerability_by_id, update_vulnerability from .auth import get_current_active_user from .login_gov import callback, login @@ -318,7 +318,7 @@ async def callback_route(request: Request): # GET Current User -@api_router.get("/users/me", tags=["users"]) +@api_router.get("/users/me", tags=["Users"]) async def read_users_me(current_user: User = Depends(get_current_active_user)): return current_user @@ -327,7 +327,7 @@ async def read_users_me(current_user: User = Depends(get_current_active_user)): "/users/{regionId}", response_model=List[UserSchema], # dependencies=[Depends(get_current_active_user)], - tags=["User"], + tags=["Users"], ) async def call_get_users(regionId): """ @@ -345,6 +345,21 @@ async def call_get_users(regionId): return get_users(regionId) +@api_router.delete("/users/{userId}", tags=["Users"]) +async def call_delete_user(userId: str): + """ + call delete_user() + Args: + userId: UUID of the user to delete. + + Returns: + User: The user that was deleted. + + """ + + return delete_user(userId) + + ###################### # API-Keys ###################### From 0222f9707bf438d72b460f66cac87dede3063997 Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Tue, 22 Oct 2024 09:59:18 -0500 Subject: [PATCH 02/13] Add Accept Terms Endpoint. Add accept_terms function to api_methods/user.py Add /users/acceptTerms endpoint to views.py Add UpdateUser class to schema_models/user.py Add can_access_user function to auth.py --- .../xfd_django/xfd_api/api_methods/user.py | 22 +++++++++++++++++++ backend/src/xfd_django/xfd_api/auth.py | 7 ++++++ .../xfd_django/xfd_api/schema_models/user.py | 8 +++++++ backend/src/xfd_django/xfd_api/views.py | 18 ++++++++++++++- 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index 55b3c3b2b..da2ea6ff8 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -3,6 +3,7 @@ """ # Standard Python Libraries +from datetime import datetime from typing import List, Optional # Third-Party Libraries @@ -12,6 +13,27 @@ from ..schema_models.user import User as UserSchema +async def accept_terms(current_user: User, version: str): + """ + Accept the latest terms of service. + + Args: + current_user (User): The current authenticated user. + version (str): The version of the terms of service. + + Returns: + User: The updated user. + """ + if not current_user: + raise HTTPException(status_code=401, detail="User not authenticated.") + + current_user.dateAcceptedTerms = datetime.now() + current_user.acceptedTermsVersion = version + current_user.save() + + return UserSchema.from_orm(current_user) + + def get_users(regionId): """ Retrieve a list of users based on optional filter parameters. diff --git a/backend/src/xfd_django/xfd_api/auth.py b/backend/src/xfd_django/xfd_api/auth.py index 8981e746f..0ffc81058 100644 --- a/backend/src/xfd_django/xfd_api/auth.py +++ b/backend/src/xfd_django/xfd_api/auth.py @@ -5,6 +5,7 @@ import hashlib from hashlib import sha256 import os +from typing import Optional from urllib.parse import urlencode import uuid @@ -409,6 +410,12 @@ async def get_jwt_from_code(auth_code: str): # return user +def can_access_user(current_user, user_id: Optional[str]) -> bool: + return ( + user_id and (current_user.id == user_id) or is_global_write_admin(current_user) + ) + + def is_global_write_admin(current_user) -> bool: """Check if the user has global write admin permissions.""" return current_user and current_user.userType == "globalAdmin" diff --git a/backend/src/xfd_django/xfd_api/schema_models/user.py b/backend/src/xfd_django/xfd_api/schema_models/user.py index 40465fe12..25795a541 100644 --- a/backend/src/xfd_django/xfd_api/schema_models/user.py +++ b/backend/src/xfd_django/xfd_api/schema_models/user.py @@ -58,3 +58,11 @@ def model_dump(self, **kwargs): class Config: from_attributes = True + + +class UpdateUser(BaseModel): + firstName: str + lastName: str + email: str + userType: str + state: Optional[str] diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index 85a2fd880..6b55760cc 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -36,7 +36,7 @@ from .api_methods.cve import get_cves_by_id, get_cves_by_name from .api_methods.domain import export_domains, get_domain_by_id, search_domains from .api_methods.organization import get_organizations, read_orgs -from .api_methods.user import delete_user, get_users +from .api_methods.user import accept_terms, delete_user, get_users from .api_methods.vulnerability import get_vulnerability_by_id, update_vulnerability from .auth import get_current_active_user from .login_gov import callback, login @@ -318,6 +318,22 @@ async def callback_route(request: Request): # GET Current User +@api_router.post("/users/acceptTerms", tags=["Users"]) +async def call_accept_terms( + version: str, current_user: User = Depends(get_current_active_user) +): + """ + Accept the latest terms of service. + + Args: + version (str): The version of the terms of service. + + Returns: + User: The updated user. + """ + return accept_terms(current_user, version) + + @api_router.get("/users/me", tags=["Users"]) async def read_users_me(current_user: User = Depends(get_current_active_user)): return current_user From 40b2c3034dfc91c43a03912465dd74507508bdda Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Tue, 22 Oct 2024 10:33:44 -0500 Subject: [PATCH 03/13] Modify and reorder delete user endpoint; add todos for authentication and permissions. --- .../xfd_django/xfd_api/api_methods/user.py | 34 ++++++++++--------- backend/src/xfd_django/xfd_api/views.py | 33 ++++++++++-------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index da2ea6ff8..ed9eb5c0e 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -7,7 +7,8 @@ from typing import List, Optional # Third-Party Libraries -from fastapi import HTTPException, Query +from fastapi import HTTPException +from fastapi.responses import JSONResponse from ..models import User from ..schema_models.user import User as UserSchema @@ -34,42 +35,43 @@ async def accept_terms(current_user: User, version: str): return UserSchema.from_orm(current_user) -def get_users(regionId): +# TODO: Add user context and permissions +def delete_user(user_id: str): """ - Retrieve a list of users based on optional filter parameters. + Delete a user by ID. Args: - regionId : Region ID to filter users by. + user_id : The ID of the user to delete. Raises: - HTTPException: If the user is not authorized or no users are found. + HTTPException: If the user is not authorized or the user is not found. Returns: - List[User]: A list of users matching the filter criteria. + JSONResponse: The result of the deletion. """ try: - users = User.objects.filter(regionId=regionId).prefetch_related("roles") - return [UserSchema.from_orm(user) for user in users] + user = User.objects.get(id=user_id) + result = user.delete() + return JSONResponse(status_code=200, content={"result": result}) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -def delete_user(user_id: str): +def get_users(regionId): """ - Delete a user by ID. + Retrieve a list of users based on optional filter parameters. Args: - user_id : The ID of the user to delete. + regionId : Region ID to filter users by. Raises: - HTTPException: If the user is not authorized or the user is not found. + HTTPException: If the user is not authorized or no users are found. Returns: - User: The user that was deleted. + List[User]: A list of users matching the filter criteria. """ try: - user = User.objects.get(id=user_id) - user.delete() - return UserSchema.from_orm(user) + users = User.objects.filter(regionId=regionId).prefetch_related("roles") + return [UserSchema.from_orm(user) for user in users] except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index 6b55760cc..bf94746c4 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -334,6 +334,24 @@ async def call_accept_terms( return accept_terms(current_user, version) +# TODO: Add authentication and permissions +@api_router.delete("/users/{userId}", tags=["Users"]) +async def call_delete_user(userId: str): + """ + Delete a user by ID. + + Args: + userId : The ID of the user to delete. + + Raises: + HTTPException: If the user is not authorized or the user is not found. + + Returns: + JSONResponse: The result of the deletion. + """ + return delete_user(userId) + + @api_router.get("/users/me", tags=["Users"]) async def read_users_me(current_user: User = Depends(get_current_active_user)): return current_user @@ -361,21 +379,6 @@ async def call_get_users(regionId): return get_users(regionId) -@api_router.delete("/users/{userId}", tags=["Users"]) -async def call_delete_user(userId: str): - """ - call delete_user() - Args: - userId: UUID of the user to delete. - - Returns: - User: The user that was deleted. - - """ - - return delete_user(userId) - - ###################### # API-Keys ###################### From df742ac07b5e78287c586d2cf9a72612a59ef420 Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Fri, 25 Oct 2024 09:40:48 -0500 Subject: [PATCH 04/13] Add Update Users Endpoint Add update users endpoint to views Add update users definition to api_methods/user.py Refactor user endpoints to use Request object for passing arguments --- .../xfd_django/xfd_api/api_methods/user.py | 144 +++++++++++++++--- backend/src/xfd_django/xfd_api/views.py | 42 +++-- 2 files changed, 150 insertions(+), 36 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index ed9eb5c0e..9359aae30 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -7,41 +7,57 @@ from typing import List, Optional # Third-Party Libraries -from fastapi import HTTPException +from fastapi import HTTPException, Request from fastapi.responses import JSONResponse +from ..auth import ( + can_access_user, + is_global_view_admin, + is_global_write_admin, + is_regional_admin, +) from ..models import User +from ..schema_models.user import UpdateUser as UpdateUserSchema from ..schema_models.user import User as UserSchema -async def accept_terms(current_user: User, version: str): +async def accept_terms(request: Request): """ Accept the latest terms of service. - Args: - current_user (User): The current authenticated user. - version (str): The version of the terms of service. + request : The HTTP request containing the user and the terms version. Returns: User: The updated user. """ - if not current_user: - raise HTTPException(status_code=401, detail="User not authenticated.") - - current_user.dateAcceptedTerms = datetime.now() - current_user.acceptedTermsVersion = version - current_user.save() - - return UserSchema.from_orm(current_user) + try: + current_user = request.state.user + if not current_user: + raise HTTPException(status_code=401, detail="User not authenticated.") + + body = await request.json() + version = body.get("version") + if not version: + raise HTTPException( + status_code=400, detail="Missing version in request body." + ) + + current_user.dateAcceptedTerms = datetime.now() + current_user.acceptedTermsVersion = version + current_user.save() + + return UserSchema.model_validate(current_user) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) # TODO: Add user context and permissions -def delete_user(user_id: str): +def delete_user(request: Request): """ Delete a user by ID. - Args: - user_id : The ID of the user to delete. + request : The HTTP request containing authorization and target for deletion.. + Raises: HTTPException: If the user is not authorized or the user is not found. @@ -50,28 +66,110 @@ def delete_user(user_id: str): """ try: - user = User.objects.get(id=user_id) - result = user.delete() + # current_user = request.state.user + target_user_id = request.path_params["user_id"] + target_user = User.objects.get(id=target_user_id) + result = target_user.delete() return JSONResponse(status_code=200, content={"result": result}) + except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -def get_users(regionId): +def get_users(request: Request): """ - Retrieve a list of users based on optional filter parameters. + Retrieve a list of all users. + Args: + request : The HTTP request containing authorization information. + + Raises: + HTTPException: If the user is not authorized. + + Returns: + List[User]: A list of all users. + """ + try: + current_user = request.state.user + if not (is_global_view_admin(current_user) or is_regional_admin(current_user)): + raise HTTPException(status_code=401, detail="Unauthorized") + + users = User.objects.all().prefetch_related("roles", "roles.organization") + return [UserSchema.model_validate(user) for user in users] + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +def get_users_v2(request: Request): + """ + Retrieve a list of users based on optional filter parameters. Args: - regionId : Region ID to filter users by. + request : The HTTP request containing query parameters. + Raises: HTTPException: If the user is not authorized or no users are found. Returns: List[User]: A list of users matching the filter criteria. """ + try: + query_params = request.query_params + filters = {} + + if "state" in query_params: + filters["state"] = query_params["state"] + if "regionId" in query_params: + filters["regionId"] = query_params["regionId"] + if "invitePending" in query_params: + filters["invitePending"] = query_params["invitePending"] + + users = User.objects.filter(**filters).prefetch_related("roles") + return [UserSchema.model_validate(user) for user in users] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +async def update_user(request: Request): + """ + Update a particular user. + Args: + request: The HTTP request containing the update data. + + Raises: + HTTPException: If the user is not authorized or the user is not found. + + Returns: + User: The updated user. + """ try: - users = User.objects.filter(regionId=regionId).prefetch_related("roles") - return [UserSchema.from_orm(user) for user in users] + # Check if the current user can access the user to be updated + current_user = request.state.user + target_user_id = request.path_params["user_id"] + if not can_access_user(current_user, target_user_id): + raise HTTPException(status_code=401, detail="Unauthorized") + + # Validate the user ID + if not target_user_id or not User.objects.filter(id=target_user_id).exists(): + raise HTTPException(status_code=404, detail="User not found") + + # Parse and validate the request body + body = await request.json() + update_data = UpdateUserSchema(**body) + + # Check if the current user can set the userType + if not is_global_write_admin(current_user) and update_data.userType: + raise HTTPException(status_code=401, detail="Unauthorized to set userType") + + # Retrieve the user to be updated + user = User.objects.get(id=target_user_id) + user.firstName = update_data.firstName or user.firstName + user.lastName = update_data.lastName or user.lastName + user.fullName = f"{user.firstName} {user.lastName}" + user.userType = update_data.userType or user.userType + + # Save the updated user + user.save() + + return user except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index bf94746c4..4aa965eb1 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -36,7 +36,7 @@ from .api_methods.cve import get_cves_by_id, get_cves_by_name from .api_methods.domain import export_domains, get_domain_by_id, search_domains from .api_methods.organization import get_organizations, read_orgs -from .api_methods.user import accept_terms, delete_user, get_users +from .api_methods.user import accept_terms, delete_user, get_users, update_user from .api_methods.vulnerability import get_vulnerability_by_id, update_vulnerability from .auth import get_current_active_user from .login_gov import callback, login @@ -112,7 +112,6 @@ async def call_read_orgs(): async def list_assessments(): """ Lists all assessments for the logged-in user. - Args: current_user (User): The current authenticated user. @@ -320,28 +319,27 @@ async def callback_route(request: Request): # GET Current User @api_router.post("/users/acceptTerms", tags=["Users"]) async def call_accept_terms( - version: str, current_user: User = Depends(get_current_active_user) + request: Request, current_user: User = Depends(get_current_active_user) ): """ Accept the latest terms of service. - Args: version (str): The version of the terms of service. Returns: User: The updated user. """ - return accept_terms(current_user, version) + return accept_terms(request) # TODO: Add authentication and permissions @api_router.delete("/users/{userId}", tags=["Users"]) -async def call_delete_user(userId: str): +async def call_delete_user(userId, request: Request): """ Delete a user by ID. - Args: userId : The ID of the user to delete. + request : The HTTP request containing authorization. Raises: HTTPException: If the user is not authorized or the user is not found. @@ -349,7 +347,8 @@ async def call_delete_user(userId: str): Returns: JSONResponse: The result of the deletion. """ - return delete_user(userId) + request.path_params["user_id"] = userId + return delete_user(request) @api_router.get("/users/me", tags=["Users"]) @@ -358,17 +357,16 @@ async def read_users_me(current_user: User = Depends(get_current_active_user)): @api_router.get( - "/users/{regionId}", + "/users", response_model=List[UserSchema], # dependencies=[Depends(get_current_active_user)], tags=["Users"], ) -async def call_get_users(regionId): +async def call_get_users(request: Request): """ Call get_users() - Args: - regionId: Region IDs to filter users by. + request : The HTTP request containing query parameters. Raises: HTTPException: If the user is not authorized or no users are found. @@ -376,7 +374,25 @@ async def call_get_users(regionId): Returns: List[User]: A list of users matching the filter criteria. """ - return get_users(regionId) + return get_users(request) + + +@api_router.post("/users/{userId}", tags=["Users"]) +async def call_update_user(userId, request: Request): + """ + Update a user by ID. + Args: + userId : The ID of the user to update. + request : The HTTP request containing authorization and target for update. + + Raises: + HTTPException: If the user is not authorized or the user is not found. + + Returns: + JSONResponse: The result of the update. + """ + request.path_params["user_id"] = userId + return update_user(request) ###################### From 72269af836048c4d4c00fda51189c9415181820e Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Mon, 28 Oct 2024 09:13:23 -0500 Subject: [PATCH 05/13] Update User Models and Endpoints; Update Input Validation for Update User Endpoints. --- .../xfd_django/xfd_api/api_methods/user.py | 71 +++++++++++++++++-- .../xfd_django/xfd_api/schema_models/user.py | 34 +++++++-- backend/src/xfd_django/xfd_api/views.py | 66 ++++++++++++++--- 3 files changed, 152 insertions(+), 19 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index 9359aae30..61f21013b 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -17,6 +17,7 @@ is_regional_admin, ) from ..models import User +from ..schema_models.user import NewUser as NewUserSchema from ..schema_models.user import UpdateUser as UpdateUserSchema from ..schema_models.user import User as UserSchema @@ -142,34 +143,90 @@ async def update_user(request: Request): User: The updated user. """ try: - # Check if the current user can access the user to be updated current_user = request.state.user target_user_id = request.path_params["user_id"] if not can_access_user(current_user, target_user_id): raise HTTPException(status_code=401, detail="Unauthorized") - # Validate the user ID if not target_user_id or not User.objects.filter(id=target_user_id).exists(): raise HTTPException(status_code=404, detail="User not found") - # Parse and validate the request body + body = await request.json() + update_data = NewUserSchema(**body) + + if not is_global_write_admin(current_user) and update_data.userType: + raise HTTPException(status_code=401, detail="Unauthorized to set userType") + + user = User.objects.get(id=target_user_id) + user.firstName = update_data.firstName or user.firstName + user.lastName = update_data.lastName or user.lastName + user.fullName = f"{user.firstName} {user.lastName}" + user.userType = update_data.userType or user.userType + user.state = update_data.state or user.state + user.regionId = update_data.regionId or user.regionId + user.email = update_data.email or user.email + user.organization = update_data.organization or user.organization + user.organizationAdmin = ( + update_data.organizationAdmin + if update_data.organizationAdmin is not None + else user.organizationAdmin + ) + + user.save() + + return UserSchema.model_validate(user) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +async def update_user_v2(request: Request): + """ + Update a particular user. + Args: + request: The HTTP request containing the update data. + + Raises: + HTTPException: If the user is not authorized or the user is not found. + + Returns: + User: The updated user. + """ + try: + current_user = request.state.user + target_user_id = request.path_params["user_id"] + if not can_access_user(current_user, target_user_id): + raise HTTPException(status_code=401, detail="Unauthorized") + + if not target_user_id or not User.objects.filter(id=target_user_id).exists(): + raise HTTPException(status_code=404, detail="User not found") + body = await request.json() update_data = UpdateUserSchema(**body) - # Check if the current user can set the userType if not is_global_write_admin(current_user) and update_data.userType: raise HTTPException(status_code=401, detail="Unauthorized to set userType") - # Retrieve the user to be updated user = User.objects.get(id=target_user_id) user.firstName = update_data.firstName or user.firstName user.lastName = update_data.lastName or user.lastName user.fullName = f"{user.firstName} {user.lastName}" user.userType = update_data.userType or user.userType + user.state = update_data.state or user.state + user.regionId = update_data.regionId or user.regionId + user.invitePending = ( + update_data.invitePending + if update_data.invitePending is not None + else user.invitePending + ) + user.loginBlockedByMaintenance = ( + update_data.loginBlockedByMaintenance + if update_data.loginBlockedByMaintenance is not None + else user.loginBlockedByMaintenance + ) + user.organization = update_data.organization or user.organization - # Save the updated user user.save() - return user + return UserSchema.model_validate(user) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/src/xfd_django/xfd_api/schema_models/user.py b/backend/src/xfd_django/xfd_api/schema_models/user.py index 25795a541..593c71086 100644 --- a/backend/src/xfd_django/xfd_api/schema_models/user.py +++ b/backend/src/xfd_django/xfd_api/schema_models/user.py @@ -2,6 +2,7 @@ # Standard Python Libraries from datetime import datetime +from enum import Enum from typing import List, Optional from uuid import UUID @@ -12,6 +13,14 @@ from .role import Role +class UserType(Enum): + GLOBAL_ADMIN = "globalAdmin" + GLOBAL_VIEW = "globalView" + REGIONAL_ADMIN = "regionalAdmin" + READY_SET_CYBER = "readySetCyber" + STANDARD = "standard" + + class User(BaseModel): """User schema.""" @@ -29,7 +38,7 @@ class User(BaseModel): dateAcceptedTerms: Optional[datetime] acceptedTermsVersion: Optional[str] lastLoggedIn: Optional[datetime] - userType: str + userType: UserType regionId: Optional[str] state: Optional[str] oktaId: Optional[str] @@ -60,9 +69,26 @@ class Config: from_attributes = True -class UpdateUser(BaseModel): +# TODO: Confirm that userType is set during user creation +class NewUser(BaseModel): + email: str firstName: str lastName: str - email: str - userType: str + organization: Optional[str] + organizationAdmin: Optional[bool] + regionId: Optional[str] + state: Optional[str] + userType: UserType + + +class UpdateUser(BaseModel): + firstName: Optional[str] + fullName: Optional[str] + invitePending: Optional[bool] + lastName: Optional[str] + loginBlockedByMaintenance: Optional[bool] + organization: Optional[str] + regionId: Optional[str] + role: Optional[str] state: Optional[str] + userType: Optional[UserType] diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index bb3a80f49..3087d11aa 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -117,7 +117,6 @@ async def call_read_orgs(): async def list_assessments(): """ Lists all assessments for the logged-in user. - Args: current_user (User): The current authenticated user. @@ -328,23 +327,55 @@ async def callback_route(request: Request): # GET Current User -@api_router.get("/users/me", tags=["users"]) +@api_router.post("/users/acceptTerms", tags=["Users"]) +async def call_accept_terms(request: Request): + """ + Accept the latest terms of service. + + Args: + request : The HTTP request containing the user and the terms version. + + Returns: + User: The updated user. + """ + + return accept_terms(request) + + +# TODO: Add authentication and permissions +@api_router.delete("/users/{userId}", tags=["Users"]) +async def call_delete_user(request: Request): + """ + Delete a user by ID. + + Args: + userId : The ID of the user to delete. + + Raises: + HTTPException: If the user is not authorized or the user is not found. + + Returns: + JSONResponse: The result of the deletion. + """ + return delete_user(request) + + +@api_router.get("/users/me", tags=["Users"]) async def read_users_me(current_user: User = Depends(get_current_active_user)): return current_user @api_router.get( - "/users/{regionId}", + "/users", response_model=List[UserSchema], # dependencies=[Depends(get_current_active_user)], - tags=["User"], + tags=["Users"], ) -async def call_get_users(regionId): +async def call_get_users(request: Request): """ Call get_users() - Args: - regionId: Region IDs to filter users by. + request : The HTTP request containing query parameters. Raises: HTTPException: If the user is not authorized or no users are found. @@ -352,7 +383,26 @@ async def call_get_users(regionId): Returns: List[User]: A list of users matching the filter criteria. """ - return get_users(regionId) + return get_users(request) + + +@api_router.get("/v2/users") +@api_router.post("/users/{userId}", tags=["Users"]) +async def call_update_user(userId, request: Request): + """ + Update a user by ID. + Args: + userId : The ID of the user to update. + request : The HTTP request containing authorization and target for update. + + Raises: + HTTPException: If the user is not authorized or the user is not found. + + Returns: + JSONResponse: The result of the update. + """ + request.path_params["user_id"] = userId + return update_user(request) ###################### From f333d2bc0e137f6e580d9a90eba7b390e8575822 Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Tue, 29 Oct 2024 09:46:06 -0500 Subject: [PATCH 06/13] Add send_email Helper Functions. --- .../xfd_api/helpers/sendInviteEmail.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 backend/src/xfd_django/xfd_api/helpers/sendInviteEmail.py diff --git a/backend/src/xfd_django/xfd_api/helpers/sendInviteEmail.py b/backend/src/xfd_django/xfd_api/helpers/sendInviteEmail.py new file mode 100644 index 000000000..5b3d50682 --- /dev/null +++ b/backend/src/xfd_django/xfd_api/helpers/sendInviteEmail.py @@ -0,0 +1,61 @@ +# Standard Python Libraries +import os +from typing import Optional + +# Third-Party Libraries +import boto3 +from botocore.exceptions import BotoCoreError, ClientError +from fastapi import HTTPException + + +async def send_email(recipient: str, subject: str, body: str): + ses_client = boto3.client("ses", region_name="us-east-1") + try: + response = ses_client.send_email( + Source=os.getenv("CROSSFEED_SUPPORT_EMAIL_SENDER"), + Destination={ + "ToAddresses": [recipient], + }, + Message={ + "Subject": { + "Data": subject, + }, + "Body": { + "Text": { + "Data": body, + }, + }, + }, + ReplyToAddresses=[os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO")], + ) + except (BotoCoreError, ClientError) as e: + print(f"Error sending email: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to send email") + + +async def send_invite_email(email: str, organization: Optional[str] = None): + staging = os.getenv("NODE_ENV") != "production" + frontend_domain = os.getenv("FRONTEND_DOMAIN") + support_email = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") + + subject = "CyHy Dashboard Invitation" + body = f""" + Hi there, + + You've been invited to join {f"the {organization} organization on " if organization else ""}CyHy Dashboard. To accept the invitation and start using CyHy Dashboard, sign on at {frontend_domain}/signup. + + CyHy Dashboard access instructions: + + 1. Visit {frontend_domain}/signup. + 2. Select "Create Account." + 3. Enter your email address and a new password for CyHy Dashboard. + 4. A confirmation code will be sent to your email. Enter this code when you receive it. + 5. You will be prompted to enable MFA. Scan the QR code with an authenticator app on your phone, such as Microsoft Authenticator. Enter the MFA code you see after scanning. + 6. After configuring your account, you will be redirected to CyHy Dashboard. + + For more information on using CyHy Dashboard, view the CyHy Dashboard user guide at https://docs.crossfeed.cyber.dhs.gov/user-guide/quickstart/. + + If you encounter any difficulties, please feel free to reply to this email (or send an email to {support_email}). + """ + + await send_email(email, subject, body) From 0cc85d390795b56a3a485fce6f66056eca2bbc46 Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Tue, 29 Oct 2024 10:31:45 -0500 Subject: [PATCH 07/13] Add get users by state and get users by region endpoints. --- .../xfd_django/xfd_api/api_methods/user.py | 72 +++++++++++++++++++ backend/src/xfd_django/xfd_api/views.py | 54 +++++++++++++- 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index 61f21013b..040154e4e 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -101,6 +101,78 @@ def get_users(request: Request): raise HTTPException(status_code=500, detail=str(e)) +async def get_users_by_region_id(request: Request): + """ + List users with specific regionId. + Args: + request : The HTTP request containing the regionId. + + Returns: + JSONResponse: The list of users with the specified regionId. + """ + try: + current_user = request.state.user + if not is_regional_admin(current_user): + raise HTTPException(status_code=401, detail="Unauthorized") + + region_id = request.path_params.get("regionId") + if not region_id: + raise HTTPException( + status_code=400, detail="Missing regionId in path parameters" + ) + + users = User.objects.filter(regionId=region_id).prefetch_related( + "roles", "roles.organization" + ) + if users: + return JSONResponse( + status_code=200, + content=[UserSchema.model_validate(user) for user in users], + ) + else: + raise HTTPException( + status_code=404, detail="No users found for the specified regionId" + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +async def get_users_by_state(request: Request): + """ + List users with specific state. + Args: + request : The HTTP request containing the state. + + Returns: + JSONResponse: The list of users with the specified state. + """ + try: + current_user = request.state.user + if not is_regional_admin(current_user): + raise HTTPException(status_code=401, detail="Unauthorized") + + state = request.path_params.get("state") + if not state: + raise HTTPException( + status_code=400, detail="Missing state in path parameters" + ) + + users = User.objects.filter(state=state).prefetch_related( + "roles", "roles.organization" + ) + if users: + return JSONResponse( + status_code=200, + content=[UserSchema.model_validate(user) for user in users], + ) + else: + raise HTTPException( + status_code=404, detail="No users found for the specified state" + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + def get_users_v2(request: Request): """ Retrieve a list of users based on optional filter parameters. diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index 9147227ff..d55366bd6 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -25,7 +25,15 @@ update_saved_search, ) from .api_methods.search import export, search_post -from .api_methods.user import accept_terms, delete_user, get_users, update_user +from .api_methods.user import ( + accept_terms, + delete_user, + get_users, + get_users_by_region_id, + get_users_by_state, + get_users_v2, + update_user, +) from .api_methods.vulnerability import ( get_vulnerability_by_id, search_vulnerabilities, @@ -405,6 +413,50 @@ async def call_get_users(request: Request): return get_users(request) +@api_router.get( + "/users/regionId/{regionId}", + response_model=List[UserSchema], + # dependencies=[Depends(get_current_active_user)], + tags=["Users"], +) +async def call_get_users_by_region_id(request: Request): + """ + Call get_users_by_region_id() + Args: + request : The HTTP request containing query parameters. + + Raises: + HTTPException: If the user is not authorized or no users are found. + + Returns: + List[User]: A list of users matching the filter criteria. + """ + request.path_params["region_id"] = request.path_params["regionId"] + return get_users_by_region_id(request) + + +@api_router.get( + "/users/state/{state}", + response_model=List[UserSchema], + # dependencies=[Depends(get_current_active_user)], + tags=["Users"], +) +async def call_get_users_by_state(request: Request): + """ + Call get_users_by_state() + Args: + request : The HTTP request containing query parameters. + + Raises: + HTTPException: If the user is not authorized or no users are found. + + Returns: + List[User]: A list of users matching the filter criteria. + """ + request.path_params["state"] = request.path_params["state"] + return get_users_by_state(request) + + @api_router.get("/v2/users") @api_router.post("/users/{userId}", tags=["Users"]) async def call_update_user(userId, request: Request): From 7195c9040dcc3be35bdc40e2a57c7b9c86453f81 Mon Sep 17 00:00:00 2001 From: aloftus23 Date: Wed, 30 Oct 2024 14:14:23 -0400 Subject: [PATCH 08/13] Add register endpoints and post User with tests in test_user.py --- .../xfd_django/xfd_api/api_methods/user.py | 191 ++++- backend/src/xfd_django/xfd_api/auth.py | 4 +- .../src/xfd_django/xfd_api/helpers/email.py | 155 ++++ .../xfd_django/xfd_api/helpers/s3_client.py | 222 +++--- .../xfd_api/helpers/sendInviteEmail.py | 61 -- .../xfd_django/xfd_api/schema_models/user.py | 48 +- .../src/xfd_django/xfd_api/tests/test_user.py | 659 ++++++++++++++++++ backend/src/xfd_django/xfd_api/views.py | 43 +- 8 files changed, 1203 insertions(+), 180 deletions(-) create mode 100644 backend/src/xfd_django/xfd_api/helpers/email.py delete mode 100644 backend/src/xfd_django/xfd_api/helpers/sendInviteEmail.py diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index 040154e4e..087f98764 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -4,9 +4,12 @@ """ # Standard Python Libraries from datetime import datetime +import os from typing import List, Optional +import uuid # Third-Party Libraries +from django.core.exceptions import ObjectDoesNotExist from fastapi import HTTPException, Request from fastapi.responses import JSONResponse @@ -14,14 +17,31 @@ can_access_user, is_global_view_admin, is_global_write_admin, + is_org_admin, is_regional_admin, + matches_user_region, ) -from ..models import User +from ..helpers.email import ( + send_invite_email, + send_registration_approved_email, + send_registration_denied_email, +) +from ..helpers.regionStateMap import REGION_STATE_MAP +from ..models import Organization, Role, User from ..schema_models.user import NewUser as NewUserSchema from ..schema_models.user import UpdateUser as UpdateUserSchema from ..schema_models.user import User as UserSchema +def is_valid_uuid(val: str) -> bool: + """Check if the given string is a valid UUID.""" + try: + uuid_obj = uuid.UUID(val, version=4) + except ValueError: + return False + return str(uuid_obj) == val + + async def accept_terms(request: Request): """ Accept the latest terms of service. @@ -302,3 +322,172 @@ async def update_user_v2(request: Request): return UserSchema.model_validate(user) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +def user_register_approve(user_id, current_user): + """Approve a registered user.""" + if not is_valid_uuid(user_id): + raise HTTPException(status_code=404, detail="Invalid user ID.") + + try: + # Retrieve the user by ID + user = User.objects.get(id=user_id) + except ObjectDoesNotExist: + raise HTTPException(status_code=404, detail="User not found.") + + # Ensure authorizer's region matches the user's region + if not matches_user_region(current_user, user.regionId): + raise HTTPException(status_code=403, detail="Unauthorized region access.") + + # Send email notification + try: + send_registration_approved_email( + user.email, + subject="CyHy Dashboard Registration Approved", + first_name=user.firstName, + last_name=user.lastName, + template="crossfeed_approval_notification.html", + ) + + except HTTPException as http_exc: + raise http_exc + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to send email: {str(e)}") + + return {"statusCode": 200, "body": "User registration approved."} + + +def deny_user_registration(user_id: str, current_user: User): + """Deny a user's registration by user ID.""" + try: + # Validate UUID format for the user_id + if not is_valid_uuid(user_id): + raise HTTPException(status_code=404, detail="User not found.") + + # Retrieve the user object + user = User.objects.filter(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found.") + + # Ensure authorizer's region matches the user's region + if not matches_user_region(current_user, user.regionId): + raise HTTPException(status_code=403, detail="Unauthorized region access.") + + # Send registration denial email to the user + send_registration_denied_email( + user.email, + subject="CyHy Dashboard Registration Denied", + first_name=user.firstName, + last_name=user.lastName, + template="crossfeed_denial_notification.html", + ) + + return {"statusCode": 200, "body": "User registration denied."} + + except HTTPException as http_exc: + raise http_exc + except ObjectDoesNotExist: + raise HTTPException(status_code=404, detail="User not found.") + except Exception as e: + print(f"Error denying registration: {e}") + raise HTTPException( + status_code=500, detail="Error processing registration denial." + ) + + +def invite(new_user_data, current_user): + """Invite a user.""" + + try: + # Validate permissions + if new_user_data.organization: + if not is_org_admin(current_user, new_user_data.organization): + raise HTTPException(status_code=403, detail="Unauthorized access.") + else: + if not is_global_write_admin(current_user): + raise HTTPException(status_code=403, detail="Unauthorized access.") + + # Non-global admins cannot set userType + if not is_global_write_admin(current_user) and new_user_data.userType: + raise HTTPException(status_code=403, detail="Unauthorized access.") + + # Lowercase the email for consistency + new_user_data.email = new_user_data.email.lower() + + # Map state to region ID if state is provided + if new_user_data.state: + new_user_data.regionId = REGION_STATE_MAP.get(new_user_data.state) + + # Check if the user already exists + user = User.objects.filter(email=new_user_data.email).first() + organization = ( + Organization.objects.filter(id=new_user_data.organization).first() + if new_user_data.organization + else None + ) + + if not user: + # Create a new user if they do not exist + user = User.objects.create( + invitePending=True, + **new_user_data.dict( + exclude_unset=True, + exclude={"organizationAdmin", "organization", "userType"}, + ), + ) + if not os.getenv("IS_LOCAL"): + send_invite_email(user.email, organization) + elif not user.firstName and not user.lastName: + # Update first and last name if the user exists but has no name set + user.firstName = new_user_data.firstName + user.lastName = new_user_data.lastName + user.save() + + # Always update userType if specified + if new_user_data.userType: + user.userType = new_user_data.userType + user.save() + + # Assign role if an organization is specified + if organization: + Role.objects.update_or_create( + user=user, + organization=organization, + defaults={ + "approved": True, + "createdBy": current_user, + "approvedBy": current_user, + "role": "admin" if new_user_data.organizationAdmin else "user", + }, + ) + # Return the updated user with relevant details + return { + "id": str(user.id), + "firstName": user.firstName, + "lastName": user.lastName, + "email": user.email, + "userType": user.userType, + "roles": [ + { + "id": str(role.id), + "role": role.role, + "approved": role.approved, + "organization": { + "id": str(role.organization.id), + "name": role.organization.name, + } + if role.organization + else {}, + } + for role in user.roles.select_related("organization").all() + ], + "invitePending": user.invitePending, + } + + except HTTPException as http_exc: + raise http_exc + + except Exception as e: + print(f"Error inviting user: {e}") + raise HTTPException(status_code=500, detail="Error inviting user.") diff --git a/backend/src/xfd_django/xfd_api/auth.py b/backend/src/xfd_django/xfd_api/auth.py index c1cca2e83..50ba3b29a 100644 --- a/backend/src/xfd_django/xfd_api/auth.py +++ b/backend/src/xfd_django/xfd_api/auth.py @@ -504,8 +504,8 @@ def matches_user_region(current_user, user_region_id: str) -> bool: return True # Ensure the user has a region associated with them - if not current_user.region_id or not user_region_id: + if not current_user.regionId or not user_region_id: return False # Compare the region IDs - return user_region_id == current_user.region_id + return user_region_id == current_user.regionId diff --git a/backend/src/xfd_django/xfd_api/helpers/email.py b/backend/src/xfd_django/xfd_api/helpers/email.py new file mode 100644 index 000000000..86de5bf5f --- /dev/null +++ b/backend/src/xfd_django/xfd_api/helpers/email.py @@ -0,0 +1,155 @@ +# Standard Python Libraries +import os +from typing import Optional + +# Third-Party Libraries +import boto3 +from botocore.exceptions import BotoCoreError, ClientError +from fastapi import HTTPException +from .s3_client import S3Client +from jinja2 import Template + + +def send_invite_email(email, organization=None): + """Send an invitation email to the specified address.""" + frontend_domain = os.getenv("FRONTEND_DOMAIN") + reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") + + org_name_part = f"the {organization.name} organization on " if organization else "" + message = f""" + Hi there, + + You've been invited to join {org_name_part}CyHy Dashboard. To accept the invitation and start using CyHy Dashboard, sign on at {frontend_domain}/signup. + + CyHy Dashboard access instructions: + + 1. Visit {frontend_domain}/signup. + 2. Select "Create Account." + 3. Enter your email address and a new password for CyHy Dashboard. + 4. A confirmation code will be sent to your email. Enter this code when you receive it. + 5. You will be prompted to enable MFA. Scan the QR code with an authenticator app on your phone, such as Microsoft Authenticator. Enter the MFA code you see after scanning. + 6. After configuring your account, you will be redirected to CyHy Dashboard. + + For more information on using CyHy Dashboard, view the CyHy Dashboard user guide at https://docs.crossfeed.cyber.dhs.gov/user-guide/quickstart/. + + If you encounter any difficulties, please feel free to reply to this email (or send an email to {reply_to}). + """ + send_email(email, "CyHy Dashboard Invitation", message) + + +def send_email(recipient, subject, body): + """Send an email using AWS SES.""" + ses_client = boto3.client("ses", region_name="us-east-1") + sender = os.getenv("CROSSFEED_SUPPORT_EMAIL_SENDER") + reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") + + email_params = { + "Source": sender, + "Destination": {"ToAddresses": [recipient]}, + "Message": { + "Subject": {"Data": subject}, + "Body": {"Text": {"Data": body}} + }, + "ReplyToAddresses": [reply_to] + } + + try: + ses_client.send_email(**email_params) + print(f"Email sent to {recipient}") + except ClientError as e: + print(f"Error sending email: {e}") + +def send_registration_approved_email(recipient: str, subject: str, first_name: str, last_name: str, template): + """Send registration approved email.""" + try: + # Initialize S3 client and fetch email template + client = S3Client() + html_template = client.get_email_asset(template) + + if not html_template: + raise ValueError("Email template not found or empty.") + + # Set up the email content with Jinja2 template rendering + template = Template(html_template) + data = { + "firstName": first_name, + "lastName": last_name, + "domain": os.getenv("FRONTEND_DOMAIN") + } + html_to_send = template.render(data) + + # Email configuration + sender = os.getenv("CROSSFEED_SUPPORT_EMAIL_SENDER") + reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") + + email_params = { + "Source": sender, + "Destination": { + "ToAddresses": [recipient] + }, + "Message": { + "Subject": {"Data": subject}, + "Body": {"Html": {"Data": html_to_send}} + }, + "ReplyToAddresses": [reply_to] + } + # SES client + if not os.getenv("IS_LOCAL"): + ses_client = boto3.client("ses", region_name="us-east-1") + # Send email + ses_client.send_email(**email_params) + print("Email sent successfully to:", recipient) + else: + print(email_params) + print("Local environment cannot send email") + + + except (ClientError, ValueError) as e: + print("Email error:", e) + +def send_registration_denied_email(recipient: str, subject: str, first_name: str, last_name: str, template): + """Send registration denied email.""" + try: + # Initialize S3 client and fetch email template + client = S3Client() + html_template = client.get_email_asset(template) + + if not html_template: + raise ValueError("Email template not found or empty.") + + # Set up the email content with Jinja2 template rendering + template = Template(html_template) + data = { + "firstName": first_name, + "lastName": last_name, + } + html_to_send = template.render(data) + + # Email configuration + sender = os.getenv("CROSSFEED_SUPPORT_EMAIL_SENDER") + reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") + + email_params = { + "Source": sender, + "Destination": { + "ToAddresses": [recipient] + }, + "Message": { + "Subject": {"Data": subject}, + "Body": {"Html": {"Data": html_to_send}} + }, + "ReplyToAddresses": [reply_to] + } + # SES client + if not os.getenv("IS_LOCAL"): + ses_client = boto3.client("ses", region_name="us-east-1") + # Send email + ses_client.send_email(**email_params) + print("Email sent successfully to:", recipient) + else: + print(email_params) + print("Local environment cannot send email") + + + except (ClientError, ValueError) as e: + print("Email error:", e) \ No newline at end of file diff --git a/backend/src/xfd_django/xfd_api/helpers/s3_client.py b/backend/src/xfd_django/xfd_api/helpers/s3_client.py index 499eb284f..794bbeef0 100644 --- a/backend/src/xfd_django/xfd_api/helpers/s3_client.py +++ b/backend/src/xfd_django/xfd_api/helpers/s3_client.py @@ -1,117 +1,121 @@ # Standard Python Libraries -import csv from datetime import datetime import os -from random import randint +import random +from urllib.parse import urlparse # Third-Party Libraries import boto3 -import boto3.s3 -from django.core.paginator import Paginator -from django.http import HttpResponse -from fastapi import HTTPException -from minio import Minio - - -def get_minio_client(): - return Minio( - "localhost:9000", - access_key=os.environ["MINIO_ACCESS_KEY"], - secret_key=os.environ["MINIO_SECRET_KEY"], - secure=False, - ) - - -async def save_minio(data, key: str): - client = get_minio_client() - try: - if not client.bucket_exists(os.environ["EXPORT_BUCKET_NAME"]): - client.make_bucket(os.environ["EXPORT_BUCKET_NAME"]) - - client.put_object(os.environ["EXPORT_BUCKET_NAME"], data, key) - print("File uploaded successfully") - except Exception as e: - print(f"Error uploading file to Minio: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -async def export_to_csv(pages: Paginator, queryset, name, is_local: bool = True): - try: - filename = f"{randint(0, 1000000000)}/{name}-{datetime.now()}.csv" - response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = f"attachment; filename={filename}" - - writer = csv.writer(response) - if queryset.count() > 0: - writer.writerow( - [ - "id", - "createdAt", - "updatedAt", - "syncedAt", - "ip", - "fromRootDomain", - "subdomainSource", - "ipOnly", - "reverseName", - "name", - "screenshot", - "country", - "asn", - "cloudHosted", - "ssl", - "censysCertificatesResults", - "trustymailResults", - "discoveredBy_id", - "organization_id", - ] +from botocore.exceptions import ClientError + + +class S3Client: + def __init__(self, is_local=None): + self.is_local = ( + is_local + if is_local is not None + else bool(os.getenv("IS_OFFLINE") or os.getenv("IS_LOCAL")) + ) + + if self.is_local: + self.s3 = boto3.client( + "s3", + endpoint_url="http://minio:9000", + config=boto3.session.Config(s3={"addressing_style": "path"}), + ) + else: + self.s3 = boto3.client( + "s3", + config=boto3.session.Config( + s3={"addressing_style": "virtual"}, + retries={"max_attempts": 3}, + http={"keep_alive": False}, + ), + ) + + def save_csv(self, body, name=""): + """Saves a CSV file in S3 and returns a temporary URL for access""" + try: + key = f"{random.random()}/{name}-{datetime.utcnow().isoformat()}.csv" + bucket = os.getenv("EXPORT_BUCKET_NAME") + + # Save CSV to S3 + self.s3.put_object( + Bucket=bucket, Key=key, Body=body, ContentType="text/csv" + ) + + # Generate signed URL + url = self.s3.generate_presigned_url( + ClientMethod="get_object", + Params={"Bucket": bucket, "Key": key}, + ExpiresIn=60 * 5, + ) + return url.replace("minio:9000", "localhost:9000") if self.is_local else url + except ClientError as e: + print("Error saving CSV to S3: %s", e) + raise + + def export_report(self, report_name, org_id): + """Generates a presigned URL for a report""" + try: + key = f"{org_id}/{report_name}" + bucket = os.getenv("REPORTS_BUCKET_NAME") + + url = self.s3.generate_presigned_url( + ClientMethod="get_object", + Params={"Bucket": bucket, "Key": key}, + ExpiresIn=60 * 5, + ) + return url.replace("minio:9000", "localhost:9000") if self.is_local else url + except ClientError as e: + print("Error exporting report from S3: %s", e) + raise + + def list_reports(self, org_id): + """Lists all reports in a specified organization's folder""" + try: + bucket = os.getenv("REPORTS_BUCKET_NAME") + prefix = f"{org_id}/" + + response = self.s3.list_objects_v2( + Bucket=bucket, Prefix=prefix, Delimiter="" ) + return response.get("Contents", []) + except ClientError as e: + print("Error listing reports from S3: %s", e) + raise - for page_number in pages.page_range: - page = pages.page(page_number) - for obj in page.object_list: - print(obj) - writer.writerow( - [ - obj.id, - obj.createdAt, - obj.updatedAt, - obj.syncedAt, - obj.ip, - obj.fromRootDomain, - obj.subdomainSource, - obj.ipOnly, - obj.reverseName, - obj.name, - obj.screnshot, - obj.country, - obj.asn, - obj.cloudHosted, - obj.ssl, - obj.censysCertificatesResults, - obj.trustymailResults, - obj.discoveredBy_id, - obj.organization_id, - ] - ) - # if not is_local: - # s3_client = boto3.client("s3") - # s3_client.put_object( - # Body=response.content, - # Bucket=os.environ['EXPORT_BUCKET_NAME'], - # Key=filename - # ) - # else: - # client = get_minio_client() - # csv_values = response.getvalue() - # if not client.bucket_exists(os.environ['EXPORT_BUCKET_NAME']): - # client.make_bucket(os.environ['EXPORT_BUCKET_NAME']) - # client.put_object( - # os.environ['EXPORT_BUCKET_NAME'], - # filename, - # len(csv_values), - # content_type='text/csv' - # ) - return response - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + def pull_daily_vs(self, filename): + """Retrieves a specified daily VS file from S3""" + bucket = os.getenv("VS_BUCKET_NAME", "vs-extracts") + + try: + response = self.s3.head_object(Bucket=bucket, Key=filename) + if response: + print(f"File '{filename}' exists in bucket {bucket}.") + except self.s3.exceptions.NoSuchKey: + print(f"File '{filename}' does not exist in bucket {bucket}.") + return None + except ClientError as e: + print("Error checking for file in S3: %s", e) + raise + + try: + response = self.s3.get_object(Bucket=bucket, Key=filename) + return response["Body"].read() if "Body" in response else None + except ClientError as e: + print("Error downloading file from S3: %s", e) + raise + + def get_email_asset(self, file_name): + """Retrieves an email template asset from S3""" + bucket = os.getenv("EMAIL_BUCKET_NAME") + + try: + response = self.s3.get_object(Bucket=bucket, Key=file_name) + return ( + response["Body"].read().decode("utf-8") if "Body" in response else None + ) + except ClientError as e: + print("Error retrieving email asset from S3: %s", e) + raise diff --git a/backend/src/xfd_django/xfd_api/helpers/sendInviteEmail.py b/backend/src/xfd_django/xfd_api/helpers/sendInviteEmail.py deleted file mode 100644 index 5b3d50682..000000000 --- a/backend/src/xfd_django/xfd_api/helpers/sendInviteEmail.py +++ /dev/null @@ -1,61 +0,0 @@ -# Standard Python Libraries -import os -from typing import Optional - -# Third-Party Libraries -import boto3 -from botocore.exceptions import BotoCoreError, ClientError -from fastapi import HTTPException - - -async def send_email(recipient: str, subject: str, body: str): - ses_client = boto3.client("ses", region_name="us-east-1") - try: - response = ses_client.send_email( - Source=os.getenv("CROSSFEED_SUPPORT_EMAIL_SENDER"), - Destination={ - "ToAddresses": [recipient], - }, - Message={ - "Subject": { - "Data": subject, - }, - "Body": { - "Text": { - "Data": body, - }, - }, - }, - ReplyToAddresses=[os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO")], - ) - except (BotoCoreError, ClientError) as e: - print(f"Error sending email: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to send email") - - -async def send_invite_email(email: str, organization: Optional[str] = None): - staging = os.getenv("NODE_ENV") != "production" - frontend_domain = os.getenv("FRONTEND_DOMAIN") - support_email = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") - - subject = "CyHy Dashboard Invitation" - body = f""" - Hi there, - - You've been invited to join {f"the {organization} organization on " if organization else ""}CyHy Dashboard. To accept the invitation and start using CyHy Dashboard, sign on at {frontend_domain}/signup. - - CyHy Dashboard access instructions: - - 1. Visit {frontend_domain}/signup. - 2. Select "Create Account." - 3. Enter your email address and a new password for CyHy Dashboard. - 4. A confirmation code will be sent to your email. Enter this code when you receive it. - 5. You will be prompted to enable MFA. Scan the QR code with an authenticator app on your phone, such as Microsoft Authenticator. Enter the MFA code you see after scanning. - 6. After configuring your account, you will be redirected to CyHy Dashboard. - - For more information on using CyHy Dashboard, view the CyHy Dashboard user guide at https://docs.crossfeed.cyber.dhs.gov/user-guide/quickstart/. - - If you encounter any difficulties, please feel free to reply to this email (or send an email to {support_email}). - """ - - await send_email(email, subject, body) diff --git a/backend/src/xfd_django/xfd_api/schema_models/user.py b/backend/src/xfd_django/xfd_api/schema_models/user.py index 593c71086..ecbe0dae7 100644 --- a/backend/src/xfd_django/xfd_api/schema_models/user.py +++ b/backend/src/xfd_django/xfd_api/schema_models/user.py @@ -69,19 +69,50 @@ class Config: from_attributes = True -# TODO: Confirm that userType is set during user creation +class UserRoleOrg(BaseModel): + """User role organization schema.""" + + id: str + name: str + + +class UserRole(BaseModel): + """User role schema.""" + + id: str + role: str + approved: bool + organization: Optional[UserRoleOrg] = None + + class NewUser(BaseModel): + """New user schema.""" + email: str firstName: str lastName: str - organization: Optional[str] - organizationAdmin: Optional[bool] - regionId: Optional[str] - state: Optional[str] + organization: Optional[str] = None + organizationAdmin: Optional[bool] = None + regionId: Optional[str] = None + state: Optional[str] = None + userType: Optional[UserType] = None + + +class NewUserResponseModel(BaseModel): + """New user response schema.""" + + id: str + firstName: str + lastName: str + email: str + invitePending: bool userType: UserType + roles: Optional[List[UserRole]] = [] class UpdateUser(BaseModel): + """Update user schema.""" + firstName: Optional[str] fullName: Optional[str] invitePending: Optional[bool] @@ -92,3 +123,10 @@ class UpdateUser(BaseModel): role: Optional[str] state: Optional[str] userType: Optional[UserType] + + +class RegisterUserResponse(BaseModel): + """Register or deny user response.""" + + statusCode: int + body: str diff --git a/backend/src/xfd_django/xfd_api/tests/test_user.py b/backend/src/xfd_django/xfd_api/tests/test_user.py index e69de29bb..74f0385eb 100644 --- a/backend/src/xfd_django/xfd_api/tests/test_user.py +++ b/backend/src/xfd_django/xfd_api/tests/test_user.py @@ -0,0 +1,659 @@ +# Standard Python Libraries +from datetime import datetime +import secrets +from unittest.mock import patch + +# Third-Party Libraries +from fastapi.testclient import TestClient +import pytest +from xfd_api.auth import create_jwt_token +from xfd_api.models import Organization, Role, Scan, ScanTask, User, UserType +from xfd_django.asgi import app + +client = TestClient(app) + + +@pytest.mark.django_db(transaction=True) +def test_invite_by_regular_user_should_not_work(): + """Invite by a regular user should not work.""" + user = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.STANDARD, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + organization = Organization.objects.create( + name=f"test-{secrets.token_hex(4)}", + rootDomains=[f"test-{secrets.token_hex(4)}"], + ipBlocks=[], + isPassive=False, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + # Assign standard user role to the user for the organization + Role.objects.create( + user=user, + organization=organization, + role="user", + ) + + response = client.post( + "/users", + json={ + "firstName": "first name", + "lastName": "last name", + "email": f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + }, + headers={"Authorization": f"Bearer {create_jwt_token(user)}"}, + ) + + assert response.status_code == 403 + assert response.json() == {"detail": "Unauthorized access."} + + +@pytest.mark.django_db(transaction=True) +def test_invite_by_global_admin_should_work(): + """Invite by a global admin should work.""" + user = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.GLOBAL_ADMIN, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + email = f"{secrets.token_hex(4)}@crossfeed.cisa.gov" + response = client.post( + "/users", + json={"firstName": "first name", "lastName": "last name", "email": email}, + headers={"Authorization": f"Bearer {create_jwt_token(user)}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["email"] == email + assert data["invitePending"] is True + assert data["firstName"] == "first name" + assert data["lastName"] == "last name" + assert data["roles"] == [] + assert data["userType"] == UserType.STANDARD + + +@pytest.mark.django_db(transaction=True) +def test_invite_by_global_admin_with_user_type_setting(): + """Invite by a global admin should work if setting user type.""" + user = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.GLOBAL_ADMIN, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + email = f"{secrets.token_hex(4)}@crossfeed.cisa.gov" + response = client.post( + "/users", + json={ + "firstName": "first name", + "lastName": "last name", + "email": email, + "userType": UserType.GLOBAL_ADMIN, + }, + headers={"Authorization": f"Bearer {create_jwt_token(user)}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["email"] == email + assert data["invitePending"] is True + assert data["firstName"] == "first name" + assert data["lastName"] == "last name" + assert data["roles"] == [] + assert data["userType"] == UserType.GLOBAL_ADMIN + + +@pytest.mark.django_db(transaction=True) +def test_invite_by_global_view_should_not_work(): + """Invite by a global view should not work.""" + user = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.GLOBAL_VIEW, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + response = client.post( + "/users", + json={ + "firstName": "first name", + "lastName": "last name", + "email": f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + }, + headers={"Authorization": f"Bearer {create_jwt_token(user)}"}, + ) + + print(response.json()) + assert response.status_code == 403 + assert response.json() == {"detail": "Unauthorized access."} + + +@pytest.mark.django_db(transaction=True) +def test_invite_by_organization_admin_should_work(): + """Invite by an organization admin should work.""" + user = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.STANDARD, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + organization = Organization.objects.create( + name=f"test-{secrets.token_hex(4)}", + rootDomains=[f"test-{secrets.token_hex(4)}"], + ipBlocks=[], + isPassive=False, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + # Assign admin role to the user for the organization + Role.objects.create( + user=user, + organization=organization, + role="admin", + ) + + email = f"{secrets.token_hex(4)}@crossfeed.cisa.gov" + print("here") + response = client.post( + "/users", + json={ + "firstName": "first name", + "lastName": "last name", + "email": email, + "organization": str(organization.id), + "organizationAdmin": False, + }, + headers={"Authorization": f"Bearer {create_jwt_token(user)}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["email"] == email + assert data["invitePending"] is True + assert data["firstName"] == "first name" + assert data["lastName"] == "last name" + assert data["roles"][0]["approved"] is True + assert data["roles"][0]["role"] == "user" + assert data["roles"][0]["organization"]["id"] == str(organization.id) + + +@pytest.mark.django_db(transaction=True) +def test_invite_by_organization_admin_should_not_work_if_setting_user_type(): + """Invite by an organization admin should not work if setting user type.""" + user = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.STANDARD, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + organization = Organization.objects.create( + name=f"test-{secrets.token_hex(4)}", + rootDomains=[f"test-{secrets.token_hex(4)}"], + ipBlocks=[], + isPassive=False, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + # Assign admin role to the user for the organization + Role.objects.create( + user=user, + organization=organization, + role="admin", + ) + + email = f"{secrets.token_hex(4)}@crossfeed.cisa.gov" + response = client.post( + "/users", + json={ + "firstName": "first name", + "lastName": "last name", + "email": email, + "organization": str(organization.id), + "organizationAdmin": False, + "userType": UserType.GLOBAL_ADMIN, + }, + headers={"Authorization": f"Bearer {create_jwt_token(user)}"}, + ) + + assert response.status_code == 403 + assert response.json() == {"detail": "Unauthorized access."} + + +@pytest.mark.django_db(transaction=True) +def test_invite_existing_user_by_different_org_admin_should_not_modify_other_user_details(): + """Invite existing user by a different organization admin should work, and should not modify other user details.""" + organization = Organization.objects.create( + name=f"test-{secrets.token_hex(4)}", + rootDomains=[f"test-{secrets.token_hex(4)}"], + ipBlocks=[], + isPassive=False, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + organization2 = Organization.objects.create( + name=f"test-{secrets.token_hex(4)}", + rootDomains=[f"test-{secrets.token_hex(4)}"], + ipBlocks=[], + isPassive=False, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + email = f"{secrets.token_hex(4)}@crossfeed.cisa.gov" + user = User.objects.create( + firstName="first name", lastName="last name", email=email + ) + Role.objects.create( + role="user", approved=False, organization=organization, user=user + ) + + user2 = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.STANDARD, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + # Assign admin role to the user for the organization + Role.objects.create( + user=user2, + organization=organization2, + role="admin", + ) + + response = client.post( + "/users", + json={ + "firstName": "new first name", + "lastName": "new last name", + "email": email, + "organization": str(organization2.id), + "organizationAdmin": False, + }, + headers={"Authorization": f"Bearer {create_jwt_token(user2)}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == str(user.id) + assert data["email"] == email + assert data["invitePending"] is False + assert data["firstName"] == "first name" + assert data["lastName"] == "last name" + role_for_org2 = [ + role + for role in data["roles"] + if role["organization"]["id"] == str(organization2.id) + ] + assert role_for_org2, f"No role found for organization {organization2.id}" + assert role_for_org2[0]["approved"] is True + assert role_for_org2[0]["role"] == "user" + + +@pytest.mark.django_db(transaction=True) +def test_invite_existing_user_by_different_org_admin_should_modify_user_name_if_initially_blank(): + """Invite existing user by a different organization admin should modify user name if user name is initially blank.""" + organization = Organization.objects.create( + name=f"test-{secrets.token_hex(4)}", + rootDomains=[f"test-{secrets.token_hex(4)}"], + ipBlocks=[], + isPassive=False, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + organization2 = Organization.objects.create( + name=f"test-{secrets.token_hex(4)}", + rootDomains=[f"test-{secrets.token_hex(4)}"], + ipBlocks=[], + isPassive=False, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + email = f"{secrets.token_hex(4)}@crossfeed.cisa.gov" + user = User.objects.create(firstName="", lastName="", email=email) + Role.objects.create( + role="user", approved=False, organization=organization, user=user + ) + + user2 = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.STANDARD, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + # Assign admin role to the user for the organization + Role.objects.create( + user=user2, + organization=organization2, + role="admin", + ) + + response = client.post( + "/users", + json={ + "firstName": "new first name", + "lastName": "new last name", + "email": email, + "organization": str(organization2.id), + "organizationAdmin": False, + }, + headers={"Authorization": f"Bearer {create_jwt_token(user2)}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == str(user.id) + assert data["email"] == email + assert data["invitePending"] is False + assert data["firstName"] == "new first name" + assert data["lastName"] == "new last name" + role_for_org2 = [ + role + for role in data["roles"] + if role["organization"]["id"] == str(organization2.id) + ] + assert role_for_org2, f"No role found for organization {organization2.id}" + assert role_for_org2[0]["approved"] is True + assert role_for_org2[0]["role"] == "user" + + +@pytest.mark.django_db(transaction=True) +def test_invite_existing_user_by_same_org_admin_should_update_user_org_role(): + """Invite existing user by same organization admin should work, and should update the user organization role.""" + organization = Organization.objects.create( + name=f"test-{secrets.token_hex(4)}", + rootDomains=[f"test-{secrets.token_hex(4)}"], + ipBlocks=[], + isPassive=False, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + admin_user = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + ) + email = f"{secrets.token_hex(4)}@crossfeed.cisa.gov" + user = User.objects.create(firstName="first", lastName="last", email=email) + Role.objects.create( + role="user", + approved=False, + organization=organization, + user=user, + createdBy=admin_user, + approvedBy=admin_user, + ) + + user2 = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.STANDARD, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + # Assign admin role to the user for the organization + Role.objects.create( + user=user2, + organization=organization, + role="admin", + ) + + response = client.post( + "/users", + json={ + "firstName": "first", + "lastName": "last", + "email": email, + "organization": str(organization.id), + "organizationAdmin": True, + }, + headers={"Authorization": f"Bearer {create_jwt_token(user2)}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == str(user.id) + assert data["email"] == email + assert data["invitePending"] is False + assert data["firstName"] == "first" + assert data["lastName"] == "last" + assert data["roles"][0]["approved"] is True + assert data["roles"][0]["role"] == "admin" + + +@pytest.mark.django_db(transaction=True) +def test_invite_existing_user_by_global_admin_should_update_user_type(): + """Invite existing user by global admin that updates user type should work.""" + admin_user = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + ) + email = f"{secrets.token_hex(4)}@crossfeed.cisa.gov" + user = User.objects.create(firstName="first", lastName="last", email=email) + + user2 = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.GLOBAL_ADMIN, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + response = client.post( + "/users", + json={ + "firstName": "first", + "lastName": "last", + "email": email, + "userType": UserType.GLOBAL_ADMIN, + }, + headers={"Authorization": f"Bearer {create_jwt_token(user2)}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == str(user.id) + assert data["email"] == email + assert data["invitePending"] is False + assert data["firstName"] == "first" + assert data["lastName"] == "last" + assert data["roles"] == [] + assert data["userType"] == UserType.GLOBAL_ADMIN + + +@pytest.mark.django_db(transaction=True) +def test_invite_existing_user_by_global_view_should_not_work(): + """Invite existing user by global view should not work.""" + admin_user = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + ) + email = f"{secrets.token_hex(4)}@crossfeed.cisa.gov" + user = User.objects.create(firstName="first", lastName="last", email=email) + + user2 = User.objects.create( + firstName="first", + lastName="last", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.GLOBAL_VIEW, + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + response = client.post( + "/users", + json={"firstName": "first", "lastName": "last", "email": email}, + headers={"Authorization": f"Bearer {create_jwt_token(user2)}"}, + ) + + assert response.status_code == 403 + assert response.json() == {"detail": "Unauthorized access."} + + +@pytest.mark.django_db(transaction=True) +@patch("xfd_api.api_methods.user.send_registration_approved_email") +def test_register_approve_success(mock_email): + """Test successful user registration approval.""" + mock_email.return_value = "test" + current_user = User.objects.create( + firstName="Admin", + lastName="User", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.GLOBAL_ADMIN, + regionId="region-1", + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + user_to_approve = User.objects.create( + firstName="Test", + lastName="User", + email=f"{secrets.token_hex(4)}@example.com", + regionId="region-1", + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + # Mock email sending + response = client.put( + f"/users/{user_to_approve.id}/register/approve", + headers={"Authorization": f"Bearer {create_jwt_token(current_user)}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["body"] == "User registration approved." + mock_email.assert_called_once_with( + user_to_approve.email, + subject="CyHy Dashboard Registration Approved", + first_name=user_to_approve.firstName, + last_name=user_to_approve.lastName, + template="crossfeed_approval_notification.html", + ) + + +@pytest.mark.django_db(transaction=True) +def test_register_approve_unauthorized_region(): + """Test approval with unauthorized region access.""" + current_user = User.objects.create( + firstName="Admin", + lastName="User", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.REGIONAL_ADMIN, + regionId="1", + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + user_to_approve = User.objects.create( + firstName="Test", + lastName="User", + email=f"{secrets.token_hex(4)}@example.com", + regionId="2", + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + response = client.put( + f"/users/{user_to_approve.id}/register/approve", + headers={"Authorization": f"Bearer {create_jwt_token(current_user)}"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Unauthorized region access." + + +@pytest.mark.django_db(transaction=True) +@patch("xfd_api.api_methods.user.send_registration_denied_email") +def test_register_deny_success(mock_denied_email): + """Test successful user registration denial.""" + mock_denied_email.return_value = "test" + current_user = User.objects.create( + firstName="Admin", + lastName="User", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.GLOBAL_ADMIN, + regionId="1", + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + user_to_deny = User.objects.create( + firstName="Test", + lastName="User", + email=f"{secrets.token_hex(4)}@example.com", + regionId="1", + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + response = client.put( + f"/users/{user_to_deny.id}/register/deny", + headers={"Authorization": f"Bearer {create_jwt_token(current_user)}"}, + ) + + assert response.status_code == 200 + assert response.json()["body"] == "User registration denied." + mock_denied_email.assert_called_once_with( + user_to_deny.email, + subject="CyHy Dashboard Registration Denied", + first_name=user_to_deny.firstName, + last_name=user_to_deny.lastName, + template="crossfeed_denial_notification.html", + ) + + +@pytest.mark.django_db(transaction=True) +def test_register_deny_unauthorized_region(): + """Test registration denial with unauthorized region access.""" + current_user = User.objects.create( + firstName="Admin", + lastName="User", + email=f"{secrets.token_hex(4)}@crossfeed.cisa.gov", + userType=UserType.REGIONAL_ADMIN, + regionId="1", + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + user_to_deny = User.objects.create( + firstName="Test", + lastName="User", + email=f"{secrets.token_hex(4)}@example.com", + regionId="2", + createdAt=datetime.now(), + updatedAt=datetime.now(), + ) + + response = client.put( + f"/users/{user_to_deny.id}/register/deny", + headers={"Authorization": f"Bearer {create_jwt_token(current_user)}"}, + ) + + print(response.json()) + assert response.status_code == 403 + assert response.json()["detail"] == "Unauthorized region access." diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index d55366bd6..2f451273f 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -13,7 +13,7 @@ from .api_methods import api_key as api_key_methods from .api_methods import auth as auth_methods from .api_methods import notification as notification_methods -from .api_methods import organization, proxy, scan, scan_tasks +from .api_methods import organization, proxy, scan, scan_tasks, user from .api_methods.cpe import get_cpes_by_id from .api_methods.cve import get_cves_by_id, get_cves_by_name from .api_methods.domain import export_domains, get_domain_by_id, search_domains @@ -54,6 +54,7 @@ from .schema_models.role import Role as RoleSchema from .schema_models.saved_search import SavedSearch as SavedSearchSchema from .schema_models.search import SearchBody, SearchRequest, SearchResponse +from .schema_models.user import NewUser, NewUserResponseModel, RegisterUserResponse from .schema_models.user import User as UserSchema from .schema_models.vulnerability import Vulnerability as VulnerabilitySchema from .schema_models.vulnerability import VulnerabilitySearch @@ -457,7 +458,6 @@ async def call_get_users_by_state(request: Request): return get_users_by_state(request) -@api_router.get("/v2/users") @api_router.post("/users/{userId}", tags=["Users"]) async def call_update_user(userId, request: Request): """ @@ -476,6 +476,45 @@ async def call_update_user(userId, request: Request): return update_user(request) +@api_router.put( + "/users/{user_id}/register/approve", + dependencies=[Depends(get_current_active_user)], + response_model=RegisterUserResponse, + tags=["Users"], +) +async def register_approve( + user_id: str, current_user: User = Depends(get_current_active_user) +): + """Approve a registered user.""" + return user.user_register_approve(user_id, current_user) + + +@api_router.put( + "/users/{user_id}/register/deny", + dependencies=[Depends(get_current_active_user)], + response_model=RegisterUserResponse, + tags=["Users"], +) +async def register_deny( + user_id: str, current_user: User = Depends(get_current_active_user) +): + """Deny a registered user.""" + return user.deny_user_registration(user_id, current_user) + + +@api_router.post( + "/users", + dependencies=[Depends(get_current_active_user)], + response_model=NewUserResponseModel, + tags=["Users"], +) +async def invite_user( + new_user: NewUser, current_user: User = Depends(get_current_active_user) +): + """Invite a user.""" + return user.invite(new_user, current_user) + + # ======================================== # Api-Key Endpoints # ======================================== From 6e6d527e8fdc93bb8d95933689d9763d6fac0425 Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Wed, 30 Oct 2024 16:30:24 -0500 Subject: [PATCH 09/13] Update python user endpoints. --- .../xfd_django/xfd_api/api_methods/user.py | 46 +++++------ .../xfd_django/xfd_api/schema_models/user.py | 26 ++++++- backend/src/xfd_django/xfd_api/views.py | 76 ++++++++++++++----- 3 files changed, 100 insertions(+), 48 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index 040154e4e..d36acbac3 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -4,18 +4,24 @@ """ # Standard Python Libraries from datetime import datetime -from typing import List, Optional +import inspect +from typing import List, Optional, Tuple # Third-Party Libraries +from django.core.paginator import Paginator +from django.db.models import Q +from django.forms import model_to_dict from fastapi import HTTPException, Request from fastapi.responses import JSONResponse from ..auth import ( can_access_user, + get_org_memberships, is_global_view_admin, is_global_write_admin, is_regional_admin, ) +from ..helpers.filter_helpers import sort_direction from ..models import User from ..schema_models.user import NewUser as NewUserSchema from ..schema_models.user import UpdateUser as UpdateUserSchema @@ -53,7 +59,7 @@ async def accept_terms(request: Request): # TODO: Add user context and permissions -def delete_user(request: Request): +def delete_user(current_user, target_user_id): """ Delete a user by ID. Args: @@ -68,7 +74,6 @@ def delete_user(request: Request): try: # current_user = request.state.user - target_user_id = request.path_params["user_id"] target_user = User.objects.get(id=target_user_id) result = target_user.delete() return JSONResponse(status_code=200, content={"result": result}) @@ -77,7 +82,7 @@ def delete_user(request: Request): raise HTTPException(status_code=500, detail=str(e)) -def get_users(request: Request): +def get_users(current_user): """ Retrieve a list of all users. Args: @@ -90,8 +95,7 @@ def get_users(request: Request): List[User]: A list of all users. """ try: - current_user = request.state.user - if not (is_global_view_admin(current_user) or is_regional_admin(current_user)): + if not (is_global_view_admin(current_user)): raise HTTPException(status_code=401, detail="Unauthorized") users = User.objects.all().prefetch_related("roles", "roles.organization") @@ -101,7 +105,7 @@ def get_users(request: Request): raise HTTPException(status_code=500, detail=str(e)) -async def get_users_by_region_id(request: Request): +async def get_users_by_region_id(current_user, region_id): """ List users with specific regionId. Args: @@ -111,11 +115,9 @@ async def get_users_by_region_id(request: Request): JSONResponse: The list of users with the specified regionId. """ try: - current_user = request.state.user if not is_regional_admin(current_user): raise HTTPException(status_code=401, detail="Unauthorized") - region_id = request.path_params.get("regionId") if not region_id: raise HTTPException( status_code=400, detail="Missing regionId in path parameters" @@ -137,7 +139,7 @@ async def get_users_by_region_id(request: Request): raise HTTPException(status_code=500, detail=str(e)) -async def get_users_by_state(request: Request): +async def get_users_by_state(state, current_user): """ List users with specific state. Args: @@ -147,11 +149,9 @@ async def get_users_by_state(request: Request): JSONResponse: The list of users with the specified state. """ try: - current_user = request.state.user if not is_regional_admin(current_user): raise HTTPException(status_code=401, detail="Unauthorized") - state = request.path_params.get("state") if not state: raise HTTPException( status_code=400, detail="Missing state in path parameters" @@ -173,7 +173,7 @@ async def get_users_by_state(request: Request): raise HTTPException(status_code=500, detail=str(e)) -def get_users_v2(request: Request): +def get_users_v2(state, regionId, invitePending, current_user): """ Retrieve a list of users based on optional filter parameters. Args: @@ -186,23 +186,22 @@ def get_users_v2(request: Request): List[User]: A list of users matching the filter criteria. """ try: - query_params = request.query_params filters = {} - if "state" in query_params: - filters["state"] = query_params["state"] - if "regionId" in query_params: - filters["regionId"] = query_params["regionId"] - if "invitePending" in query_params: - filters["invitePending"] = query_params["invitePending"] + if state: + filters["state"] = state + if regionId: + filters["regionId"] = regionId + if invitePending: + filters["invitePending"] = invitePending users = User.objects.filter(**filters).prefetch_related("roles") - return [UserSchema.model_validate(user) for user in users] + return [model_to_dict(user) for user in users] except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -async def update_user(request: Request): +async def update_user(target_user_id, body, current_user): """ Update a particular user. Args: @@ -215,15 +214,12 @@ async def update_user(request: Request): User: The updated user. """ try: - current_user = request.state.user - target_user_id = request.path_params["user_id"] if not can_access_user(current_user, target_user_id): raise HTTPException(status_code=401, detail="Unauthorized") if not target_user_id or not User.objects.filter(id=target_user_id).exists(): raise HTTPException(status_code=404, detail="User not found") - body = await request.json() update_data = NewUserSchema(**body) if not is_global_write_admin(current_user) and update_data.userType: diff --git a/backend/src/xfd_django/xfd_api/schema_models/user.py b/backend/src/xfd_django/xfd_api/schema_models/user.py index 593c71086..acd56f6c2 100644 --- a/backend/src/xfd_django/xfd_api/schema_models/user.py +++ b/backend/src/xfd_django/xfd_api/schema_models/user.py @@ -3,11 +3,11 @@ # Standard Python Libraries from datetime import datetime from enum import Enum -from typing import List, Optional +from typing import List, Literal, Optional from uuid import UUID # Third-Party Libraries -from pydantic import BaseModel +from pydantic import BaseModel, Field from .api_key import ApiKey from .role import Role @@ -45,6 +45,28 @@ class User(BaseModel): roles: Optional[List[Role]] = [] apiKeys: Optional[List[ApiKey]] = [] + +class UserResponse(BaseModel): + """User response schema.""" + + cognitoId: Optional[str] + loginGovId: Optional[str] + firstName: str + lastName: str + fullName: str + email: str + invitePending: bool + loginBlockedByMaintenance: bool + dateAcceptedTerms: Optional[datetime] + acceptedTermsVersion: Optional[str] + lastLoggedIn: Optional[datetime] + userType: UserType + regionId: Optional[str] + state: Optional[str] + oktaId: Optional[str] + roles: Optional[List[Role]] = [] + apiKeys: Optional[List[ApiKey]] = [] + @classmethod def model_validate(cls, obj): # Convert fields before passing to Pydantic Schema diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index d55366bd6..6c1570f65 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -7,7 +7,7 @@ # Third-Party Libraries from django.shortcuts import render from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status -from fastapi.responses import RedirectResponse +from fastapi.responses import JSONResponse, RedirectResponse # from .schemas import Cpe from .api_methods import api_key as api_key_methods @@ -54,7 +54,9 @@ from .schema_models.role import Role as RoleSchema from .schema_models.saved_search import SavedSearch as SavedSearchSchema from .schema_models.search import SearchBody, SearchRequest, SearchResponse +from .schema_models.user import UpdateUser from .schema_models.user import User as UserSchema +from .schema_models.user import UserResponse from .schema_models.vulnerability import Vulnerability as VulnerabilitySchema from .schema_models.vulnerability import VulnerabilitySearch @@ -290,7 +292,7 @@ async def export_vulnerabilities(): "/vulnerabilities/{vulnerabilityId}", # dependencies=[Depends(get_current_active_user)], response_model=VulnerabilitySchema, - tags="Get vulnerability by id", + tags=["Vulnerabilities"], ) async def call_get_vulnerability_by_id(vuln_id): """ @@ -305,7 +307,7 @@ async def call_get_vulnerability_by_id(vuln_id): "/vulnerabilities/{vulnerabilityId}", # dependencies=[Depends(get_current_active_user)], response_model=VulnerabilitySchema, - tags="Update vulnerability", + tags=["Vulnerabilities"], ) async def call_update_vulnerability(vuln_id, data: VulnerabilitySchema): """ @@ -370,8 +372,12 @@ async def call_accept_terms(request: Request): # TODO: Add authentication and permissions -@api_router.delete("/users/{userId}", tags=["Users"]) -async def call_delete_user(request: Request): +@api_router.delete( + "/users/{userId}", dependencies=[Depends(get_current_active_user)], tags=["Users"] +) +async def call_delete_user( + userId, current_user: User = Depends(get_current_active_user) +): """ Delete a user by ID. @@ -384,7 +390,7 @@ async def call_delete_user(request: Request): Returns: JSONResponse: The result of the deletion. """ - return delete_user(request) + return delete_user(current_user, userId) @api_router.get("/users/me", tags=["Users"]) @@ -395,10 +401,10 @@ async def read_users_me(current_user: User = Depends(get_current_active_user)): @api_router.get( "/users", response_model=List[UserSchema], - # dependencies=[Depends(get_current_active_user)], + dependencies=[Depends(get_current_active_user)], tags=["Users"], ) -async def call_get_users(request: Request): +async def call_get_users(current_user: User = Depends(get_current_active_user)): """ Call get_users() Args: @@ -410,16 +416,18 @@ async def call_get_users(request: Request): Returns: List[User]: A list of users matching the filter criteria. """ - return get_users(request) + return get_users(current_user) @api_router.get( "/users/regionId/{regionId}", response_model=List[UserSchema], - # dependencies=[Depends(get_current_active_user)], + dependencies=[Depends(get_current_active_user)], tags=["Users"], ) -async def call_get_users_by_region_id(request: Request): +async def call_get_users_by_region_id( + regionId, current_user: User = Depends(get_current_active_user) +): """ Call get_users_by_region_id() Args: @@ -431,17 +439,18 @@ async def call_get_users_by_region_id(request: Request): Returns: List[User]: A list of users matching the filter criteria. """ - request.path_params["region_id"] = request.path_params["regionId"] - return get_users_by_region_id(request) + return get_users_by_region_id(regionId, current_user) @api_router.get( "/users/state/{state}", response_model=List[UserSchema], - # dependencies=[Depends(get_current_active_user)], + dependencies=[Depends(get_current_active_user)], tags=["Users"], ) -async def call_get_users_by_state(request: Request): +async def call_get_users_by_state( + state, current_user: User = Depends(get_current_active_user) +): """ Call get_users_by_state() Args: @@ -453,13 +462,39 @@ async def call_get_users_by_state(request: Request): Returns: List[User]: A list of users matching the filter criteria. """ - request.path_params["state"] = request.path_params["state"] - return get_users_by_state(request) + return get_users_by_state(state, current_user) + + +@api_router.get( + "/v2/users", + response_model=List[UserResponse], + dependencies=[Depends(get_current_active_user)], + tags=["Users"], +) +async def call_get_users_v2( + state: Optional[str] = Query(None), + regionId: Optional[str] = Query(None), + invitePending: Optional[bool] = Query(None), + current_user: User = Depends(get_current_active_user), +): + """ + Call get_users_v2() + Args: + request : The HTTP request containing query parameters. + + Raises: + HTTPException: If the user is not authorized or no users are found. + + Returns: + List[User]: A list of users matching the filter criteria. + """ + return get_users_v2(state, regionId, invitePending, current_user) -@api_router.get("/v2/users") @api_router.post("/users/{userId}", tags=["Users"]) -async def call_update_user(userId, request: Request): +async def call_update_user( + userId, body, current_user: User = Depends(get_current_active_user) +): """ Update a user by ID. Args: @@ -472,8 +507,7 @@ async def call_update_user(userId, request: Request): Returns: JSONResponse: The result of the update. """ - request.path_params["user_id"] = userId - return update_user(request) + return update_user(userId, body, current_user) # ======================================== From 0e43828ec5162dab388d937bb145922ea27e204f Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Wed, 30 Oct 2024 16:55:29 -0500 Subject: [PATCH 10/13] Merge origin MG-python-serverless into remote MG-python-serverless. --- .../xfd_django/xfd_api/api_methods/user.py | 175 +++++++++++++++++- backend/src/xfd_django/xfd_api/views.py | 43 ++++- 2 files changed, 211 insertions(+), 7 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index 3490a8038..53f845ebc 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -5,20 +5,17 @@ # Standard Python Libraries from datetime import datetime import os -from typing import List, Optional, Tuple +from typing import List import uuid # Third-Party Libraries from django.core.exceptions import ObjectDoesNotExist -from django.core.paginator import Paginator -from django.db.models import Q from django.forms import model_to_dict from fastapi import HTTPException, Request from fastapi.responses import JSONResponse from ..auth import ( can_access_user, - get_org_memberships, is_global_view_admin, is_global_write_admin, is_org_admin, @@ -30,7 +27,6 @@ send_registration_approved_email, send_registration_denied_email, ) -from ..helpers.filter_helpers import sort_direction from ..helpers.regionStateMap import REGION_STATE_MAP from ..models import Organization, Role, User from ..schema_models.user import NewUser as NewUserSchema @@ -317,3 +313,172 @@ async def update_user_v2(request: Request): return UserSchema.model_validate(user) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +def user_register_approve(user_id, current_user): + """Approve a registered user.""" + if not is_valid_uuid(user_id): + raise HTTPException(status_code=404, detail="Invalid user ID.") + + try: + # Retrieve the user by ID + user = User.objects.get(id=user_id) + except ObjectDoesNotExist: + raise HTTPException(status_code=404, detail="User not found.") + + # Ensure authorizer's region matches the user's region + if not matches_user_region(current_user, user.regionId): + raise HTTPException(status_code=403, detail="Unauthorized region access.") + + # Send email notification + try: + send_registration_approved_email( + user.email, + subject="CyHy Dashboard Registration Approved", + first_name=user.firstName, + last_name=user.lastName, + template="crossfeed_approval_notification.html", + ) + + except HTTPException as http_exc: + raise http_exc + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to send email: {str(e)}") + + return {"statusCode": 200, "body": "User registration approved."} + + +def deny_user_registration(user_id: str, current_user: User): + """Deny a user's registration by user ID.""" + try: + # Validate UUID format for the user_id + if not is_valid_uuid(user_id): + raise HTTPException(status_code=404, detail="User not found.") + + # Retrieve the user object + user = User.objects.filter(id=user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found.") + + # Ensure authorizer's region matches the user's region + if not matches_user_region(current_user, user.regionId): + raise HTTPException(status_code=403, detail="Unauthorized region access.") + + # Send registration denial email to the user + send_registration_denied_email( + user.email, + subject="CyHy Dashboard Registration Denied", + first_name=user.firstName, + last_name=user.lastName, + template="crossfeed_denial_notification.html", + ) + + return {"statusCode": 200, "body": "User registration denied."} + + except HTTPException as http_exc: + raise http_exc + except ObjectDoesNotExist: + raise HTTPException(status_code=404, detail="User not found.") + except Exception as e: + print(f"Error denying registration: {e}") + raise HTTPException( + status_code=500, detail="Error processing registration denial." + ) + + +def invite(new_user_data, current_user): + """Invite a user.""" + + try: + # Validate permissions + if new_user_data.organization: + if not is_org_admin(current_user, new_user_data.organization): + raise HTTPException(status_code=403, detail="Unauthorized access.") + else: + if not is_global_write_admin(current_user): + raise HTTPException(status_code=403, detail="Unauthorized access.") + + # Non-global admins cannot set userType + if not is_global_write_admin(current_user) and new_user_data.userType: + raise HTTPException(status_code=403, detail="Unauthorized access.") + + # Lowercase the email for consistency + new_user_data.email = new_user_data.email.lower() + + # Map state to region ID if state is provided + if new_user_data.state: + new_user_data.regionId = REGION_STATE_MAP.get(new_user_data.state) + + # Check if the user already exists + user = User.objects.filter(email=new_user_data.email).first() + organization = ( + Organization.objects.filter(id=new_user_data.organization).first() + if new_user_data.organization + else None + ) + + if not user: + # Create a new user if they do not exist + user = User.objects.create( + invitePending=True, + **new_user_data.dict( + exclude_unset=True, + exclude={"organizationAdmin", "organization", "userType"}, + ), + ) + if not os.getenv("IS_LOCAL"): + send_invite_email(user.email, organization) + elif not user.firstName and not user.lastName: + # Update first and last name if the user exists but has no name set + user.firstName = new_user_data.firstName + user.lastName = new_user_data.lastName + user.save() + + # Always update userType if specified + if new_user_data.userType: + user.userType = new_user_data.userType + user.save() + + # Assign role if an organization is specified + if organization: + Role.objects.update_or_create( + user=user, + organization=organization, + defaults={ + "approved": True, + "createdBy": current_user, + "approvedBy": current_user, + "role": "admin" if new_user_data.organizationAdmin else "user", + }, + ) + # Return the updated user with relevant details + return { + "id": str(user.id), + "firstName": user.firstName, + "lastName": user.lastName, + "email": user.email, + "userType": user.userType, + "roles": [ + { + "id": str(role.id), + "role": role.role, + "approved": role.approved, + "organization": { + "id": str(role.organization.id), + "name": role.organization.name, + } + if role.organization + else {}, + } + for role in user.roles.select_related("organization").all() + ], + "invitePending": user.invitePending, + } + + except HTTPException as http_exc: + raise http_exc + + except Exception as e: + print(f"Error inviting user: {e}") + raise HTTPException(status_code=500, detail="Error inviting user.") diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index 6c1570f65..570d0cc88 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -13,7 +13,7 @@ from .api_methods import api_key as api_key_methods from .api_methods import auth as auth_methods from .api_methods import notification as notification_methods -from .api_methods import organization, proxy, scan, scan_tasks +from .api_methods import organization, proxy, scan, scan_tasks, user from .api_methods.cpe import get_cpes_by_id from .api_methods.cve import get_cves_by_id, get_cves_by_name from .api_methods.domain import export_domains, get_domain_by_id, search_domains @@ -54,7 +54,7 @@ from .schema_models.role import Role as RoleSchema from .schema_models.saved_search import SavedSearch as SavedSearchSchema from .schema_models.search import SearchBody, SearchRequest, SearchResponse -from .schema_models.user import UpdateUser +from .schema_models.user import NewUser, NewUserResponseModel, RegisterUserResponse from .schema_models.user import User as UserSchema from .schema_models.user import UserResponse from .schema_models.vulnerability import Vulnerability as VulnerabilitySchema @@ -510,6 +510,45 @@ async def call_update_user( return update_user(userId, body, current_user) +@api_router.put( + "/users/{user_id}/register/approve", + dependencies=[Depends(get_current_active_user)], + response_model=RegisterUserResponse, + tags=["Users"], +) +async def register_approve( + user_id: str, current_user: User = Depends(get_current_active_user) +): + """Approve a registered user.""" + return user.user_register_approve(user_id, current_user) + + +@api_router.put( + "/users/{user_id}/register/deny", + dependencies=[Depends(get_current_active_user)], + response_model=RegisterUserResponse, + tags=["Users"], +) +async def register_deny( + user_id: str, current_user: User = Depends(get_current_active_user) +): + """Deny a registered user.""" + return user.deny_user_registration(user_id, current_user) + + +@api_router.post( + "/users", + dependencies=[Depends(get_current_active_user)], + response_model=NewUserResponseModel, + tags=["Users"], +) +async def invite_user( + new_user: NewUser, current_user: User = Depends(get_current_active_user) +): + """Invite a user.""" + return user.invite(new_user, current_user) + + # ======================================== # Api-Key Endpoints # ======================================== From 06771bc277e9439b2cdf4545303a4d1ab29cc14a Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Thu, 31 Oct 2024 08:49:21 -0500 Subject: [PATCH 11/13] Fix lint issues in email.py. --- .../src/xfd_django/xfd_api/helpers/email.py | 68 +++++++++---------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/helpers/email.py b/backend/src/xfd_django/xfd_api/helpers/email.py index 86de5bf5f..165c42482 100644 --- a/backend/src/xfd_django/xfd_api/helpers/email.py +++ b/backend/src/xfd_django/xfd_api/helpers/email.py @@ -6,15 +6,16 @@ import boto3 from botocore.exceptions import BotoCoreError, ClientError from fastapi import HTTPException -from .s3_client import S3Client from jinja2 import Template +from .s3_client import S3Client + def send_invite_email(email, organization=None): """Send an invitation email to the specified address.""" frontend_domain = os.getenv("FRONTEND_DOMAIN") reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") - + org_name_part = f"the {organization.name} organization on " if organization else "" message = f""" Hi there, @@ -42,30 +43,30 @@ def send_email(recipient, subject, body): ses_client = boto3.client("ses", region_name="us-east-1") sender = os.getenv("CROSSFEED_SUPPORT_EMAIL_SENDER") reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") - + email_params = { "Source": sender, "Destination": {"ToAddresses": [recipient]}, - "Message": { - "Subject": {"Data": subject}, - "Body": {"Text": {"Data": body}} - }, - "ReplyToAddresses": [reply_to] + "Message": {"Subject": {"Data": subject}, "Body": {"Text": {"Data": body}}}, + "ReplyToAddresses": [reply_to], } - + try: ses_client.send_email(**email_params) print(f"Email sent to {recipient}") except ClientError as e: print(f"Error sending email: {e}") -def send_registration_approved_email(recipient: str, subject: str, first_name: str, last_name: str, template): + +def send_registration_approved_email( + recipient: str, subject: str, first_name: str, last_name: str, template +): """Send registration approved email.""" try: # Initialize S3 client and fetch email template client = S3Client() html_template = client.get_email_asset(template) - + if not html_template: raise ValueError("Email template not found or empty.") @@ -74,7 +75,7 @@ def send_registration_approved_email(recipient: str, subject: str, first_name: s data = { "firstName": first_name, "lastName": last_name, - "domain": os.getenv("FRONTEND_DOMAIN") + "domain": os.getenv("FRONTEND_DOMAIN"), } html_to_send = template.render(data) @@ -83,15 +84,13 @@ def send_registration_approved_email(recipient: str, subject: str, first_name: s reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") email_params = { - "Source": sender, - "Destination": { - "ToAddresses": [recipient] - }, - "Message": { - "Subject": {"Data": subject}, - "Body": {"Html": {"Data": html_to_send}} - }, - "ReplyToAddresses": [reply_to] + "Source": sender, + "Destination": {"ToAddresses": [recipient]}, + "Message": { + "Subject": {"Data": subject}, + "Body": {"Html": {"Data": html_to_send}}, + }, + "ReplyToAddresses": [reply_to], } # SES client if not os.getenv("IS_LOCAL"): @@ -102,18 +101,20 @@ def send_registration_approved_email(recipient: str, subject: str, first_name: s else: print(email_params) print("Local environment cannot send email") - except (ClientError, ValueError) as e: print("Email error:", e) -def send_registration_denied_email(recipient: str, subject: str, first_name: str, last_name: str, template): + +def send_registration_denied_email( + recipient: str, subject: str, first_name: str, last_name: str, template +): """Send registration denied email.""" try: # Initialize S3 client and fetch email template client = S3Client() html_template = client.get_email_asset(template) - + if not html_template: raise ValueError("Email template not found or empty.") @@ -130,15 +131,13 @@ def send_registration_denied_email(recipient: str, subject: str, first_name: str reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") email_params = { - "Source": sender, - "Destination": { - "ToAddresses": [recipient] - }, - "Message": { - "Subject": {"Data": subject}, - "Body": {"Html": {"Data": html_to_send}} - }, - "ReplyToAddresses": [reply_to] + "Source": sender, + "Destination": {"ToAddresses": [recipient]}, + "Message": { + "Subject": {"Data": subject}, + "Body": {"Html": {"Data": html_to_send}}, + }, + "ReplyToAddresses": [reply_to], } # SES client if not os.getenv("IS_LOCAL"): @@ -149,7 +148,6 @@ def send_registration_denied_email(recipient: str, subject: str, first_name: str else: print(email_params) print("Local environment cannot send email") - except (ClientError, ValueError) as e: - print("Email error:", e) \ No newline at end of file + print("Email error:", e) From d6ea71ef83ba180c1dbb52bbde14d07ed7117471 Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Wed, 20 Nov 2024 12:54:27 -0600 Subject: [PATCH 12/13] Refactor User Endpoints to Follow Best Practice Add user context and permissions to delete_user and remove associated todo Move non-data/service connections outside of try blocks Replace os.getenv() calls with settings() in email.py Add regional admin permission checks to can_access_user in auth.py --- .../xfd_django/xfd_api/api_methods/user.py | 28 +++++++++---------- backend/src/xfd_django/xfd_api/auth.py | 20 ++++++++++--- .../src/xfd_django/xfd_api/helpers/email.py | 27 +++++++++--------- 3 files changed, 42 insertions(+), 33 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index 53f845ebc..0ebf075fc 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -73,7 +73,6 @@ async def accept_terms(request: Request): raise HTTPException(status_code=500, detail=str(e)) -# TODO: Add user context and permissions def delete_user(current_user, target_user_id): """ Delete a user by ID. @@ -86,9 +85,10 @@ def delete_user(current_user, target_user_id): Returns: JSONResponse: The result of the deletion. """ + if not can_access_user(current_user, target_user_id): + return HTTPException(status_code=401, detail="Unauthorized") try: - # current_user = request.state.user target_user = User.objects.get(id=target_user_id) result = target_user.delete() return JSONResponse(status_code=200, content={"result": result}) @@ -274,21 +274,18 @@ async def update_user_v2(request: Request): Returns: User: The updated user. """ - try: - current_user = request.state.user - target_user_id = request.path_params["user_id"] - if not can_access_user(current_user, target_user_id): - raise HTTPException(status_code=401, detail="Unauthorized") + current_user = request.state.user + target_user_id = request.path_params["user_id"] + if not can_access_user(current_user, target_user_id): + raise HTTPException(status_code=401, detail="Unauthorized") + try: if not target_user_id or not User.objects.filter(id=target_user_id).exists(): raise HTTPException(status_code=404, detail="User not found") body = await request.json() update_data = UpdateUserSchema(**body) - if not is_global_write_admin(current_user) and update_data.userType: - raise HTTPException(status_code=401, detail="Unauthorized to set userType") - user = User.objects.get(id=target_user_id) user.firstName = update_data.firstName or user.firstName user.lastName = update_data.lastName or user.lastName @@ -315,7 +312,7 @@ async def update_user_v2(request: Request): raise HTTPException(status_code=500, detail=str(e)) -def user_register_approve(user_id, current_user): +def approve_user_registration(user_id, current_user): """Approve a registered user.""" if not is_valid_uuid(user_id): raise HTTPException(status_code=404, detail="Invalid user ID.") @@ -351,11 +348,12 @@ def user_register_approve(user_id, current_user): def deny_user_registration(user_id: str, current_user: User): """Deny a user's registration by user ID.""" - try: - # Validate UUID format for the user_id - if not is_valid_uuid(user_id): - raise HTTPException(status_code=404, detail="User not found.") + # Validate UUID format for the user_id + if not is_valid_uuid(user_id): + raise HTTPException(status_code=404, detail="User not found.") + + try: # Retrieve the user object user = User.objects.filter(id=user_id).first() if not user: diff --git a/backend/src/xfd_django/xfd_api/auth.py b/backend/src/xfd_django/xfd_api/auth.py index d88e7b441..59fc0e842 100644 --- a/backend/src/xfd_django/xfd_api/auth.py +++ b/backend/src/xfd_django/xfd_api/auth.py @@ -296,10 +296,22 @@ async def get_jwt_from_code(auth_code: str): pass -def can_access_user(current_user, user_id: Optional[str]) -> bool: - return ( - user_id and (current_user.id == user_id) or is_global_write_admin(current_user) - ) +def can_access_user(current_user, target_user_id) -> bool: + """Check if current user is allowed to modify.the target user.""" + + if not target_user_id: + return False + + # Check if the current user is the target user or a global write admin + if current_user.id == target_user_id or is_global_write_admin(current_user): + return True + + # Check if the user is a regional admin and the target user is in the same region + if is_regional_admin(current_user): + target_user = User.objects.get(id=target_user_id) + return current_user.regionId == target_user.regionId + + return False def is_global_write_admin(current_user) -> bool: diff --git a/backend/src/xfd_django/xfd_api/helpers/email.py b/backend/src/xfd_django/xfd_api/helpers/email.py index 165c42482..3e31c7790 100644 --- a/backend/src/xfd_django/xfd_api/helpers/email.py +++ b/backend/src/xfd_django/xfd_api/helpers/email.py @@ -1,11 +1,10 @@ # Standard Python Libraries import os -from typing import Optional # Third-Party Libraries import boto3 -from botocore.exceptions import BotoCoreError, ClientError -from fastapi import HTTPException +from botocore.exceptions import ClientError +from django.conf import settings from jinja2 import Template from .s3_client import S3Client @@ -13,8 +12,8 @@ def send_invite_email(email, organization=None): """Send an invitation email to the specified address.""" - frontend_domain = os.getenv("FRONTEND_DOMAIN") - reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") + frontend_domain = settings("FRONTEND_DOMAIN") + reply_to = settings("CROSSFEED_SUPPORT_EMAIL_REPLYTO") org_name_part = f"the {organization.name} organization on " if organization else "" message = f""" @@ -41,8 +40,8 @@ def send_invite_email(email, organization=None): def send_email(recipient, subject, body): """Send an email using AWS SES.""" ses_client = boto3.client("ses", region_name="us-east-1") - sender = os.getenv("CROSSFEED_SUPPORT_EMAIL_SENDER") - reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") + sender = settings("CROSSFEED_SUPPORT_EMAIL_SENDER") + reply_to = settings("CROSSFEED_SUPPORT_EMAIL_REPLYTO") email_params = { "Source": sender, @@ -75,13 +74,13 @@ def send_registration_approved_email( data = { "firstName": first_name, "lastName": last_name, - "domain": os.getenv("FRONTEND_DOMAIN"), + "domain": settings("FRONTEND_DOMAIN"), } html_to_send = template.render(data) # Email configuration - sender = os.getenv("CROSSFEED_SUPPORT_EMAIL_SENDER") - reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") + sender = settings("CROSSFEED_SUPPORT_EMAIL_SENDER") + reply_to = settings("CROSSFEED_SUPPORT_EMAIL_REPLYTO") email_params = { "Source": sender, @@ -93,7 +92,7 @@ def send_registration_approved_email( "ReplyToAddresses": [reply_to], } # SES client - if not os.getenv("IS_LOCAL"): + if not settings("IS_LOCAL"): ses_client = boto3.client("ses", region_name="us-east-1") # Send email ses_client.send_email(**email_params) @@ -127,8 +126,8 @@ def send_registration_denied_email( html_to_send = template.render(data) # Email configuration - sender = os.getenv("CROSSFEED_SUPPORT_EMAIL_SENDER") - reply_to = os.getenv("CROSSFEED_SUPPORT_EMAIL_REPLYTO") + sender = settings("CROSSFEED_SUPPORT_EMAIL_SENDER") + reply_to = settings("CROSSFEED_SUPPORT_EMAIL_REPLYTO") email_params = { "Source": sender, @@ -140,7 +139,7 @@ def send_registration_denied_email( "ReplyToAddresses": [reply_to], } # SES client - if not os.getenv("IS_LOCAL"): + if not settings("IS_LOCAL"): ses_client = boto3.client("ses", region_name="us-east-1") # Send email ses_client.send_email(**email_params) From c1545e1493638edbea72d26fc6780ba4ab9da47c Mon Sep 17 00:00:00 2001 From: "Grayson, Matthew" Date: Wed, 20 Nov 2024 14:12:25 -0600 Subject: [PATCH 13/13] Revert Deleted User Endpoints --- .../xfd_django/xfd_api/api_methods/user.py | 2 +- backend/src/xfd_django/xfd_api/views.py | 170 +++++++++++++++++- 2 files changed, 165 insertions(+), 7 deletions(-) diff --git a/backend/src/xfd_django/xfd_api/api_methods/user.py b/backend/src/xfd_django/xfd_api/api_methods/user.py index 0ebf075fc..fa4fe8b4a 100644 --- a/backend/src/xfd_django/xfd_api/api_methods/user.py +++ b/backend/src/xfd_django/xfd_api/api_methods/user.py @@ -101,7 +101,7 @@ def get_users(current_user): """ Retrieve a list of all users. Args: - request : The HTTP request containing authorization information. + current_user : The user making the request. Raises: HTTPException: If the user is not authorized. diff --git a/backend/src/xfd_django/xfd_api/views.py b/backend/src/xfd_django/xfd_api/views.py index 05eb751d0..1aa7de115 100644 --- a/backend/src/xfd_django/xfd_api/views.py +++ b/backend/src/xfd_django/xfd_api/views.py @@ -358,19 +358,47 @@ async def callback_route(request: Request): # ======================================== +@api_router.post("/users/acceptTerms", tags=["Users"]) +async def call_accept_terms(request: Request): + """ + Accept the latest terms of service. + + Args: + request : The HTTP request containing the user and the terms version. + + Returns: + User: The updated user. + """ + + return accept_terms(request) + + # GET Current User -@api_router.get("/users/me", tags=["users"]) +@api_router.get("/users/me", tags=["Users"]) async def read_users_me(current_user: User = Depends(get_current_active_user)): return current_user +@api_router.delete("/users/{userId}", tags=["Users"]) +async def call_delete_user(current_user, userId: str): + """ + call delete_user() + Args: + userId: UUID of the user to delete. + Returns: + User: The user that was deleted. + """ + + return delete_user(current_user, userId) + + @api_router.get( - "/users/{regionId}", + "/users/", response_model=List[UserSchema], - # dependencies=[Depends(get_current_active_user)], - tags=["User"], + dependencies=[Depends(get_current_active_user)], + tags=["Users"], ) -async def call_get_users(regionId): +async def call_get_users(current_user: User = Depends(get_current_active_user)): """ Call get_users() @@ -383,7 +411,137 @@ async def call_get_users(regionId): Returns: List[User]: A list of users matching the filter criteria. """ - return get_users(regionId) + return get_users(current_user) + + +@api_router.get( + "/users/regionId/{regionId}", + response_model=List[UserSchema], + dependencies=[Depends(get_current_active_user)], + tags=["Users"], +) +async def call_get_users_by_region_id( + regionId, current_user: User = Depends(get_current_active_user) +): + """ + Call get_users_by_region_id() + Args: + request : The HTTP request containing query parameters. + + Raises: + HTTPException: If the user is not authorized or no users are found. + + Returns: + List[User]: A list of users matching the filter criteria. + """ + return get_users_by_region_id(regionId, current_user) + + +@api_router.get( + "/users/state/{state}", + response_model=List[UserSchema], + dependencies=[Depends(get_current_active_user)], + tags=["Users"], +) +async def call_get_users_by_state( + state, current_user: User = Depends(get_current_active_user) +): + """ + Call get_users_by_state() + Args: + request : The HTTP request containing query parameters. + + Raises: + HTTPException: If the user is not authorized or no users are found. + + Returns: + List[User]: A list of users matching the filter criteria. + """ + return get_users_by_state(state, current_user) + + +@api_router.get( + "/v2/users", + response_model=List[UserResponse], + dependencies=[Depends(get_current_active_user)], + tags=["Users"], +) +async def call_get_users_v2( + state: Optional[str] = Query(None), + regionId: Optional[str] = Query(None), + invitePending: Optional[bool] = Query(None), + current_user: User = Depends(get_current_active_user), +): + """ + Call get_users_v2() + Args: + request : The HTTP request containing query parameters. + + Raises: + HTTPException: If the user is not authorized or no users are found. + + Returns: + List[User]: A list of users matching the filter criteria. + """ + return get_users_v2(state, regionId, invitePending, current_user) + + +@api_router.post("/users/{userId}", tags=["Users"]) +async def call_update_user( + userId, body, current_user: User = Depends(get_current_active_user) +): + """ + Update a user by ID. + Args: + userId : The ID of the user to update. + request : The HTTP request containing authorization and target for update. + + Raises: + HTTPException: If the user is not authorized or the user is not found. + + Returns: + JSONResponse: The result of the update. + """ + return update_user(userId, body, current_user) + + +@api_router.put( + "/users/{user_id}/register/approve", + dependencies=[Depends(get_current_active_user)], + response_model=RegisterUserResponse, + tags=["Users"], +) +async def register_approve( + user_id: str, current_user: User = Depends(get_current_active_user) +): + """Approve a registered user.""" + return user.approve_user_registration(user_id, current_user) + + +@api_router.put( + "/users/{user_id}/register/deny", + dependencies=[Depends(get_current_active_user)], + response_model=RegisterUserResponse, + tags=["Users"], +) +async def register_deny( + user_id: str, current_user: User = Depends(get_current_active_user) +): + """Deny a registered user.""" + return user.deny_user_registration(user_id, current_user) + + +@api_router.post( + "/users", + dependencies=[Depends(get_current_active_user)], + response_model=NewUserResponseModel, + tags=["Users"], +) +async def invite_user( + new_user: NewUser, current_user: User = Depends(get_current_active_user) +): + """Invite a user.""" + return user.invite(new_user, current_user) # ========================================