From a620137b99026cea8a7117c8b35e09cebae7afc5 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:03:24 +0530 Subject: [PATCH 1/4] feat(*): login via email flow --- backend/app/api/docs/auth/magic_link.md | 30 +++++ .../app/api/docs/auth/magic_link_verify.md | 21 +++ backend/app/api/routes/auth.py | 122 +++++++++++++++++- backend/app/core/config.py | 3 + .../email-templates/build/invite_user.html | 2 +- .../build/magic_link_login.html | 52 ++++++++ backend/app/models/__init__.py | 1 + backend/app/models/auth.py | 4 + backend/app/services/auth.py | 81 +++++++++--- backend/app/utils.py | 16 +++ 10 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 backend/app/api/docs/auth/magic_link.md create mode 100644 backend/app/api/docs/auth/magic_link_verify.md create mode 100644 backend/app/email-templates/build/magic_link_login.html 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..101348f9b --- /dev/null +++ b/backend/app/api/docs/auth/magic_link.md @@ -0,0 +1,30 @@ +# 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 and is active. +2. Generates a short-lived login token (15 minutes). +3. Sends an email with a "Sign In Now" button linking to the frontend. +4. Returns the same success message regardless of whether the user exists (prevents email enumeration). + +## Response + +Always returns: +```json +{ + "success": true, + "data": { + "message": "If an account exists, a login link has been sent." + } +} +``` + +## Error Responses + +- **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..11e1b9236 --- /dev/null +++ b/backend/app/api/docs/auth/magic_link_verify.md @@ -0,0 +1,21 @@ +# 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. +- **403**: User account is inactive. +- **404**: User account not found. diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index f77e21926..a659b46aa 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 @@ -12,6 +13,7 @@ from app.models import ( GoogleAuthRequest, GoogleAuthResponse, + MagicLinkRequest, Message, SelectProjectRequest, Token, @@ -20,10 +22,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__) @@ -243,3 +252,114 @@ def verify_invitation(session: SessionDep, token: str) -> JSONResponse: f"[verify_invitation] Invitation verified | user_id: {user.id}, project_id: {invite_data['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: + # Return success even if user not found to prevent email enumeration + logger.info( + f"[request_magic_link] Magic link requested for non-existent email: {body.email}" + ) + return APIResponse.success_response( + data=Message(message="If an account exists, a login link has been sent.") + ) + + if not user.is_active: + logger.info( + f"[request_magic_link] Magic link requested for inactive user | user_id: {user.id}" + ) + return APIResponse.success_response( + data=Message(message="If an account exists, a login link has been sent.") + ) + + 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", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is inactive", + ) + + # 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/core/config.py b/backend/app/core/config.py index 3694e3c89..5e1cebf68 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -63,6 +63,9 @@ class Settings(BaseSettings): # Invitation token expiry (default 7 days) INVITE_TOKEN_EXPIRE_HOURS: int = 24 * 7 + # 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/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 @@

- Kaapi Konsole + {{ app_name }}

{{ 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 @@ + + + + + + Sign in to {{ app_name }} + + + + + + +
+ + + + +
+

+ {{ app_name }} +

+

+ Sign in to your account +

+ + + + +
+

+ We received a sign-in request for {{ email }}. +

+

+ Click the button below to sign in. +

+ + + + +
+ + Sign In Now + +
+

+ This link expires in {{ valid_minutes }} minutes.
+ If you did not request this, you can safely ignore this email. +

+
+
+ + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9341401aa..4e4c1e25e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,6 +4,7 @@ AuthContext, GoogleAuthRequest, GoogleAuthResponse, + MagicLinkRequest, SelectProjectRequest, Token, TokenPayload, diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index bfbda1f6a..510076242 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -37,6 +37,10 @@ class SelectProjectRequest(SQLModel): project_id: int +class MagicLinkRequest(SQLModel): + email: str + + class AuthContext(SQLModel): user: User organization: Organization | None = None diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 594f68daa..eb5aa984f 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -178,42 +178,89 @@ def validate_refresh_token( return user, token_data -def generate_invite_token( +def generate_email_token( email: str, - organization_id: int, - project_id: int, + token_type: str, + expires_delta: timedelta, + organization_id: int | None = None, + project_id: int | None = None, ) -> str: - """Generate a JWT invitation token for a user.""" - delta = timedelta(hours=settings.INVITE_TOKEN_EXPIRE_HOURS) + """ + Generate a JWT email token (invite or magic_link). + + Args: + email: User's email address (stored as 'sub' claim) + token_type: Token type identifier (e.g. "invite", "magic_link") + expires_delta: Token expiration duration + organization_id: Optional org ID to embed (for invite tokens) + project_id: Optional project ID to embed (for invite tokens) + """ now = datetime.now(timezone.utc) - expires = now + delta - to_encode = { - "exp": expires.timestamp(), + to_encode: dict = { + "exp": (now + expires_delta).timestamp(), "nbf": now, "sub": email, - "org_id": organization_id, - "project_id": project_id, - "type": "invite", + "type": token_type, } + if organization_id is not None: + to_encode["org_id"] = organization_id + if project_id is not None: + to_encode["project_id"] = project_id return pyjwt.encode(to_encode, settings.SECRET_KEY, algorithm=security.ALGORITHM) -def verify_invite_token(token: str) -> dict | None: +def verify_email_token(token: str, expected_type: str) -> dict | None: """ - Verify an invitation token and return the payload. + Verify a JWT email token and return the payload. + + Args: + token: The JWT token string + expected_type: Expected token type (e.g. "invite", "magic_link") - Returns dict with email, org_id, project_id or None if invalid. + Returns: + Dict with token claims or None if invalid/expired/wrong type. """ try: payload = pyjwt.decode( token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] ) - if payload.get("type") != "invite": + if payload.get("type") != expected_type: return None return { "email": payload["sub"], - "organization_id": payload["org_id"], - "project_id": payload["project_id"], + "organization_id": payload.get("org_id"), + "project_id": payload.get("project_id"), } except (InvalidTokenError, KeyError): return None + + +def generate_invite_token(email: str, organization_id: int, project_id: int) -> str: + """Generate a JWT invitation token for a user (expires in INVITE_TOKEN_EXPIRE_HOURS).""" + return generate_email_token( + email=email, + token_type="invite", + expires_delta=timedelta(hours=settings.INVITE_TOKEN_EXPIRE_HOURS), + organization_id=organization_id, + project_id=project_id, + ) + + +def verify_invite_token(token: str) -> dict | None: + """Verify an invitation token. Returns dict with email, org_id, project_id or None.""" + return verify_email_token(token, expected_type="invite") + + +def generate_magic_link_token(email: str) -> str: + """Generate a short-lived magic link login token (expires in MAGIC_LINK_TOKEN_EXPIRE_MINUTES).""" + return generate_email_token( + email=email, + token_type="magic_link", + expires_delta=timedelta(minutes=settings.MAGIC_LINK_TOKEN_EXPIRE_MINUTES), + ) + + +def verify_magic_link_token(token: str) -> str | None: + """Verify a magic link token. Returns email string or None.""" + result = verify_email_token(token, expected_type="magic_link") + return result["email"] if result else None diff --git a/backend/app/utils.py b/backend/app/utils.py index 89ea857ae..81dab17f6 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -207,6 +207,22 @@ def generate_invite_email( return EmailData(html_content=html_content, subject=subject) +def generate_magic_link_email(*, email_to: str, magic_link_token: str) -> EmailData: + app_name = settings.PROJECT_NAME + subject = f"{app_name} - Sign in to your account" + link = f"{settings.FRONTEND_HOST}/verify?token={magic_link_token}" + html_content = render_email_template( + template_name="magic_link_login.html", + context={ + "app_name": app_name, + "email": email_to, + "link": link, + "valid_minutes": settings.MAGIC_LINK_TOKEN_EXPIRE_MINUTES, + }, + ) + return EmailData(html_content=html_content, subject=subject) + + def generate_password_reset_token(email: str) -> str: delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) now = datetime.now(timezone.utc) From 451f62cc064227d910e197647b4fe8dc67e4900c Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:30:43 +0530 Subject: [PATCH 2/4] fix(*): send the error message if the user is not exist --- backend/app/api/docs/auth/magic_link.md | 22 +++++------------ backend/app/api/routes/auth.py | 24 ++++++++----------- backend/app/tests/api/test_auth.py | 32 ++++++++++++++++++------- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/backend/app/api/docs/auth/magic_link.md b/backend/app/api/docs/auth/magic_link.md index 101348f9b..bf5f48d22 100644 --- a/backend/app/api/docs/auth/magic_link.md +++ b/backend/app/api/docs/auth/magic_link.md @@ -8,23 +8,13 @@ Send a magic link login email to the user's email address. ## Behavior -1. Checks if the user exists and is active. -2. Generates a short-lived login token (15 minutes). -3. Sends an email with a "Sign In Now" button linking to the frontend. -4. Returns the same success message regardless of whether the user exists (prevents email enumeration). - -## Response - -Always returns: -```json -{ - "success": true, - "data": { - "message": "If an account exists, a login link has been sent." - } -} -``` +1. Checks if the user exists — returns 404 if not. +2. Checks if the user is active — returns 403 if inactive. +3. Generates a short-lived login token (15 minutes). +4. Sends an email with a "Sign In Now" button linking to the frontend. ## Error Responses +- **403**: User account is inactive. +- **404**: No account found for this email. - **500**: Email service is not configured or failed to send. diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index a659b46aa..e4e90caad 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -264,20 +264,12 @@ def request_magic_link(session: SessionDep, body: MagicLinkRequest) -> Any: user = get_user_by_email(session=session, email=body.email) if not user: - # Return success even if user not found to prevent email enumeration logger.info( f"[request_magic_link] Magic link requested for non-existent email: {body.email}" ) - return APIResponse.success_response( - data=Message(message="If an account exists, a login link has been sent.") - ) - - if not user.is_active: - logger.info( - f"[request_magic_link] Magic link requested for inactive user | user_id: {user.id}" - ) - return APIResponse.success_response( - data=Message(message="If an account exists, a login link has been sent.") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No account found for this email.", ) token = generate_magic_link_token(email=body.email) @@ -338,10 +330,14 @@ def verify_magic_link(session: SessionDep, token: str) -> JSONResponse: detail="User account not found", ) + # Activate user if not already active if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User account is inactive", + 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 diff --git a/backend/app/tests/api/test_auth.py b/backend/app/tests/api/test_auth.py index d473cef9b..b7c2db2a4 100644 --- a/backend/app/tests/api/test_auth.py +++ b/backend/app/tests/api/test_auth.py @@ -317,21 +317,31 @@ def test_magic_link_email_not_configured(self, mock_settings, client: TestClient assert resp.status_code == 500 def test_magic_link_nonexistent_user(self, client: TestClient): - """Test returns success even for non-existent user (no enumeration).""" + """Test returns 404 for non-existent user.""" resp = client.post(MAGIC_LINK_URL, json={"email": "nonexistent@example.com"}) - assert resp.status_code == 200 - assert "login link has been sent" in resp.json()["data"]["message"] + assert resp.status_code == 404 + assert "No account found" in resp.json()["error"] - def test_magic_link_inactive_user(self, db: Session, client: TestClient): - """Test returns success for inactive user (no enumeration).""" + @patch("app.api.routes.auth.send_email") + @patch("app.api.routes.auth.settings") + def test_magic_link_inactive_user_allowed( + self, mock_settings, mock_send, db: Session, client: TestClient + ): + """Test inactive user can still request magic link to reactivate.""" user = create_random_user(db) user.is_active = False db.add(user) db.commit() + mock_settings.emails_enabled = True + mock_settings.MAGIC_LINK_TOKEN_EXPIRE_MINUTES = 15 + mock_settings.SECRET_KEY = settings.SECRET_KEY + mock_settings.FRONTEND_HOST = "http://localhost:3000" + mock_settings.PROJECT_NAME = "Kaapi" + resp = client.post(MAGIC_LINK_URL, json={"email": user.email}) assert resp.status_code == 200 - assert "login link has been sent" in resp.json()["data"]["message"] + mock_send.assert_called_once() @patch("app.api.routes.auth.send_email") @patch("app.api.routes.auth.settings") @@ -377,16 +387,20 @@ def test_verify_user_not_found(self, client: TestClient): resp = client.get(f"{MAGIC_LINK_VERIFY_URL}?token={token}") assert resp.status_code == 404 - def test_verify_inactive_user(self, db: Session, client: TestClient): - """Test returns 403 for inactive user.""" + def test_verify_activates_inactive_user(self, db: Session, client: TestClient): + """Test magic link verify activates inactive user.""" user = create_random_user(db) user.is_active = False db.add(user) db.commit() + db.refresh(user) token = generate_magic_link_token(email=user.email) resp = client.get(f"{MAGIC_LINK_VERIFY_URL}?token={token}") - assert resp.status_code == 403 + assert resp.status_code == 200 + + db.refresh(user) + assert user.is_active is True def test_verify_success(self, db: Session, client: TestClient): """Test successful magic link verification logs user in.""" From ba96e109e360aa74e3ca5ced00b0ca0e600466ef Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:34:39 +0530 Subject: [PATCH 3/4] fix(*): api docs updated --- backend/app/api/docs/auth/magic_link.md | 6 ++---- backend/app/api/docs/auth/magic_link_verify.md | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/app/api/docs/auth/magic_link.md b/backend/app/api/docs/auth/magic_link.md index bf5f48d22..714bb9830 100644 --- a/backend/app/api/docs/auth/magic_link.md +++ b/backend/app/api/docs/auth/magic_link.md @@ -9,12 +9,10 @@ Send a magic link login email to the user's email address. ## Behavior 1. Checks if the user exists — returns 404 if not. -2. Checks if the user is active — returns 403 if inactive. -3. Generates a short-lived login token (15 minutes). -4. Sends an email with a "Sign In Now" button linking to the frontend. +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 -- **403**: User account is inactive. - **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 index 11e1b9236..e4654f0d4 100644 --- a/backend/app/api/docs/auth/magic_link_verify.md +++ b/backend/app/api/docs/auth/magic_link_verify.md @@ -17,5 +17,4 @@ Verify a magic link login token and log the user in. ## Error Responses - **400**: Invalid or expired login link. -- **403**: User account is inactive. - **404**: User account not found. From af3e0b35e0ae4b6c71a8608c21538cfebb042e6d Mon Sep 17 00:00:00 2001 From: Ayush <80516839+Ayush8923@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:26:15 +0530 Subject: [PATCH 4/4] Auth: Credentials Management by Org & Project (#748) --- .../credentials/delete_all_by_org_project.md | 7 + .../delete_provider_by_org_project.md | 8 + .../app/api/docs/credentials/get_provider.md | 2 +- .../get_provider_by_org_project.md | 8 + backend/app/api/docs/credentials/list.md | 2 +- .../docs/credentials/list_by_org_project.md | 12 ++ backend/app/api/docs/credentials/update.md | 33 +++- .../docs/credentials/update_by_org_project.md | 38 +++++ backend/app/api/routes/credentials.py | 156 +++++++++++++++++- backend/app/core/providers.py | 50 +++++- backend/app/crud/credentials.py | 33 +++- backend/app/models/credentials.py | 18 +- 12 files changed, 340 insertions(+), 27 deletions(-) create mode 100644 backend/app/api/docs/credentials/delete_all_by_org_project.md create mode 100644 backend/app/api/docs/credentials/delete_provider_by_org_project.md create mode 100644 backend/app/api/docs/credentials/get_provider_by_org_project.md create mode 100644 backend/app/api/docs/credentials/list_by_org_project.md create mode 100644 backend/app/api/docs/credentials/update_by_org_project.md 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/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/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/models/credentials.py b/backend/app/models/credentials.py index b295927c2..e6741b2e9 100644 --- a/backend/app/models/credentials.py +++ b/backend/app/models/credentials.py @@ -4,6 +4,7 @@ import sqlalchemy as sa from sqlmodel import Field, Relationship, SQLModel +from app.core.providers import mask_credential_fields from app.core.util import now from app.models.organization import Organization from app.models.project import Project @@ -113,19 +114,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. + """ + # Local import to avoid circular dependency (security imports app.models) 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, )