diff --git a/backend/app/alembic/versions/050_add_userproject_table.py b/backend/app/alembic/versions/050_add_userproject_table.py new file mode 100644 index 000000000..7b71ff942 --- /dev/null +++ b/backend/app/alembic/versions/050_add_userproject_table.py @@ -0,0 +1,60 @@ +"""Add userproject table + +Revision ID: 050 +Revises: 049 +Create Date: 2026-04-01 12:17:42.165482 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "050" +down_revision = "049" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "user_project", + sa.Column( + "user_id", sa.Integer(), nullable=False, comment="Reference to the user" + ), + sa.Column( + "organization_id", + sa.Integer(), + nullable=False, + comment="Reference to the organization", + ), + sa.Column( + "project_id", + sa.Integer(), + nullable=False, + comment="Reference to the project", + ), + sa.Column( + "id", + sa.Integer(), + nullable=False, + comment="Unique identifier for the user-project mapping", + ), + sa.Column( + "inserted_at", + sa.DateTime(), + nullable=False, + comment="Timestamp when the mapping was created", + ), + sa.ForeignKeyConstraint( + ["organization_id"], ["organization.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "project_id", name="uq_user_project"), + ) + + +def downgrade(): + op.drop_table("user_project") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6370aa206..a99e0d824 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -8,6 +8,8 @@ from pydantic import ValidationError from sqlmodel import Session +from sqlmodel import and_, select + from app.core import security from app.core.config import settings from app.core.db import engine @@ -15,11 +17,13 @@ from app.crud.organization import validate_organization from app.crud.project import validate_project from app.models import ( + APIKey, AuthContext, Organization, Project, TokenPayload, User, + UserProject, ) @@ -64,10 +68,11 @@ def _authenticate_with_jwt(session: Session, token: str) -> AuthContext: ) 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=403, detail="Inactive user") + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User access has been revoked", + ) organization: Organization | None = None project: Project | None = None @@ -77,6 +82,39 @@ def _authenticate_with_jwt(session: Session, token: str) -> AuthContext: if token_data.project_id: project = validate_project(session=session, project_id=token_data.project_id) + # Verify user still has access to this project + if project: + has_access = session.exec( + select(UserProject.id) + .where( + and_( + UserProject.user_id == user.id, + UserProject.project_id == project.id, + ) + ) + .limit(1) + ).first() + + if not has_access: + # Fallback: check APIKey table for backward compatibility + has_api_key = session.exec( + select(APIKey.id) + .where( + and_( + APIKey.user_id == user.id, + APIKey.project_id == project.id, + APIKey.is_deleted.is_(False), + ) + ) + .limit(1) + ).first() + + if not has_api_key: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User access to this project has been revoked", + ) + return AuthContext(user=user, organization=organization, project=project) diff --git a/backend/app/api/docs/user_project/add.md b/backend/app/api/docs/user_project/add.md new file mode 100644 index 000000000..2a191c347 --- /dev/null +++ b/backend/app/api/docs/user_project/add.md @@ -0,0 +1,17 @@ +Add one or more users to a project by email. **Requires superuser access.** + +**Request Body:** +- `organization_id` (required): The ID of the organization the project belongs to. +- `project_id` (required): The ID of the project to add users to. +- `users` (required): Array of user objects. + - `email` (required): User's email address. + - `full_name` (optional): User's full name. + +**Examples:** +- **Single user**: `{"organization_id": 1, "project_id": 1, "users": [{"email": "user@gmail.com", "full_name": "User Name"}]}` +- **Multiple users**: `{"organization_id": 1, "project_id": 1, "users": [{"email": "a@gmail.com"}, {"email": "b@gmail.com"}]}` + +**Behavior per email:** +- If the user does not exist, a new account is created with `is_active: false`. The user will be activated on their first Google login. +- If the user already exists and is already in this project, they are skipped. +- If the user exists but is not in this project, they are added. diff --git a/backend/app/api/docs/user_project/delete.md b/backend/app/api/docs/user_project/delete.md new file mode 100644 index 000000000..4b765c47f --- /dev/null +++ b/backend/app/api/docs/user_project/delete.md @@ -0,0 +1,9 @@ +Remove a user from a project. **Requires superuser access.** + +**Path Parameters:** +- `user_id` (required): The ID of the user to remove. + +**Query Parameters:** +- `project_id` (required): The ID of the project to remove the user from. + +This only removes the user-project mapping — the user account itself is not deleted. You cannot remove yourself from a project. diff --git a/backend/app/api/docs/user_project/list.md b/backend/app/api/docs/user_project/list.md new file mode 100644 index 000000000..8c1cd3693 --- /dev/null +++ b/backend/app/api/docs/user_project/list.md @@ -0,0 +1,6 @@ +List all users that belong to a project. + +**Query Parameters:** +- `project_id` (required): The ID of the project to list users for. + +Returns user details including their active status — users added via invitation will have `is_active: false` until they complete their first Google login. diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 2421cf97a..98ce324c5 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -7,7 +7,7 @@ config, doc_transformation_job, documents, - google_auth, + auth, login, languages, llm, @@ -18,6 +18,7 @@ responses, private, threads, + user_project, users, utils, onboarding, @@ -40,7 +41,7 @@ api_router.include_router(cron.router) api_router.include_router(documents.router) api_router.include_router(doc_transformation_job.router) -api_router.include_router(google_auth.router) +api_router.include_router(auth.router) api_router.include_router(evaluations.router) api_router.include_router(languages.router) api_router.include_router(llm.router) @@ -52,6 +53,7 @@ api_router.include_router(project.router) api_router.include_router(responses.router) api_router.include_router(threads.router) +api_router.include_router(user_project.router) api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(fine_tuning.router) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 000000000..a7a7465b1 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -0,0 +1,200 @@ +import logging + +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.responses import JSONResponse +from google.auth.transport import requests as google_requests +from google.oauth2 import id_token + +from app.api.deps import AuthContextDep, SessionDep +from app.core.config import settings +from app.crud import get_user_by_email +from app.crud.auth import get_user_accessible_projects +from app.models import ( + GoogleAuthRequest, + GoogleAuthResponse, + Message, + SelectProjectRequest, + Token, +) +from app.services.auth import ( + build_google_auth_response, + build_token_response, + clear_auth_cookies, + validate_refresh_token, +) +from app.utils import APIResponse, load_description + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +@router.post( + "/google", + description=load_description("auth/google.md"), + response_model=APIResponse[GoogleAuthResponse], +) +def google_auth(session: SessionDep, body: GoogleAuthRequest) -> JSONResponse: + """Authenticate a user via Google OAuth ID token.""" + + if not settings.GOOGLE_CLIENT_ID: + logger.error("[google_auth] GOOGLE_CLIENT_ID is not configured") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Google authentication is not configured", + ) + + # Verify the Google ID token + try: + idinfo = id_token.verify_oauth2_token( + body.token, + google_requests.Request(), + settings.GOOGLE_CLIENT_ID, + ) + except ValueError as e: + logger.warning(f"[google_auth] Invalid Google token: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired Google token", + ) + + if not idinfo.get("email_verified", False): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Google email is not verified", + ) + + email: str = idinfo["email"] + + user = get_user_by_email(session=session, email=email) + if not user: + logger.info(f"[google_auth] No account found for email: {email}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="No account found for this Google email. Please Contact Support to add your account.", + ) + + # Activate user on first Google login + if not user.is_active: + user.is_active = True + session.add(user) + session.commit() + session.refresh(user) + logger.info(f"[google_auth] User activated on first login | user_id: {user.id}") + + google_profile = { + "email": idinfo.get("email"), + "name": idinfo.get("name"), + "picture": idinfo.get("picture"), + "given_name": idinfo.get("given_name"), + "family_name": idinfo.get("family_name"), + } + + available_projects = get_user_accessible_projects(session=session, user_id=user.id) + + if len(available_projects) == 1: + proj = available_projects[0] + logger.info( + f"[google_auth] User authenticated via Google (auto-selected project) | user_id: {user.id}" + ) + return build_google_auth_response( + user=user, + google_profile=google_profile, + available_projects=available_projects, + organization_id=proj["organization_id"], + project_id=proj["project_id"], + ) + elif len(available_projects) > 1: + logger.info( + f"[google_auth] User authenticated via Google (requires project selection) | user_id: {user.id}" + ) + return build_google_auth_response( + user=user, + google_profile=google_profile, + available_projects=available_projects, + requires_project_selection=True, + ) + else: + logger.info( + f"[google_auth] User authenticated via Google (no projects) | user_id: {user.id}" + ) + return build_google_auth_response( + user=user, + google_profile=google_profile, + available_projects=[], + ) + + +@router.post( + "/select-project", + response_model=APIResponse[Token], +) +def select_project( + session: SessionDep, + auth_context: AuthContextDep, + body: SelectProjectRequest, +) -> JSONResponse: + """Select a project and get a new JWT token with org/project embedded.""" + + user = auth_context.user + + available_projects = get_user_accessible_projects(session=session, user_id=user.id) + matching = [p for p in available_projects if p["project_id"] == body.project_id] + + if not matching: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this project", + ) + + proj = matching[0] + response = build_token_response( + user_id=user.id, + organization_id=proj["organization_id"], + project_id=proj["project_id"], + ) + + logger.info( + f"[select_project] Project selected | user_id: {user.id}, project_id: {body.project_id}" + ) + return response + + +@router.post( + "/refresh", + response_model=APIResponse[Token], +) +def refresh_access_token(request: Request, session: SessionDep) -> JSONResponse: + """Use a refresh token to get a new access token without re-authenticating.""" + + refresh_token = request.cookies.get("refresh_token") + if not refresh_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token not found", + ) + + user, token_data = validate_refresh_token(session, refresh_token) + + response = build_token_response( + user_id=user.id, + organization_id=token_data.org_id, + project_id=token_data.project_id, + ) + + logger.info(f"[refresh_access_token] Token refreshed | user_id: {user.id}") + return response + + +@router.post( + "/logout", + response_model=APIResponse[Message], +) +def logout() -> JSONResponse: + """Clear auth cookies to log the user out.""" + api_response = APIResponse.success_response( + data=Message(message="Logged out successfully") + ) + response = JSONResponse(content=api_response.model_dump()) + clear_auth_cookies(response) + return response diff --git a/backend/app/api/routes/google_auth.py b/backend/app/api/routes/google_auth.py deleted file mode 100644 index 254e53cdd..000000000 --- a/backend/app/api/routes/google_auth.py +++ /dev/null @@ -1,372 +0,0 @@ -import logging -from datetime import timedelta - -import jwt as pyjwt -from fastapi import APIRouter, HTTPException, Request, status -from fastapi.responses import JSONResponse -from google.auth.transport import requests as google_requests -from google.oauth2 import id_token -from jwt.exceptions import ExpiredSignatureError, InvalidTokenError -from sqlmodel import Session, and_, select - -from app.api.deps import AuthContextDep, SessionDep -from app.core import security -from app.core.config import settings -from app.crud import get_user_by_email -from app.models import ( - APIKey, - GoogleAuthRequest, - GoogleAuthResponse, - Message, - Organization, - Project, - SelectProjectRequest, - Token, - TokenPayload, - User, - UserPublic, -) -from app.utils import APIResponse, load_description - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/auth", tags=["Authentication"]) - - -def _get_user_projects(session: Session, user_id: int) -> list[dict]: - """Query distinct org/project pairs for a user from active API keys.""" - statement = ( - select(Organization.id, Organization.name, Project.id, Project.name) - .select_from(APIKey) - .join(Organization, Organization.id == APIKey.organization_id) - .join(Project, Project.id == APIKey.project_id) - .where( - and_( - APIKey.user_id == user_id, - APIKey.is_deleted.is_(False), - Organization.is_active.is_(True), - Project.is_active.is_(True), - ) - ) - .distinct() - ) - results = session.exec(statement).all() - return [ - { - "organization_id": org_id, - "organization_name": org_name, - "project_id": proj_id, - "project_name": proj_name, - } - for org_id, org_name, proj_id, proj_name in results - ] - - -def _set_auth_cookies( - response: JSONResponse, - access_token: str, - refresh_token: str, -) -> None: - """Set access_token and refresh_token as HTTP-only cookies on the response.""" - is_secure = settings.ENVIRONMENT in ("staging", "production") - - response.set_cookie( - key="access_token", - value=access_token, - httponly=True, - secure=is_secure, - samesite="lax", - max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, - path="/", - ) - response.set_cookie( - key="refresh_token", - value=refresh_token, - httponly=True, - secure=is_secure, - samesite="lax", - max_age=settings.REFRESH_TOKEN_EXPIRE_MINUTES * 60, - path=f"{settings.API_V1_STR}/auth", - ) - - -def _create_token_pair( - user_id: int, - organization_id: int | None = None, - project_id: int | None = None, -) -> tuple[str, str]: - """Create an access token and refresh token pair.""" - access_token = security.create_access_token( - user_id, - expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), - organization_id=organization_id, - project_id=project_id, - ) - refresh_token = security.create_refresh_token( - user_id, - expires_delta=timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES), - organization_id=organization_id, - project_id=project_id, - ) - return access_token, refresh_token - - -def _create_token_and_response( - user, - google_profile: dict, - available_projects: list[dict], - organization_id: int | None = None, - project_id: int | None = None, - requires_project_selection: bool = False, -) -> JSONResponse: - """Create JWT token pair, build response, and set cookies.""" - access_token, refresh_token = _create_token_pair( - user.id, - organization_id=organization_id, - project_id=project_id, - ) - - response_data = GoogleAuthResponse( - access_token=access_token, - user=UserPublic.model_validate(user), - google_profile=google_profile, - requires_project_selection=requires_project_selection, - available_projects=available_projects, - ) - - api_response = APIResponse.success_response(data=response_data) - response = JSONResponse(content=api_response.model_dump()) - _set_auth_cookies(response, access_token, refresh_token) - return response - - -@router.post( - "/google", - description=load_description("auth/google.md"), - response_model=APIResponse[GoogleAuthResponse], -) -def google_auth(session: SessionDep, body: GoogleAuthRequest) -> JSONResponse: - """Authenticate a user via Google OAuth ID token.""" - - if not settings.GOOGLE_CLIENT_ID: - logger.error("[google_auth] GOOGLE_CLIENT_ID is not configured") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Google authentication is not configured", - ) - - # Verify the Google ID token - try: - idinfo = id_token.verify_oauth2_token( - body.token, - google_requests.Request(), - settings.GOOGLE_CLIENT_ID, - ) - except ValueError as e: - logger.warning(f"[google_auth] Invalid Google token: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid or expired Google token", - ) - - # Ensure the email is verified by Google - if not idinfo.get("email_verified", False): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Google email is not verified", - ) - - email: str = idinfo["email"] - - # Look up user by email - user = get_user_by_email(session=session, email=email) - if not user: - logger.info(f"[google_auth] No account found for email: {email}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="No account found for this Google email. Please Contact Support to add your account.", - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Inactive user account", - ) - - google_profile = { - "email": idinfo.get("email"), - "name": idinfo.get("name"), - "picture": idinfo.get("picture"), - "given_name": idinfo.get("given_name"), - "family_name": idinfo.get("family_name"), - } - - # Query user's org/project access - available_projects = _get_user_projects(session, user.id) - - if len(available_projects) == 1: - # Auto-select the only org/project - proj = available_projects[0] - logger.info( - f"[google_auth] User authenticated via Google (auto-selected project) | user_id: {user.id}" - ) - return _create_token_and_response( - user=user, - google_profile=google_profile, - available_projects=available_projects, - organization_id=proj["organization_id"], - project_id=proj["project_id"], - ) - elif len(available_projects) > 1: - # Multiple projects — return token without org/project, frontend must select - logger.info( - f"[google_auth] User authenticated via Google (requires project selection) | user_id: {user.id}" - ) - return _create_token_and_response( - user=user, - google_profile=google_profile, - available_projects=available_projects, - requires_project_selection=True, - ) - else: - # No projects — return token with user only - logger.info( - f"[google_auth] User authenticated via Google (no projects) | user_id: {user.id}" - ) - return _create_token_and_response( - user=user, - google_profile=google_profile, - available_projects=[], - ) - - -@router.post( - "/select-project", - response_model=APIResponse[Token], -) -def select_project( - session: SessionDep, - auth_context: AuthContextDep, - body: SelectProjectRequest, -) -> JSONResponse: - """Select a project and get a new JWT token with org/project embedded.""" - - user = auth_context.user - - # Verify the user has access to this project via an active API key - available_projects = _get_user_projects(session, user.id) - matching = [p for p in available_projects if p["project_id"] == body.project_id] - - if not matching: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You do not have access to this project", - ) - - proj = matching[0] - - access_token, refresh_token = _create_token_pair( - user.id, - organization_id=proj["organization_id"], - project_id=proj["project_id"], - ) - - api_response = APIResponse.success_response(data=Token(access_token=access_token)) - response = JSONResponse(content=api_response.model_dump()) - _set_auth_cookies(response, access_token, refresh_token) - - logger.info( - f"[select_project] Project selected | user_id: {user.id}, project_id: {body.project_id}" - ) - return response - - -@router.post( - "/refresh", - response_model=APIResponse[Token], -) -def refresh_access_token(request: Request, session: SessionDep) -> JSONResponse: - """Use a refresh token to get a new access token without re-authenticating.""" - - refresh_token = request.cookies.get("refresh_token") - if not refresh_token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token not found", - ) - - # Decode and validate the refresh token - try: - payload = pyjwt.decode( - refresh_token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] - ) - token_data = TokenPayload(**payload) - except ExpiredSignatureError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token has expired. Please login again.", - ) - except InvalidTokenError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid refresh token", - ) - - # Ensure this is a refresh token, not an access token - if token_data.type != "refresh": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token type", - ) - - # Validate the user still exists and is active - 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=403, detail="Inactive user") - - # Issue a new access token with the same org/project claims - access_token, new_refresh_token = _create_token_pair( - user.id, - organization_id=token_data.org_id, - project_id=token_data.project_id, - ) - - api_response = APIResponse.success_response(data=Token(access_token=access_token)) - response = JSONResponse(content=api_response.model_dump()) - _set_auth_cookies(response, access_token, new_refresh_token) - - logger.info(f"[refresh_access_token] Token refreshed | user_id: {user.id}") - return response - - -@router.post( - "/logout", - response_model=APIResponse[Message], -) -def logout() -> JSONResponse: - """Clear auth cookies to log the user out.""" - api_response = APIResponse.success_response( - data=Message(message="Logged out successfully") - ) - response = JSONResponse(content=api_response.model_dump()) - - is_secure = settings.ENVIRONMENT in ("staging", "production") - - response.delete_cookie( - key="access_token", - httponly=True, - secure=is_secure, - samesite="lax", - path="/", - ) - response.delete_cookie( - key="refresh_token", - httponly=True, - secure=is_secure, - samesite="lax", - path=f"{settings.API_V1_STR}/auth", - ) - - return response diff --git a/backend/app/api/routes/project.py b/backend/app/api/routes/project.py index c8c50738b..71fcf50ee 100644 --- a/backend/app/api/routes/project.py +++ b/backend/app/api/routes/project.py @@ -85,11 +85,11 @@ def update_project(*, session: SessionDep, project_id: int, project_in: ProjectU raise HTTPException(status_code=404, detail="Project not found") project_data = project_in.model_dump(exclude_unset=True) - project = project.model_copy(update=project_data) + project.sqlmodel_update(project_data) session.add(project) session.commit() - session.flush() + session.refresh(project) logger.info( f"[update_project] Project updated successfully | project_id={project.id}" ) diff --git a/backend/app/api/routes/user_project.py b/backend/app/api/routes/user_project.py new file mode 100644 index 000000000..5052761f7 --- /dev/null +++ b/backend/app/api/routes/user_project.py @@ -0,0 +1,137 @@ +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.api.deps import AuthContextDep, SessionDep +from app.api.permissions import Permission, require_permission +from app.crud.user_project import ( + add_user_to_project, + get_users_by_project, + remove_user_from_project, +) +from app.models import ( + AddUsersToProjectRequest, + Message, + UserProjectPublic, +) +from app.utils import APIResponse, load_description + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/user-projects", tags=["User Projects"]) + + +@router.get( + "/", + description=load_description("user_project/list.md"), + response_model=APIResponse[list[UserProjectPublic]], +) +def list_project_users( + session: SessionDep, + auth_context: AuthContextDep, + project_id: int, +) -> Any: + """List all users in a project.""" + users = get_users_by_project(session=session, project_id=project_id) + return APIResponse.success_response(data=users) + + +@router.post( + "/", + dependencies=[Depends(require_permission(Permission.SUPERUSER))], + description=load_description("user_project/add.md"), + response_model=APIResponse[list[UserProjectPublic]], + status_code=status.HTTP_201_CREATED, +) +def add_project_users( + session: SessionDep, + body: AddUsersToProjectRequest, +) -> Any: + """Add one or more users to a project by email.""" + same_project_emails = [] + different_project_emails = [] + + for entry in body.users: + _, add_status = add_user_to_project( + session=session, + email=str(entry.email), + organization_id=body.organization_id, + project_id=body.project_id, + full_name=entry.full_name, + ) + if add_status == "same_project": + same_project_emails.append(str(entry.email)) + elif add_status == "different_project": + different_project_emails.append(str(entry.email)) + + if same_project_emails or different_project_emails: + session.rollback() + errors = [] + if same_project_emails: + errors.append( + f"Already added to this project: {', '.join(same_project_emails)}" + ) + if different_project_emails: + errors.append( + f"Already assigned to another project: {', '.join(different_project_emails)}" + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="; ".join(errors), + ) + + session.commit() + + # Re-fetch all users for this project to return the full list + results = get_users_by_project(session=session, project_id=body.project_id) + + logger.info( + f"[add_project_users] Users added to project | " + f"project_id: {body.project_id}, count: {len(body.users)}" + ) + + return APIResponse.success_response(data=results) + + +@router.delete( + "/{user_id}", + dependencies=[Depends(require_permission(Permission.SUPERUSER))], + description=load_description("user_project/delete.md"), + response_model=APIResponse[Message], +) +def delete_project_user( + session: SessionDep, + auth_context: AuthContextDep, + user_id: int, + project_id: int, +) -> Any: + """Remove a user from a project.""" + if user_id == auth_context.user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot remove yourself from the project", + ) + + removed = remove_user_from_project( + session=session, + user_id=user_id, + project_id=project_id, + ) + + if not removed: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found in this project", + ) + + session.commit() + + logger.info( + f"[delete_project_user] User removed from project | " + f"user_id: {user_id}, project_id: {project_id}" + ) + + return APIResponse.success_response( + data=Message(message="User removed from project successfully") + ) diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py index 9baa5defd..d15e5df89 100644 --- a/backend/app/crud/__init__.py +++ b/backend/app/crud/__init__.py @@ -81,6 +81,13 @@ from .onboarding import onboard_project +from .user_project import ( + add_user_to_project, + get_user_projects, + get_users_by_project, + remove_user_from_project, +) + from .file import ( create_file, get_file_by_id, diff --git a/backend/app/crud/auth.py b/backend/app/crud/auth.py new file mode 100644 index 000000000..39147b86e --- /dev/null +++ b/backend/app/crud/auth.py @@ -0,0 +1,63 @@ +import logging + +from sqlmodel import Session, and_, select + +from app.models import ( + APIKey, + Organization, + Project, + UserProject, +) + +logger = logging.getLogger(__name__) + + +def get_user_accessible_projects(*, session: Session, user_id: int) -> list[dict]: + """ + Query distinct org/project pairs for a user from both + the UserProject table and the APIKey table (backward compatibility). + """ + # Query from UserProject table + from_user_project = ( + select(Organization.id, Organization.name, Project.id, Project.name) + .select_from(UserProject) + .join(Organization, Organization.id == UserProject.organization_id) + .join(Project, Project.id == UserProject.project_id) + .where( + and_( + UserProject.user_id == user_id, + Organization.is_active.is_(True), + Project.is_active.is_(True), + ) + ) + ) + + # Query from APIKey table (backward compatibility) + from_api_key = ( + select(Organization.id, Organization.name, Project.id, Project.name) + .select_from(APIKey) + .join(Organization, Organization.id == APIKey.organization_id) + .join(Project, Project.id == APIKey.project_id) + .where( + and_( + APIKey.user_id == user_id, + APIKey.is_deleted.is_(False), + Organization.is_active.is_(True), + Project.is_active.is_(True), + ) + ) + ) + + # Union both queries and deduplicate + combined = from_user_project.union(from_api_key) + results = session.exec(combined).all() + + return [ + { + "organization_id": org_id, + "organization_name": org_name, + "project_id": proj_id, + "project_name": proj_name, + } + for org_id, org_name, proj_id, proj_name in results + ] diff --git a/backend/app/crud/user_project.py b/backend/app/crud/user_project.py new file mode 100644 index 000000000..fec57c01d --- /dev/null +++ b/backend/app/crud/user_project.py @@ -0,0 +1,138 @@ +import logging +import secrets +from typing import Sequence + +from sqlmodel import Session, and_, select + +from app.core.security import get_password_hash +from app.models import ( + User, + UserProject, + UserProjectPublic, +) + +logger = logging.getLogger(__name__) + + +def get_users_by_project( + *, session: Session, project_id: int +) -> list[UserProjectPublic]: + """Get all users mapped to a project.""" + statement = ( + select( + User.id, User.email, User.full_name, User.is_active, UserProject.inserted_at + ) + .join(UserProject, UserProject.user_id == User.id) + .where(UserProject.project_id == project_id) + .order_by(UserProject.inserted_at.desc()) + ) + results = session.exec(statement).all() + return [ + UserProjectPublic( + user_id=user_id, + email=email, + full_name=full_name, + is_active=is_active, + inserted_at=inserted_at, + ) + for user_id, email, full_name, is_active, inserted_at in results + ] + + +def add_user_to_project( + *, + session: Session, + email: str, + organization_id: int, + project_id: int, + full_name: str | None = None, +) -> tuple[User, str]: + """ + Add a user to a project. Creates the user if they don't exist (is_active=False). + + Returns: + Tuple of (user, status) where status is one of: + - "added": User was successfully added to the project + - "same_project": User is already in this project + - "different_project": User is already assigned to another project + """ + user = session.exec(select(User).where(User.email == email)).first() + + if not user: + user = User( + email=email, + full_name=full_name, + is_active=False, + hashed_password=get_password_hash(secrets.token_urlsafe(16)), + ) + session.add(user) + session.flush() + elif full_name and not user.full_name: + user.full_name = full_name + session.add(user) + session.flush() + + # Check if user is already assigned to any project + existing = session.exec( + select(UserProject).where(UserProject.user_id == user.id) + ).first() + + if existing: + if existing.project_id == project_id: + return user, "same_project" + else: + return user, "different_project" + + user_project = UserProject( + user_id=user.id, + organization_id=organization_id, + project_id=project_id, + ) + session.add(user_project) + session.flush() + + return user, "added" + + +def remove_user_from_project( + *, session: Session, user_id: int, project_id: int +) -> bool: + """ + Remove a user from a project. If this was their last project, + deactivate the user account. + + Returns True if removed, False if not found. + """ + user_project = session.exec( + select(UserProject).where( + and_( + UserProject.user_id == user_id, + UserProject.project_id == project_id, + ) + ) + ).first() + + if not user_project: + return False + + session.delete(user_project) + session.flush() + + # Check if user has any remaining projects + remaining = session.exec( + select(UserProject.id).where(UserProject.user_id == user_id).limit(1) + ).first() + + if not remaining: + user = session.get(User, user_id) + if user and not user.is_superuser: + session.delete(user) + session.flush() + + return True + + +def get_user_projects(*, session: Session, user_id: int) -> Sequence[UserProject]: + """Get all project mappings for a user.""" + statement = select(UserProject).where(UserProject.user_id == user_id) + return session.exec(statement).all() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f91a8de74..9341401aa 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -173,8 +173,6 @@ NewPassword, User, UserCreate, - UserMeResponse, - UserProjectAccess, UserPublic, UserRegister, UserUpdate, @@ -182,3 +180,10 @@ UsersPublic, UpdatePassword, ) + +from .user_project import ( + UserProject, + AddUsersToProjectRequest, + UserEntry, + UserProjectPublic, +) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 673c4c4da..f38aafc2a 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -80,17 +80,6 @@ class UserPublic(UserBase): id: int -class UserProjectAccess(SQLModel): - organization_id: int - organization_name: str - project_id: int - project_name: str - - -class UserMeResponse(UserPublic): - projects: list[UserProjectAccess] = [] - - class UsersPublic(SQLModel): data: list[UserPublic] count: int diff --git a/backend/app/models/user_project.py b/backend/app/models/user_project.py new file mode 100644 index 000000000..c231c6d0f --- /dev/null +++ b/backend/app/models/user_project.py @@ -0,0 +1,74 @@ +from datetime import datetime + +from pydantic import EmailStr +from sqlmodel import Field, SQLModel, UniqueConstraint + +from app.core.util import now + + +class UserProjectBase(SQLModel): + """Base model for user-project mapping.""" + + user_id: int = Field( + foreign_key="user.id", + nullable=False, + ondelete="CASCADE", + sa_column_kwargs={"comment": "Reference to the user"}, + ) + organization_id: int = Field( + foreign_key="organization.id", + nullable=False, + ondelete="CASCADE", + sa_column_kwargs={"comment": "Reference to the organization"}, + ) + project_id: int = Field( + foreign_key="project.id", + nullable=False, + ondelete="CASCADE", + sa_column_kwargs={"comment": "Reference to the project"}, + ) + + +class UserProject(UserProjectBase, table=True): + """Maps users to projects within organizations.""" + + __tablename__ = "user_project" + __table_args__ = ( + UniqueConstraint("user_id", "project_id", name="uq_user_project"), + ) + + id: int = Field( + default=None, + primary_key=True, + sa_column_kwargs={"comment": "Unique identifier for the user-project mapping"}, + ) + inserted_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={"comment": "Timestamp when the mapping was created"}, + ) + + +class UserEntry(SQLModel): + """A single user entry with email and optional name.""" + + email: EmailStr + full_name: str | None = Field(default=None, max_length=255) + + +class AddUsersToProjectRequest(SQLModel): + """Request to add one or more users to a project.""" + + organization_id: int + project_id: int + users: list[UserEntry] = Field(min_length=1) + + +class UserProjectPublic(SQLModel): + """Public response model for a user in a project.""" + + user_id: int + email: str + full_name: str | None + is_active: bool + inserted_at: datetime diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 000000000..5c266092e --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,178 @@ +import logging +from datetime import timedelta + +import jwt as pyjwt +from fastapi import HTTPException, status +from fastapi.responses import JSONResponse +from jwt.exceptions import ExpiredSignatureError, InvalidTokenError +from sqlmodel import Session + +from app.core import security +from app.core.config import settings +from app.models import ( + GoogleAuthResponse, + Token, + TokenPayload, + User, + UserPublic, +) +from app.utils import APIResponse + +logger = logging.getLogger(__name__) + + +def create_token_pair( + user_id: int, + organization_id: int | None = None, + project_id: int | None = None, +) -> tuple[str, str]: + """Create an access token and refresh token pair.""" + access_token = security.create_access_token( + user_id, + expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + organization_id=organization_id, + project_id=project_id, + ) + refresh_token = security.create_refresh_token( + user_id, + expires_delta=timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES), + organization_id=organization_id, + project_id=project_id, + ) + return access_token, refresh_token + + +def set_auth_cookies( + response: JSONResponse, + access_token: str, + refresh_token: str, +) -> None: + """Set access_token and refresh_token as HTTP-only cookies on the response.""" + is_secure = settings.ENVIRONMENT in ("staging", "production") + + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=is_secure, + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + path="/", + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=is_secure, + samesite="lax", + max_age=settings.REFRESH_TOKEN_EXPIRE_MINUTES * 60, + path="/", + ) + + +def clear_auth_cookies(response: JSONResponse) -> None: + """Clear access_token and refresh_token cookies from the response.""" + is_secure = settings.ENVIRONMENT in ("staging", "production") + + response.delete_cookie( + key="access_token", + httponly=True, + secure=is_secure, + samesite="lax", + path="/", + ) + response.delete_cookie( + key="refresh_token", + httponly=True, + secure=is_secure, + samesite="lax", + path="/", + ) + + +def build_google_auth_response( + user: User, + google_profile: dict, + available_projects: list[dict], + organization_id: int | None = None, + project_id: int | None = None, + requires_project_selection: bool = False, +) -> JSONResponse: + """Create JWT token pair, build Google auth response, and set cookies.""" + access_token, refresh_token = create_token_pair( + user.id, + organization_id=organization_id, + project_id=project_id, + ) + + response_data = GoogleAuthResponse( + access_token=access_token, + user=UserPublic.model_validate(user), + google_profile=google_profile, + requires_project_selection=requires_project_selection, + available_projects=available_projects, + ) + + api_response = APIResponse.success_response(data=response_data) + response = JSONResponse(content=api_response.model_dump()) + set_auth_cookies(response, access_token, refresh_token) + return response + + +def build_token_response( + user_id: int, + organization_id: int | None = None, + project_id: int | None = None, +) -> JSONResponse: + """Create JWT token pair, build token response, and set cookies.""" + access_token, refresh_token = create_token_pair( + user_id, + organization_id=organization_id, + project_id=project_id, + ) + + api_response = APIResponse.success_response(data=Token(access_token=access_token)) + response = JSONResponse(content=api_response.model_dump()) + set_auth_cookies(response, access_token, refresh_token) + return response + + +def validate_refresh_token( + session: Session, refresh_token_value: str +) -> tuple[User, TokenPayload]: + """ + Validate a refresh token and return the user and token data. + + Raises HTTPException on invalid/expired token or inactive user. + """ + try: + payload = pyjwt.decode( + refresh_token_value, + settings.SECRET_KEY, + algorithms=[security.ALGORITHM], + ) + token_data = TokenPayload(**payload) + except ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token has expired. Please login again.", + ) + except InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + ) + + if token_data.type != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type", + ) + + 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=403, detail="Inactive user") + + return user, token_data diff --git a/backend/app/tests/api/test_google_auth.py b/backend/app/tests/api/test_auth.py similarity index 93% rename from backend/app/tests/api/test_google_auth.py rename to backend/app/tests/api/test_auth.py index df1c5a94c..2ae0e76ea 100644 --- a/backend/app/tests/api/test_google_auth.py +++ b/backend/app/tests/api/test_auth.py @@ -31,7 +31,7 @@ def _mock_idinfo(email: str, email_verified: bool = True) -> dict: class TestGoogleAuth: """Test suite for POST /auth/google endpoint.""" - @patch("app.api.routes.google_auth.settings") + @patch("app.api.routes.auth.settings") def test_google_auth_not_configured(self, mock_settings, client: TestClient): """Test returns 500 when GOOGLE_CLIENT_ID is not set.""" mock_settings.GOOGLE_CLIENT_ID = "" @@ -39,8 +39,8 @@ def test_google_auth_not_configured(self, mock_settings, client: TestClient): assert resp.status_code == 500 assert "not configured" in resp.json()["error"] - @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") - @patch("app.api.routes.google_auth.settings") + @patch("app.api.routes.auth.id_token.verify_oauth2_token") + @patch("app.api.routes.auth.settings") def test_google_auth_invalid_token( self, mock_settings, mock_verify, client: TestClient ): @@ -56,8 +56,8 @@ def test_google_auth_invalid_token( assert resp.status_code == 400 assert "Invalid or expired" in resp.json()["error"] - @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") - @patch("app.api.routes.google_auth.settings") + @patch("app.api.routes.auth.id_token.verify_oauth2_token") + @patch("app.api.routes.auth.settings") def test_google_auth_unverified_email( self, mock_settings, mock_verify, client: TestClient ): @@ -71,8 +71,8 @@ def test_google_auth_unverified_email( assert resp.status_code == 400 assert "not verified" in resp.json()["error"] - @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") - @patch("app.api.routes.google_auth.settings") + @patch("app.api.routes.auth.id_token.verify_oauth2_token") + @patch("app.api.routes.auth.settings") def test_google_auth_user_not_found( self, mock_settings, mock_verify, client: TestClient ): @@ -84,8 +84,8 @@ def test_google_auth_user_not_found( assert resp.status_code == 401 assert "No account found" in resp.json()["error"] - @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") - @patch("app.api.routes.google_auth.settings") + @patch("app.api.routes.auth.id_token.verify_oauth2_token") + @patch("app.api.routes.auth.settings") def test_google_auth_inactive_user_rejected( self, mock_settings, mock_verify, db: Session, client: TestClient ): @@ -103,8 +103,8 @@ def test_google_auth_inactive_user_rejected( assert resp.status_code == 403 assert "Inactive" in resp.json()["error"] - @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") - @patch("app.api.routes.google_auth.settings") + @patch("app.api.routes.auth.id_token.verify_oauth2_token") + @patch("app.api.routes.auth.settings") def test_google_auth_success_no_projects( self, mock_settings, mock_verify, db: Session, client: TestClient ): @@ -130,8 +130,8 @@ def test_google_auth_success_no_projects( assert data["available_projects"] == [] assert "access_token" in resp.cookies - @patch("app.api.routes.google_auth.id_token.verify_oauth2_token") - @patch("app.api.routes.google_auth.settings") + @patch("app.api.routes.auth.id_token.verify_oauth2_token") + @patch("app.api.routes.auth.settings") def test_google_auth_success_single_project_via_api_key( self, mock_settings,