Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion fob_api/managers/proxy_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
75 changes: 47 additions & 28 deletions fob_api/routes/openstack.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,35 +31,52 @@
"""
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"])
Expand Down Expand Up @@ -194,10 +211,12 @@
"""
# 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
Expand Down Expand Up @@ -236,7 +255,7 @@
# check if user has any quotas assigned to project
project_quotas = session.exec(select(db_models.UserQuotaShare).where(db_models.UserQuotaShare.project_id == db_project.id, db_models.UserQuotaShare.user_id == user_to_remove.id)).all()
# try to set all quotas to 0
old_quotas_map = {k: 0 for k in db_models.QuotaType}

Check warning on line 258 in fob_api/routes/openstack.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace with dict fromkeys method call

See more on https://sonarcloud.io/project/issues?id=LaboInfra_fob-api&issues=AZsCOupERwmy363BaG98&open=AZsCOupERwmy363BaG98&pullRequest=44
for project_quota in project_quotas:
old_quotas_map[project_quota.type] = project_quota.quantity
project_quota.quantity = 0
Expand Down
135 changes: 79 additions & 56 deletions fob_api/routes/quota.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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}

Check warning on line 44 in fob_api/routes/quota.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace with dict fromkeys method call

See more on https://sonarcloud.io/project/issues?id=LaboInfra_fob-api&issues=AZsCOurNRwmy363BaG99&open=AZsCOurNRwmy363BaG99&pullRequest=44
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}

Check warning on line 61 in fob_api/routes/quota.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace with dict fromkeys method call

See more on https://sonarcloud.io/project/issues?id=LaboInfra_fob-api&issues=AZsCOurNRwmy363BaG9-&open=AZsCOurNRwmy363BaG9-&pullRequest=44
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()
Expand All @@ -77,17 +89,21 @@
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

#--------------------------------

Expand All @@ -112,7 +128,7 @@
)
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(
Expand All @@ -127,7 +143,7 @@
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(
Expand All @@ -140,7 +156,7 @@
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(
Expand Down Expand Up @@ -201,7 +217,7 @@
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
Expand Down Expand Up @@ -230,7 +246,7 @@
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"])
Expand All @@ -245,7 +261,7 @@
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(
Expand All @@ -260,12 +276,19 @@
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))

Check warning on line 283 in fob_api/routes/quota.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace set constructor call with a set comprehension.

See more on https://sonarcloud.io/project/issues?id=LaboInfra_fob-api&issues=AZsCOurNRwmy363BaG9_&open=AZsCOurNRwmy363BaG9_&pullRequest=44
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,
Expand Down
54 changes: 43 additions & 11 deletions fob_api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand All @@ -27,7 +68,7 @@
Returns all users
"""
auth.is_admin(user)
return [item for item in session.exec(select(User))]

Check warning on line 71 in fob_api/routes/users.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this comprehension with passing the iterable to the collection constructor call

See more on https://sonarcloud.io/project/issues?id=LaboInfra_fob-api&issues=AZsCPUU4UiqXPnt3xXG0&open=AZsCPUU4UiqXPnt3xXG0&pullRequest=44

@router.post("/", response_model=UserInfo, tags=["users"])
def create_user(
Expand Down Expand Up @@ -174,17 +215,8 @@
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()
Expand Down