fix(team): hydrate object_permission on cache-refreshing team updates
Some checks failed
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Security / security (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled
Unit Tests: Proxy DB Operations / schema-migration (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled

The Prisma update calls in update_team, team_model_add, and
team_model_delete returned a team row with object_permission_id set
but object_permission=None (the relation was not requested via
include=). _refresh_cached_team then wrote that to the in-memory
LiteLLM_TeamTableCachedObj, and the cache-hit path in get_team_object
returns the cached object without re-hydrating. Downstream consumers
(validate_key_search_tools_against_team, the MCP/agent authz paths)
treat a missing object_permission as no team-level restriction, so
a team-write op silently dropped object-permission enforcement until
the cache TTL expired or a DB-fetch path re-hydrated it.

Add include={"object_permission": True} to all three updates so the
refresh writes a complete cached team. Extend the LIT-3244 regression
test to pin both the cached object_permission and the include shape
on the Prisma call.

Surfaced in PR review of LIT-3244.
This commit is contained in:
Yuneng Jiang 2026-05-23 10:01:39 -07:00
parent e5d63af402
commit 5235471e3b
No known key found for this signature in database
2 changed files with 50 additions and 5 deletions

View file

@ -1879,7 +1879,13 @@ async def update_team( # noqa: PLR0915
await prisma_client.db.litellm_teamtable.update(
where={"team_id": data.team_id},
data=updated_kv,
include={"litellm_model_table": True}, # type: ignore
# `object_permission` is included so `_refresh_cached_team`
# doesn't write a cached team with the relation nulled out —
# see team_model_add for the full rationale.
include={
"litellm_model_table": True,
"object_permission": True,
}, # type: ignore
)
)
@ -4624,9 +4630,15 @@ async def team_model_add(
)
updated_models = add_new_models_to_team(team_obj=team_obj, new_models=data.models)
# Update team
# Update team. `include` mirrors the relations the auth path consumes
# off the cached team object so that `_refresh_cached_team` doesn't
# null them out — see object_permission_utils.validate_key_search_tools_against_team
# and the MCP/agent authz paths, which treat a missing object_permission
# as "no team-level restriction".
updated_team = await prisma_client.db.litellm_teamtable.update(
where={"team_id": data.team_id}, data={"models": updated_models}
where={"team_id": data.team_id},
data={"models": updated_models},
include={"object_permission": True}, # type: ignore
)
await _refresh_cached_team(
@ -4710,9 +4722,11 @@ async def team_model_delete(
# Remove specified models
updated_models = [m for m in current_models if m not in data.models]
# Update team
# Update team. See team_model_add for the rationale on `include`.
updated_team = await prisma_client.db.litellm_teamtable.update(
where={"team_id": data.team_id}, data={"models": updated_models}
where={"team_id": data.team_id},
data={"models": updated_models},
include={"object_permission": True}, # type: ignore
)
await _refresh_cached_team(

View file

@ -1582,6 +1582,11 @@ async def test_team_model_add_delete_refresh_team_cache(endpoint_name):
existing_team.model_dump.return_value = {
"team_id": "team-1234",
"models": ["bedrock-claude-sonnet-4", "openai/*"],
"object_permission_id": "op-1234",
"object_permission": {
"object_permission_id": "op-1234",
"search_tools": ["allowed-tool-A"],
},
}
updated_team = MagicMock()
@ -1589,6 +1594,14 @@ async def test_team_model_add_delete_refresh_team_cache(endpoint_name):
updated_team.model_dump.return_value = {
"team_id": "team-1234",
"models": ["bedrock-claude-sonnet-4", "openai/*", "team-byok-1"],
# The Prisma update must come back with `object_permission` populated
# (via `include={"object_permission": True}`), otherwise the cache
# write below would null it out — see LIT-3244 follow-up.
"object_permission_id": "op-1234",
"object_permission": {
"object_permission_id": "op-1234",
"search_tools": ["allowed-tool-A"],
},
}
with (
@ -1638,6 +1651,24 @@ async def test_team_model_add_delete_refresh_team_cache(endpoint_name):
"openai/*",
"team-byok-1",
]
# And the cached object MUST carry the `object_permission` relation
# (LIT-3244 follow-up). If the Prisma update were missing
# `include={"object_permission": True}`, the cached team would have
# object_permission=None, and downstream consumers like
# `validate_key_search_tools_against_team` would treat that as
# "no team-level restriction" and stop enforcing the team's
# search-tool allowlist on key issuance.
assert call_kwargs["team_table"].object_permission is not None
assert call_kwargs["team_table"].object_permission.search_tools == [
"allowed-tool-A"
]
# Pin the Prisma call shape too — the regression is in *what the
# update returns*, so the contract that the update asks for
# `object_permission` belongs in this test.
update_call_kwargs = (
mock_prisma_client.db.litellm_teamtable.update.call_args.kwargs
)
assert update_call_kwargs.get("include", {}).get("object_permission") is True
@pytest.mark.asyncio