2222 apply_flow_version_patch_attachments ,
2323 attach_flow_versions ,
2424 deployment_pagination_params ,
25+ ensure_flow_deploy_for_version_ids ,
2526 flow_version_ids_for_flows ,
2627 get_deployment_row_or_404 ,
2728 get_deployment_synced ,
7778from langflow .services .authorization .fetch import deny_to_404
7879from langflow .services .authorization .utils import _resolve_authz_domain
7980from langflow .services .database .models .deployment .crud import (
81+ UNCONFIRMED_DELETE_ROWCOUNT ,
8082 count_deployments_by_provider ,
8183 delete_deployment_by_id ,
8284 get_deployment_by_resource_key ,
@@ -230,6 +232,7 @@ async def _count_provider_deployments_after_reconciliation(
230232 deployment_count = await count_deployments_by_provider (
231233 session ,
232234 user_id = user_id ,
235+ row_owner_id = user_id ,
233236 deployment_provider_account_id = provider_account .id ,
234237 )
235238 if deployment_count <= 0 :
@@ -243,6 +246,7 @@ async def _count_provider_deployments_after_reconciliation(
243246 deployment_adapter = deployment_adapter ,
244247 deployment_mapper = deployment_mapper ,
245248 user_id = user_id ,
249+ row_owner_id = user_id ,
246250 provider_id = provider_account .id ,
247251 db = session ,
248252 page = 1 ,
@@ -259,7 +263,30 @@ async def _count_provider_deployments_after_reconciliation(
259263 return deployment_count
260264
261265
262- async def _delete_local_deployment_row_with_commit_retry (
266+ async def _delete_deployment_strictly_or_raise (
267+ * ,
268+ session : DbSession ,
269+ deployment_id : UUID ,
270+ user_id : UUID ,
271+ ) -> None :
272+ """Delete exactly one deployment row by PK + owner scope, or raise.
273+
274+ Raises:
275+ HTTPException: 404 when the driver confirmed zero rows matched; 500 when
276+ the affected-row count could not be confirmed
277+ (:data:`UNCONFIRMED_DELETE_ROWCOUNT`).
278+ """
279+ deleted = await delete_deployment_by_id (session , user_id = user_id , deployment_id = deployment_id )
280+ if deleted is UNCONFIRMED_DELETE_ROWCOUNT :
281+ raise HTTPException (
282+ status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
283+ detail = "Deployment delete could not be confirmed." ,
284+ )
285+ if deleted == 0 :
286+ raise HTTPException (status_code = status .HTTP_404_NOT_FOUND , detail = "Deployment not found." )
287+
288+
289+ async def _delete_local_deployment_row (
263290 * ,
264291 session : DbSession ,
265292 deployment_id : UUID ,
@@ -268,36 +295,47 @@ async def _delete_local_deployment_row_with_commit_retry(
268295) -> None :
269296 """Delete the local deployment row, retrying once if the commit fails.
270297
271- Delete is provider-first, so by the time this helper runs the provider
272- resource is already gone (or was already missing). If the first DB commit
273- fails, retry the local delete once after a rollback so we do not strand a
274- stale Langflow row that still blocks later reads or provider-account
275- deletion.
298+ When ``include_provider`` was true on the route, the provider resource is
299+ already gone (or was already missing) before this helper runs. Either way,
300+ if the first DB commit fails, retry the local delete once after a rollback
301+ so we do not strand a stale Langflow row.
302+
303+ Requires exactly one row deleted (PK + owner scope). A zero-row delete
304+ means the local row was already gone or the owner scope was wrong — raise
305+ 404 rather than returning success.
276306 """
277307 try :
278- await delete_deployment_by_id ( session , user_id = user_id , deployment_id = deployment_id )
308+ await _delete_deployment_strictly_or_raise ( session = session , user_id = user_id , deployment_id = deployment_id )
279309 await session .commit ()
310+ except HTTPException :
311+ raise
280312 except Exception : # noqa: BLE001
281313 await session .rollback ()
282314 logger .warning (
283- "Local deployment cleanup failed for deployment %s (resource_key=%s) after provider delete ; retrying." ,
315+ "Local deployment cleanup failed for deployment %s (resource_key=%s); retrying." ,
284316 deployment_id ,
285317 resource_key ,
286318 exc_info = True ,
287319 )
288320 try :
289- await delete_deployment_by_id ( session , user_id = user_id , deployment_id = deployment_id )
321+ await _delete_deployment_strictly_or_raise ( session = session , user_id = user_id , deployment_id = deployment_id )
290322 await session .commit ()
323+ except HTTPException :
324+ raise
291325 except Exception as exc :
292326 await session .rollback ()
293327 logger .exception (
294- "Retrying local deployment cleanup failed for deployment %s (resource_key=%s) after provider delete ." ,
328+ "Retrying local deployment cleanup failed for deployment %s (resource_key=%s)." ,
295329 deployment_id ,
296330 resource_key ,
297331 )
298332 raise HTTPException (
299333 status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
300- detail = "Deployment was deleted from the provider, but local cleanup failed. Retry the delete request." ,
334+ detail = (
335+ "Failed to delete the tracked deployment in Langflow. "
336+ "It may already have been deleted from the provider. "
337+ "Retry the delete request."
338+ ),
301339 ) from exc
302340
303341
@@ -487,6 +525,27 @@ async def create_deployment(
487525
488526 deployment_adapter = resolve_deployment_adapter (provider_account .provider_key )
489527 deployment_mapper = get_deployment_mapper (provider_account .provider_key )
528+
529+ # Authorize create (and flow:deploy) before any provider get/create so a
530+ # denied caller cannot trigger provider reads, rate limits, or provider-side
531+ # audit events via the existing-resource onboarding probe.
532+ project_id = await resolve_project_id_for_deployment_create (payload = payload , user_id = current_user .id , db = session )
533+ await ensure_deployment_permission (current_user , DeploymentAction .CREATE , project_id = project_id )
534+ flow_version_ids = deployment_mapper .util_create_flow_version_ids (payload )
535+ await validate_project_scoped_flow_version_ids (
536+ flow_version_ids = flow_version_ids ,
537+ user_id = current_user .id ,
538+ project_id = project_id ,
539+ db = session ,
540+ )
541+ await ensure_flow_deploy_for_version_ids (
542+ user = current_user ,
543+ flow_version_ids = flow_version_ids ,
544+ owner_id = current_user .id ,
545+ db = session ,
546+ project_id = project_id ,
547+ )
548+
490549 existing_resource_key = deployment_mapper .util_existing_deployment_resource_key_for_create (payload )
491550 existing_provider_resource : DeploymentGetResult | None = None
492551 if existing_resource_key is not None :
@@ -510,15 +569,6 @@ async def create_deployment(
510569 db = session ,
511570 )
512571 should_create_provider_resource = existing_resource_key is None
513- project_id = await resolve_project_id_for_deployment_create (payload = payload , user_id = current_user .id , db = session )
514- await ensure_deployment_permission (current_user , DeploymentAction .CREATE , project_id = project_id )
515- flow_version_ids = deployment_mapper .util_create_flow_version_ids (payload )
516- await validate_project_scoped_flow_version_ids (
517- flow_version_ids = flow_version_ids ,
518- user_id = current_user .id ,
519- project_id = project_id ,
520- db = session ,
521- )
522572 if should_create_provider_resource :
523573 adapter_payload = await deployment_mapper .resolve_deployment_create (
524574 user_id = current_user .id ,
@@ -621,7 +671,9 @@ async def list_deployments(
621671 load_from_provider : Annotated [
622672 bool ,
623673 Query (
624- description = ("When true, list deployments directly from the provider (bypassing Langflow deployment rows)." )
674+ description = (
675+ "When true, list deployments directly from the provider (bypassing Langflow-tracked deployments)."
676+ )
625677 ),
626678 ] = False ,
627679 flow_version_ids : Annotated [
@@ -741,7 +793,7 @@ async def list_deployments(
741793 deployment_adapter = deployment_adapter ,
742794 deployment_mapper = deployment_mapper ,
743795 user_id = current_user .id ,
744- provider_owner_id = provider_account .user_id ,
796+ row_owner_id = provider_account .user_id ,
745797 provider_id = provider_id ,
746798 db = session ,
747799 page = params .page ,
@@ -1280,6 +1332,14 @@ async def update_snapshot(
12801332 project_id = deployment .project_id ,
12811333 db = session ,
12821334 )
1335+ await ensure_flow_deploy_for_version_ids (
1336+ user = current_user ,
1337+ flow_version_ids = [body .flow_version_id ],
1338+ owner_id = owner_id ,
1339+ db = session ,
1340+ project_id = deployment .project_id ,
1341+ workspace_id = deployment .workspace_id ,
1342+ )
12831343
12841344 provider_account = await get_owned_provider_account_or_404 (
12851345 provider_id = deployment .deployment_provider_account_id ,
@@ -1502,12 +1562,6 @@ async def update_deployment(
15021562 # owner's scope. ``current_user`` is still the actor for authorization
15031563 # and audit, but the data plane operates in the owner's namespace.
15041564 owner_id = deployment_row .user_id
1505- adapter_payload = await deployment_mapper .resolve_deployment_update (
1506- user_id = owner_id ,
1507- deployment_db_id = deployment_row_id ,
1508- db = session ,
1509- payload = payload ,
1510- )
15111565 added_flow_version_ids , remove_flow_version_ids = resolve_flow_version_patch_for_update (
15121566 deployment_mapper = deployment_mapper ,
15131567 payload = payload ,
@@ -1518,6 +1572,25 @@ async def update_deployment(
15181572 project_id = deployment_row .project_id ,
15191573 db = session ,
15201574 )
1575+ # Only newly published versions need flow:deploy; removals are gated by
1576+ # deployment:write alone today. There is no flow:undeploy (or similar) in
1577+ # FlowAction yet — consider adding one if detaching a version from a
1578+ # deployment should require its own grant. Authorize before
1579+ # resolve_deployment_update reads flow data.
1580+ await ensure_flow_deploy_for_version_ids (
1581+ user = current_user ,
1582+ flow_version_ids = added_flow_version_ids ,
1583+ owner_id = owner_id ,
1584+ db = session ,
1585+ project_id = deployment_row .project_id ,
1586+ workspace_id = deployment_row .workspace_id ,
1587+ )
1588+ adapter_payload = await deployment_mapper .resolve_deployment_update (
1589+ user_id = owner_id ,
1590+ deployment_db_id = deployment_row_id ,
1591+ db = session ,
1592+ payload = payload ,
1593+ )
15211594 with handle_adapter_errors (mapper = deployment_mapper ), deployment_provider_scope (deployment_provider_account_id ):
15221595 update_result : DeploymentUpdateResult = await deployment_adapter .update (
15231596 deployment_id = deployment_resource_key ,
@@ -1561,15 +1634,15 @@ async def update_deployment(
15611634 # Roll back the session to discard any pending DB changes (or reset
15621635 # it from the "inactive" state after a failed commit) so the mapper
15631636 # can query the original attachment rows and build a compensating
1564- # payload.
1637+ # payload. Compensating update must stay in the owner's namespace.
15651638 await session .rollback ()
15661639 await rollback_provider_update (
15671640 deployment_adapter = deployment_adapter ,
15681641 deployment_mapper = deployment_mapper ,
15691642 deployment_db_id = deployment_row_id ,
15701643 deployment_resource_key = deployment_resource_key ,
15711644 deployment_provider_account_id = deployment_provider_account_id ,
1572- user_id = current_user . id ,
1645+ user_id = owner_id ,
15731646 db = session ,
15741647 )
15751648 if isinstance (exc , AttachmentConflictError ):
@@ -1629,7 +1702,7 @@ async def delete_deployment(
16291702 deployment_row .resource_key ,
16301703 deployment_row .deployment_provider_account_id ,
16311704 )
1632- await _delete_local_deployment_row_with_commit_retry (
1705+ await _delete_local_deployment_row (
16331706 session = session ,
16341707 deployment_id = deployment_row .id ,
16351708 user_id = deployment_row .user_id ,
0 commit comments