From 97cc6338c35e6764782bba04a540b37bbe5353e8 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Fri, 21 Mar 2025 13:34:58 +0530 Subject: [PATCH 1/3] Intial setup api key --- backend/app/api/main.py | 3 +- backend/app/api/routes/api_keys.py | 94 ++++++++++++++++++++++++++++++ backend/app/crud/api_key.py | 65 +++++++++++++++++++++ backend/app/crud/organization.py | 17 +++++- backend/app/crud/project_user.py | 20 ++++++- backend/app/models/__init__.py | 9 ++- backend/app/models/api_key.py | 27 +++++++++ backend/app/models/organization.py | 2 + backend/app/models/user.py | 2 + 9 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 backend/app/api/routes/api_keys.py create mode 100644 backend/app/crud/api_key.py create mode 100644 backend/app/models/api_key.py diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 8c05b6a65..9799c3f9a 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils,project,organization, project_user +from app.api.routes import items, login, private, users, utils,project,organization, project_user, api_keys from app.core.config import settings api_router = APIRouter() @@ -10,6 +10,7 @@ api_router.include_router(organization.router) api_router.include_router(project.router) api_router.include_router(project_user.router) +api_router.include_router(api_keys.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/api_keys.py b/backend/app/api/routes/api_keys.py new file mode 100644 index 000000000..9343e37c1 --- /dev/null +++ b/backend/app/api/routes/api_keys.py @@ -0,0 +1,94 @@ +import uuid +from fastapi import APIRouter, Depends, HTTPException +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 +from app.crud.organization import get_organization_by_id, validate_organization +from app.crud.project_user import is_user_part_of_organization +from app.models import APIKeyPublic, User +from app.utils import APIResponse + +router = APIRouter(prefix="/apikeys", tags=["API Keys"]) + + +# Create API Key +@router.post("/", response_model=APIResponse[APIKeyPublic]) +def create_key( + organization_id: int, + user_id: uuid.UUID, + session: Session = Depends(get_db), + current_user: User = Depends(get_current_active_superuser) +): + """ + Generate a new API key for the user's organization. + """ + try: + # 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") + + # Create and return API key + api_key = create_api_key(session, organization_id=organization_id, user_id=user_id) + return APIResponse.success_response(api_key) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +# List API Keys +@router.get("/", response_model=APIResponse[list[APIKeyPublic]]) +def list_keys( + organization_id: int, + session: Session = Depends(get_db), + current_user: User = Depends(get_current_active_superuser), +): + """ + Retrieve all API keys for the user's organization. + """ + try: + # Validate organization + validate_organization(session, organization_id) + + # Retrieve API keys + api_keys = get_api_keys_by_organization(session, organization_id) + return APIResponse.success_response(api_keys) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +# Get API Key by ID +@router.get("/{api_key_id}", response_model=APIResponse[APIKeyPublic]) +def get_key( + api_key_id: int, + session: Session = Depends(get_db), + current_user: User = Depends(get_current_active_superuser) +): + """ + Retrieve an API key by ID. + """ + api_key = get_api_key(session, api_key_id) + if not api_key: + raise HTTPException(status_code=404, detail="API Key does not exist") + + return APIResponse.success_response(api_key) + + +# Revoke API Key (Soft Delete) +@router.delete("/{api_key_id}", response_model=APIResponse[dict]) +def revoke_key( + api_key_id: int, + session: Session = Depends(get_db), + current_user: User = Depends(get_current_active_superuser) +): + """ + Soft delete an API key (revoke access). + """ + try: + delete_api_key(session, api_key_id) + return APIResponse.success_response({"message": "API key revoked successfully"}) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/crud/api_key.py b/backend/app/crud/api_key.py new file mode 100644 index 000000000..dcc87c758 --- /dev/null +++ b/backend/app/crud/api_key.py @@ -0,0 +1,65 @@ +import uuid +import secrets +from datetime import datetime +from sqlmodel import Session, select + +from app.models import APIKey, APIKeyPublic + + +# Create API Key +def create_api_key(session: Session, organization_id: uuid.UUID, user_id: uuid.UUID) -> APIKeyPublic: + """ + Generates a new API key for an organization and associates it with a user. + """ + api_key = APIKey( + key=secrets.token_urlsafe(32), + organization_id=organization_id, + user_id=user_id + ) + + session.add(api_key) + session.commit() + session.refresh(api_key) + + return APIKeyPublic.model_validate(api_key) + + +# Get API Key by ID +def get_api_key(session: Session, api_key_id: int) -> APIKeyPublic | None: + """ + Retrieves an API key by its ID if it exists and is not deleted. + """ + api_key = session.exec( + select(APIKey).where(APIKey.id == api_key_id, APIKey.is_deleted == False) + ).first() + + return APIKeyPublic.model_validate(api_key) if api_key else None + + +# Get API Keys for an Organization +def get_api_keys_by_organization(session: Session, organization_id: uuid.UUID) -> list[APIKeyPublic]: + """ + Retrieves all active API keys associated with an organization. + """ + api_keys = session.exec( + select(APIKey).where(APIKey.organization_id == organization_id, APIKey.is_deleted == False) + ).all() + + return [APIKeyPublic.model_validate(api_key) for api_key in api_keys] + + +# Soft Delete (Revoke) API Key +def delete_api_key(session: Session, api_key_id: int) -> None: + """ + Soft deletes (revokes) an API key by marking it as deleted. + """ + 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.") + + api_key.is_deleted = True + api_key.deleted_at = datetime.utcnow() + + session.add(api_key) + session.commit() diff --git a/backend/app/crud/organization.py b/backend/app/crud/organization.py index 0a4213c1d..a6a550047 100644 --- a/backend/app/crud/organization.py +++ b/backend/app/crud/organization.py @@ -12,10 +12,25 @@ def create_organization(*, session: Session, org_create: OrganizationCreate) -> return db_org -def get_organization_by_id(*, session: Session, org_id: int) -> Optional[Organization]: +# Get organization by ID +def get_organization_by_id(session: Session, org_id: int) -> Optional[Organization]: statement = select(Organization).where(Organization.id == org_id) return session.exec(statement).first() def get_organization_by_name(*, session: Session, name: str) -> Optional[Organization]: statement = select(Organization).where(Organization.name == name) return session.exec(statement).first() + +# Validate if organization exists and is active +def validate_organization(session: Session, org_id: int) -> Organization: + """ + Ensures that an organization exists and is active. + """ + organization = get_organization_by_id(session, org_id) + if not organization: + raise ValueError("Organization not found") + + if not organization.is_active: + raise ValueError("Organization is not active") + + return organization diff --git a/backend/app/crud/project_user.py b/backend/app/crud/project_user.py index f27204acc..99fc80b5f 100644 --- a/backend/app/crud/project_user.py +++ b/backend/app/crud/project_user.py @@ -1,6 +1,6 @@ import uuid from sqlmodel import Session, select, delete, func -from app.models import ProjectUser, ProjectUserPublic, User +from app.models import ProjectUser, ProjectUserPublic, User, Project from datetime import datetime @@ -80,3 +80,21 @@ def get_users_by_project( users = session.exec(statement).all() return [ProjectUserPublic.model_validate(user) for user in users], total_count + + +# Check if a user belongs to an at least one project in organization +def is_user_part_of_organization(session: Session, user_id: uuid.UUID, org_id: int) -> bool: + """ + Checks if a user is part of at least one project within the organization. + """ + user_in_org = session.exec( + select(ProjectUser) + .join(Project, ProjectUser.project_id == Project.id) + .where( + Project.organization_id == org_id, + ProjectUser.user_id == user_id, + ProjectUser.is_deleted == False + ) + ).first() + + return bool(user_in_org) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 75789039a..bc29192e0 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,6 +16,12 @@ ProjectUpdate, ) +from .api_key import ( + APIKey, + APIKeyBase, + APIKeyPublic +) + from .organization import ( Organization, OrganizationCreate, @@ -34,6 +40,5 @@ UserUpdateMe, NewPassword, UpdatePassword, - UserProjectOrg - + UserProjectOrg ) diff --git a/backend/app/models/api_key.py b/backend/app/models/api_key.py new file mode 100644 index 000000000..a1082bd3a --- /dev/null +++ b/backend/app/models/api_key.py @@ -0,0 +1,27 @@ +import uuid +import secrets +from datetime import datetime +from typing import Optional, List +from sqlmodel import SQLModel, Field, Relationship + + +class APIKeyBase(SQLModel): + organization_id: int = Field(foreign_key="organization.id", nullable=False, ondelete="CASCADE") + user_id: uuid.UUID = Field(foreign_key="user.id", nullable=False, ondelete="CASCADE") + key: str = Field(default_factory=lambda: secrets.token_urlsafe(32), unique=True, index=True) + + +class APIKeyPublic(APIKeyBase): + id: int + created_at: datetime + + +class APIKey(APIKeyBase, table=True): + id: int = Field(default=None, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) + is_deleted: bool = Field(default=False, nullable=False) + deleted_at: Optional[datetime] = Field(default=None, nullable=True) + + # Relationships + organization: "Organization" = Relationship(back_populates="api_keys") + user: "User" = Relationship(back_populates="api_keys") diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py index c7fe4a5d4..3c073352c 100644 --- a/backend/app/models/organization.py +++ b/backend/app/models/organization.py @@ -21,6 +21,8 @@ class OrganizationUpdate(SQLModel): # Database model for Organization class Organization(OrganizationBase, table=True): id: int = Field(default=None, primary_key=True) + + api_keys: list["APIKey"] = Relationship(back_populates="organization") # Properties to return via API diff --git a/backend/app/models/user.py b/backend/app/models/user.py index dba900ab4..892d58b8a 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -11,6 +11,7 @@ class UserBase(SQLModel): is_superuser: bool = False full_name: str | None = Field(default=None, max_length=255) + class UserProjectOrg(UserBase): id: uuid.UUID # User ID project_id: int @@ -54,6 +55,7 @@ class User(UserBase, table=True): hashed_password: str items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) projects: list["ProjectUser"] = Relationship(back_populates="user", cascade_delete=True) + api_keys: list["APIKey"] = Relationship(back_populates="user") # Properties to return via API, id is always required From f4e245a88d9ea7ccd2f022b449ba301d8183e464 Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Fri, 21 Mar 2025 18:57:28 +0530 Subject: [PATCH 2/3] added Api key auth flow --- backend/app/api/deps.py | 94 +++++++++++++++++++----------- backend/app/api/routes/api_keys.py | 6 +- backend/app/crud/api_key.py | 18 +++++- backend/app/models/__init__.py | 3 +- backend/app/models/user.py | 8 ++- 5 files changed, 90 insertions(+), 39 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index ba4542a01..5a1165019 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,10 +1,10 @@ from collections.abc import Generator -from typing import Annotated +from typing import Annotated, Optional import jwt -from fastapi import Depends, HTTPException, status, Request +from fastapi import Depends, HTTPException, status, Request, Header, Security from fastapi.responses import JSONResponse -from fastapi.security import OAuth2PasswordBearer +from fastapi.security import OAuth2PasswordBearer, APIKeyHeader from jwt.exceptions import InvalidTokenError from pydantic import ValidationError from sqlmodel import Session, select @@ -13,42 +13,66 @@ from app.core.config import settings from app.core.db import engine from app.utils import APIResponse -from app.models import TokenPayload, User, UserProjectOrg, ProjectUser, Project, Organization +from app.crud.organization import validate_organization +from app.crud.api_key import get_api_key_by_value +from app.models import TokenPayload, User, UserProjectOrg, UserOrganization, ProjectUser, Project, Organization -reusable_oauth2 = OAuth2PasswordBearer( - tokenUrl=f"{settings.API_V1_STR}/login/access-token" -) +# reusable_oauth2 = OAuth2PasswordBearer( +# tokenUrl=f"{settings.API_V1_STR}/login/access-token" +# ) def get_db() -> Generator[Session, None, None]: with Session(engine) as session: yield session - +api_key_header = APIKeyHeader(name="Authorization", auto_error=False) SessionDep = Annotated[Session, Depends(get_db)] -TokenDep = Annotated[str, Depends(reusable_oauth2)] - - -def get_current_user(session: SessionDep, token: TokenDep) -> User: - try: - payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] - ) - token_data = TokenPayload(**payload) - except (InvalidTokenError, ValidationError): - raise HTTPException( - 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 user - - -CurrentUser = Annotated[User, Depends(get_current_user)] +# TokenDep = Annotated[str, Depends(reusable_oauth2)] + +def get_current_user( + session: SessionDep, + auth_header: str = Security(api_key_header), +) -> UserOrganization: + """Authenticate user via API Key first, fallback to JWT token.""" + + if auth_header.startswith("ApiKey "): + api_key = auth_header.split(" ", 1)[1] + api_key_record = get_api_key_by_value(session, api_key) + if not api_key_record: + raise HTTPException(status_code=401, detail="Invalid API Key") + + user = session.get(User, api_key_record.user_id) + 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) + + if auth_header.startswith("Bearer "): + try: + token = auth_header.split(" ", 1)[1] + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (InvalidTokenError, ValidationError): + raise HTTPException( + 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) + raise HTTPException(status_code=401, detail="Invalid Authorization header format") + +CurrentUser = Annotated[UserOrganization, Depends(get_current_user)] def get_current_active_superuser(current_user: CurrentUser) -> User: @@ -78,6 +102,8 @@ def verify_user_project_organization( Verify that the authenticated user is part of the project 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") project_organization = db.exec( select(Project, Organization) @@ -105,9 +131,11 @@ def verify_user_project_organization( 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: - return UserProjectOrg(**current_user.model_dump(), project_id=project_id, organization_id=organization_id) + return UserProjectOrg(**current_user.model_dump(), project_id=project_id) # Check if the user is part of the project user_in_project = db.exec( @@ -121,4 +149,4 @@ def verify_user_project_organization( if not user_in_project: raise HTTPException(status_code=403, detail="User is not part of the project") - return UserProjectOrg(**current_user.model_dump(), project_id=project_id, organization_id=organization_id) + return UserProjectOrg(**current_user.model_dump(), project_id=project_id) diff --git a/backend/app/api/routes/api_keys.py b/backend/app/api/routes/api_keys.py index 9343e37c1..91bcff292 100644 --- a/backend/app/api/routes/api_keys.py +++ b/backend/app/api/routes/api_keys.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException 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 +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.models import APIKeyPublic, User @@ -30,6 +30,10 @@ def create_key( 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") + # Create and return API key api_key = create_api_key(session, organization_id=organization_id, user_id=user_id) return APIResponse.success_response(api_key) diff --git a/backend/app/crud/api_key.py b/backend/app/crud/api_key.py index dcc87c758..d232bde6d 100644 --- a/backend/app/crud/api_key.py +++ b/backend/app/crud/api_key.py @@ -12,7 +12,7 @@ def create_api_key(session: Session, organization_id: uuid.UUID, user_id: uuid.U Generates a new API key for an organization and associates it with a user. """ api_key = APIKey( - key=secrets.token_urlsafe(32), + key='ApiKey '+secrets.token_urlsafe(32), organization_id=organization_id, user_id=user_id ) @@ -63,3 +63,19 @@ def delete_api_key(session: Session, api_key_id: int) -> None: session.add(api_key) session.commit() + +def get_api_key_by_value(session: Session, api_key_value: str) -> APIKey | None: + """ + Retrieve an API Key record by its value. + """ + return session.exec(select(APIKey).where(APIKey.key == api_key_value, APIKey.is_deleted == False)).first() + +def get_api_key_by_user_org(session: Session, organization_id: int, user_id: str) -> APIKey | None: + """ + Retrieve an API key for a specific user and organization. + """ + statement = select(APIKey).where( + APIKey.organization_id == organization_id, + APIKey.user_id == user_id + ) + return session.exec(statement).first() \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index bc29192e0..abcb6c164 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -40,5 +40,6 @@ UserUpdateMe, NewPassword, UpdatePassword, - UserProjectOrg + UserProjectOrg, + UserOrganization ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 892d58b8a..e6e49cdce 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -11,12 +11,14 @@ 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(UserBase): - id: uuid.UUID # User ID +class UserProjectOrg(UserOrganization): project_id: int - organization_id: int + # Properties to receive via API on creation class UserCreate(UserBase): password: str = Field(min_length=8, max_length=40) From 1bceed2b475bc7dd25efd1066081d1457b47bc9f Mon Sep 17 00:00:00 2001 From: avirajsingh7 Date: Fri, 21 Mar 2025 19:48:56 +0530 Subject: [PATCH 3/3] support both api key and oauth --- backend/app/api/deps.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 5a1165019..ad554de0f 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -17,27 +17,28 @@ from app.crud.api_key import get_api_key_by_value from app.models import TokenPayload, User, UserProjectOrg, UserOrganization, ProjectUser, Project, Organization -# reusable_oauth2 = OAuth2PasswordBearer( -# tokenUrl=f"{settings.API_V1_STR}/login/access-token" -# ) +reusable_oauth2 = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/login/access-token", + auto_error= False +) def get_db() -> Generator[Session, None, None]: with Session(engine) as session: yield session -api_key_header = APIKeyHeader(name="Authorization", auto_error=False) +api_key_header = APIKeyHeader(name="X-API-KEY", auto_error=False) SessionDep = Annotated[Session, Depends(get_db)] -# TokenDep = Annotated[str, Depends(reusable_oauth2)] +TokenDep = Annotated[str, Depends(reusable_oauth2)] def get_current_user( session: SessionDep, - auth_header: str = Security(api_key_header), + token: TokenDep, + api_key: Annotated[str, Depends(api_key_header)], ) -> UserOrganization: """Authenticate user via API Key first, fallback to JWT token.""" - if auth_header.startswith("ApiKey "): - api_key = auth_header.split(" ", 1)[1] + if api_key: api_key_record = get_api_key_by_value(session, api_key) if not api_key_record: raise HTTPException(status_code=401, detail="Invalid API Key") @@ -51,9 +52,8 @@ def get_current_user( # Return UserOrganization model with organization ID return UserOrganization(**user.model_dump(), organization_id=api_key_record.organization_id) - if auth_header.startswith("Bearer "): + if token: try: - token = auth_header.split(" ", 1)[1] payload = jwt.decode( token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] ) @@ -70,7 +70,8 @@ def get_current_user( raise HTTPException(status_code=400, detail="Inactive user") return UserOrganization(**user.model_dump(), organization_id=None) - raise HTTPException(status_code=401, detail="Invalid Authorization header format") + + raise HTTPException(status_code=401, detail="Invalid Authorization format") CurrentUser = Annotated[UserOrganization, Depends(get_current_user)]