Skip to content
Merged
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
51 changes: 36 additions & 15 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ def get_current_user(
session: SessionDep,
token: TokenDep,
api_key: Annotated[str, Depends(api_key_header)],
) -> UserOrganization:
"""Authenticate user via API Key first, fallback to JWT token."""
) -> User:
"""Authenticate user via API Key first, fallback to JWT token. Returns only User."""

if api_key:
api_key_record = get_api_key_by_value(session, api_key)
Expand All @@ -47,10 +47,7 @@ def get_current_user(
if not user:
raise HTTPException(status_code=404, detail="User linked to API Key not found")

validate_organization(session, api_key_record.organization_id)

# Return UserOrganization model with organization ID
return UserOrganization(**user.model_dump(), organization_id=api_key_record.organization_id)
return user # Return only User object

if token:
try:
Expand All @@ -63,18 +60,37 @@ def get_current_user(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)

user = session.get(User, token_data.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")

return UserOrganization(**user.model_dump(), organization_id=None)
return user # Return only User object

raise HTTPException(status_code=401, detail="Invalid Authorization format")

CurrentUser = Annotated[UserOrganization, Depends(get_current_user)]
CurrentUser = Annotated[User, Depends(get_current_user)]

def get_current_user_org(
current_user: CurrentUser,
session: SessionDep,
request: Request
) -> UserOrganization:
"""Extend `User` with organization_id if available, otherwise return UserOrganization without it."""

organization_id = None
api_key = request.headers.get("X-API-KEY")
if api_key:
api_key_record = get_api_key_by_value(session, api_key)
if api_key_record:
validate_organization(session, api_key_record.organization_id)
organization_id = api_key_record.organization_id

return UserOrganization(**current_user.model_dump(), organization_id=organization_id)

CurrentUserOrg = Annotated[UserOrganization, Depends(get_current_user_org)]

def get_current_active_superuser(current_user: CurrentUser) -> User:
if not current_user.is_superuser:
Expand All @@ -83,6 +99,12 @@ def get_current_active_superuser(current_user: CurrentUser) -> User:
)
return current_user

def get_current_active_superuser_org(current_user: CurrentUserOrg) -> User:
if not current_user.is_superuser:
raise HTTPException(
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user

async def http_exception_handler(request: Request, exc: HTTPException):
"""
Expand All @@ -95,7 +117,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):

def verify_user_project_organization(
db: SessionDep,
current_user: CurrentUser,
current_user: CurrentUserOrg,
project_id: int,
organization_id: int,
) -> UserProjectOrg:
Expand All @@ -104,7 +126,7 @@ def verify_user_project_organization(
and that the project belongs to the organization.
"""
if current_user.organization_id and current_user.organization_id != organization_id:
raise HTTPException(status_code=403, detail="User does not belong to the specified organization")
raise HTTPException(status_code=403, detail="User is not part of organization")

project_organization = db.exec(
select(Project, Organization)
Expand All @@ -130,12 +152,10 @@ def verify_user_project_organization(
raise HTTPException(status_code=400, detail="Project is not active") # Use 400 for inactive resources

raise HTTPException(status_code=403, detail="Project does not belong to the organization")


current_user.organization_id = organization_id

# Superuser bypasses all checks
if current_user.is_superuser:
# Superuser bypasses all checks and If Api key request we give access to all the project in organization
if current_user.is_superuser or current_user.organization_id:
current_user.organization_id = organization_id
return UserProjectOrg(**current_user.model_dump(), project_id=project_id)

# Check if the user is part of the project
Expand All @@ -150,4 +170,5 @@ def verify_user_project_organization(
if not user_in_project:
raise HTTPException(status_code=403, detail="User is not part of the project")

current_user.organization_id = organization_id
return UserProjectOrg(**current_user.model_dump(), project_id=project_id)
7 changes: 1 addition & 6 deletions backend/app/api/routes/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
from sqlmodel import Session
from app.api.deps import get_db, get_current_active_superuser
from app.crud.api_key import create_api_key, get_api_key, get_api_keys_by_organization, delete_api_key, get_api_key_by_user_org
from app.crud.organization import get_organization_by_id, validate_organization
from app.crud.project_user import is_user_part_of_organization
from app.crud.organization import validate_organization
from app.models import APIKeyPublic, User
from app.utils import APIResponse

Expand All @@ -26,10 +25,6 @@ def create_key(
# Validate organization
validate_organization(session, organization_id)

# Check if user belongs to organization
if not is_user_part_of_organization(session, user_id, organization_id):
raise HTTPException(status_code=403, detail="User is not part of any project in the organization")

existing_api_key = get_api_key_by_user_org(session, organization_id, user_id)
if existing_api_key:
raise HTTPException(status_code=400, detail="API Key already exists for this user and organization")
Expand Down
31 changes: 25 additions & 6 deletions backend/app/api/routes/project_user.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlmodel import Session
from typing import Annotated
from app.api.deps import get_db, verify_user_project_organization
Expand All @@ -14,6 +14,7 @@
# Add a user to a project
@router.post("/{user_id}", response_model=APIResponse[ProjectUserPublic])
def add_user(
request: Request,
user_id: uuid.UUID,
is_admin: bool = False,
session: Session = Depends(get_db),
Expand All @@ -28,9 +29,17 @@ def add_user(
if not user:
raise HTTPException(status_code=404, detail="User not found")

# Only allow superusers or project admins to add users
if not current_user.is_superuser and not is_project_admin(session, current_user.id, project_id):
raise HTTPException(status_code=403, detail="Only project admins or superusers can add users.")
# Only allow superusers, project admins, or API key-authenticated requests to add users
if (
not current_user.is_superuser
and not request.headers.get("X-API-KEY")
and not is_project_admin(session, current_user.id, project_id)
):
raise HTTPException(
status_code=403,
detail="Only project admins or superusers can add users."
)

try:
added_user = add_user_to_project(session, project_id, user_id, is_admin)
return APIResponse.success_response(added_user)
Expand Down Expand Up @@ -62,6 +71,7 @@ def list_project_users(
# Remove a user from a project
@router.delete("/{user_id}", response_model=APIResponse[Message])
def remove_user(
request: Request,
user_id: uuid.UUID,
session: Session = Depends(get_db),
current_user: UserProjectOrg = Depends(verify_user_project_organization)
Expand All @@ -76,8 +86,17 @@ def remove_user(
if not user:
raise HTTPException(status_code=404, detail="User not found")

if not current_user.is_superuser and not is_project_admin(session, current_user.id, project_id):
raise HTTPException(status_code=403, detail="Only project admins or superusers can remove users.")
# Only allow superusers, project admins, or API key-authenticated requests to remove users
if (
not current_user.is_superuser
and not request.headers.get("X-API-KEY")
and not is_project_admin(session, current_user.id, project_id)
):
raise HTTPException(
status_code=403,
detail="Only project admins or superusers can remove users."
)

try:
remove_user_from_project(session, project_id, user_id)
return APIResponse.success_response({"message": "User removed from project successfully."})
Expand Down
5 changes: 3 additions & 2 deletions backend/app/crud/api_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def delete_api_key(session: Session, api_key_id: int) -> None:
api_key = session.get(APIKey, api_key_id)

if not api_key or api_key.is_deleted:
raise ValueError("API key not found or already deleted.")
raise ValueError("API key not found or already deleted")

api_key.is_deleted = True
api_key.deleted_at = datetime.utcnow()
Expand All @@ -76,6 +76,7 @@ def get_api_key_by_user_org(session: Session, organization_id: int, user_id: str
"""
statement = select(APIKey).where(
APIKey.organization_id == organization_id,
APIKey.user_id == user_id
APIKey.user_id == user_id,
APIKey.is_deleted == False
)
return session.exec(statement).first()
16 changes: 9 additions & 7 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,6 @@ class UserBase(SQLModel):
is_superuser: bool = False
full_name: str | None = Field(default=None, max_length=255)

class UserOrganization(UserBase):
id: uuid.UUID
organization_id: int | None

class UserProjectOrg(UserOrganization):
project_id: int


# Properties to receive via API on creation
class UserCreate(UserBase):
Expand Down Expand Up @@ -60,6 +53,15 @@ class User(UserBase, table=True):
api_keys: list["APIKey"] = Relationship(back_populates="user")


class UserOrganization(UserBase):
id : uuid.UUID
organization_id: int | None


class UserProjectOrg(UserOrganization):
project_id: int


# Properties to return via API, id is always required
class UserPublic(UserBase):
id: uuid.UUID
Expand Down
Loading