diff --git a/backend/src/controller.py b/backend/src/controller.py index b0a3996..6267c79 100644 --- a/backend/src/controller.py +++ b/backend/src/controller.py @@ -126,13 +126,25 @@ class AuthController: @staticmethod def logout() -> JSONResponse: """ - Logout the user by clearing the authentication cookie. + Logout the user by clearing the authentication cookie and returning + the Keycloak logout URL for full session termination. Returns: - JSONResponse: Success message with cookie cleared. + JSONResponse: Contains the Keycloak logout URL and clears the cookie. """ + # Build Keycloak logout URL + keycloak_logout_url = ( + f"{settings.keycloak_external_url}realms/{settings.keycloak_realm}" + f"/protocol/openid-connect/logout" + f"?client_id={settings.keycloak_client_id}" + f"&post_logout_redirect_uri={settings.frontend_url}" + ) + response = JSONResponse( - content={"message": "Successfully logged out"}, + content={ + "message": "Successfully logged out", + "logout_url": keycloak_logout_url + }, status_code=status.HTTP_200_OK ) diff --git a/backend/src/main.py b/backend/src/main.py index b4b5ee7..cacff0b 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -266,34 +266,52 @@ def _build_requirement_response(req) -> RequirementResponse: @app.get("/api/requirements", response_model=List[RequirementResponse]) async def get_requirements( + request: Request, group_id: Optional[int] = None, tag_id: Optional[int] = None, db: AsyncSession = Depends(get_db) ): """ - Get all requirements, optionally filtered by group or tag. + Get all requirements for the authenticated user, optionally filtered by group or tag. Args: group_id: Optional group ID to filter by tag_id: Optional tag ID to filter by Returns: - List of all requirements with their related data. + List of requirements owned by the authenticated user. """ + # Get the current user from cookie + user_info = AuthController.get_current_user(request) + + # Get the user's database ID + from src.repositories import UserRepository + user_repo = UserRepository(db) + user = await user_repo.get_by_sub(user_info.sub) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found in database" + ) + req_repo = RequirementRepository(db) if group_id: - requirements = await req_repo.get_by_group_id(group_id) + requirements = await req_repo.get_by_group_id(group_id, user_id=user.id) elif tag_id: - requirements = await req_repo.get_by_tag_id(tag_id) + requirements = await req_repo.get_by_tag_id(tag_id, user_id=user.id) else: - requirements = await req_repo.get_all() + requirements = await req_repo.get_by_user_id(user.id) return [_build_requirement_response(req) for req in requirements] @app.get("/api/requirements/{requirement_id}", response_model=RequirementResponse) -async def get_requirement(requirement_id: int, db: AsyncSession = Depends(get_db)): +async def get_requirement( + requirement_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): """ Get a specific requirement by ID. @@ -301,8 +319,21 @@ async def get_requirement(requirement_id: int, db: AsyncSession = Depends(get_db requirement_id: The requirement ID Returns: - The requirement if found. + The requirement if found and owned by the authenticated user. """ + # Get the current user from cookie + user_info = AuthController.get_current_user(request) + + # Get the user's database ID + from src.repositories import UserRepository + user_repo = UserRepository(db) + user = await user_repo.get_by_sub(user_info.sub) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found in database" + ) + req_repo = RequirementRepository(db) requirement = await req_repo.get_by_id(requirement_id) if not requirement: @@ -310,6 +341,14 @@ async def get_requirement(requirement_id: int, db: AsyncSession = Depends(get_db status_code=status.HTTP_404_NOT_FOUND, detail=f"Requirement with id {requirement_id} not found" ) + + # Verify user owns this requirement + if requirement.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to access this requirement" + ) + return _build_requirement_response(requirement) @@ -386,6 +425,21 @@ async def update_requirement( ) req_repo = RequirementRepository(db) + + # First check if requirement exists and user owns it + existing_req = await req_repo.get_by_id(requirement_id) + if not existing_req: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Requirement with id {requirement_id} not found" + ) + + if existing_req.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to update this requirement" + ) + requirement = await req_repo.update( requirement_id=requirement_id, editor_id=user.id, @@ -396,12 +450,6 @@ async def update_requirement( group_ids=req_data.group_ids, ) - if not requirement: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Requirement with id {requirement_id} not found" - ) - await db.commit() return _build_requirement_response(requirement) @@ -418,16 +466,34 @@ async def delete_requirement( Args: requirement_id: The requirement ID to delete """ - # Verify user is authenticated - AuthController.get_current_user(request) + # Get the current user from cookie + user_info = AuthController.get_current_user(request) + + # Get the user's database ID + from src.repositories import UserRepository + user_repo = UserRepository(db) + user = await user_repo.get_by_sub(user_info.sub) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found in database" + ) req_repo = RequirementRepository(db) - deleted = await req_repo.delete(requirement_id) - if not deleted: + # First check if requirement exists and user owns it + existing_req = await req_repo.get_by_id(requirement_id) + if not existing_req: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Requirement with id {requirement_id} not found" ) + if existing_req.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to delete this requirement" + ) + + await req_repo.delete(requirement_id) await db.commit() diff --git a/backend/src/repositories/requirement_repository.py b/backend/src/repositories/requirement_repository.py index a61bdba..cd31ba1 100644 --- a/backend/src/repositories/requirement_repository.py +++ b/backend/src/repositories/requirement_repository.py @@ -36,6 +36,29 @@ class RequirementRepository: ) return list(result.scalars().all()) + async def get_by_user_id(self, user_id: int) -> List[Requirement]: + """ + Get all requirements for a specific user. + + Args: + user_id: The user's database ID + + Returns: + List of requirements owned by the user + """ + result = await self.session.execute( + select(Requirement) + .options( + selectinload(Requirement.tag), + selectinload(Requirement.priority), + selectinload(Requirement.groups), + selectinload(Requirement.validations).selectinload(Validation.status), + ) + .where(Requirement.user_id == user_id) + .order_by(Requirement.created_at.desc()) + ) + return list(result.scalars().all()) + async def get_by_id(self, requirement_id: int) -> Optional[Requirement]: """ Get a requirement by ID with its related data. @@ -60,17 +83,18 @@ class RequirementRepository: ) return result.scalar_one_or_none() - async def get_by_group_id(self, group_id: int) -> List[Requirement]: + async def get_by_group_id(self, group_id: int, user_id: Optional[int] = None) -> List[Requirement]: """ Get all requirements belonging to a specific group. Args: group_id: The group ID + user_id: Optional user ID to filter by Returns: List of requirements in the group """ - result = await self.session.execute( + query = ( select(Requirement) .options( selectinload(Requirement.tag), @@ -80,21 +104,27 @@ class RequirementRepository: ) .join(Requirement.groups) .where(Group.id == group_id) - .order_by(Requirement.created_at.desc()) ) + + if user_id is not None: + query = query.where(Requirement.user_id == user_id) + + query = query.order_by(Requirement.created_at.desc()) + result = await self.session.execute(query) return list(result.scalars().all()) - async def get_by_tag_id(self, tag_id: int) -> List[Requirement]: + async def get_by_tag_id(self, tag_id: int, user_id: Optional[int] = None) -> List[Requirement]: """ Get all requirements with a specific tag. Args: tag_id: The tag ID + user_id: Optional user ID to filter by Returns: List of requirements with the tag """ - result = await self.session.execute( + query = ( select(Requirement) .options( selectinload(Requirement.tag), @@ -103,8 +133,13 @@ class RequirementRepository: selectinload(Requirement.validations).selectinload(Validation.status), ) .where(Requirement.tag_id == tag_id) - .order_by(Requirement.created_at.desc()) ) + + if user_id is not None: + query = query.where(Requirement.user_id == user_id) + + query = query.order_by(Requirement.created_at.desc()) + result = await self.session.execute(query) return list(result.scalars().all()) async def create( diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index bc5f9c9..5b9be35 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -33,7 +33,8 @@ class AuthService { } /** - * Logout the current user by clearing the session cookie. + * Logout the current user by clearing the session cookie + * and redirecting to Keycloak logout to end the SSO session. */ async logout(): Promise { try { @@ -48,6 +49,13 @@ class AuthService { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } + + const data = await response.json() + + // Redirect to Keycloak logout URL to end the SSO session + if (data.logout_url) { + window.location.href = data.logout_url + } } catch (error) { console.error('Logout failed:', error) throw error