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
29 changes: 29 additions & 0 deletions backend/app/api/docs/onboarding/onboarding.md
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**.

---

## 🔄 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**.
147 changes: 14 additions & 133 deletions backend/app/api/routes/onboarding.py
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"),
)
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)
4 changes: 4 additions & 0 deletions backend/app/crud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@
from .project import (
create_project,
get_project_by_id,
get_project_by_name,
get_projects_by_organization,
validate_project,
)

from .api_key import (
create_api_key,
generate_api_key,
get_api_key,
get_api_key_by_value,
get_api_keys_by_project,
Expand Down Expand Up @@ -64,3 +66,5 @@
create_conversation,
delete_conversation,
)

from .onboarding import onboard_project
134 changes: 134 additions & 0 deletions backend/app/crud/onboarding.py
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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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}'",
)

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,
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,
)
9 changes: 9 additions & 0 deletions backend/app/crud/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ def get_project_by_id(*, session: Session, project_id: int) -> Optional[Project]
return session.exec(statement).first()


def get_project_by_name(
*, session: Session, project_name: str, organization_id: int
) -> Optional[Project]:
statement = select(Project).where(
Project.name == project_name, Project.organization_id == organization_id
)
return session.exec(statement).first()


def get_projects_by_organization(*, session: Session, org_id: int) -> List[Project]:
statement = select(Project).where(Project.organization_id == org_id)
return session.exec(statement).all()
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,5 @@
OpenAIConversationBase,
OpenAIConversationCreate,
)

from .onboarding import OnboardingRequest, OnboardingResponse
Loading