Skip to content

Commit e73fd4b

Browse files
fix: release SQLite lock after API key auth (#14381)
* fix: release SQLite lock after API key auth * [autofix.ci] apply automated fixes * fix: isolate API key bookkeeping transaction * fix: isolate API key authentication transactions --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 34fa3fe commit e73fd4b

10 files changed

Lines changed: 851 additions & 228 deletions

File tree

.secrets.baseline

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,7 +1068,7 @@
10681068
"filename": "src/backend/base/langflow/services/database/models/api_key/crud.py",
10691069
"hashed_secret": "920f8f5815b381ea692e9e7c2f7119f2b1aa620a",
10701070
"is_verified": false,
1071-
"line_number": 139,
1071+
"line_number": 173,
10721072
"is_secret": false
10731073
}
10741074
],
@@ -7203,5 +7203,5 @@
72037203
}
72047204
]
72057205
},
7206-
"generated_at": "2026-07-30T21:43:28Z"
7206+
"generated_at": "2026-08-03T20:07:18Z"
72077207
}

src/backend/base/langflow/__main__.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -918,17 +918,13 @@ async def _create_superuser(username: str, password: str, auth_token: str | None
918918
# Validate the auth token
919919
try:
920920
auth_user = None
921-
async with session_scope() as session:
922-
# Try JWT first
923-
user = None
924-
try:
925-
user = await get_current_user_from_access_token(auth_token, session)
926-
except (InvalidTokenError, HTTPException):
927-
# Try API key
928-
api_key_result = await check_key(session, auth_token)
929-
if api_key_result and hasattr(api_key_result, "is_superuser"):
930-
user = api_key_result
931-
auth_user = user
921+
# Try JWT first, closing its session before falling back to API-key
922+
# authentication, which owns a separate database transaction.
923+
try:
924+
async with session_scope() as session:
925+
auth_user = await get_current_user_from_access_token(auth_token, session)
926+
except (InvalidTokenError, HTTPException):
927+
auth_user = await check_key(auth_token)
932928

933929
if not auth_user or not auth_user.is_superuser:
934930
typer.echo(

src/backend/base/langflow/api/v1/a2a.py

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -124,41 +124,41 @@ async def _enforce_a2a_auth(flow: Flow, request: Request) -> User | None:
124124
- anything else (an auth type A2A doesn't understand) -> fail closed with 403: treating a
125125
*protected* folder as public would expose an owner-identity run anonymously.
126126
127-
Uses ``check_key`` directly, NOT ``api_key_security``: under AUTO_LOGIN the latter
127+
Uses ``authenticate_api_key`` directly, NOT ``api_key_security``: under AUTO_LOGIN the latter
128128
returns the superuser for a *missing* key, which would silently bypass this gate.
129129
"""
130-
# Short writable session (check_key flushes usage counters), closed before
131-
# dispatch so no lock is held across the up-to-300s run.
132-
async with session_scope() as session:
130+
# Resolve the folder policy first, then close that read session before API-key
131+
# authentication opens its owned write transaction.
132+
async with session_scope_readonly() as session:
133133
auth_type = await folder_auth_type(flow, session)
134-
if auth_type == "none":
135-
return None # public agent
136-
if auth_type not in ("apikey", "oauth"):
137-
# Protected folder with a scheme A2A can't enforce: fail closed, never public.
138-
raise HTTPException(
139-
status_code=status.HTTP_403_FORBIDDEN,
140-
detail=f"A2A access is disabled for this agent: unsupported folder auth type {auth_type!r}.",
141-
)
142-
api_key = request.headers.get(A2A_APIKEY_HEADER)
143-
if not api_key:
144-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API key required")
145-
api_key_result = await authenticate_api_key(session, api_key)
146-
# Same message for invalid and wrong-owner: don't reveal a key is valid for another user.
147-
if api_key_result is None or api_key_result.user.id != flow.user_id:
148-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
149-
user = api_key_result.user
150-
set_current_auth_context(AuthCredentialContext.from_api_key_result(api_key_result))
151-
try:
152-
await ensure_flow_permission(
153-
user,
154-
FlowAction.EXECUTE,
155-
flow_id=flow.id,
156-
flow_user_id=flow.user_id,
157-
folder_id=flow.folder_id,
158-
)
159-
except HTTPException as exc:
160-
raise deny_to_404(exc, detail="Not Found") from exc
161-
return user
134+
if auth_type == "none":
135+
return None # public agent
136+
if auth_type not in ("apikey", "oauth"):
137+
# Protected folder with a scheme A2A can't enforce: fail closed, never public.
138+
raise HTTPException(
139+
status_code=status.HTTP_403_FORBIDDEN,
140+
detail=f"A2A access is disabled for this agent: unsupported folder auth type {auth_type!r}.",
141+
)
142+
api_key = request.headers.get(A2A_APIKEY_HEADER)
143+
if not api_key:
144+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API key required")
145+
api_key_result = await authenticate_api_key(api_key)
146+
# Same message for invalid and wrong-owner: don't reveal a key is valid for another user.
147+
if api_key_result is None or api_key_result.user.id != flow.user_id:
148+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
149+
user = api_key_result.user
150+
set_current_auth_context(AuthCredentialContext.from_api_key_result(api_key_result))
151+
try:
152+
await ensure_flow_permission(
153+
user,
154+
FlowAction.EXECUTE,
155+
flow_id=flow.id,
156+
flow_user_id=flow.user_id,
157+
folder_id=flow.folder_id,
158+
)
159+
except HTTPException as exc:
160+
raise deny_to_404(exc, detail="Not Found") from exc
161+
return user
162162

163163

164164
class _FlowContextBuilder(DefaultServerCallContextBuilder):

src/backend/base/langflow/api/v1/mcp_projects.py

Lines changed: 49 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
3535
from sqlalchemy.orm import selectinload
3636
from sqlmodel import select
37-
from sqlmodel.ext.asyncio.session import AsyncSession
3837

3938
from langflow.api.utils import (
4039
CurrentActiveMCPUser,
@@ -91,7 +90,6 @@
9190

9291

9392
async def verify_project_auth(
94-
db: AsyncSession,
9593
project_id: UUID,
9694
query_param: str | None,
9795
header_param: str | None,
@@ -110,26 +108,26 @@ async def verify_project_auth(
110108

111109
settings_service = get_settings_service()
112110

113-
project = (await db.exec(select(Folder).where(Folder.id == project_id))).first()
114-
115-
if not project:
116-
raise HTTPException(status_code=404, detail="Project not found")
117-
118-
auth_settings: AuthSettings | None = None
119-
# Check if this project requires API key only authentication
120-
if project.auth_settings:
121-
auth_settings = AuthSettings(**project.auth_settings)
111+
# Resolve project authentication policy before API-key authentication opens
112+
# its owned transaction. Keep only scalar values after this scope exits.
113+
async with session_scope() as db:
114+
project = (await db.exec(select(Folder).where(Folder.id == project_id))).first()
115+
if not project:
116+
raise HTTPException(status_code=404, detail="Project not found")
117+
project_user_id = project.user_id
118+
auth_settings = AuthSettings(**project.auth_settings) if project.auth_settings else None
122119

123120
project_auth_type = auth_settings.auth_type if auth_settings else None
124121
if project_auth_type == "oauth" and composer_backend_token:
125122
mcp_composer_service: MCPComposerService = cast(
126123
MCPComposerService, get_service(ServiceType.MCP_COMPOSER_SERVICE)
127124
)
128125
if mcp_composer_service.validate_backend_auth_token(str(project_id), composer_backend_token):
129-
if project.user_id:
130-
project_user = await db.get(User, project.user_id)
131-
if project_user:
132-
return project_user
126+
if project_user_id:
127+
async with session_scope() as db:
128+
project_user = await db.get(User, project_user_id)
129+
if project_user:
130+
return project_user
133131
raise HTTPException(status_code=404, detail="Project owner not found")
134132

135133
# OAuth projects must present a valid API key at the Langflow transport endpoint: network-level
@@ -159,33 +157,30 @@ async def verify_project_auth(
159157
)
160158

161159
# Validate the API key
162-
api_key_result = await authenticate_api_key(db, api_key)
160+
api_key_result = await authenticate_api_key(api_key)
163161
if not api_key_result:
164162
raise HTTPException(status_code=401, detail="Invalid API key")
165163
set_current_auth_context(AuthCredentialContext.from_api_key_result(api_key_result))
166164
user = api_key_result.user
167165

168166
# Verify user has access to the project
169-
project_access = (
170-
await db.exec(select(Folder).where(Folder.id == project_id, Folder.user_id == user.id))
171-
).first()
172-
173-
if not project_access:
167+
if project_user_id != user.id:
174168
raise HTTPException(status_code=404, detail="Project not found")
175169

176170
return user
177171

178-
return await _superuser_fallback(db, settings_service)
172+
return await _superuser_fallback(settings_service)
179173

180174

181-
async def _superuser_fallback(db: AsyncSession, settings_service) -> User:
175+
async def _superuser_fallback(settings_service) -> User:
182176
"""Resolve the configured superuser for unauthenticated MCP paths that allow fallback."""
183177
if not settings_service.auth_settings.SUPERUSER:
184178
raise HTTPException(
185179
status_code=status.HTTP_400_BAD_REQUEST,
186180
detail="Missing superuser username in auth settings",
187181
)
188-
result = await get_user_by_username(db, settings_service.auth_settings.SUPERUSER)
182+
async with session_scope() as db:
183+
result = await get_user_by_username(db, settings_service.auth_settings.SUPERUSER)
189184
if result:
190185
logger.warning(AUTO_LOGIN_WARNING)
191186
set_current_auth_context(AuthCredentialContext(method=AUTH_METHOD_AUTO_LOGIN))
@@ -206,51 +201,47 @@ async def verify_project_auth_conditional(
206201
- MCP Composer enabled + API key auth: Only allow API keys
207202
- All other cases: Use standard MCP auth (JWT + API keys)
208203
"""
204+
# Extract token
205+
token: str | None = None
206+
auth_header = request.headers.get("authorization")
207+
if auth_header and auth_header.startswith("Bearer "):
208+
token = auth_header[7:]
209+
210+
# Extract API keys
211+
api_key_query_value = request.query_params.get("x-api-key")
212+
api_key_header_value = request.headers.get("x-api-key")
213+
composer_backend_token = request.headers.get(COMPOSER_BACKEND_AUTH_HEADER)
214+
215+
# The composer path performs its own short project-policy read before auth.
216+
if get_settings_service().settings.mcp_composer_enabled:
217+
return await verify_project_auth(
218+
project_id,
219+
api_key_query_value,
220+
api_key_header_value,
221+
composer_backend_token,
222+
)
223+
224+
# Preserve the existing not-found-before-auth behavior, but close this read
225+
# scope before API-key authentication opens its owned transaction.
209226
async with session_scope() as session:
210-
# Get project to check auth settings
211227
project = (await session.exec(select(Folder).where(Folder.id == project_id))).first()
212-
213228
if not project:
214229
raise HTTPException(status_code=404, detail="Project not found")
230+
project_user_id = project.user_id
215231

216-
# Extract token
217-
token: str | None = None
218-
auth_header = request.headers.get("authorization")
219-
if auth_header and auth_header.startswith("Bearer "):
220-
token = auth_header[7:]
221-
222-
# Extract API keys
223-
api_key_query_value = request.query_params.get("x-api-key")
224-
api_key_header_value = request.headers.get("x-api-key")
225-
composer_backend_token = request.headers.get(COMPOSER_BACKEND_AUTH_HEADER)
226-
227-
# Check if this project requires API key only authentication
228-
if get_settings_service().settings.mcp_composer_enabled:
229-
return await verify_project_auth(
230-
session,
231-
project_id,
232-
api_key_query_value,
233-
api_key_header_value,
234-
composer_backend_token,
235-
)
236-
237-
# For all other cases, use standard MCP authentication (allows JWT + API keys)
238-
# Call the MCP auth function directly
239-
from langflow.services.auth.utils import get_current_user_mcp
232+
# For all other cases, use standard MCP authentication (allows JWT + API keys).
233+
# This session has not executed a query before API-key authentication.
234+
from langflow.services.auth.utils import get_current_user_mcp
240235

236+
async with session_scope() as session:
241237
user = await get_current_user_mcp(
242238
token=token or "", query_param=api_key_query_value, header_param=api_key_header_value, db=session
243239
)
244240

245-
# Verify project access
246-
project_access = (
247-
await session.exec(select(Folder).where(Folder.id == project_id, Folder.user_id == user.id))
248-
).first()
249-
250-
if not project_access:
251-
raise HTTPException(status_code=404, detail="Project not found")
241+
if project_user_id != user.id:
242+
raise HTTPException(status_code=404, detail="Project not found")
252243

253-
return user
244+
return user
254245

255246

256247
# Create project-specific context variable

0 commit comments

Comments
 (0)