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.
+
+
+
+ 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,
)
|