diff --git a/backend/app/api/docs/auth/magic_link.md b/backend/app/api/docs/auth/magic_link.md new file mode 100644 index 000000000..714bb9830 --- /dev/null +++ b/backend/app/api/docs/auth/magic_link.md @@ -0,0 +1,18 @@ +# Request Magic Link Login + +Send a magic link login email to the user's email address. + +## Request Body + +- **email** (required): The user's email address. + +## Behavior + +1. Checks if the user exists — returns 404 if not. +2. Generates a short-lived login token (15 minutes). +3. Sends an email with a "Sign In Now" button linking to the frontend. + +## Error Responses + +- **404**: No account found for this email. +- **500**: Email service is not configured or failed to send. diff --git a/backend/app/api/docs/auth/magic_link_verify.md b/backend/app/api/docs/auth/magic_link_verify.md new file mode 100644 index 000000000..e4654f0d4 --- /dev/null +++ b/backend/app/api/docs/auth/magic_link_verify.md @@ -0,0 +1,20 @@ +# Verify Magic Link + +Verify a magic link login token and log the user in. + +## Query Parameters + +- **token** (required): The login JWT token from the email link. + +## Behavior + +1. Validates the magic link token (checks signature, expiry, and type). +2. Looks up the user by the email embedded in the token. +3. Verifies the user is active. +4. If the user has exactly one project, it is auto-selected and embedded in the JWT. +5. Returns a JWT access token and sets HTTP-only cookies. + +## Error Responses + +- **400**: Invalid or expired login link. +- **404**: User account not found. 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 000000000..15f8e9cc7 --- /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 000000000..ba69b89bd --- /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 2f3a76920..c7ec981ce 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 000000000..accb96fab --- /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 c660229bc..ff0612661 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 000000000..12dad77e6 --- /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 0377f0e4b..cf08360d4 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 000000000..c010871d4 --- /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/auth.py b/backend/app/api/routes/auth.py index 24c0c5c13..4c118d73c 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -1,4 +1,5 @@ import logging +from typing import Any from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import JSONResponse @@ -14,6 +15,7 @@ from app.models import ( GoogleAuthRequest, GoogleAuthResponse, + MagicLinkRequest, Message, SelectProjectRequest, Token, @@ -22,10 +24,17 @@ build_google_auth_response, build_token_response, clear_auth_cookies, + generate_magic_link_token, validate_refresh_token, verify_invite_token, + verify_magic_link_token, +) +from app.utils import ( + APIResponse, + generate_magic_link_email, + load_description, + send_email, ) -from app.utils import APIResponse, load_description logger = logging.getLogger(__name__) @@ -250,3 +259,110 @@ def verify_invitation(session: SessionDep, token: str) -> JSONResponse: f"[verify_invitation] Invitation verified | user_id: {user.id}, project_id: {invite_payload.project_id}" ) return response + + +@router.post( + "/magic-link", + description=load_description("auth/magic_link.md"), + response_model=APIResponse[Message], +) +def request_magic_link(session: SessionDep, body: MagicLinkRequest) -> Any: + """Send a magic link login email to the user.""" + + user = get_user_by_email(session=session, email=body.email) + if not user: + logger.info( + f"[request_magic_link] Magic link requested for non-existent email: {body.email}" + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No account found for this email.", + ) + + token = generate_magic_link_token(email=body.email) + + if settings.emails_enabled: + try: + email_data = generate_magic_link_email( + email_to=body.email, + magic_link_token=token, + ) + send_email( + email_to=body.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + logger.info( + f"[request_magic_link] Magic link email sent | email: {body.email}" + ) + except Exception as e: + logger.error( + f"[request_magic_link] Failed to send magic link email | email: {body.email}, error: {e}" + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to send login email. Please try again later.", + ) + else: + logger.warning("[request_magic_link] Email sending is not configured") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Email service is not configured", + ) + + return APIResponse.success_response( + data=Message(message="If an account exists, a login link has been sent.") + ) + + +@router.get( + "/magic-link/verify", + description=load_description("auth/magic_link_verify.md"), + response_model=APIResponse[Token], +) +def verify_magic_link(session: SessionDep, token: str) -> JSONResponse: + """Verify a magic link token and log the user in.""" + + email = verify_magic_link_token(token) + if not email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired login link. Please request a new one.", + ) + + user = get_user_by_email(session=session, email=email) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User account not found", + ) + + # Activate user if not already active + if not user.is_active: + user.is_active = True + session.add(user) + session.commit() + session.refresh(user) + logger.info( + f"[verify_magic_link] User activated via magic link | user_id: {user.id}" + ) + + # Get user's projects to embed in token + available_projects = get_user_accessible_projects(session=session, user_id=user.id) + + organization_id = None + project_id = None + if len(available_projects) == 1: + organization_id = available_projects[0]["organization_id"] + project_id = available_projects[0]["project_id"] + + response = build_token_response( + user_id=user.id, + organization_id=organization_id, + project_id=project_id, + ) + + logger.info( + f"[verify_magic_link] User logged in via magic link | user_id: {user.id}" + ) + return response diff --git a/backend/app/api/routes/credentials.py b/backend/app/api/routes/credentials.py index 8e1e94b41..be75b5b98 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,147 @@ def delete_all_credentials( return APIResponse.success_response( {"message": "All credentials deleted successfully"} ) + + +@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/config.py b/backend/app/core/config.py index 26c744435..ec0692997 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -63,6 +63,9 @@ class Settings(BaseSettings): # Invitation token expiry (default 24 hours) INVITE_TOKEN_EXPIRE_HOURS: int = 24 + # Magic link login token expiry (default 15 minutes) + MAGIC_LINK_TOKEN_EXPIRE_MINUTES: int = 15 + # SMTP / Email SMTP_HOST: str = "" SMTP_PORT: int = 587 diff --git a/backend/app/core/providers.py b/backend/app/core/providers.py index 7248ea1df..793995422 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 e6c1ded6e..6853c455a 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/email-templates/build/invite_user.html b/backend/app/email-templates/build/invite_user.html index 1b7da47eb..6371a63d1 100644 --- a/backend/app/email-templates/build/invite_user.html +++ b/backend/app/email-templates/build/invite_user.html @@ -13,7 +13,7 @@
{{ organization_name }} diff --git a/backend/app/email-templates/build/magic_link_login.html b/backend/app/email-templates/build/magic_link_login.html new file mode 100644 index 000000000..daf376d9c --- /dev/null +++ b/backend/app/email-templates/build/magic_link_login.html @@ -0,0 +1,52 @@ + + +
+ + +
+
|
+