diff --git a/fob_api/managers/proxy_manager.py b/fob_api/managers/proxy_manager.py index cf6f5a1..2fc019e 100644 --- a/fob_api/managers/proxy_manager.py +++ b/fob_api/managers/proxy_manager.py @@ -75,7 +75,7 @@ def build_treafik_config(self): new_maps["http"]["services"]["{}-service".format(uniq_name)] = { "loadBalancer": { "servers": [ - {"url": "".format(service.target)} + {"url": "{}".format(service.target)} ], "passHostHeader": True, } diff --git a/fob_api/routes/openstack.py b/fob_api/routes/openstack.py index be3efc0..43cb48f 100644 --- a/fob_api/routes/openstack.py +++ b/fob_api/routes/openstack.py @@ -31,35 +31,52 @@ def list_openstack_project_for_user( """ auth.is_admin_or_self(user, username) user_find = session.exec(select(User).where(User.username == username)).first() - projects_owner = session.exec(select(Project).where(Project.owner_id == user_find.id)).all() - # get all owner projects + + # Get all project IDs for owned and member projects + owned_project_ids = [p.id for p in session.exec(select(Project).where(Project.owner_id == user_find.id)).all()] + member_project_ids = [pm.project_id for pm in session.exec(select(ProjectUserMembership).where(ProjectUserMembership.user_id == user_find.id)).all()] + all_project_ids = list(set(owned_project_ids + member_project_ids)) + + if not all_project_ids: + return [] + + # Batch fetch all projects + projects = {p.id: p for p in session.exec(select(Project).where(Project.id.in_(all_project_ids))).all()} + + # Batch fetch all memberships for these projects + memberships = session.exec(select(ProjectUserMembership).where(ProjectUserMembership.project_id.in_(all_project_ids))).all() + memberships_by_project = {} + user_ids = set() + for membership in memberships: + if membership.project_id not in memberships_by_project: + memberships_by_project[membership.project_id] = [] + memberships_by_project[membership.project_id].append(membership.user_id) + user_ids.add(membership.user_id) + + # Add owners to user_ids set + for project in projects.values(): + user_ids.add(project.owner_id) + + # Batch fetch all users + users = {u.id: u.username for u in session.exec(select(User).where(User.id.in_(list(user_ids)))).all()} + + # Build response data = [] - for project in projects_owner: - db_members = session.exec(select(ProjectUserMembership).where(ProjectUserMembership.project_id == project.id)).all() + for project_id in all_project_ids: + project = projects.get(project_id) + if not project: + continue + + owner_username = users.get(project.owner_id, "") + member_usernames = [users.get(uid) for uid in memberships_by_project.get(project_id, []) if users.get(uid)] + data.append(OpenStackProjectAPI( id=project.id, name=project.name, - owner=username, - members=[ - session.exec(select(User).where(User.id == member.user_id)).first().username - for member in db_members - ] - )) - # get all member projects and add to data - project_memberships = session.exec(select(ProjectUserMembership).where(ProjectUserMembership.user_id == user_find.id)).all() - for project_membership in project_memberships: - local_project = session.exec(select(Project).where(Project.id == project_membership.project_id)).first() - db_members = session.exec(select(ProjectUserMembership).where(ProjectUserMembership.project_id == local_project.id)).all() - owner = session.exec(select(User).where(User.id == local_project.owner_id)).first() - data.append(OpenStackProjectAPI( - id=local_project.id, - name=local_project.name, - owner=owner.username, - members=[ - session.exec(select(User).where(User.id == member.user_id)).first().username - for member in db_members - ] + owner=owner_username, + members=member_usernames )) + return data @router.post("/projects/{project_name}", tags=["openstack"]) @@ -194,10 +211,12 @@ def remove_user_from_project( """ # check if owner of the project or is admin db_project = session.exec(select(Project).where(Project.name == project_name)).first() - db_project_members_name = [ - session.exec(select(User).where(User.id == member.user_id)).first().username - for member in session.exec(select(ProjectUserMembership).where(ProjectUserMembership.project_id == db_project.id)).all() - ] + + # Batch fetch project members to avoid N+1 queries + project_members = session.exec(select(ProjectUserMembership).where(ProjectUserMembership.project_id == db_project.id)).all() + member_user_ids = [member.user_id for member in project_members] + member_users = session.exec(select(User).where(User.id.in_(member_user_ids))).all() if member_user_ids else [] + db_project_members_name = [u.username for u in member_users] # action allowed if anyone of the following is true # 1. user is admin diff --git a/fob_api/routes/quota.py b/fob_api/routes/quota.py index 7de05e2..e8da618 100644 --- a/fob_api/routes/quota.py +++ b/fob_api/routes/quota.py @@ -1,4 +1,4 @@ -from typing import List, Annotated +from typing import List, Annotated, Optional from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select @@ -19,44 +19,56 @@ #-------------------------------- # TODO: move to tasks -def calculate_user_quota_by_type(user: db_models.User, quota_type: db_models.QuotaType) -> api_models.AdjustUserQuota: - with Session(engine) as session: - calculated_quota = 0 - for q in session.exec(select(db_models.UserQuota).where(db_models.UserQuota.user_id == user.id).where(db_models.UserQuota.type == quota_type)).all(): - calculated_quota += q.quantity - return api_models.AdjustUserQuota( - username=user.username, - type=quota_type, - quantity=calculated_quota, - comment="Calculated total quota for user" - ) +def calculate_user_quota_by_type(user: db_models.User, quota_type: db_models.QuotaType, session: Optional[Session] = None) -> api_models.AdjustUserQuota: + # Use provided session or create a new one for backward compatibility + if session is None: + with Session(engine) as session: + return calculate_user_quota_by_type(user, quota_type, session) + + calculated_quota = 0 + for q in session.exec(select(db_models.UserQuota).where(db_models.UserQuota.user_id == user.id).where(db_models.UserQuota.type == quota_type)).all(): + calculated_quota += q.quantity + return api_models.AdjustUserQuota( + username=user.username, + type=quota_type, + quantity=calculated_quota, + comment="Calculated total quota for user" + ) -def calculate_user_quota(user: db_models.User) -> List[api_models.AdjustUserQuota]: - with Session(engine) as session: - user_max_quota_dict = {k: 0 for k in db_models.QuotaType} - for q in session.exec(select(db_models.UserQuota).where(db_models.UserQuota.user_id == user.id)).all(): - user_max_quota_dict[db_models.QuotaType.from_str(q.type)] += q.quantity - - return [api_models.AdjustUserQuota( - username=user.username, - type=k, - quantity=v, - comment="Calculated total all type quota for user" - ) for k, v in user_max_quota_dict.items()] - -def calculate_project_quota(project: db_models.Project) -> List[api_models.AdjustProjectQuota]: - with Session(engine) as session: - project_max_quota_dict = {k: 0 for k in db_models.QuotaType} - for q in session.exec(select(db_models.UserQuotaShare).where(db_models.UserQuotaShare.project_id == project.id)).all(): - project_max_quota_dict[db_models.QuotaType.from_str(q.type)] += q.quantity - - return [api_models.AdjustProjectQuota( - username="", - project_name=project.name, - type=k, - quantity=v, - comment="Calculated total all type quota for project" - ) for k, v in project_max_quota_dict.items()] +def calculate_user_quota(user: db_models.User, session: Optional[Session] = None) -> List[api_models.AdjustUserQuota]: + # Use provided session or create a new one for backward compatibility + if session is None: + with Session(engine) as session: + return calculate_user_quota(user, session) + + user_max_quota_dict = {k: 0 for k in db_models.QuotaType} + for q in session.exec(select(db_models.UserQuota).where(db_models.UserQuota.user_id == user.id)).all(): + user_max_quota_dict[db_models.QuotaType.from_str(q.type)] += q.quantity + + return [api_models.AdjustUserQuota( + username=user.username, + type=k, + quantity=v, + comment="Calculated total all type quota for user" + ) for k, v in user_max_quota_dict.items()] + +def calculate_project_quota(project: db_models.Project, session: Optional[Session] = None) -> List[api_models.AdjustProjectQuota]: + # Use provided session or create a new one for backward compatibility + if session is None: + with Session(engine) as session: + return calculate_project_quota(project, session) + + project_max_quota_dict = {k: 0 for k in db_models.QuotaType} + for q in session.exec(select(db_models.UserQuotaShare).where(db_models.UserQuotaShare.project_id == project.id)).all(): + project_max_quota_dict[db_models.QuotaType.from_str(q.type)] += q.quantity + + return [api_models.AdjustProjectQuota( + username="", + project_name=project.name, + type=k, + quantity=v, + comment="Calculated total all type quota for project" + ) for k, v in project_max_quota_dict.items()] def sync_project_quota(openstack_project: db_models.Project) -> None: nova_client = openstack.get_nova_client() @@ -77,17 +89,21 @@ def sync_project_quota(openstack_project: db_models.Project) -> None: case _: print(f"Unknown quota type: {quota.type} for project: {openstack_project.name} with quantity: {quota.quantity}") -def get_user_left_quota_by_type(user: db_models.User, quota_type: db_models.QuotaType) -> int: - with Session(engine) as session: - user_quota_own = 0 - for q in session.exec(select(db_models.UserQuota).where(db_models.UserQuota.user_id == user.id).where(db_models.UserQuota.type == quota_type)).all(): - user_quota_own += q.quantity +def get_user_left_quota_by_type(user: db_models.User, quota_type: db_models.QuotaType, session: Optional[Session] = None) -> int: + # Use provided session or create a new one for backward compatibility + if session is None: + with Session(engine) as session: + return get_user_left_quota_by_type(user, quota_type, session) + + user_quota_own = 0 + for q in session.exec(select(db_models.UserQuota).where(db_models.UserQuota.user_id == user.id).where(db_models.UserQuota.type == quota_type)).all(): + user_quota_own += q.quantity - user_quota_used = 0 - for q in session.exec(select(db_models.UserQuotaShare).where(db_models.UserQuotaShare.user_id == user.id).where(db_models.UserQuotaShare.type == quota_type)).all(): - user_quota_used += q.quantity + user_quota_used = 0 + for q in session.exec(select(db_models.UserQuotaShare).where(db_models.UserQuotaShare.user_id == user.id).where(db_models.UserQuotaShare.type == quota_type)).all(): + user_quota_used += q.quantity - return user_quota_own - user_quota_used + return user_quota_own - user_quota_used #-------------------------------- @@ -112,7 +128,7 @@ def give_quota_to_user( ) session.add(new_quota) session.commit() - return calculate_user_quota_by_type(user_find, create_quota.type) + return calculate_user_quota_by_type(user_find, create_quota.type, session) @router.delete("/adjust-user/{id}", tags=["quota"]) def remove_quota_attribution_for_user( @@ -127,7 +143,7 @@ def remove_quota_attribution_for_user( raise HTTPException(status_code=400, detail="Adjustement not found") session.delete(quota) session.commit() - return calculate_user_quota_by_type(user, quota.type) + return calculate_user_quota_by_type(user, quota.type, session) @router.get("/user/{username}/total", tags=["quota"]) def show_user_quota( @@ -140,7 +156,7 @@ def show_user_quota( user_find = session.exec(select(db_models.User).where(db_models.User.username == username)).first() if not user_find: raise HTTPException(status_code=400, detail="User not found") - return calculate_user_quota(user_find) + return calculate_user_quota(user_find, session) @router.get("/user/{username}/adjustements", tags=["quota"]) def show_user_adjustements( @@ -201,7 +217,7 @@ def set_quota_to_project( previous_quantity = quota.quantity # check if user has enough quota to share - if get_user_left_quota_by_type(user_find, db_models.QuotaType.from_str(create_quota.type)) + previous_quantity < create_quota.quantity: + if get_user_left_quota_by_type(user_find, db_models.QuotaType.from_str(create_quota.type), session) + previous_quantity < create_quota.quantity: raise HTTPException(status_code=400, detail="User do not have enough quota to share") # check if user has already shared quota @@ -230,7 +246,7 @@ def set_quota_to_project( raise HTTPException(status_code=400, detail="Error while setting quota you may use the quota that is already used") # this append when quota is set but project already use the quota so we need to rollback - return calculate_project_quota(project_find) + return calculate_project_quota(project_find, session) @router.get("/project/{project_name}/total", tags=["quota"]) @@ -245,7 +261,7 @@ def show_project_quota( raise HTTPException(status_code=400, detail="Project not found") if not user.is_admin and not session.exec(select(db_models.ProjectUserMembership).where(db_models.ProjectUserMembership.project_id == project_find.id, db_models.ProjectUserMembership.user_id == user.id)).first() and project_find.owner_id != user.id: raise HTTPException(status_code=403, detail="Not allowed to see Total quota for this project") - return calculate_project_quota(project_find) + return calculate_project_quota(project_find, session) @router.get("/project/{project_name}/adjustements", tags=["quota"]) def show_project_adjustements( @@ -260,12 +276,19 @@ def show_project_adjustements( if not user.is_admin and not session.exec(select(db_models.ProjectUserMembership).where(db_models.ProjectUserMembership.project_id == project_find.id, db_models.ProjectUserMembership.user_id == user.id)).first() and project_find.owner_id != user.id: raise HTTPException(status_code=403, detail="Not allowed to see Adjustements for this project") + # Fetch all quota shares for the project + quota_shares = session.exec(select(db_models.UserQuotaShare).where(db_models.UserQuotaShare.project_id == project_find.id)).all() + + # Batch fetch all users involved + user_ids = list(set(q.user_id for q in quota_shares)) + users_map = {u.id: u.username for u in session.exec(select(db_models.User).where(db_models.User.id.in_(user_ids))).all()} if user_ids else {} + + # Build response shared_quotas = [] - for q in session.exec(select(db_models.UserQuotaShare).where(db_models.UserQuotaShare.project_id == project_find.id)).all(): - user = session.exec(select(db_models.User).where(db_models.User.id == q.user_id)).first() + for q in quota_shares: shared_quotas.append(api_models.AdjustProjectQuotaID( id=q.id, - username=user.username, + username=users_map.get(q.user_id, ""), project_name=project_find.name, type=db_models.QuotaType.from_str(q.type), quantity=q.quantity, diff --git a/fob_api/routes/users.py b/fob_api/routes/users.py index 3ef007c..d3e1626 100644 --- a/fob_api/routes/users.py +++ b/fob_api/routes/users.py @@ -18,6 +18,47 @@ router = APIRouter(prefix="/users") +# Password validation constants +SPECIAL_CHARS = "!@#$%^&*()-_=+[]{}|;:,.<>?/" + +def validate_password_strength(password: str) -> None: + """ + Validate password strength in a single pass. + Raises HTTPException if password doesn't meet requirements. + """ + if len(password) < 13: + raise HTTPException(status_code=400, detail="Password must be at least 12 characters long") + + has_digit = False + has_upper = False + has_lower = False + has_special = False + + for char in password: + if char.isdigit(): + has_digit = True + elif char.isupper(): + has_upper = True + elif char.islower(): + has_lower = True + elif char in SPECIAL_CHARS: + has_special = True + + # Early exit if all requirements are met + if has_digit and has_upper and has_lower and has_special: + return + + # Check which requirements were not met + if not has_digit: + raise HTTPException(status_code=400, detail="Password must contain at least one digit") + if not has_upper: + raise HTTPException(status_code=400, detail="Password must contain at least one uppercase letter") + if not has_lower: + raise HTTPException(status_code=400, detail="Password must contain at least one lowercase letter") + if not has_special: + raise HTTPException(status_code=400, detail="Password must contain at least one special character") + + @router.get("/", response_model=list[UserInfo], tags=["users"]) def get_users( user: Annotated[User, Depends(auth.get_current_user)], @@ -174,17 +215,8 @@ def reset_password( session.delete(user_reset_password) session.commit() raise HTTPException(status_code=404, detail="Unable to reset password") - # Check password strength (i know this is not the best way to do but i am lazy :p ) - if len(password) <= 12: - raise HTTPException(status_code=400, detail="Password must be at least 12 characters long") - if not any(char.isdigit() for char in password): - raise HTTPException(status_code=400, detail="Password must contain at least one digit") - if not any(char.isupper() for char in password): - raise HTTPException(status_code=400, detail="Password must contain at least one uppercase letter") - if not any(char.islower() for char in password): - raise HTTPException(status_code=400, detail="Password must contain at least one lowercase letter") - if not any(char in "!@#$%^&*()-_=+[]{}|;:,.<>?/" for char in password): - raise HTTPException(status_code=400, detail="Password must contain at least one special character") + # Validate password strength + validate_password_strength(password) user.password = hash_password(password) session.delete(user_reset_password) session.commit()