Skip to content
Merged
168 changes: 95 additions & 73 deletions backend/app/api/routes/credentials.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from typing import List

from fastapi import APIRouter, Depends

from app.api.deps import SessionDep, get_current_active_superuser
from app.api.deps import SessionDep, get_current_user_org_project
from app.crud.credentials import (
get_creds_by_org,
get_provider_credential,
Expand All @@ -11,10 +9,7 @@
update_creds_for_org,
remove_provider_credential,
)
from app.crud import validate_organization, validate_project
from app.models import CredsCreate, CredsPublic, CredsUpdate
from app.models.organization import Organization
from app.models.project import Project
from app.models import CredsCreate, CredsPublic, CredsUpdate, UserProjectOrg
from app.utils import APIResponse
from app.core.providers import validate_provider
from app.core.exception_handlers import HTTPException
Expand All @@ -24,31 +19,25 @@

@router.post(
"/",
dependencies=[Depends(get_current_active_superuser)],
response_model=APIResponse[List[CredsPublic]],
summary="Create new credentials for an organization and project",
description="Creates new credentials for a specific organization and project combination. This endpoint requires superuser privileges. Each organization can have different credentials for different providers and projects. Only one credential per provider is allowed per organization-project combination.",
response_model=APIResponse[list[CredsPublic]],
summary="Create new credentials for the current organization and project",
description="Creates new credentials for the caller's organization and project. Each organization can have different credentials for different providers and projects. Only one credential per provider is allowed per organization-project combination.",
)
def create_new_credential(*, session: SessionDep, creds_in: CredsCreate):
# Validate organization
validate_organization(session, creds_in.organization_id)

# Validate project if provided
if creds_in.project_id:
project = validate_project(session, creds_in.project_id)
if project.organization_id != creds_in.organization_id:
raise HTTPException(
status_code=400,
detail="Project does not belong to the specified organization",
)
def create_new_credential(
*,
session: SessionDep,
creds_in: CredsCreate,
_current_user: UserProjectOrg = Depends(get_current_user_org_project),
):
# Project comes from API key context; no cross-org check needed here

# Prevent duplicate credentials
for provider in creds_in.credential.keys():
existing_cred = get_provider_credential(
session=session,
org_id=creds_in.organization_id,
org_id=_current_user.organization_id,
provider=provider,
project_id=creds_in.project_id,
project_id=_current_user.project_id,
)
Comment on lines 35 to 41
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Prevent NoneType error when creds_in.credential is missing.

Accessing keys on None will raise at runtime. Validate before the loop.

Apply:

   # Prevent duplicate credentials
-  for provider in creds_in.credential.keys():
+  if not creds_in.credential:
+      raise HTTPException(status_code=400, detail="No credentials provided")
+  for provider in creds_in.credential.keys():
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for provider in creds_in.credential.keys():
existing_cred = get_provider_credential(
session=session,
org_id=creds_in.organization_id,
org_id=_current_user.organization_id,
provider=provider,
project_id=creds_in.project_id,
project_id=_current_user.project_id,
)
# Prevent duplicate credentials
if not creds_in.credential:
raise HTTPException(status_code=400, detail="No credentials provided")
for provider in creds_in.credential.keys():
existing_cred = get_provider_credential(
session=session,
org_id=_current_user.organization_id,
provider=provider,
project_id=_current_user.project_id,
)
🤖 Prompt for AI Agents
In backend/app/api/routes/credentials.py around lines 41 to 47, the code loops
over creds_in.credential without validating it, which will raise an
AttributeError if creds_in.credential is None; add a guard before the loop to
verify creds_in.credential is present and is a mapping (e.g. if not
creds_in.credential or not isinstance(creds_in.credential, dict): raise
HTTPException(status_code=400, detail="Missing or invalid credential payload")
or alternatively treat missing as empty dict and skip processing), then proceed
with the for loop; ensure any raised HTTPException is imported from fastapi and
tests/clients expect a 400 for missing credentials.

if existing_cred:
raise HTTPException(
Expand All @@ -60,95 +49,121 @@ def create_new_credential(*, session: SessionDep, creds_in: CredsCreate):
)

# Create credentials
new_creds = set_creds_for_org(session=session, creds_add=creds_in)
if not new_creds:
created_creds = set_creds_for_org(
session=session,
creds_add=creds_in,
organization_id=_current_user.organization_id,
project_id=_current_user.project_id,
)
if not created_creds:
raise Exception(status_code=500, detail="Failed to create credentials")

return APIResponse.success_response([cred.to_public() for cred in new_creds])
return APIResponse.success_response([cred.to_public() for cred in created_creds])


@router.get(
"/{org_id}",
dependencies=[Depends(get_current_active_superuser)],
response_model=APIResponse[List[CredsPublic]],
summary="Get all credentials for an organization and project",
description="Retrieves all provider credentials associated with a specific organization and project combination. If project_id is not provided, returns credentials for the organization level. This endpoint requires superuser privileges.",
"/",
response_model=APIResponse[list[CredsPublic]],
summary="Get all credentials for current org and project",
description="Retrieves all provider credentials associated with the caller's organization and project.",
)
def read_credential(*, session: SessionDep, org_id: int, project_id: int | None = None):
creds = get_creds_by_org(session=session, org_id=org_id, project_id=project_id)
def read_credential(
*,
session: SessionDep,
_current_user: UserProjectOrg = Depends(get_current_user_org_project),
):
creds = get_creds_by_org(
session=session,
org_id=_current_user.organization_id,
project_id=_current_user.project_id,
)
if not creds:
raise HTTPException(status_code=404, detail="Credentials not found")

return APIResponse.success_response([cred.to_public() for cred in creds])


@router.get(
"/{org_id}/{provider}",
dependencies=[Depends(get_current_active_superuser)],
"/provider/{provider}",
response_model=APIResponse[dict],
summary="Get specific provider credentials for an organization and project",
description="Retrieves credentials for a specific provider (e.g., 'openai', 'anthropic') for a given organization and project combination. If project_id is not provided, returns organization-level credentials. This endpoint requires superuser privileges.",
summary="Get specific provider credentials for current org and project",
description="Retrieves credentials for a specific provider (e.g., 'openai', 'anthropic') for the caller's organization and project.",
)
def read_provider_credential(
*, session: SessionDep, org_id: int, provider: str, project_id: int | None = None
*,
session: SessionDep,
provider: str,
_current_user: UserProjectOrg = Depends(get_current_user_org_project),
):
provider_enum = validate_provider(provider)
provider_creds = get_provider_credential(
credential = get_provider_credential(
session=session,
org_id=org_id,
org_id=_current_user.organization_id,
provider=provider_enum,
project_id=project_id,
project_id=_current_user.project_id,
)
if provider_creds is None:
if credential is None:
raise HTTPException(status_code=404, detail="Provider credentials not found")

return APIResponse.success_response(provider_creds)
return APIResponse.success_response(credential)


@router.patch(
"/{org_id}",
dependencies=[Depends(get_current_active_superuser)],
response_model=APIResponse[List[CredsPublic]],
summary="Update organization and project credentials",
description="Updates credentials for a specific organization and project combination. Can update specific provider credentials or add new providers. If project_id is provided in the update, credentials will be moved to that project. This endpoint requires superuser privileges.",
"/",
response_model=APIResponse[list[CredsPublic]],
summary="Update credentials for current org and project",
description="Updates credentials for a specific provider of the caller's organization and project.",
)
def update_credential(*, session: SessionDep, org_id: int, creds_in: CredsUpdate):
validate_organization(session, org_id)
def update_credential(
*,
session: SessionDep,
creds_in: CredsUpdate,
_current_user: UserProjectOrg = Depends(get_current_user_org_project),
):
if not creds_in or not creds_in.provider or not creds_in.credential:
raise HTTPException(
status_code=400, detail="Provider and credential must be provided"
)

updated_creds = update_creds_for_org(
session=session, org_id=org_id, creds_in=creds_in
# Pass project_id directly to the CRUD function since CredsUpdate no longer has this field
updated_credential = update_creds_for_org(
session=session,
org_id=_current_user.organization_id,
creds_in=creds_in,
project_id=_current_user.project_id,
)

return APIResponse.success_response([cred.to_public() for cred in updated_creds])
return APIResponse.success_response(
[cred.to_public() for cred in updated_credential]
)


@router.delete(
"/{org_id}/{provider}",
dependencies=[Depends(get_current_active_superuser)],
"/provider/{provider}",
response_model=APIResponse[dict],
summary="Delete specific provider credentials for an organization and project",
summary="Delete specific provider credentials for current org and project",
)
def delete_provider_credential(
*, session: SessionDep, org_id: int, provider: str, project_id: int | None = None
*,
session: SessionDep,
provider: str,
_current_user: UserProjectOrg = Depends(get_current_user_org_project),
):
provider_enum = validate_provider(provider)
if not provider_enum:
raise HTTPException(status_code=400, detail="Invalid provider")
provider_creds = get_provider_credential(
session=session,
org_id=org_id,
org_id=_current_user.organization_id,
provider=provider_enum,
project_id=project_id,
project_id=_current_user.project_id,
)
if provider_creds is None:
raise HTTPException(status_code=404, detail="Provider credentials not found")

updated_creds = remove_provider_credential(
session=session, org_id=org_id, provider=provider_enum, project_id=project_id
remove_provider_credential(
session=session,
org_id=_current_user.organization_id,
provider=provider_enum,
project_id=_current_user.project_id,
)

return APIResponse.success_response(
Expand All @@ -157,19 +172,26 @@ def delete_provider_credential(


@router.delete(
"/{org_id}",
dependencies=[Depends(get_current_active_superuser)],
"/",
response_model=APIResponse[dict],
summary="Delete all credentials for an organization and project",
description="Removes all credentials for a specific organization and project combination. If project_id is provided, only removes credentials for that project. This is a soft delete operation that marks credentials as inactive. This endpoint requires superuser privileges.",
summary="Delete all credentials for current org and project",
description="Removes all credentials for the caller's organization and project. This is a soft delete operation that marks credentials as inactive.",
)
def delete_all_credentials(
*, session: SessionDep, org_id: int, project_id: int | None = None
*,
session: SessionDep,
_current_user: UserProjectOrg = Depends(get_current_user_org_project),
):
creds = remove_creds_for_org(session=session, org_id=org_id, project_id=project_id)
creds = remove_creds_for_org(
session=session,
org_id=_current_user.organization_id,
project_id=_current_user.project_id,
)
if not creds:
raise HTTPException(
status_code=404, detail="Credentials for organization not found"
status_code=404, detail="Credentials for organization/project not found"
)

return APIResponse.success_response({"message": "Credentials deleted successfully"})
return APIResponse.success_response(
{"message": "All credentials deleted successfully"}
)
18 changes: 11 additions & 7 deletions backend/app/crud/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
from app.core.exception_handlers import HTTPException


def set_creds_for_org(*, session: Session, creds_add: CredsCreate) -> List[Credential]:
def set_creds_for_org(
*, session: Session, creds_add: CredsCreate, organization_id: int, project_id: int
) -> List[Credential]:
"""Set credentials for an organization. Creates a separate row for each provider."""
created_credentials = []

Expand All @@ -31,8 +33,8 @@ def set_creds_for_org(*, session: Session, creds_add: CredsCreate) -> List[Crede

# Create a row for each provider
credential = Credential(
organization_id=creds_add.organization_id,
project_id=creds_add.project_id,
organization_id=organization_id,
project_id=project_id,
is_active=creds_add.is_active,
provider=provider,
credential=encrypted_credentials,
Expand Down Expand Up @@ -127,7 +129,11 @@ def get_providers(


def update_creds_for_org(
*, session: Session, org_id: int, creds_in: CredsUpdate
*,
session: Session,
org_id: int,
creds_in: CredsUpdate,
project_id: Optional[int] = None,
) -> List[Credential]:
"""Updates credentials for a specific provider of an organization."""
if not creds_in.provider or not creds_in.credential:
Expand All @@ -143,9 +149,7 @@ def update_creds_for_org(
Credential.organization_id == org_id,
Credential.provider == creds_in.provider,
Credential.is_active == True,
Credential.project_id == creds_in.project_id
if creds_in.project_id is not None
else True,
Credential.project_id == project_id if project_id is not None else True,
)
creds = session.exec(statement).first()
if creds is None:
Expand Down
6 changes: 2 additions & 4 deletions backend/app/models/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ class CredsBase(SQLModel):
is_active: bool = True


class CredsCreate(CredsBase):
class CredsCreate(SQLModel):
"""Create new credentials for an organization.
The credential field should be a dictionary mapping provider names to their credentials.
Example: {"openai": {"api_key": "..."}, "langfuse": {"public_key": "..."}}
"""

is_active: bool = True
credential: Dict[str, Any] = Field(
default=None,
description="Dictionary mapping provider names to their credentials",
Expand All @@ -42,9 +43,6 @@ class CredsUpdate(SQLModel):
is_active: Optional[bool] = Field(
default=None, description="Whether the credentials are active"
)
project_id: Optional[int] = Field(
default=None, description="Project ID to associate with these credentials"
)


class Credential(CredsBase, table=True):
Expand Down
Loading