diff --git a/backend/app/api/docs/credentials/delete_all_by_org_project.md b/backend/app/api/docs/credentials/delete_all_by_org_project.md new file mode 100644 index 00000000..15f8e9cc --- /dev/null +++ b/backend/app/api/docs/credentials/delete_all_by_org_project.md @@ -0,0 +1,7 @@ +Delete all credentials for a specific organization and project. + +Permanently removes all provider credentials associated with the specified organization and project IDs. Requires superuser access. + +### Path Parameters: +- **org_id**: Organization ID +- **project_id**: Project ID diff --git a/backend/app/api/docs/credentials/delete_provider_by_org_project.md b/backend/app/api/docs/credentials/delete_provider_by_org_project.md new file mode 100644 index 00000000..ba69b89b --- /dev/null +++ b/backend/app/api/docs/credentials/delete_provider_by_org_project.md @@ -0,0 +1,8 @@ +Delete credentials for a specific provider within an organization and project. + +Permanently removes credentials for a specific provider from the specified organization and project. Requires superuser access. + +### Path Parameters: +- **org_id**: Organization ID +- **project_id**: Project ID +- **provider**: Provider name (e.g., `openai`, `langfuse`, `google`, `sarvamai`, `elevenlabs`) diff --git a/backend/app/api/docs/credentials/get_provider.md b/backend/app/api/docs/credentials/get_provider.md index 2f3a7692..c7ec981c 100644 --- a/backend/app/api/docs/credentials/get_provider.md +++ b/backend/app/api/docs/credentials/get_provider.md @@ -1,3 +1,3 @@ Get credentials for a specific provider. -Retrieves decrypted credentials for a specific provider (e.g., `openai`, `langfuse`) for the current organization and project. +Retrieves credentials for a specific provider (e.g., `openai`, `langfuse`) for the current organization and project. Sensitive fields (e.g., `api_key`, `secret_key`) are masked in the response. If credentials for the provider are not configured, `null` is returned. diff --git a/backend/app/api/docs/credentials/get_provider_by_org_project.md b/backend/app/api/docs/credentials/get_provider_by_org_project.md new file mode 100644 index 00000000..accb96fa --- /dev/null +++ b/backend/app/api/docs/credentials/get_provider_by_org_project.md @@ -0,0 +1,8 @@ +Get credentials for a specific provider within an organization and project. + +Retrieves credentials for a specific provider (e.g., `openai`, `langfuse`) for the specified organization and project. Sensitive fields (e.g., `api_key`, `secret_key`) are masked in the response. If credentials for the provider are not configured, `null` is returned. Requires superuser access. + +### Path Parameters: +- **org_id**: Organization ID +- **project_id**: Project ID +- **provider**: Provider name (e.g., `openai`, `langfuse`, `google`, `sarvamai`, `elevenlabs`) diff --git a/backend/app/api/docs/credentials/list.md b/backend/app/api/docs/credentials/list.md index c660229b..ff061266 100644 --- a/backend/app/api/docs/credentials/list.md +++ b/backend/app/api/docs/credentials/list.md @@ -1,3 +1,3 @@ Get all credentials for current organization and project. -Returns list of all provider credentials associated with your organization and project. +Returns a list of all provider credentials associated with your organization and project. Sensitive fields (e.g., `api_key`, `secret_key`) are masked in the response. If no credentials are configured, an empty list is returned. diff --git a/backend/app/api/docs/credentials/list_by_org_project.md b/backend/app/api/docs/credentials/list_by_org_project.md new file mode 100644 index 00000000..12dad77e --- /dev/null +++ b/backend/app/api/docs/credentials/list_by_org_project.md @@ -0,0 +1,12 @@ +Get all credentials for a specific organization and project. + +Retrieves all provider credentials associated with the specified organization and project IDs. Sensitive fields (e.g., `api_key`, `secret_key`) are masked in the response. If no credentials are configured, an empty list is returned. Requires superuser access. + +### Path Parameters: +- **org_id**: Organization ID +- **project_id**: Project ID + +### Supported Providers: +- **LLM:** openai, sarvamai, google(gemini) +- **Observability:** langfuse +- **Audio:** elevenlabs diff --git a/backend/app/api/docs/credentials/update.md b/backend/app/api/docs/credentials/update.md index 0377f0e4..cf08360d 100644 --- a/backend/app/api/docs/credentials/update.md +++ b/backend/app/api/docs/credentials/update.md @@ -1,3 +1,34 @@ Update credentials for a specific provider. -Updates existing provider credentials for the current organization and project. Provider and credential fields must be provided. +Updates existing provider credentials for the current organization and project. If the credentials for the specified provider don't exist yet, they will be **created** automatically (upsert behavior). The `provider` and `credential` fields are required. + +The `credential` field accepts **two formats** (both work the same): + +### Nested format (same as create endpoint): +```json +{ + "provider": "openai", + "is_active": true, + "credential": { + "openai": { + "api_key": "sk-proj-..." + } + } +} +``` + +### Flat format: +```json +{ + "provider": "openai", + "is_active": true, + "credential": { + "api_key": "sk-proj-..." + } +} +``` + +### Supported Providers: +- **LLM:** openai, sarvamai, google(gemini) +- **Observability:** langfuse +- **Audio:** elevenlabs diff --git a/backend/app/api/docs/credentials/update_by_org_project.md b/backend/app/api/docs/credentials/update_by_org_project.md new file mode 100644 index 00000000..c010871d --- /dev/null +++ b/backend/app/api/docs/credentials/update_by_org_project.md @@ -0,0 +1,38 @@ +Update credentials for a specific provider within an organization and project. + +Updates existing provider credentials for the specified organization and project. If the credentials for the specified provider don't exist yet, they will be **created** automatically (upsert behavior). Requires superuser access. + +### Path Parameters: +- **org_id**: Organization ID +- **project_id**: Project ID + +The `credential` field accepts **two formats** (both work the same): + +### Nested format (same as create endpoint): +```json +{ + "provider": "openai", + "is_active": true, + "credential": { + "openai": { + "api_key": "sk-proj-..." + } + } +} +``` + +### Flat format: +```json +{ + "provider": "openai", + "is_active": true, + "credential": { + "api_key": "sk-proj-..." + } +} +``` + +### Supported Providers: +- **LLM:** openai, sarvamai, google(gemini) +- **Observability:** langfuse +- **Audio:** elevenlabs diff --git a/backend/app/api/routes/credentials.py b/backend/app/api/routes/credentials.py index 8e1e94b4..92331ce0 100644 --- a/backend/app/api/routes/credentials.py +++ b/backend/app/api/routes/credentials.py @@ -5,7 +5,7 @@ from app.api.deps import AuthContextDep, SessionDep from app.api.permissions import Permission, require_permission from app.core.exception_handlers import HTTPException -from app.core.providers import validate_provider +from app.core.providers import mask_credential_fields, validate_provider from app.crud.credentials import ( get_creds_by_org, get_provider_credential, @@ -67,15 +67,13 @@ def read_credential( 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( "/provider/{provider}", - response_model=APIResponse[dict], + response_model=APIResponse[dict | None], description=load_description("credentials/get_provider.md"), dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], ) @@ -97,9 +95,11 @@ def read_provider_credential( project_id=_current_user.project_.id, ) if credential is None: - raise HTTPException(status_code=404, detail="Provider credentials not found") + return APIResponse.success_response(None) - return APIResponse.success_response(credential) + return APIResponse.success_response( + mask_credential_fields(provider_enum, credential) + ) @router.patch( @@ -184,3 +184,150 @@ def delete_all_credentials( return APIResponse.success_response( {"message": "All credentials deleted successfully"} ) + + +# --- Endpoints with explicit org_id and project_id path parameters --- + + +@router.get( + "/{org_id}/{project_id}", + response_model=APIResponse[list[CredsPublic]], + description=load_description("credentials/list_by_org_project.md"), + dependencies=[Depends(require_permission(Permission.SUPERUSER))], +) +def read_credentials_by_org_project( + *, + session: SessionDep, + org_id: int, + project_id: int, + _current_user: AuthContextDep, +): + creds = get_creds_by_org( + session=session, + org_id=org_id, + project_id=project_id, + ) + + return APIResponse.success_response([cred.to_public() for cred in creds]) + + +@router.get( + "/{org_id}/{project_id}/provider/{provider}", + response_model=APIResponse[dict | None], + description=load_description("credentials/get_provider_by_org_project.md"), + dependencies=[Depends(require_permission(Permission.SUPERUSER))], +) +def read_provider_credential_by_org_project( + *, + session: SessionDep, + org_id: int, + project_id: int, + provider: str, + _current_user: AuthContextDep, +): + try: + provider_enum = validate_provider(provider) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + credential = get_provider_credential( + session=session, + org_id=org_id, + provider=provider_enum, + project_id=project_id, + ) + if credential is None: + return APIResponse.success_response(None) + + return APIResponse.success_response( + mask_credential_fields(provider_enum, credential) + ) + + +@router.patch( + "/{org_id}/{project_id}", + response_model=APIResponse[list[CredsPublic]], + description=load_description("credentials/update_by_org_project.md"), + dependencies=[Depends(require_permission(Permission.SUPERUSER))], +) +def update_credential_by_org_project( + *, + session: SessionDep, + org_id: int, + project_id: int, + creds_in: CredsUpdate, + _current_user: AuthContextDep, +): + if not creds_in or not creds_in.provider or not creds_in.credential: + logger.error( + f"[update_credential_by_org_project] Invalid input | organization_id: {org_id}, project_id: {project_id}" + ) + raise HTTPException( + status_code=400, detail="Provider and credential must be provided" + ) + + updated_credential = update_creds_for_org( + session=session, + org_id=org_id, + creds_in=creds_in, + project_id=project_id, + ) + + return APIResponse.success_response( + [cred.to_public() for cred in updated_credential] + ) + + +@router.delete( + "/{org_id}/{project_id}/provider/{provider}", + response_model=APIResponse[dict], + description=load_description("credentials/delete_provider_by_org_project.md"), + dependencies=[Depends(require_permission(Permission.SUPERUSER))], +) +def delete_provider_credential_by_org_project( + *, + session: SessionDep, + org_id: int, + project_id: int, + provider: str, + _current_user: AuthContextDep, +): + try: + provider_enum = validate_provider(provider) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + remove_provider_credential( + session=session, + org_id=org_id, + provider=provider_enum, + project_id=project_id, + ) + + return APIResponse.success_response( + {"message": "Provider credentials removed successfully"} + ) + + +@router.delete( + "/{org_id}/{project_id}", + response_model=APIResponse[dict], + description=load_description("credentials/delete_all_by_org_project.md"), + dependencies=[Depends(require_permission(Permission.SUPERUSER))], +) +def delete_all_credentials_by_org_project( + *, + session: SessionDep, + org_id: int, + project_id: int, + _current_user: AuthContextDep, +): + remove_creds_for_org( + session=session, + org_id=org_id, + project_id=project_id, + ) + + return APIResponse.success_response( + {"message": "All credentials deleted successfully"} + ) diff --git a/backend/app/core/providers.py b/backend/app/core/providers.py index 7248ea1d..79399542 100644 --- a/backend/app/core/providers.py +++ b/backend/app/core/providers.py @@ -1,7 +1,7 @@ import logging -from typing import Dict, List +from typing import Any, Dict, List from enum import Enum -from dataclasses import dataclass +from dataclasses import dataclass, field logger = logging.getLogger(__name__) @@ -21,17 +21,27 @@ class ProviderConfig: """Configuration for a provider including its required credential fields.""" required_fields: List[str] + sensitive_fields: List[str] = field(default_factory=list) # Provider configurations PROVIDER_CONFIGS: Dict[Provider, ProviderConfig] = { - Provider.OPENAI: ProviderConfig(required_fields=["api_key"]), + Provider.OPENAI: ProviderConfig( + required_fields=["api_key"], sensitive_fields=["api_key"] + ), Provider.LANGFUSE: ProviderConfig( - required_fields=["secret_key", "public_key", "host"] + required_fields=["secret_key", "public_key", "host"], + sensitive_fields=["secret_key"], + ), + Provider.GOOGLE: ProviderConfig( + required_fields=["api_key"], sensitive_fields=["api_key"] + ), + Provider.SARVAMAI: ProviderConfig( + required_fields=["api_key"], sensitive_fields=["api_key"] + ), + Provider.ELEVENLABS: ProviderConfig( + required_fields=["api_key"], sensitive_fields=["api_key"] ), - Provider.GOOGLE: ProviderConfig(required_fields=["api_key"]), - Provider.SARVAMAI: ProviderConfig(required_fields=["api_key"]), - Provider.ELEVENLABS: ProviderConfig(required_fields=["api_key"]), } @@ -86,3 +96,29 @@ def validate_provider_credentials(provider: str, credentials: Dict[str, str]) -> def get_supported_providers() -> List[str]: """Return a list of all supported provider names.""" return [p.value for p in Provider] + + +def mask_credential_fields( + provider: str, credentials: Dict[str, Any] +) -> Dict[str, Any]: + """Mask sensitive fields in a credential dict for the given provider. + + Non-sensitive fields (e.g., langfuse `public_key`, `host`) are returned as-is. + Unknown providers are returned with no masking. + """ + from app.utils import mask_string + + if not credentials: + return credentials + + try: + provider_enum = Provider(provider.lower()) + except ValueError: + return credentials + + sensitive_fields = PROVIDER_CONFIGS[provider_enum].sensitive_fields + masked = dict(credentials) + for field_name in sensitive_fields: + if field_name in masked and isinstance(masked[field_name], str): + masked[field_name] = mask_string(masked[field_name]) + return masked diff --git a/backend/app/crud/credentials.py b/backend/app/crud/credentials.py index e6c1ded6..6853c455 100644 --- a/backend/app/crud/credentials.py +++ b/backend/app/crud/credentials.py @@ -184,8 +184,18 @@ def update_creds_for_org( if not creds_in.provider or not creds_in.credential: raise ValueError("Provider and credential must be provided") + # Auto-unwrap nested format: {"google": {"api_key": "..."}} -> {"api_key": "..."} + # so the same payload shape works for both create and update. + credential_data = creds_in.credential + if ( + isinstance(credential_data, dict) + and creds_in.provider in credential_data + and isinstance(credential_data[creds_in.provider], dict) + ): + credential_data = credential_data[creds_in.provider] + try: - validate_provider_credentials(creds_in.provider, creds_in.credential) + validate_provider_credentials(creds_in.provider, credential_data) except ValueError as e: logger.error( f"[update_creds_for_org] Validation error | organization_id: {org_id}, project_id: {project_id}, provider: {creds_in.provider}, error: {str(e)}" @@ -193,7 +203,7 @@ def update_creds_for_org( raise HTTPException(status_code=400, detail=str(e)) # Encrypt the entire credentials object - encrypted_credentials = encrypt_credentials(creds_in.credential) + encrypted_credentials = encrypt_credentials(credential_data) statement = select(Credential).where( Credential.organization_id == org_id, @@ -203,12 +213,23 @@ def update_creds_for_org( ) creds = session.exec(statement).one_or_none() if creds is None: - logger.error( - f"[update_creds_for_org] Credentials not found | organization {org_id}, provider {creds_in.provider}, project_id {project_id}" + # Create new credential if it doesn't exist + creds = Credential( + organization_id=org_id, + project_id=project_id, + is_active=creds_in.is_active if creds_in.is_active is not None else True, + provider=creds_in.provider, + credential=encrypted_credentials, + inserted_at=now(), + updated_at=now(), ) - raise HTTPException( - status_code=404, detail="Credentials not found for this provider" + session.add(creds) + session.commit() + session.refresh(creds) + logger.info( + f"[update_creds_for_org] Created new credentials | organization_id {org_id}, provider {creds_in.provider}, project_id {project_id}" ) + return [creds] creds.credential = encrypted_credentials creds.updated_at = now() diff --git a/backend/app/models/credentials.py b/backend/app/models/credentials.py index b295927c..754f821b 100644 --- a/backend/app/models/credentials.py +++ b/backend/app/models/credentials.py @@ -113,19 +113,26 @@ class Credential(CredsBase, table=True): organization: Organization | None = Relationship(back_populates="creds") project: Project | None = Relationship(back_populates="creds") - def to_public(self) -> "CredsPublic": - """Convert the database model to a public model with decrypted credentials.""" + def to_public(self, mask: bool = True) -> "CredsPublic": + """Convert the database model to a public model with decrypted credentials. + + By default, sensitive fields (e.g., api_key, secret_key) are masked so + the response is safe to return via the API. + """ + from app.core.providers import mask_credential_fields from app.core.security import decrypt_credentials + decrypted = decrypt_credentials(self.credential) if self.credential else None + if mask and decrypted: + decrypted = mask_credential_fields(self.provider, decrypted) + return CredsPublic( id=self.id, organization_id=self.organization_id, project_id=self.project_id, is_active=self.is_active, provider=self.provider, - credential=decrypt_credentials(self.credential) - if self.credential - else None, + credential=decrypted, inserted_at=self.inserted_at, updated_at=self.updated_at, )