Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
df0f2f8
feat: Complete refactor of storage locations
erichare Jan 2, 2026
302fccb
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 2, 2026
2d5c446
Update test_storage_settings_validation.py
erichare Jan 2, 2026
bdae4fc
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 2, 2026
e0ddb2c
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Jan 2, 2026
6683687
Update src/frontend/src/controllers/API/queries/storage-settings/use-…
erichare Jan 2, 2026
7005788
Update src/frontend/src/pages/SettingsPage/pages/StorageSettingsPage/…
erichare Jan 2, 2026
dfcb359
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 2, 2026
5476ec2
Fix tests
erichare Jan 2, 2026
e0ecf8f
Fix templates
erichare Jan 2, 2026
7041e87
Update test_s3_components.py
erichare Jan 2, 2026
c64fdd0
Fix tests
erichare Jan 2, 2026
14f5110
Refactor common code to a storage mixin
erichare Jan 2, 2026
0239bc8
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 2, 2026
2d433b3
Update index.tsx
erichare Jan 2, 2026
cd722c3
Address coderabbit issues
erichare Jan 2, 2026
8cb45e2
Update component_index.json
erichare Jan 2, 2026
4babe92
Update test_storage_settings_validation.py
erichare Jan 2, 2026
d6c6126
Update test_storage_settings_validation.py
erichare Jan 2, 2026
486844d
Merge branch 'main' into feat-storage-settings
erichare Jan 5, 2026
f46ec97
Update component_index.json
erichare Jan 5, 2026
579df34
Merge remote-tracking branch 'origin' into feat-storage-settings
HimavarshaVS Jan 9, 2026
8e1e2fc
resolve conflicts
HimavarshaVS Jan 9, 2026
9714977
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 9, 2026
7b85da9
Merge branch 'main' into feat-storage-settings
HimavarshaVS Jan 9, 2026
89095ae
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 9, 2026
df09c4f
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Jan 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/backend/base/langflow/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from langflow.api.v2 import files_router as files_router_v2
from langflow.api.v2 import mcp_router as mcp_router_v2
from langflow.api.v2 import registration_router as registration_router_v2
from langflow.api.v2 import storage_settings_router as storage_settings_router_v2

router_v1 = APIRouter(
prefix="/v1",
Expand Down Expand Up @@ -61,6 +62,7 @@
router_v2.include_router(files_router_v2)
router_v2.include_router(mcp_router_v2)
router_v2.include_router(registration_router_v2)
router_v2.include_router(storage_settings_router_v2)

router = APIRouter(
prefix="/api",
Expand Down
2 changes: 2 additions & 0 deletions src/backend/base/langflow/api/v2/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from langflow.api.v2.files import router as files_router
from langflow.api.v2.mcp import router as mcp_router
from langflow.api.v2.registration import router as registration_router
from langflow.api.v2.storage_settings import router as storage_settings_router

__all__ = [
"files_router",
"mcp_router",
"registration_router",
"storage_settings_router",
]
179 changes: 179 additions & 0 deletions src/backend/base/langflow/api/v2/storage_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel

from langflow.api.utils import CurrentActiveUser
from langflow.services.deps import get_settings_service
from langflow.services.settings.service import SettingsService

router = APIRouter(tags=["Storage Settings"], prefix="/storage-settings")


class StorageSettingsResponse(BaseModel):
"""Storage settings response model."""

default_storage_location: str
component_aws_access_key_id: str | None
component_aws_secret_access_key: str | None
component_aws_default_bucket: str | None
component_aws_default_region: str | None
component_google_drive_service_account_key: str | None
component_google_drive_default_folder_id: str | None


class StorageSettingsUpdate(BaseModel):
"""Storage settings update model."""

default_storage_location: str | None = None
component_aws_access_key_id: str | None = None
component_aws_secret_access_key: str | None = None
component_aws_default_bucket: str | None = None
component_aws_default_region: str | None = None
component_google_drive_service_account_key: str | None = None
component_google_drive_default_folder_id: str | None = None


@router.get("", response_model=StorageSettingsResponse)
async def get_storage_settings(
current_user: CurrentActiveUser, # noqa: ARG001
settings_service: Annotated[SettingsService, Depends(get_settings_service)],
):
"""Get global storage settings for file components."""
settings = settings_service.settings

# Mask sensitive values for security
masked_aws_secret = None
if settings.component_aws_secret_access_key:
masked_aws_secret = "*" * 8

masked_gdrive_key = None
if settings.component_google_drive_service_account_key:
masked_gdrive_key = "*" * 8

return StorageSettingsResponse(
default_storage_location=settings.default_storage_location,
component_aws_access_key_id=settings.component_aws_access_key_id,
component_aws_secret_access_key=masked_aws_secret,
component_aws_default_bucket=settings.component_aws_default_bucket,
component_aws_default_region=settings.component_aws_default_region,
component_google_drive_service_account_key=masked_gdrive_key,
component_google_drive_default_folder_id=settings.component_google_drive_default_folder_id,
)


@router.patch("", response_model=StorageSettingsResponse)
async def update_storage_settings(
settings_update: StorageSettingsUpdate,
current_user: CurrentActiveUser, # noqa: ARG001
settings_service: Annotated[SettingsService, Depends(get_settings_service)],
):
"""Update global storage settings for file components."""
settings = settings_service.settings

# Determine the final storage location after update
final_storage_location = (
settings_update.default_storage_location
if settings_update.default_storage_location is not None
else settings.default_storage_location
)

# Validate AWS credentials if AWS is selected
if final_storage_location == "AWS":
# Check if we're updating credentials or if they already exist
final_aws_key_id = (
settings_update.component_aws_access_key_id
if settings_update.component_aws_access_key_id is not None
else settings.component_aws_access_key_id
)
final_aws_secret = settings.component_aws_secret_access_key
if settings_update.component_aws_secret_access_key is not None and not all(
c == "*" for c in settings_update.component_aws_secret_access_key
):
final_aws_secret = settings_update.component_aws_secret_access_key

final_aws_bucket = (
settings_update.component_aws_default_bucket
if settings_update.component_aws_default_bucket is not None
else settings.component_aws_default_bucket
)

# Validate required AWS fields
if not final_aws_key_id:
raise HTTPException(
status_code=400,
detail="AWS Access Key ID is required when AWS storage is selected",
)
if not final_aws_secret:
raise HTTPException(
status_code=400,
detail="AWS Secret Access Key is required when AWS storage is selected",
)
if not final_aws_bucket:
raise HTTPException(
status_code=400,
detail="AWS Default Bucket is required when AWS storage is selected",
)

# Validate Google Drive credentials if Google Drive is selected
if final_storage_location == "Google Drive":
# Check if we're updating credentials or if they already exist
final_gdrive_key = settings.component_google_drive_service_account_key
if settings_update.component_google_drive_service_account_key is not None and not all(
c == "*" for c in settings_update.component_google_drive_service_account_key
):
final_gdrive_key = settings_update.component_google_drive_service_account_key

# Validate required Google Drive fields
if not final_gdrive_key:
raise HTTPException(
status_code=400,
detail="Google Drive Service Account Key is required when Google Drive storage is selected",
)

# Update only provided fields
if settings_update.default_storage_location is not None:
settings.default_storage_location = settings_update.default_storage_location

if settings_update.component_aws_access_key_id is not None:
settings.component_aws_access_key_id = settings_update.component_aws_access_key_id

# Only update secret if not masked (not just asterisks)
if settings_update.component_aws_secret_access_key is not None and not all(
c == "*" for c in settings_update.component_aws_secret_access_key
):
settings.component_aws_secret_access_key = settings_update.component_aws_secret_access_key

if settings_update.component_aws_default_bucket is not None:
settings.component_aws_default_bucket = settings_update.component_aws_default_bucket

if settings_update.component_aws_default_region is not None:
settings.component_aws_default_region = settings_update.component_aws_default_region

# Only update service account key if not masked
if settings_update.component_google_drive_service_account_key is not None and not all(
c == "*" for c in settings_update.component_google_drive_service_account_key
):
settings.component_google_drive_service_account_key = settings_update.component_google_drive_service_account_key

if settings_update.component_google_drive_default_folder_id is not None:
settings.component_google_drive_default_folder_id = settings_update.component_google_drive_default_folder_id

# Return masked values for security
masked_aws_secret = None
if settings.component_aws_secret_access_key:
masked_aws_secret = "*" * 8

masked_gdrive_key = None
if settings.component_google_drive_service_account_key:
masked_gdrive_key = "*" * 8

return StorageSettingsResponse(
default_storage_location=settings.default_storage_location,
component_aws_access_key_id=settings.component_aws_access_key_id,
component_aws_secret_access_key=masked_aws_secret,
component_aws_default_bucket=settings.component_aws_default_bucket,
component_aws_default_region=settings.component_aws_default_region,
component_google_drive_service_account_key=masked_gdrive_key,
component_google_drive_default_folder_id=settings.component_google_drive_default_folder_id,
)

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

185 changes: 185 additions & 0 deletions src/backend/tests/unit/api/v2/test_storage_settings_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Tests for storage settings validation."""

import os

import pytest
from fastapi import HTTPException
from langflow.api.v2.storage_settings import StorageSettingsUpdate, update_storage_settings
from langflow.services.settings.service import SettingsService
from lfx.services.settings.auth import AuthSettings
from lfx.services.settings.base import Settings


@pytest.fixture
def settings_service(tmp_path):
"""Create a settings service with temporary config."""
cfg_dir = tmp_path.as_posix()
settings = Settings(config_dir=cfg_dir)
auth_settings = AuthSettings(CONFIG_DIR=cfg_dir)
return SettingsService(settings, auth_settings)


@pytest.fixture
def mock_user():
"""Create a mock user."""
from unittest.mock import MagicMock

user = MagicMock()
user.id = "test-user-id"
return user


@pytest.mark.asyncio
async def test_validation_fails_when_switching_to_aws_without_credentials(settings_service, mock_user):
"""Test that validation fails when switching to AWS without providing credentials."""
settings_update = StorageSettingsUpdate(default_storage_location="AWS")

with pytest.raises(HTTPException) as exc_info:
await update_storage_settings(settings_update, mock_user, settings_service)

assert exc_info.value.status_code == 400
assert "AWS Access Key ID is required" in exc_info.value.detail


@pytest.mark.asyncio
async def test_validation_fails_when_switching_to_aws_with_only_key_id(settings_service, mock_user):
"""Test that validation fails when switching to AWS with only access key ID."""
settings_update = StorageSettingsUpdate(default_storage_location="AWS", component_aws_access_key_id="test-key-id")

with pytest.raises(HTTPException) as exc_info:
await update_storage_settings(settings_update, mock_user, settings_service)

assert exc_info.value.status_code == 400
assert "AWS Secret Access Key is required" in exc_info.value.detail


@pytest.mark.asyncio
async def test_validation_fails_when_switching_to_aws_without_bucket(settings_service, mock_user):
"""Test that validation fails when switching to AWS without bucket."""
settings_update = StorageSettingsUpdate(
default_storage_location="AWS",
component_aws_access_key_id="test-key-id",
component_aws_secret_access_key=os.getenv("TEST_AWS_SECRET_ACCESS_KEY", "default-secret"),
)

with pytest.raises(HTTPException) as exc_info:
await update_storage_settings(settings_update, mock_user, settings_service)

assert exc_info.value.status_code == 400
assert "AWS Default Bucket is required" in exc_info.value.detail


@pytest.mark.asyncio
async def test_validation_passes_when_switching_to_aws_with_all_credentials(settings_service, mock_user):
"""Test that validation passes when switching to AWS with all credentials."""
settings_update = StorageSettingsUpdate(
default_storage_location="AWS",
component_aws_access_key_id="test-key-id",
component_aws_secret_access_key=os.getenv("TEST_AWS_SECRET_ACCESS_KEY", "default-secret"),
component_aws_default_bucket="test-bucket",
)

response = await update_storage_settings(settings_update, mock_user, settings_service)

assert response.default_storage_location == "AWS"
assert response.component_aws_access_key_id == "test-key-id"
# Assert that the AWS secret access key is masked for security purposes
# The value "********" is a placeholder for a masked AWS secret access key, not a hardcoded password.
assert response.component_aws_secret_access_key == "********" # Masked # noqa: S105
assert response.component_aws_default_bucket == "test-bucket"


@pytest.mark.asyncio
async def test_validation_fails_when_switching_to_google_drive_without_credentials(settings_service, mock_user):
"""Test that validation fails when switching to Google Drive without credentials."""
settings_update = StorageSettingsUpdate(default_storage_location="Google Drive")

with pytest.raises(HTTPException) as exc_info:
await update_storage_settings(settings_update, mock_user, settings_service)

assert exc_info.value.status_code == 400
assert "Google Drive Service Account Key is required" in exc_info.value.detail


@pytest.mark.asyncio
async def test_validation_passes_when_switching_to_google_drive_with_credentials(settings_service, mock_user):
"""Test that validation passes when switching to Google Drive with credentials."""
settings_update = StorageSettingsUpdate(
default_storage_location="Google Drive",
component_google_drive_service_account_key='{"type": "service_account"}',
)

response = await update_storage_settings(settings_update, mock_user, settings_service)

assert response.default_storage_location == "Google Drive"
assert response.component_google_drive_service_account_key == "********" # Masked


@pytest.mark.asyncio
async def test_validation_passes_when_staying_on_local(settings_service, mock_user):
"""Test that validation passes when staying on Local (no credentials needed)."""
settings_update = StorageSettingsUpdate(default_storage_location="Local")

response = await update_storage_settings(settings_update, mock_user, settings_service)

assert response.default_storage_location == "Local"


@pytest.mark.asyncio
async def test_validation_passes_when_aws_already_configured(settings_service, mock_user):
"""Test that validation passes when AWS is already configured and not changing storage."""
# Pre-configure AWS settings
settings_service.settings.default_storage_location = "AWS"
settings_service.settings.component_aws_access_key_id = "existing-key-id"
settings_service.settings.component_aws_secret_access_key = os.getenv(
"TEST_AWS_SECRET_ACCESS_KEY", "default-secret"
)
settings_service.settings.component_aws_default_bucket = "existing-bucket"

# Update region only, keeping AWS as storage location
settings_update = StorageSettingsUpdate(component_aws_default_region="us-west-2")

response = await update_storage_settings(settings_update, mock_user, settings_service)

assert response.default_storage_location == "AWS"
assert response.component_aws_default_region == "us-west-2"


@pytest.mark.asyncio
async def test_validation_passes_when_updating_credentials_without_changing_storage(settings_service, mock_user):
"""Test that validation passes when updating credentials without changing storage location."""
# Pre-configure AWS settings
settings_service.settings.default_storage_location = "AWS"
settings_service.settings.component_aws_access_key_id = "old-key-id"
settings_service.settings.component_aws_secret_access_key = os.getenv(
"TEST_OLD_AWS_SECRET_ACCESS_KEY", "default-old-secret"
)
settings_service.settings.component_aws_default_bucket = "old-bucket"

# Update bucket only
settings_update = StorageSettingsUpdate(component_aws_default_bucket="new-bucket")

response = await update_storage_settings(settings_update, mock_user, settings_service)

assert response.component_aws_default_bucket == "new-bucket"


@pytest.mark.asyncio
async def test_validation_fails_when_clearing_required_aws_credential(settings_service, mock_user):
"""Test that validation fails when clearing a required AWS credential."""
# Pre-configure AWS settings
settings_service.settings.default_storage_location = "AWS"
settings_service.settings.component_aws_access_key_id = "existing-key-id"
settings_service.settings.component_aws_secret_access_key = os.getenv(
"EXISTING_AWS_SECRET_ACCESS_KEY", "default-secret"
)
settings_service.settings.component_aws_default_bucket = "existing-bucket"

# Try to clear the bucket
settings_update = StorageSettingsUpdate(component_aws_default_bucket="")

with pytest.raises(HTTPException) as exc_info:
await update_storage_settings(settings_update, mock_user, settings_service)

assert exc_info.value.status_code == 400
assert "AWS Default Bucket is required" in exc_info.value.detail
Loading
Loading