feat(backend): develop post /api/organizations api (org project) (#12263)

Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Chuck Butkus <chuck@all-hands.dev>
This commit is contained in:
Hiep Le
2026-01-21 15:14:27 +07:00
committed by GitHub
parent 3bc56740b9
commit 030ff59c40
16 changed files with 2399 additions and 25 deletions

View File

@@ -77,6 +77,15 @@ class SaasUserAuth(UserAuth):
self.access_token = SecretStr(tokens['access_token'])
self.refresh_token = SecretStr(tokens['refresh_token'])
self.refreshed = True
if not self.email or not self.email_verified or not self.user_id:
# We don't need to verify the signature here because we just refreshed
# this token from the IDP via token_manager.refresh()
access_token_payload = jwt.decode(
tokens['access_token'], options={'verify_signature': False}
)
self.user_id = access_token_payload['sub']
self.email = access_token_payload['email']
self.email_verified = access_token_payload['email_verified']
def _is_token_expired(self, token: SecretStr):
logger.debug('saas_user_auth_is_token_expired')
@@ -273,11 +282,13 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
if not user_id:
return None
offline_token = await token_manager.load_offline_token(user_id)
return SaasUserAuth(
saas_user_auth = SaasUserAuth(
user_id=user_id,
refresh_token=SecretStr(offline_token),
auth_type=AuthType.BEARER,
)
await saas_user_auth.refresh()
return saas_user_auth
except Exception as exc:
raise BearerTokenError from exc

View File

@@ -0,0 +1,68 @@
"""
Email domain validation utilities for enterprise endpoints.
"""
from fastapi import Depends, HTTPException, Request, status
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_auth, get_user_id
async def get_admin_user_id(
request: Request, user_id: str | None = Depends(get_user_id)
) -> str:
"""
Dependency that validates user has @openhands.dev email domain.
This dependency can be used in place of get_user_id for endpoints that
should only be accessible to admin users. Currently, this is implemented
by checking for @openhands.dev email domain.
TODO: In the future, this should be replaced with an explicit is_admin flag
in user/org settings instead of relying on email domain validation.
Args:
request: FastAPI request object
user_id: User ID from get_user_id dependency
Returns:
str: User ID if email domain is valid
Raises:
HTTPException: 403 if email domain is not @openhands.dev
HTTPException: 401 if user is not authenticated
Example:
@router.post('/endpoint')
async def create_resource(
user_id: str = Depends(get_admin_user_id),
):
# Only admin users can access this endpoint
pass
"""
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
user_auth = await get_user_auth(request)
user_email = await user_auth.get_user_email()
if not user_email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User email not available',
)
if not user_email.endswith('@openhands.dev'):
logger.warning(
'Access denied - invalid email domain',
extra={'user_id': user_id, 'email_domain': user_email.split('@')[-1]},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Access restricted to @openhands.dev users',
)
return user_id

View File

@@ -0,0 +1,67 @@
from pydantic import BaseModel, EmailStr, Field
class OrgCreationError(Exception):
"""Base exception for organization creation errors."""
pass
class OrgNameExistsError(OrgCreationError):
"""Raised when an organization name already exists."""
def __init__(self, name: str):
self.name = name
super().__init__(f'Organization with name "{name}" already exists')
class LiteLLMIntegrationError(OrgCreationError):
"""Raised when LiteLLM integration fails."""
pass
class OrgDatabaseError(OrgCreationError):
"""Raised when database operations fail."""
pass
class OrgCreate(BaseModel):
"""Request model for creating a new organization."""
# Required fields
name: str = Field(min_length=1, max_length=255, strip_whitespace=True)
contact_name: str
contact_email: EmailStr = Field(strip_whitespace=True)
class OrgResponse(BaseModel):
"""Response model for organization."""
id: str
name: str
contact_name: str
contact_email: str
conversation_expiration: int | None = None
agent: str | None = None
default_max_iterations: int | None = None
security_analyzer: str | None = None
confirmation_mode: bool | None = None
default_llm_model: str | None = None
default_llm_api_key_for_byor: str | None = None
default_llm_base_url: str | None = None
remote_runtime_resource_factor: int | None = None
enable_default_condenser: bool = True
billing_margin: float | None = None
enable_proactive_conversation_starters: bool = True
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
org_version: int = 0
mcp_config: dict | None = None
search_api_key: str | None = None
sandbox_api_key: str | None = None
max_budget_per_task: float | None = None
enable_solvability_analysis: bool | None = None
v1_enabled: bool | None = None
credits: float | None = None

View File

@@ -0,0 +1,117 @@
from fastapi import APIRouter, Depends, HTTPException, status
from server.email_validation import get_admin_user_id
from server.routes.org_models import (
LiteLLMIntegrationError,
OrgCreate,
OrgDatabaseError,
OrgNameExistsError,
OrgResponse,
)
from storage.org_service import OrgService
from openhands.core.logger import openhands_logger as logger
# Initialize API router
org_router = APIRouter(prefix='/api/organizations')
@org_router.post('', response_model=OrgResponse, status_code=status.HTTP_201_CREATED)
async def create_org(
org_data: OrgCreate,
user_id: str = Depends(get_admin_user_id),
) -> OrgResponse:
"""Create a new organization.
This endpoint allows authenticated users with @openhands.dev email to create
a new organization. The user who creates the organization automatically becomes
its owner.
Args:
org_data: Organization creation data
user_id: Authenticated user ID (injected by dependency)
Returns:
OrgResponse: The created organization details
Raises:
HTTPException: 403 if user email domain is not @openhands.dev
HTTPException: 409 if organization name already exists
HTTPException: 500 if creation fails
"""
logger.info(
'Creating new organization',
extra={
'user_id': user_id,
'org_name': org_data.name,
},
)
try:
# Use service layer to create organization
org = await OrgService.create_org_with_owner(
name=org_data.name,
contact_name=org_data.contact_name,
contact_email=org_data.contact_email,
user_id=user_id,
)
# Retrieve credits from LiteLLM
credits = await OrgService.get_org_credits(user_id, org.id)
return OrgResponse(
id=str(org.id),
name=org.name,
contact_name=org.contact_name,
contact_email=org.contact_email,
conversation_expiration=org.conversation_expiration,
agent=org.agent,
default_max_iterations=org.default_max_iterations,
security_analyzer=org.security_analyzer,
confirmation_mode=org.confirmation_mode,
default_llm_model=org.default_llm_model,
default_llm_base_url=org.default_llm_base_url,
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
enable_default_condenser=org.enable_default_condenser,
billing_margin=org.billing_margin,
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters,
sandbox_base_container_image=org.sandbox_base_container_image,
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
org_version=org.org_version,
mcp_config=org.mcp_config,
max_budget_per_task=org.max_budget_per_task,
enable_solvability_analysis=org.enable_solvability_analysis,
v1_enabled=org.v1_enabled,
credits=credits,
)
except OrgNameExistsError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
except LiteLLMIntegrationError as e:
logger.error(
'LiteLLM integration failed',
extra={'user_id': user_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create LiteLLM integration',
)
except OrgDatabaseError as e:
logger.error(
'Database operation failed',
extra={'user_id': user_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create organization',
)
except Exception as e:
logger.exception(
'Unexpected error creating organization',
extra={'user_id': user_id, 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)