Skip to content

fix(policy_engine): preserve config-defined policies across DB sync and expose them via list APIs - #35263

Merged
mateo-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_config_policies_survive_db_sync
Jul 31, 2026
Merged

fix(policy_engine): preserve config-defined policies across DB sync and expose them via list APIs#35263
mateo-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_config_policies_survive_db_sync

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Config-defined policies stop being enforced once a DB is connected
  • Config-defined policies and attachments never show up in list APIs or the UI

How it solves it:

  • Registries track provenance and preserve config entries across every DB sync
  • List endpoints merge config entries, marked definition_location: "config"

Relevant issues

Fixes #35255

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Live proxy with a Postgres DB attached, real OpenAI calls, no mocks. Before leg captured at ae242fdd06 (litellm_internal_staging at the time), after legs captured at 35f770f43e and every checkpoint re-verified at b42ef469cf (this PR's head). Both proxies were booted with the same config and the same clean DB, and every request was sent more than 20 seconds after boot so the periodic DB sync had already run

Config used:

model_list:
  - model_name: gpt-4o-mini
    litellm_params:
      model: openai/gpt-4o-mini
      api_key: os.environ/OPENAI_API_KEY
guardrails:
  - guardrail_name: "tooling"
    litellm_params:
      guardrail: litellm_content_filter
      mode: pre_call
      default_on: false
      blocked_words:
        - keyword: "FORBIDDENWORD"
          action: BLOCK
policies:
  config-policy:
    description: "Policy defined in config.yaml"
    guardrails:
      add:
        - tooling
policy_attachments:
  - policy: config-policy
    scope: "*"
general_settings:
  master_key: sk-1234
litellm_settings:
  drop_params: True
  telemetry: False
Checkpoint Before ae242fdd06 After b42ef469cf
/policies/list shows config policy FAIL (empty) PASS
/policies/attachments/list shows attachment FAIL (empty) PASS
FORBIDDENWORD request blocked FAIL (200 passthrough) PASS (400 blocked)
Clean request succeeds PASS PASS (guardrail header set)
Config policy visible next to draft DB version FAIL (hidden) PASS (both listed)
Config policy visible in stale sync window FAIL (hidden) PASS (listed)
Config policy enforces immediately after DB override deleted FAIL (200 passthrough) PASS (400 blocked)
Delete-all response warns config policy reactivates FAIL (message only) PASS (warning present)

Before, at ae242fdd06:

$ curl -s http://localhost:52741/policies/list -H "Authorization: Bearer sk-1234"
{"policies":[],"total_count":0}

$ curl -s http://localhost:52741/policies/attachments/list -H "Authorization: Bearer sk-1234"
{"attachments":[],"total_count":0}

$ curl -s -i http://localhost:52741/v1/chat/completions -H "Authorization: Bearer sk-1234" \
    -H "Content-Type: application/json" \
    -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Please repeat exactly: FORBIDDENWORD"}]}'
HTTP/1.1 200 OK
{"id":"chatcmpl-E7QHPXS0BpQntP4n8N56H3wlDtLk5", ... "message":{"content":"I'm sorry, but I can't repeat that." ...}

The request with the blocked word went straight to the provider with no x-litellm-applied-guardrails header; the config policy was wiped by the DB sync one second after startup

After, at 35f770f43e (proxy on port 58231):

$ curl -s http://localhost:58231/policies/list -H "Authorization: Bearer sk-1234"
{"policies":[{"policy_id":"config-policy","policy_name":"config-policy","version_number":1,"version_status":"production", ... "description":"Policy defined in config.yaml","guardrails_add":["tooling"],"guardrails_remove":[], ... "definition_location":"config"}],"total_count":1}

$ curl -s http://localhost:58231/policies/attachments/list -H "Authorization: Bearer sk-1234"
{"attachments":[{"attachment_id":"config-0","policy_name":"config-policy","scope":"*","teams":[],"keys":[],"models":[],"tags":[],"created_at":null,"updated_at":null,"created_by":null,"updated_by":null,"definition_location":"config"}],"total_count":1}

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:58231/v1/chat/completions -H "Authorization: Bearer sk-1234" \
    -H "Content-Type: application/json" \
    -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Please repeat exactly: FORBIDDENWORD"}]}'
400

$ curl -s -D - -o /dev/null http://localhost:58231/v1/chat/completions -H "Authorization: Bearer sk-1234" \
    -H "Content-Type: application/json" \
    -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Say hello in three words"}]}' | grep -iE "^HTTP|applied-guardrails"
HTTP/1.1 200 OK
x-litellm-applied-guardrails: tooling

Review fix in 91290c6020: a draft or published DB version sharing a config policy's name no longer hides the config entry from the unfiltered list. Reproduced live by creating a DB policy named config-policy through the API, creating a draft v2 from it, then deleting the production v1 so only the draft remains, which is the state an operator hits midway through migrating a config policy into the DB

$ curl -s -X POST http://localhost:58231/policies -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d '{"policy_name":"config-policy","description":"DB copy being drafted","guardrails_add":[]}'
$ curl -s -X POST http://localhost:58231/policies/name/config-policy/versions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{}'
$ curl -s -X DELETE http://localhost:58231/policies/<production_policy_id> -H "Authorization: Bearer sk-1234"

With the pre-fix list logic (the behavior at the first head 4e9df55392), the unfiltered list showed only the draft even though the config policy is the one enforcing, so the API disagreed with enforcement. At 35f770f43e both rows are returned and the FORBIDDENWORD request still returns 400 at the same moment

$ curl -s http://localhost:58231/policies/list -H "Authorization: Bearer sk-1234"
total: 2
config-policy draft db
config-policy production config
forbidden_status_with_draft_present=400

Second review fix in ec016d1bd8: the list endpoint no longer consults the registry's in-memory provenance, which can lag the DB by up to one sync interval. Reproduced live by loading a production DB policy named config-policy into the registry (marking the name db-sourced), then deleting that row directly in Postgres, simulating another pod, and listing immediately, inside the stale window

$ curl -s -X POST http://localhost:58231/policies ... -d '{"policy_name":"config-policy","description":"DB production override","guardrails_add":[]}'
$ psql -d litellm -c "DELETE FROM \"LiteLLM_PolicyTable\" WHERE policy_name='config-policy';" && \
    curl -s http://localhost:58231/policies/list -H "Authorization: Bearer sk-1234"

With the pre-fix logic the enforced config policy vanished from the list until the next sync. Re-run at 35f770f43e, the fresh DB query alone decides suppression, so the config entry is listed immediately

DELETE 1
total: 1
config-policy production config

Third review fix in 35f770f43e: deleting the production DB override through the API now re-activates the same-named config policy immediately instead of leaving an unguarded window until the next periodic sync. Repro: with the config above, create a same-named production override with no guardrails, delete it via the API, and send the blocked word immediately after

$ curl -s -X POST http://localhost:<port>/policies -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d '{"policy_name":"config-policy","description":"DB production override","guardrails_add":[]}'
$ # FORBIDDENWORD now returns 200: the DB override with no guardrails wins, as intended
$ curl -s -X DELETE http://localhost:<port>/policies/<policy_id> -H "Authorization: Bearer sk-1234"
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:<port>/v1/chat/completions -H "Authorization: Bearer sk-1234" \
    -H "Content-Type: application/json" \
    -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Please repeat exactly: FORBIDDENWORD"}]}'

Before, at ec016d1bd8 (proxy on port 53817): the blocked word sailed through with 200 immediately after the delete even though /policies/list already showed the config policy as the active production entry, and enforcement only recovered at the next periodic sync

delete_status=200
forbidden_status_immediately_after_delete=200
list_row: config-policy production config
(65s later, after the next sync)
forbidden_status_after_sync=400

After, at 35f770f43e (proxy on port 58231): enforcement and the list agree at every moment, and the delete response says what happened

delete_status=200
forbidden_status_immediately_after_delete=400
list_row: config-policy production config

$ curl -s -X DELETE http://localhost:58231/policies/<policy_id> -H "Authorization: Bearer sk-1234"
{"message":"Policy <policy_id> deleted successfully","warning":"Production version was deleted. The config-defined policy with the same name is active again."}

Fourth review fix in b42ef469cf: DELETE /policies/name/{name}/all-versions now carries the same warning as the single-version delete when a config-defined policy takes over. Repro: create a same-named production override with no guardrails (FORBIDDENWORD passes through with 200), then delete all versions and send the blocked word immediately after

Before, at 35f770f43e, the response said nothing about the config policy silently coming back:

$ curl -s -X DELETE http://localhost:31508/policies/name/config-policy/all-versions -H "Authorization: Bearer sk-1234"
{"message": "All versions of policy 'config-policy' deleted successfully"}
forbidden_status_immediately_after=400

After, at b42ef469cf:

$ curl -s -X DELETE http://localhost:31508/policies/name/config-policy/all-versions -H "Authorization: Bearer sk-1234"
{"message": "All versions of policy 'config-policy' deleted successfully",
 "warning": "All DB versions were deleted. The config-defined policy with the same name is active again."}
forbidden_status_immediately_after=400

UI check steps (config badge, disabled actions, and draft coexistence):

  1. Boot the proxy with the config above and a DB attached, plus npm run dev in ui/litellm-dashboard
  2. Open http://localhost:4000/ui/?page=policies and confirm the config-policy row shows a "Config" badge with edit and delete disabled, and row click does not open the DB detail view
  3. Create a same-named DB draft: curl -s -X POST http://localhost:4000/policies -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"policy_name":"config-policy","description":"DB copy","guardrails_add":[]}', then curl -s -X POST http://localhost:4000/policies/name/config-policy/versions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{}', then DELETE the production policy_id returned by the first call
  4. Refresh the policies page and confirm two config-policy rows: a DB row that is clickable with edit and delete enabled, and a Config row with the badge and disabled actions
  5. Switch to the attachments tab and confirm the config attachment row shows delete disabled

Type

🐛 Bug Fix

Changes

PolicyRegistry now records where each policy came from (_sources, get_source, list_config_policies), keeps a snapshot of config-loaded policies, and sync_policies_from_db merges DB production versions on top of that snapshot instead of wiping the registry. On a name conflict the DB version wins and a warning is logged; if the conflicting DB row is later deleted, the config version resurfaces on the next sync. The draft/published _policies_by_id cache behavior is unchanged

AttachmentRegistry keeps the config-loaded attachments in a separate tuple and sync_attachments_from_db rebuilds the active list as config attachments plus DB rows, so config attachments survive every sync

GET /policies/list and GET /policies/attachments/list now merge config entries into the response with definition_location: "config" (DB rows keep their exact shape and carry the default "db"), and both endpoints return config entries instead of raising 500 when no database is connected. Config policies are listed as production versions with policy_id equal to the policy name; config attachments get a synthetic attachment_id of config-<index>. PolicyDBResponse and PolicyAttachmentDBResponse gained the definition_location field, and the policy_engine fragment of the lazy OpenAPI snapshot plus the dashboard schema.d.ts were regenerated

From review feedback, the list endpoint's name conflict suppression now only counts production DB versions, matching runtime resolution where only production DB versions override a config policy during sync. Previously a draft or published DB version with the same name hid the config entry from the unfiltered list even though the config version was still the one being enforced, making the management API and dashboard disagree with actual enforcement

From the second review round, that suppression is now decided by the endpoint's fresh DB query alone; the redundant get_source clause was dropped because it read the registry's in-memory provenance, which lags the DB by up to one sync interval. When another pod deleted or demoted the production override, the stale clause kept hiding the active config policy until the next sync even though the fresh query showed no conflict

From the third review round, remove_policy now restores the config-defined policy immediately when a same-named DB entry is removed, instead of dropping the name entirely and waiting for the next periodic sync. This closes an enforcement gap where deleting the production DB override through DELETE /policies/{id} or DELETE /policies/name/{name}/versions left the pod serving unguarded traffic for up to one sync interval while the list API already reported the config policy as active. The delete response's warning now says the config-defined policy is active again instead of telling the operator to promote another version

From the fourth review round, delete_all_versions returns the same config-reactivation warning as the single-version delete, so both delete paths tell the operator when a config-defined policy becomes active again

Also from the third review round, the dashboard policies table now renders config policies as their own rows instead of grouping them with same-named DB versions. Previously the config entry became the whole row's primary policy, which hid the version count, disabled row navigation, and locked edit and delete, making a same-named DB draft unreachable from the UI; exactly the state an operator is in midway through migrating a config policy into the DB. Config rows keep the "Config" badge and disabled actions; DB rows keep version grouping and full actions

Regression tests: sync_policies_from_db and sync_attachments_from_db preserve config entries and still resolve guardrails after sync, DB wins on name conflict and the config entry comes back once the DB row is gone, the list endpoints include config entries, keep DB rows unchanged, respect the version_status filter, work without a prisma client, and keep the config entry visible next to a draft DB version of the same name, add_policy(source="config") entries survive a subsequent sync, clear() also drops the config snapshot so a later sync cannot resurrect cleared policies or attachments, and stale db provenance left in the registry after the production override disappears does not hide the config entry from the list. For the third round, remove_policy restores the config version immediately (and still fully removes DB-only policies), delete_policy_from_db and delete_all_versions re-activate the config policy, both delete responses warn about it (and delete-all stays warning-free when no config twin exists), and two PolicyTable component tests pin that a config policy and a same-named DB draft render as separate rows with the draft still clickable and editable. All of these fail on the pre-fix code

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Note

Medium Risk
Changes core policy enforcement and registry merge logic on every DB sync and delete path; mistakes could mis-enforce guardrails or show wrong policy sources, though behavior is heavily regression-tested.

Overview
Fixes #35255: config-defined policies and attachments were dropped after DB sync and never appeared in list APIs or the dashboard.

Registries now keep a config snapshot and merge DB data on sync instead of replacing in-memory state. Production DB policies override same-named config entries at runtime; deleting a production DB override immediately restores the config policy via remove_policy (no wait for the next sync). Attachments follow the same merge pattern.

List APIs (GET /policies/list, GET /policies/attachments/list) merge config entries with definition_location: "config", work without a DB connection, and only hide a config policy when a production DB version shares the name (draft/published DB rows no longer suppress the enforced config entry). OpenAPI/schema types add definition_location and related docs.

Dashboard shows config policies/attachments on separate rows with a Config badge; edit/delete are disabled for config-defined resources. DB drafts with the same name stay reachable alongside the config row.

Regression tests cover sync preservation, list behavior, delete warnings, and UI grouping.

Reviewed by Cursor Bugbot for commit b42ef46. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread litellm/proxy/policy_engine/policy_endpoints.py Outdated
@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR preserves config-defined policies and attachments during database synchronization and exposes their provenance through management APIs and the dashboard.

  • Merges config-backed entries with database-backed entries while retaining production override semantics.
  • Restores config policy enforcement immediately when a same-named database override is deleted.
  • Lists config entries without a database connection and marks them with definition_location: "config".
  • Renders config policies separately from same-named database versions and disables unsupported config-entry actions.
  • Adds regression coverage for synchronization, listing, deletion, filtering, and dashboard behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported production-filtering and stale-provenance issues are corrected at current HEAD, and the accepted periodic synchronization lag does not require another change.

Important Files Changed

Filename Overview
litellm/proxy/policy_engine/policy_registry.py Preserves config policy snapshots across database synchronization, tracks provenance, and immediately restores config fallbacks after database deletion.
litellm/proxy/policy_engine/attachment_registry.py Preserves config-defined attachments while rebuilding the active attachment registry from database state.
litellm/proxy/policy_engine/policy_endpoints.py Merges config entries into list responses and correctly limits name-conflict suppression to fresh production database rows.
litellm/types/proxy/policy_engine/resolver_types.py Adds the backward-compatible definition-location discriminator to policy and attachment responses.
ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.tsx Separates config policies from same-named database version groups so database drafts remain reachable and actionable.
ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTableColumns.tsx Labels config policies and disables dashboard actions that apply only to database-backed entries.
tests/test_litellm/proxy/policy_engine/test_policy_engine_endpoints.py Covers config/DB merging, production conflict handling, status filtering, stale provenance, and operation without Prisma.
tests/test_litellm/proxy/policy_engine/test_policy_versioning.py Covers config preservation, database precedence, fallback restoration, and delete-response behavior.
ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.test.tsx Verifies separate config and database rows and confirms same-named database drafts remain accessible.

Reviews (6): Last reviewed commit: "fix(policy_engine): warn that the config..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_config_policies_survive_db_sync (b42ef46) with litellm_internal_staging (6e26087)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (2dbcb9a) during the generation of this report, so 2593168 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/policy_engine/policy_endpoints.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@yucheng-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Config not restored after DB delete
    • remove_policy now reinstates the preserved config policy (setting its source back to config) when a same-named DB entry is removed, so enforcement matches /policies/list without waiting for the next sync.
  • ✅ Fixed: Config row hides DB draft versions
    • groupPoliciesByName and getRowId now key off (policy_name, definition_location) so a config policy and a same-named DB draft/published version render as separate, independently interactable rows.

Create PR

Or push these changes by commenting:

@cursor push 5e2c834660
Preview (5e2c834660)
diff --git a/litellm/proxy/policy_engine/policy_registry.py b/litellm/proxy/policy_engine/policy_registry.py
--- a/litellm/proxy/policy_engine/policy_registry.py
+++ b/litellm/proxy/policy_engine/policy_registry.py
@@ -340,20 +340,30 @@
 
     def remove_policy(self, policy_name: str) -> bool:
         """
-        Remove a policy by name.
+        Remove a policy by name. If a config-defined policy with the same name
+        exists, it is restored so enforcement stays in sync with the list API
+        (which reports the config policy as active) instead of dropping the
+        policy until the next ``sync_policies_from_db`` call.
 
         Args:
             policy_name: Name of the policy to remove
 
         Returns:
-            True if policy was removed, False if it didn't exist
+            True if a policy was present under this name, False otherwise
         """
-        if policy_name in self._policies:
+        if policy_name not in self._policies:
+            return False
+
+        config_fallback = self._config_policies.get(policy_name)
+        if config_fallback is not None:
+            self._policies[policy_name] = config_fallback
+            self._sources = {**self._sources, policy_name: "config"}
+            verbose_proxy_logger.debug(f"Restored config fallback for policy: {policy_name}")
+        else:
             del self._policies[policy_name]
             self._sources = {name: source for name, source in self._sources.items() if name != policy_name}
             verbose_proxy_logger.debug(f"Removed policy: {policy_name}")
-            return True
-        return False
+        return True
 
     # ─────────────────────────────────────────────────────────────────────────
     # Database CRUD Methods

diff --git a/tests/test_litellm/proxy/policy_engine/test_policy_versioning.py b/tests/test_litellm/proxy/policy_engine/test_policy_versioning.py
--- a/tests/test_litellm/proxy/policy_engine/test_policy_versioning.py
+++ b/tests/test_litellm/proxy/policy_engine/test_policy_versioning.py
@@ -248,7 +248,40 @@
         assert "warning" not in result
         assert registry.has_policy("my-policy")
 
+    @pytest.mark.asyncio
+    async def test_delete_production_restores_config_fallback_immediately(self):
+        registry = PolicyRegistry()
+        registry.load_policies({"shared-name": {"guardrails": {"add": ["config-guard"]}}})
+        db_row = _make_row(policy_id="db-1", policy_name="shared-name", guardrails_add=["db-guard"])
+        prisma = MagicMock()
+        prisma.db.litellm_policytable.find_many = AsyncMock(side_effect=[[db_row], []])
+        await registry.sync_policies_from_db(prisma)
+        assert registry.get_source("shared-name") == "db"
 
+        prod_row = _make_row(policy_id="db-1", policy_name="shared-name", version_status="production")
+        prisma.db.litellm_policytable.find_unique = AsyncMock(return_value=prod_row)
+        prisma.db.litellm_policytable.delete = AsyncMock()
+
+        await registry.delete_policy_from_db(policy_id="db-1", prisma_client=prisma)
+
+        policy = registry.get_policy("shared-name")
+        assert policy is not None
+        assert policy.guardrails.add == ["config-guard"]
+        assert registry.get_source("shared-name") == "config"
+
+    def test_remove_policy_drops_pure_db_entry(self):
+        registry = PolicyRegistry()
+        registry.add_policy("db-only", MagicMock())
+
+        assert registry.remove_policy("db-only") is True
+        assert not registry.has_policy("db-only")
+        assert registry.get_source("db-only") is None
+
+    def test_remove_policy_returns_false_for_unknown_name(self):
+        registry = PolicyRegistry()
+        assert registry.remove_policy("missing") is False
+
+
 class TestCreateNewVersion:
     """Test create_new_version copies all fields and sets draft."""
 

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.test.tsx
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.test.tsx
@@ -145,4 +145,33 @@
     await user.click(screen.getByRole("button", { name: /grouped/ }));
     expect(defaultProps.onViewClick).toHaveBeenCalledWith("prod-id");
   });
+
+  it("should keep a DB draft addressable when a same-named config policy also exists", async () => {
+    const user = userEvent.setup();
+    const configPolicy = makePolicy({
+      policy_name: "migrating",
+      policy_id: "config-migrating",
+      version_status: "production",
+      definition_location: "config",
+    });
+    const dbDraft = makePolicy({
+      policy_name: "migrating",
+      policy_id: "db-draft-id",
+      version_status: "draft",
+      version_number: 2,
+      definition_location: "db",
+    });
+    renderWithProviders(<PolicyTable {...defaultProps} policies={[configPolicy, dbDraft]} />);
+
+    expect(screen.getAllByText("migrating")).toHaveLength(2);
+    expect(screen.getByText("Config")).toBeInTheDocument();
+    expect(screen.getByRole("button", { name: "migrating" })).toBeInTheDocument();
+
+    await user.click(screen.getByRole("button", { name: "migrating" }));
+    expect(defaultProps.onViewClick).toHaveBeenCalledWith("db-draft-id");
+
+    await user.click(screen.getByTestId("policy-actions-db-draft-id"));
+    await user.click(await screen.findByTestId("policy-action-edit"));
+    expect(defaultProps.onEditClick).toHaveBeenCalledWith(dbDraft);
+  });
 });

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.tsx
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.tsx
@@ -9,15 +9,24 @@
 
 import { getPolicyTableColumns, PolicyRow } from "./PolicyTableColumns";
 
-/** One row per policy name; primaryPolicy is used for display and for Edit (FlowBuilder loads all versions) */
+/**
+ * One row per (policy name, definition source); primaryPolicy is used for display and for Edit
+ * (FlowBuilder loads all versions). Config-defined policies are kept in their own row so a
+ * DB draft/published version with the same name stays reachable during migration.
+ */
 function groupPoliciesByName(policies: Policy[]): PolicyRow[] {
-  const names = Array.from(new Set(policies.map((policy) => policy.policy_name || "(unnamed)")));
-  return names.map((policyName) => {
-    const versions = policies.filter((policy) => (policy.policy_name || "(unnamed)") === policyName);
+  const rowKey = (policy: Policy) => `${policy.policy_name || "(unnamed)"}::${policy.definition_location ?? "db"}`;
+  const keys = Array.from(new Set(policies.map(rowKey)));
+  return keys.map((key) => {
+    const versions = policies.filter((policy) => rowKey(policy) === key);
     const primary =
       versions.find((version) => version.version_status === "production") ??
       [...versions].sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0))[0];
-    return { policy_name: policyName, primaryPolicy: primary, versionCount: versions.length };
+    return {
+      policy_name: primary.policy_name || "(unnamed)",
+      primaryPolicy: primary,
+      versionCount: versions.length,
+    };
   });
 }
 
@@ -67,7 +76,7 @@
     <DataTable
       data={rows}
       columns={columns}
-      getRowId={(row) => row.policy_name}
+      getRowId={(row) => `${row.policy_name}::${row.primaryPolicy.definition_location ?? "db"}`}
       sortingMode="client"
       sorting={sorting}
       onSortingChange={setSorting}

You can send follow-ups to the cloud agent here.

Comment thread litellm/proxy/policy_engine/policy_registry.py Outdated
…rride is removed and keep same-named DB drafts reachable in the UI
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/proxy/policy_engine/policy_endpoints.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Delete-all omits config warning
    • delete_all_versions now checks get_source after remove_policy and adds a warning when a config-defined policy with the same name has become active, matching delete_policy_from_db's behavior.

Create PR

Or push these changes by commenting:

@cursor push 3e1a5a3f4c
Preview (3e1a5a3f4c)
diff --git a/litellm/proxy/policy_engine/policy_registry.py b/litellm/proxy/policy_engine/policy_registry.py
--- a/litellm/proxy/policy_engine/policy_registry.py
+++ b/litellm/proxy/policy_engine/policy_registry.py
@@ -1036,7 +1036,12 @@
         try:
             await _policy_table(prisma_client).delete_many(where={"policy_name": policy_name})
             self.remove_policy(policy_name)
-            return {"message": f"All versions of policy '{policy_name}' deleted successfully"}
+            result: dict[str, str] = {"message": f"All versions of policy '{policy_name}' deleted successfully"}
+            if self.get_source(policy_name) == "config":
+                result["warning"] = (
+                    "All DB versions were deleted. The config-defined policy with the same name is active again."
+                )
+            return result
         except Exception as e:
             verbose_proxy_logger.exception(f"Error deleting all versions: {e}")
             raise Exception(f"Error deleting all versions: {str(e)}")

diff --git a/tests/test_litellm/proxy/policy_engine/test_policy_versioning.py b/tests/test_litellm/proxy/policy_engine/test_policy_versioning.py
--- a/tests/test_litellm/proxy/policy_engine/test_policy_versioning.py
+++ b/tests/test_litellm/proxy/policy_engine/test_policy_versioning.py
@@ -613,9 +613,22 @@
         prisma = MagicMock()
         prisma.db.litellm_policytable.delete_many = AsyncMock()
 
-        await registry.delete_all_versions(policy_name="shared-name", prisma_client=prisma)
+        result = await registry.delete_all_versions(policy_name="shared-name", prisma_client=prisma)
 
         assert registry.get_source("shared-name") == "config"
         policy = registry.get_policy("shared-name")
         assert policy is not None
         assert policy.guardrails.add == ["config-guard"]
+        assert "warning" in result
+        assert "config" in result["warning"]
+
+    @pytest.mark.asyncio
+    async def test_delete_all_versions_without_config_omits_warning(self):
+        registry = PolicyRegistry()
+        registry.add_policy("db-only", Policy(guardrails=PolicyGuardrails(add=["db-guard"])), source="db")
+        prisma = MagicMock()
+        prisma.db.litellm_policytable.delete_many = AsyncMock()
+
+        result = await registry.delete_all_versions(policy_name="db-only", prisma_client=prisma)
+
+        assert "warning" not in result

You can send follow-ups to the cloud agent here.

Comment thread litellm/proxy/policy_engine/policy_registry.py
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit b42ef46. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit b42ef46. Configure here.

@mateo-berri
mateo-berri merged commit 74d2917 into litellm_internal_staging Jul 31, 2026
86 of 87 checks passed
@mateo-berri
mateo-berri deleted the litellm_config_policies_survive_db_sync branch July 31, 2026 04:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Config-defined policies and policy_attachments are never listed via API/UI and stop being enforced once a database is connected

3 participants