-
Notifications
You must be signed in to change notification settings - Fork 5
Fix Onboard API: Add optional OpenAI key and enforce transactional consistency #337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
917068d
add support of openai key
avirajsingh7 35ec006
Fix immature entries in onboard api
avirajsingh7 a292ffe
pre commit
avirajsingh7 6e31b32
add unit test for onboarding api
avirajsingh7 06d1639
precommit
avirajsingh7 e3d3c20
unit test for get_project_by_name
avirajsingh7 2261148
return masked openai key
avirajsingh7 e56b9c8
add logs
avirajsingh7 3e3a880
Remove openai key from response
avirajsingh7 c3cc3c1
Merge branch 'main' into feature/openai_key_onboard_api
avirajsingh7 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| # Onboarding API Behavior | ||
|
|
||
| ## 🏢 Organization Handling | ||
| - If `organization_name` does **not exist**, a new organization will be created. | ||
| - If `organization_name` already exists, the request will proceed to create the project under that organization. | ||
|
|
||
| --- | ||
|
|
||
| ## 📂 Project Handling | ||
| - If `project_name` does **not exist** in the organization, it will be created. | ||
| - If the project already exists in the same organization, the API will return **409 Conflict**. | ||
|
|
||
| --- | ||
|
|
||
| ## 👤 User Handling | ||
| - If `email` does **not exist**, a new user is created and linked to the project. | ||
| - If the user already exists, they are simply attached to the project. | ||
|
|
||
| --- | ||
|
|
||
| ## 🔑 OpenAI API Key (Optional) | ||
| - If provided, the API key will be **encrypted** and stored as project credentials. | ||
| - If omitted, the project will be created **without OpenAI credentials**. | ||
|
|
||
avirajsingh7 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| --- | ||
|
|
||
| ## 🔄 Transactional Guarantee | ||
| The onboarding process is **all-or-nothing**: | ||
| - If any step fails (e.g., invalid password), **no organization, project, or user will be persisted**. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,145 +1,26 @@ | ||
| import re | ||
| import secrets | ||
| from fastapi import APIRouter, Depends | ||
|
|
||
| from fastapi import APIRouter, HTTPException, Depends | ||
| from pydantic import BaseModel, EmailStr, model_validator, field_validator | ||
| from sqlmodel import Session | ||
|
|
||
| from app.crud import ( | ||
| create_organization, | ||
| get_organization_by_name, | ||
| create_project, | ||
| create_user, | ||
| create_api_key, | ||
| get_api_key_by_project_user, | ||
| ) | ||
| from app.models import ( | ||
| OrganizationCreate, | ||
| ProjectCreate, | ||
| UserCreate, | ||
| Project, | ||
| User, | ||
| APIKey, | ||
| ) | ||
| from app.api.deps import ( | ||
| SessionDep, | ||
| get_current_active_superuser, | ||
| ) | ||
| from app.crud import onboard_project | ||
| from app.models import OnboardingRequest, OnboardingResponse, User | ||
| from app.utils import APIResponse, load_description | ||
|
|
||
| router = APIRouter(tags=["onboarding"]) | ||
|
|
||
|
|
||
| # Pydantic models for input validation | ||
| class OnboardingRequest(BaseModel): | ||
| organization_name: str | ||
| project_name: str | ||
| email: EmailStr | None = None | ||
| password: str | None = None | ||
| user_name: str | None = None | ||
|
|
||
| @staticmethod | ||
| def _clean_username(raw: str, max_len: int = 200) -> str: | ||
| """ | ||
| Normalize a string into a safe username that can also be used | ||
| as the local part of an email address. | ||
| """ | ||
| username = re.sub(r"[^A-Za-z0-9._]", "_", raw.strip().lower()) | ||
| username = re.sub(r"[._]{2,}", "_", username) # collapse repeats | ||
| username = username.strip("._") # remove leading/trailing | ||
| return username[:max_len] | ||
|
|
||
| @model_validator(mode="after") | ||
| def set_defaults(self): | ||
| if self.user_name is None: | ||
| self.user_name = self.project_name + " User" | ||
|
|
||
| if self.email is None: | ||
| local_part = self._clean_username(self.user_name, max_len=200) | ||
| suffix = secrets.token_hex(3) | ||
| self.email = f"{local_part}.{suffix}@kaapi.org" | ||
|
|
||
| if self.password is None: | ||
| self.password = secrets.token_urlsafe(12) | ||
| return self | ||
|
|
||
|
|
||
| class OnboardingResponse(BaseModel): | ||
| organization_id: int | ||
| project_id: int | ||
| user_id: int | ||
| api_key: str | ||
|
|
||
|
|
||
| @router.post( | ||
| "/onboard", | ||
| dependencies=[Depends(get_current_active_superuser)], | ||
| response_model=OnboardingResponse, | ||
| response_model=APIResponse[OnboardingResponse], | ||
| status_code=201, | ||
| description=load_description("onboarding/onboarding.md"), | ||
avirajsingh7 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ) | ||
| def onboard_user(request: OnboardingRequest, session: SessionDep): | ||
| """ | ||
| Handles quick onboarding of a new user: Accepts Organization name, project name, email, password, and user name, then gives back an API key which | ||
| will be further used for authentication. | ||
| """ | ||
| # Validate organization | ||
| existing_organization = get_organization_by_name( | ||
| session=session, name=request.organization_name | ||
| ) | ||
| if existing_organization: | ||
| organization = existing_organization | ||
| else: | ||
| org_create = OrganizationCreate(name=request.organization_name) | ||
| organization = create_organization(session=session, org_create=org_create) | ||
|
|
||
| # Validate project | ||
| existing_project = ( | ||
| session.query(Project).filter(Project.name == request.project_name).first() | ||
| ) | ||
| if existing_project: | ||
| project = existing_project # Use the existing project | ||
| else: | ||
| project_create = ProjectCreate( | ||
| name=request.project_name, organization_id=organization.id | ||
| ) | ||
| project = create_project(session=session, project_create=project_create) | ||
|
|
||
| # Validate user | ||
| existing_user = session.query(User).filter(User.email == request.email).first() | ||
| if existing_user: | ||
| user = existing_user | ||
| else: | ||
| user_create = UserCreate( | ||
| full_name=request.user_name, | ||
| email=request.email, | ||
| password=request.password, | ||
| ) | ||
| user = create_user(session=session, user_create=user_create) | ||
|
|
||
| # Check if API key exists for the user and project | ||
| existing_key = get_api_key_by_project_user( | ||
| session=session, user_id=user.id, project_id=project.id | ||
| ) | ||
| if existing_key: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail="API key already exists for this user and project.", | ||
| ) | ||
|
|
||
| # Create API key | ||
| api_key_public = create_api_key( | ||
| session=session, | ||
| organization_id=organization.id, | ||
| user_id=user.id, | ||
| project_id=project.id, | ||
| ) | ||
|
|
||
| # Set user as non-superuser and save to session | ||
| user.is_superuser = False | ||
| session.add(user) | ||
| session.commit() | ||
|
|
||
| return OnboardingResponse( | ||
| organization_id=organization.id, | ||
| project_id=project.id, | ||
| user_id=user.id, | ||
| api_key=api_key_public.key, | ||
| ) | ||
| def onboard_project_route( | ||
| onboard_in: OnboardingRequest, | ||
| session: SessionDep, | ||
| current_user: User = Depends(get_current_active_superuser), | ||
| ): | ||
| response = onboard_project(session=session, onboard_in=onboard_in) | ||
| return APIResponse.success_response(data=response) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import logging | ||
| from fastapi import HTTPException | ||
| from sqlmodel import Session | ||
|
|
||
| from app.core.security import encrypt_api_key, encrypt_credentials, get_password_hash | ||
| from app.crud import ( | ||
| generate_api_key, | ||
| get_organization_by_name, | ||
| get_project_by_name, | ||
| get_user_by_email, | ||
| ) | ||
| from app.models import ( | ||
| APIKey, | ||
| Credential, | ||
| OnboardingRequest, | ||
| OnboardingResponse, | ||
| Organization, | ||
| OrganizationCreate, | ||
| Project, | ||
| ProjectCreate, | ||
| User, | ||
| UserCreate, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def onboard_project( | ||
| session: Session, onboard_in: OnboardingRequest | ||
| ) -> OnboardingResponse: | ||
| """ | ||
| Create or link resources for onboarding. | ||
|
|
||
| - Organization: | ||
| - Create new if `organization_name` does not exist. | ||
| - Otherwise, attach project to existing organization. | ||
|
|
||
| - Project: | ||
| - Create if `project_name` does not exist in org. | ||
| - If already exists, return 409 Conflict. | ||
|
|
||
| - User: | ||
| - Create and link if `email` does not exist. | ||
| - If exists, attach to project. | ||
|
Comment on lines
+42
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mention create user with username, email and generated password as those are also added |
||
|
|
||
| - OpenAI API Key (optional): | ||
| - If provided, encrypted and stored as project credentials. | ||
| - If omitted, project is created without OpenAI credentials. | ||
| """ | ||
| existing_organization = get_organization_by_name( | ||
| session=session, name=onboard_in.organization_name | ||
| ) | ||
| if existing_organization: | ||
| organization = existing_organization | ||
| else: | ||
| org_create = OrganizationCreate(name=onboard_in.organization_name) | ||
| organization = Organization.model_validate(org_create) | ||
| session.add(organization) | ||
| session.flush() | ||
|
|
||
| project = get_project_by_name( | ||
| session=session, | ||
| project_name=onboard_in.project_name, | ||
| organization_id=organization.id, | ||
| ) | ||
| if project: | ||
| raise HTTPException( | ||
| status_code=409, | ||
| detail=f"Project already exists for organization '{organization.name}'", | ||
| ) | ||
avirajsingh7 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| project_create = ProjectCreate( | ||
| name=onboard_in.project_name, organization_id=organization.id | ||
| ) | ||
| project = Project.model_validate(project_create) | ||
| session.add(project) | ||
| session.flush() | ||
|
|
||
| user = get_user_by_email(session=session, email=onboard_in.email) | ||
| if not user: | ||
| user_create = UserCreate( | ||
| email=onboard_in.email, | ||
| full_name=onboard_in.user_name, | ||
| password=onboard_in.password, | ||
| ) | ||
| user = User.model_validate( | ||
| user_create, | ||
| update={"hashed_password": get_password_hash(user_create.password)}, | ||
| ) | ||
| session.add(user) | ||
| session.flush() | ||
|
|
||
| raw_key, _ = generate_api_key() | ||
| encrypted_key = encrypt_api_key(raw_key) | ||
|
|
||
| api_key = APIKey( | ||
| key=encrypted_key, # Store the encrypted raw key | ||
| organization_id=organization.id, | ||
avirajsingh7 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| user_id=user.id, | ||
| project_id=project.id, | ||
| ) | ||
| session.add(api_key) | ||
|
|
||
| credential = None | ||
| if onboard_in.openai_api_key: | ||
| creds = {"api_key": onboard_in.openai_api_key} | ||
| encrypted_credentials = encrypt_credentials(creds) | ||
| credential = Credential( | ||
| organization_id=organization.id, | ||
| project_id=project.id, | ||
| is_active=True, | ||
| provider="openai", | ||
| credential=encrypted_credentials, | ||
| ) | ||
| session.add(credential) | ||
|
|
||
| session.commit() | ||
|
|
||
| openai_creds_id = credential.id if credential else None | ||
|
|
||
| logger.info( | ||
| "[onboard_project] Onboarding completed successfully. " | ||
| f"org_id={organization.id}, project_id={project.id}, user_id={user.id}, " | ||
| f"openai_creds_id={openai_creds_id}" | ||
| ) | ||
| return OnboardingResponse( | ||
| organization_id=organization.id, | ||
| organization_name=organization.name, | ||
| project_id=project.id, | ||
| project_name=project.name, | ||
| user_id=user.id, | ||
| user_email=user.email, | ||
| api_key=raw_key, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.