Skip to content

Commit 9d6346e

Browse files
HzaRashiderichare
andauthored
fix(rbac): add permission checks in api and use owner id for provider sync (#14104)
* fix(rbac): pass owner id to deployment crud layer and close shared-access gaps - Pass row_owner_id through list/count/sync so credentials and attachments stay in the provider-account owner namespace while user_id remains the listing actor - Use deployment_row.user_id for shared delete cleanup; confirmed zero rows → 404; unconfirmed DELETE rowcount → 500 - Keep shared update rollback in the deployment owner namespace - Add apply_owned_or_visible_prefilter so scoped API keys with a concrete visible-id set do not auto-include owned deployments (owner override still unions via restrict_to_owned_or_visible) - Document None vs [] at the deployment list prefilter call site - Enforce flow:deploy before provider mutation on create, update (added versions), and snapshot replacement via ensure_flows_permission - Authorize deployment:create before existing-resource provider get/create - Add route, CRUD, listing, and guard coverage for the above * fix(rbac): sync deployment guard retries in each flow owner's namespace Guard-retry was syncing with the actor user_id, so shared deleters/movers could not reconcile the owner's deployments. Resolve owners from the flow rows and sync per owner instead. * docs: clarify correctness of owner derivation in deployment guard sync * fix(rbac): scope deployment guard repair to authorized flows --------- Co-authored-by: Eric Hare <ericrhare@gmail.com>
1 parent 77964d4 commit 9d6346e

22 files changed

Lines changed: 1573 additions & 167 deletions

File tree

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

Lines changed: 104 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
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,
@@ -77,6 +78,7 @@
7778
from langflow.services.authorization.fetch import deny_to_404
7879
from langflow.services.authorization.utils import _resolve_authz_domain
7980
from 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,

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

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -436,8 +436,7 @@ async def operation() -> FlowRead:
436436
if folder_id_will_change:
437437
return await retry_flow_operation_on_deployment_guard(
438438
db=session,
439-
user_id=current_user.id,
440-
flow_ids=[flow_id],
439+
flow_owner_ids={flow_id: db_flow.user_id},
441440
operation=operation,
442441
)
443442
return await operation()
@@ -587,8 +586,7 @@ async def update_operation() -> FlowRead:
587586
if folder_id_will_change:
588587
flow_read = await retry_flow_operation_on_deployment_guard(
589588
db=session,
590-
user_id=current_user.id,
591-
flow_ids=[existing_flow.id],
589+
flow_owner_ids={existing_flow.id: existing_flow.user_id},
592590
operation=update_operation,
593591
)
594592
else:
@@ -629,13 +627,12 @@ async def delete_flow(
629627
session: DbSession,
630628
flow_id: UUID, # noqa: ARG001
631629
flow: AuthorizedDeleteFlow,
632-
current_user: CurrentActiveUser,
630+
current_user: CurrentActiveUser, # noqa: ARG001
633631
):
634632
"""Delete a flow."""
635633
await retry_flow_operation_on_deployment_guard(
636634
db=session,
637-
user_id=current_user.id,
638-
flow_ids=[flow.id],
635+
flow_owner_ids={flow.id: flow.user_id},
639636
operation=lambda: cascade_delete_flow(session, flow.id),
640637
)
641638
return {"message": "Flow deleted successfully"}
@@ -806,6 +803,7 @@ async def delete_multiple_flows(
806803
):
807804
"""Delete multiple flows by their IDs."""
808805
try:
806+
authorized_flow_owner_ids: dict[UUID, UUID] = {}
809807

810808
async def _delete_operation() -> int:
811809
if not flow_ids:
@@ -830,15 +828,15 @@ async def _delete_operation() -> int:
830828
workspace_id=flow.workspace_id,
831829
folder_id=flow.folder_id,
832830
)
831+
authorized_flow_owner_ids.update((flow.id, flow.user_id) for flow in flows_to_delete)
833832
for flow in flows_to_delete:
834833
await cascade_delete_flow(db, flow.id)
835834
await db.flush()
836835
return len(flows_to_delete)
837836

838837
deleted_count = await retry_flow_operation_on_deployment_guard(
839838
db=db,
840-
user_id=user.id,
841-
flow_ids=flow_ids,
839+
flow_owner_ids=authorized_flow_owner_ids,
842840
operation=_delete_operation,
843841
)
844842
except Exception as exc:

0 commit comments

Comments
 (0)