Skip to content

Commit 8bcac79

Browse files
committed
fix: address policy bundle review feedback
1 parent 87c2127 commit 8bcac79

16 files changed

Lines changed: 625 additions & 94 deletions

File tree

src/backend/base/langflow/alembic/versions/f7a9c2d4e6b8_add_shared_policy_bundle.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,9 @@ def _sync_legacy_policy(conn: sa.Connection) -> None:
272272
.one_or_none()
273273
)
274274
if active is None:
275+
if conn.execute(sa.select(sa.literal(1)).select_from(revision_table).limit(1)).first() is not None:
276+
msg = "Shared policy bundle has immutable revision history but no active singleton"
277+
raise RuntimeError(msg)
275278
return
276279
active_revision = active["revision"]
277280
bundle = (
@@ -280,7 +283,8 @@ def _sync_legacy_policy(conn: sa.Connection) -> None:
280283
.one_or_none()
281284
)
282285
if bundle is None:
283-
return
286+
msg = "Active policy bundle points to a missing immutable revision"
287+
raise RuntimeError(msg)
284288

285289
provider_table = _legacy_provider_table()
286290
conn.execute(
@@ -328,8 +332,22 @@ def _sync_legacy_policy(conn: sa.Connection) -> None:
328332
def downgrade() -> None:
329333
"""Copy the active bundle to legacy stores before removing new tables."""
330334
conn = op.get_bind()
331-
if not migration.table_exists(REVISION_TABLE, conn) or not migration.table_exists(ACTIVE_TABLE, conn):
335+
revision_exists = migration.table_exists(REVISION_TABLE, conn)
336+
active_exists = migration.table_exists(ACTIVE_TABLE, conn)
337+
if revision_exists != active_exists:
338+
existing_table = _revision_table() if revision_exists else _active_table()
339+
if conn.execute(sa.select(sa.literal(1)).select_from(existing_table).limit(1)).first() is not None:
340+
msg = "Shared policy bundle schema is partially initialized with durable data"
341+
raise RuntimeError(msg)
342+
if active_exists:
343+
op.drop_table(ACTIVE_TABLE)
344+
if revision_exists:
345+
op.drop_table(REVISION_TABLE)
346+
return
347+
348+
if not revision_exists:
332349
return
350+
333351
_sync_legacy_policy(conn)
334352
op.drop_table(ACTIVE_TABLE)
335353
op.drop_table(REVISION_TABLE)

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

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from fastapi import APIRouter, Depends, HTTPException, status
99
from lfx.services.catalog_policy import BaseCatalogPolicyService, CatalogPolicySnapshot
1010

11+
from langflow.api.v1.policy_bundle_errors import policy_bundle_revision_conflict
1112
from langflow.api.v1.schemas.catalog_policy import CatalogPolicyBlockedSet, CatalogPolicyRead
1213
from langflow.services.auth.utils import get_current_active_superuser
1314
from langflow.services.authorization.audit import audit_decision
@@ -48,17 +49,6 @@ def _raise_if_externally_managed(service: BaseCatalogPolicyService) -> None:
4849
)
4950

5051

51-
def _revision_conflict(exc: PolicyBundleRevisionConflictError) -> HTTPException:
52-
return HTTPException(
53-
status_code=status.HTTP_409_CONFLICT,
54-
detail={
55-
"message": "Policy bundle revision conflict",
56-
"expected_revision": exc.expected_revision,
57-
"active_revision": exc.active_revision,
58-
},
59-
)
60-
61-
6252
async def _audit_update(
6353
*,
6454
user_id: UUID,
@@ -115,7 +105,7 @@ async def replace_component_policy(
115105
actor_user_id=admin.id,
116106
)
117107
except PolicyBundleRevisionConflictError as exc:
118-
raise _revision_conflict(exc) from exc
108+
raise policy_bundle_revision_conflict(exc) from exc
119109
await _audit_update(
120110
user_id=admin.id,
121111
resource_kind="component",
@@ -149,7 +139,7 @@ async def replace_template_policy(
149139
actor_user_id=admin.id,
150140
)
151141
except PolicyBundleRevisionConflictError as exc:
152-
raise _revision_conflict(exc) from exc
142+
raise policy_bundle_revision_conflict(exc) from exc
153143
await _audit_update(
154144
user_id=admin.id,
155145
resource_kind="template",

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pydantic import BaseModel, Field, StringConstraints, field_validator
1111

1212
from langflow.api.utils import DbSession, DbSessionReadOnly
13+
from langflow.api.v1.policy_bundle_errors import policy_bundle_revision_conflict
1314
from langflow.services.auth.utils import get_current_active_superuser
1415
from langflow.services.authorization.audit import AUDIT_ALLOW, audit_decision
1516
from langflow.services.database.models.user.model import User
@@ -145,14 +146,7 @@ async def replace_model_provider_policy(
145146
except PolicyBundleApplicationNotSupportedError as exc:
146147
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
147148
except PolicyBundleRevisionConflictError as exc:
148-
raise HTTPException(
149-
status_code=status.HTTP_409_CONFLICT,
150-
detail={
151-
"message": "Policy bundle revision conflict",
152-
"expected_revision": exc.expected_revision,
153-
"active_revision": exc.active_revision,
154-
},
155-
) from exc
149+
raise policy_bundle_revision_conflict(exc) from exc
156150

157151
try:
158152
await audit_decision(

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

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from pydantic import BaseModel, Field, StringConstraints, field_validator
1414

1515
from langflow.api.utils import DbSession, DbSessionReadOnly
16+
from langflow.api.v1.policy_bundle_errors import policy_bundle_revision_conflict
17+
from langflow.api.v1.schemas.catalog_policy import CatalogPolicyKeyList, normalize_catalog_policy_keys
1618
from langflow.services.auth.utils import get_current_active_superuser
1719
from langflow.services.authorization.audit import AUDIT_ALLOW, audit_decision
1820
from langflow.services.database.models.policy_bundle import POLICY_BUNDLE_REASON_MAX_LENGTH
@@ -34,24 +36,13 @@
3436
ProviderId = Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9._-]*$", max_length=255)]
3537

3638

37-
def _normalize_keys(values: list[str]) -> list[str]:
38-
normalized: set[str] = set()
39-
for raw_value in values:
40-
value = raw_value.strip()
41-
if not value:
42-
msg = "Policy bundle catalog keys must not be empty"
43-
raise ValueError(msg)
44-
normalized.add(value)
45-
return sorted(normalized)
46-
47-
4839
class PolicyBundleWrite(BaseModel):
4940
"""Complete replacement guarded by the caller's observed revision."""
5041

5142
expected_revision: int = Field(ge=1)
5243
approved_provider_ids: Annotated[list[ProviderId], Field(max_length=1000)]
53-
blocked_component_keys: list[str]
54-
blocked_template_keys: list[str]
44+
blocked_component_keys: CatalogPolicyKeyList
45+
blocked_template_keys: CatalogPolicyKeyList
5546
reason: str | None = Field(default=None, max_length=POLICY_BUNDLE_REASON_MAX_LENGTH)
5647

5748
@field_validator("approved_provider_ids", mode="before")
@@ -72,7 +63,7 @@ def deduplicate_provider_ids(cls, provider_ids: list[str]) -> list[str]:
7263
@field_validator("blocked_component_keys", "blocked_template_keys")
7364
@classmethod
7465
def normalize_catalog_keys(cls, values: list[str]) -> list[str]:
75-
return _normalize_keys(values)
66+
return normalize_catalog_policy_keys(values)
7667

7768

7869
class PolicyBundleRollbackWrite(BaseModel):
@@ -131,17 +122,6 @@ def _unavailable() -> HTTPException:
131122
)
132123

133124

134-
def _conflict(exc: PolicyBundleRevisionConflictError) -> HTTPException:
135-
return HTTPException(
136-
status_code=status.HTTP_409_CONFLICT,
137-
detail={
138-
"message": "Policy bundle revision conflict",
139-
"expected_revision": exc.expected_revision,
140-
"active_revision": exc.active_revision,
141-
},
142-
)
143-
144-
145125
def _raise_if_externally_managed() -> None:
146126
if _managed_externally():
147127
raise HTTPException(
@@ -204,7 +184,7 @@ async def replace_policy_bundle(
204184
except PolicyBundleNotInitializedError as exc:
205185
raise _unavailable() from exc
206186
except PolicyBundleRevisionConflictError as exc:
207-
raise _conflict(exc) from exc
187+
raise policy_bundle_revision_conflict(exc) from exc
208188

209189
try:
210190
await _audit_bundle(snapshot, user_id=admin.id, action="policy_bundle:replace")
@@ -251,7 +231,7 @@ async def rollback_policy_bundle(
251231
except PolicyBundleNotInitializedError as exc:
252232
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
253233
except PolicyBundleRevisionConflictError as exc:
254-
raise _conflict(exc) from exc
234+
raise policy_bundle_revision_conflict(exc) from exc
255235

256236
try:
257237
await _audit_bundle(snapshot, user_id=admin.id, action="policy_bundle:rollback")
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Shared HTTP error mapping for policy-bundle-backed administration APIs."""
2+
3+
from fastapi import HTTPException, status
4+
5+
from langflow.services.policy_bundle import PolicyBundleRevisionConflictError
6+
7+
8+
def policy_bundle_revision_conflict(exc: PolicyBundleRevisionConflictError) -> HTTPException:
9+
"""Preserve the structured optimistic-concurrency response across policy APIs."""
10+
return HTTPException(
11+
status_code=status.HTTP_409_CONFLICT,
12+
detail={
13+
"message": "Policy bundle revision conflict",
14+
"expected_revision": exc.expected_revision,
15+
"active_revision": exc.active_revision,
16+
},
17+
)
18+
19+
20+
__all__ = ["policy_bundle_revision_conflict"]

src/backend/base/langflow/api/v1/schemas/catalog_policy.py

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,59 @@
22

33
from __future__ import annotations
44

5-
from pydantic import BaseModel, Field, field_validator
5+
from typing import Annotated
6+
7+
from pydantic import BaseModel, Field, StringConstraints, field_validator
8+
9+
CATALOG_POLICY_KEY_MAX_LENGTH = 255
10+
CATALOG_POLICY_KEYS_MAX_LENGTH = 1000
11+
12+
CatalogPolicyKey = Annotated[
13+
str,
14+
StringConstraints(
15+
strip_whitespace=True,
16+
min_length=1,
17+
max_length=CATALOG_POLICY_KEY_MAX_LENGTH,
18+
),
19+
]
20+
CatalogPolicyKeyList = Annotated[list[CatalogPolicyKey], Field(max_length=CATALOG_POLICY_KEYS_MAX_LENGTH)]
21+
22+
23+
def normalize_catalog_policy_keys(value: list[str]) -> list[str]:
24+
"""Deduplicate validated catalog keys and sort them deterministically."""
25+
return sorted(set(value))
626

727

828
class CatalogPolicyBlockedSet(BaseModel):
929
"""A normalized whole-set catalog block policy."""
1030

11-
blocked: list[str] = Field(
31+
blocked: CatalogPolicyKeyList = Field(
1232
...,
1333
description="Complete set of blocked catalog keys for this resource kind.",
1434
)
1535

1636
@field_validator("blocked")
1737
@classmethod
1838
def normalize_blocked_keys(cls, value: list[str]) -> list[str]:
19-
"""Trim, reject empty values, deduplicate, and sort deterministically."""
20-
normalized: set[str] = set()
21-
for raw_key in value:
22-
key = raw_key.strip()
23-
if not key:
24-
msg = "Blocked catalog keys must not be empty"
25-
raise ValueError(msg)
26-
normalized.add(key)
27-
return sorted(normalized)
28-
29-
30-
class CatalogPolicyRead(CatalogPolicyBlockedSet):
39+
"""Deduplicate and sort the already-trimmed, validated keys."""
40+
return normalize_catalog_policy_keys(value)
41+
42+
43+
class CatalogPolicyRead(BaseModel):
3144
"""Current catalog block policy and its ownership source."""
3245

46+
# Response/history schemas intentionally remain unconstrained so a policy
47+
# written by an older release can still be read and repaired.
48+
blocked: list[str]
3349
managed_externally: bool
50+
51+
52+
__all__ = [
53+
"CATALOG_POLICY_KEYS_MAX_LENGTH",
54+
"CATALOG_POLICY_KEY_MAX_LENGTH",
55+
"CatalogPolicyBlockedSet",
56+
"CatalogPolicyKey",
57+
"CatalogPolicyKeyList",
58+
"CatalogPolicyRead",
59+
"normalize_catalog_policy_keys",
60+
]

src/backend/base/langflow/services/catalog_policy/service.py

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
CatalogResourceKind,
1919
)
2020
from langflow.services.policy_bundle import (
21+
PolicyBundleRevisionConflictError,
2122
apply_policy_bundle_state,
2223
get_policy_bundle_state,
2324
replace_policy_bundle_state,
@@ -32,6 +33,9 @@
3233
from langflow.services.database.service import DatabaseService
3334

3435

36+
_CATALOG_POLICY_CAS_ATTEMPTS = 3
37+
38+
3539
def _normalize_keys(keys: Collection[str]) -> frozenset[str]:
3640
"""Trim and deduplicate keys while preserving their case."""
3741
normalized: set[str] = set()
@@ -57,6 +61,7 @@ def __init__(
5761
self._policy_bundle_service = policy_bundle_service
5862
self._legacy_snapshot = CatalogPolicySnapshot()
5963
self._legacy_hydrated = False
64+
self._projected_snapshot: tuple[PolicyBundleSnapshot, CatalogPolicySnapshot] | None = None
6065
self._write_lock = asyncio.Lock()
6166
# Direct service-class registration does not call set_ready(), unlike
6267
# factory creation. The fail-open snapshot is usable immediately.
@@ -73,10 +78,17 @@ def snapshot(self) -> CatalogPolicySnapshot:
7378
if self._policy_bundle_service is None:
7479
return self._legacy_snapshot
7580
bundle = self._policy_bundle_service.snapshot
76-
return CatalogPolicySnapshot(
77-
blocked_component_keys=bundle.blocked_component_keys,
78-
blocked_template_keys=bundle.blocked_template_keys,
79-
)
81+
projected = self._projected_snapshot
82+
if projected is None or projected[0] is not bundle:
83+
projected = (
84+
bundle,
85+
CatalogPolicySnapshot(
86+
blocked_component_keys=bundle.blocked_component_keys,
87+
blocked_template_keys=bundle.blocked_template_keys,
88+
),
89+
)
90+
self._projected_snapshot = projected
91+
return projected[1]
8092

8193
@property
8294
def policy_bundle_snapshot(self) -> PolicyBundleSnapshot:
@@ -165,25 +177,32 @@ async def _replace_blocked_keys(
165177
actor_user_id=actor_user_id,
166178
)
167179

168-
async with session_scope() as session:
169-
current = await get_policy_bundle_state(session)
170-
if resource_kind == CatalogResourceKind.COMPONENT:
171-
current_keys = current.blocked_component_keys
172-
components = desired
173-
templates = current.blocked_template_keys
174-
else:
175-
current_keys = current.blocked_template_keys
176-
components = current.blocked_component_keys
177-
templates = desired
178-
committed = await replace_policy_bundle_state(
179-
session,
180-
expected_revision=current.revision,
181-
approved_provider_ids=current.approved_provider_ids,
182-
blocked_component_keys=components,
183-
blocked_template_keys=templates,
184-
actor_user_id=actor_user_id,
185-
reason=f"Replace blocked {resource_kind.value} catalog keys",
186-
)
180+
for attempt in range(_CATALOG_POLICY_CAS_ATTEMPTS):
181+
async with session_scope() as session:
182+
current = await get_policy_bundle_state(session)
183+
if resource_kind == CatalogResourceKind.COMPONENT:
184+
current_keys = current.blocked_component_keys
185+
components = desired
186+
templates = current.blocked_template_keys
187+
else:
188+
current_keys = current.blocked_template_keys
189+
components = current.blocked_component_keys
190+
templates = desired
191+
try:
192+
committed = await replace_policy_bundle_state(
193+
session,
194+
expected_revision=current.revision,
195+
approved_provider_ids=current.approved_provider_ids,
196+
blocked_component_keys=components,
197+
blocked_template_keys=templates,
198+
actor_user_id=actor_user_id,
199+
reason=f"Replace blocked {resource_kind.value} catalog keys",
200+
)
201+
except PolicyBundleRevisionConflictError:
202+
if attempt == _CATALOG_POLICY_CAS_ATTEMPTS - 1:
203+
raise
204+
continue
205+
break
187206

188207
apply_policy_bundle_state(committed)
189208
return CatalogPolicyUpdate(

0 commit comments

Comments
 (0)