fix(proxy): normalize managed resource team owner field

This commit is contained in:
user 2026-05-04 17:05:50 -07:00
parent 799d79160a
commit 83971a8712
11 changed files with 74 additions and 75 deletions

View File

@ -104,7 +104,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
model_mappings=model_mappings,
flat_model_file_ids=list(model_mappings.values()),
created_by=user_api_key_dict.user_id,
created_by_team_id=user_api_key_dict.team_id,
team_id=user_api_key_dict.team_id,
updated_by=user_api_key_dict.user_id,
)
await self.internal_usage_cache.async_set_cache(
@ -120,7 +120,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
"model_mappings": json.dumps(model_mappings),
"flat_model_file_ids": list(model_mappings.values()),
"created_by": user_api_key_dict.user_id,
"created_by_team_id": user_api_key_dict.team_id,
"team_id": user_api_key_dict.team_id,
"updated_by": user_api_key_dict.user_id,
}
@ -178,7 +178,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
"model_object_id": model_object_id,
"file_purpose": file_purpose,
"created_by": user_api_key_dict.user_id,
"created_by_team_id": user_api_key_dict.team_id,
"team_id": user_api_key_dict.team_id,
"updated_by": user_api_key_dict.user_id,
"status": file_object.status,
},
@ -245,7 +245,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
return can_access_resource(
user_api_key_dict=user_api_key_dict,
created_by=managed_file.created_by,
created_by_team_id=managed_file.created_by_team_id,
resource_team_id=managed_file.team_id,
)
raise HTTPException(
status_code=404,
@ -265,7 +265,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
return can_access_resource(
user_api_key_dict=user_api_key_dict,
created_by=managed_object.created_by,
created_by_team_id=managed_object.created_by_team_id,
resource_team_id=managed_object.team_id,
)
raise HTTPException(
status_code=404,
@ -349,7 +349,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
"""
Get all file ids the caller is allowed to see for a list of model
object ids. Service-account keys (no user_id) are scoped to their
team via ``created_by_team_id``; admins see all matches.
team via ``team_id``; admins see all matches.
Returns:
- List of OpenAIFileObject's

View File

@ -1,6 +1,6 @@
-- Adds `created_by_team_id` to managed-resource tables so service-account API
-- Adds `team_id` to managed-resource tables so service-account API
-- keys (no `user_id`) can be scoped by team instead of bypassing the
-- `created_by` filter entirely. Existing rows keep `created_by_team_id = NULL`
-- `created_by` filter entirely. Existing rows keep `team_id = NULL`
-- and become invisible to team-only callers — that is the intended isolation
-- outcome; backfill manually if legacy rows must remain visible.
--
@ -9,13 +9,12 @@
-- request); a future operator with a large table can switch to
-- CREATE INDEX CONCURRENTLY in a follow-up migration.
ALTER TABLE "LiteLLM_ManagedFileTable" ADD COLUMN IF NOT EXISTS "created_by_team_id" TEXT;
ALTER TABLE "LiteLLM_ManagedObjectTable" ADD COLUMN IF NOT EXISTS "created_by_team_id" TEXT;
ALTER TABLE "LiteLLM_ManagedVectorStoreTable" ADD COLUMN IF NOT EXISTS "created_by_team_id" TEXT;
ALTER TABLE "LiteLLM_ManagedFileTable" ADD COLUMN IF NOT EXISTS "team_id" TEXT;
ALTER TABLE "LiteLLM_ManagedObjectTable" ADD COLUMN IF NOT EXISTS "team_id" TEXT;
ALTER TABLE "LiteLLM_ManagedVectorStoreTable" ADD COLUMN IF NOT EXISTS "team_id" TEXT;
-- Index names follow Prisma's auto-generated convention so `prisma migrate diff`
-- against the schema is clean. Postgres caps identifier length at 63 chars,
-- which truncates the vector-store name to `_created__idx`.
CREATE INDEX IF NOT EXISTS "LiteLLM_ManagedFileTable_created_by_team_id_created_at_idx" ON "LiteLLM_ManagedFileTable" ("created_by_team_id", "created_at" DESC);
CREATE INDEX IF NOT EXISTS "LiteLLM_ManagedObjectTable_created_by_team_id_created_at_idx" ON "LiteLLM_ManagedObjectTable" ("created_by_team_id", "created_at" DESC);
CREATE INDEX IF NOT EXISTS "LiteLLM_ManagedVectorStoreTable_created_by_team_id_created__idx" ON "LiteLLM_ManagedVectorStoreTable" ("created_by_team_id", "created_at" DESC);
-- against the schema is clean.
CREATE INDEX IF NOT EXISTS "LiteLLM_ManagedFileTable_team_id_created_at_idx" ON "LiteLLM_ManagedFileTable" ("team_id", "created_at" DESC);
CREATE INDEX IF NOT EXISTS "LiteLLM_ManagedObjectTable_team_id_created_at_idx" ON "LiteLLM_ManagedObjectTable" ("team_id", "created_at" DESC);
CREATE INDEX IF NOT EXISTS "LiteLLM_ManagedVectorStoreTable_team_id_created_at_idx" ON "LiteLLM_ManagedVectorStoreTable" ("team_id", "created_at" DESC);

View File

@ -885,12 +885,12 @@ model LiteLLM_ManagedFileTable {
storage_url String? // The actual storage URL where the file is stored
created_at DateTime @default(now())
created_by String?
created_by_team_id String? // Team that owns the resource; populated for service-account keys without a user_id so listings can isolate by team.
team_id String? // Team that owns the resource; populated for service-account keys without a user_id so listings can isolate by team.
updated_at DateTime @updatedAt
updated_by String?
@@index([unified_file_id])
@@index([created_by_team_id, created_at(sort: Desc)])
@@index([team_id, created_at(sort: Desc)])
}
model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use the
@ -903,13 +903,13 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t
batch_processed Boolean @default(false) // set to true by CheckBatchCost after cost is computed
created_at DateTime @default(now())
created_by String?
created_by_team_id String?
team_id String?
updated_at DateTime @updatedAt
updated_by String?
@@index([unified_object_id])
@@index([model_object_id])
@@index([created_by_team_id, created_at(sort: Desc)])
@@index([team_id, created_at(sort: Desc)])
}
model LiteLLM_ManagedVectorStoreTable {
@ -922,12 +922,12 @@ model LiteLLM_ManagedVectorStoreTable {
storage_url String? // Storage URL (if applicable)
created_at DateTime @default(now())
created_by String?
created_by_team_id String?
team_id String?
updated_at DateTime @updatedAt
updated_by String?
@@index([unified_resource_id])
@@index([created_by_team_id, created_at(sort: Desc)])
@@index([team_id, created_at(sort: Desc)])
}
model LiteLLM_ManagedVectorStoresTable {

View File

@ -174,7 +174,7 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
"model_mappings": model_mappings,
"flat_model_resource_ids": list(model_mappings.values()),
"created_by": user_api_key_dict.user_id,
"created_by_team_id": user_api_key_dict.team_id,
"team_id": user_api_key_dict.team_id,
"updated_by": user_api_key_dict.user_id,
}
@ -196,7 +196,7 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
"model_mappings": json.dumps(model_mappings),
"flat_model_resource_ids": list(model_mappings.values()),
"created_by": user_api_key_dict.user_id,
"created_by_team_id": user_api_key_dict.team_id,
"team_id": user_api_key_dict.team_id,
"updated_by": user_api_key_dict.user_id,
}
@ -332,7 +332,7 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
return can_access_resource(
user_api_key_dict=user_api_key_dict,
created_by=resource.get("created_by"),
created_by_team_id=resource.get("created_by_team_id"),
resource_team_id=resource.get("team_id"),
)
return False

View File

@ -36,7 +36,7 @@ def build_owner_filter(
- ``{}`` means no scoping (proxy admins).
- ``{"created_by": <user_id>}`` for user-keyed callers.
- ``{"created_by_team_id": <team_id>}`` for service-account callers
- ``{"team_id": <team_id>}`` for service-account callers
that have a team but no user_id.
- ``{"OR": [...]}`` when the caller has both listing must include
both their own resources and team-shared ones so it stays consistent
@ -54,7 +54,7 @@ def build_owner_filter(
return {
"OR": [
{"created_by": user_id},
{"created_by_team_id": team_id},
{"team_id": team_id},
]
}
@ -62,7 +62,7 @@ def build_owner_filter(
return {"created_by": user_id}
if team_id is not None:
return {"created_by_team_id": team_id}
return {"team_id": team_id}
return None
@ -70,12 +70,12 @@ def build_owner_filter(
def can_access_resource(
user_api_key_dict: UserAPIKeyAuth,
created_by: Optional[str],
created_by_team_id: Optional[str],
resource_team_id: Optional[str],
) -> bool:
"""Return True iff the caller may read/modify a managed resource.
Both ``created_by`` and ``created_by_team_id`` must be non-None to
match the caller's identity — guarding against the ``None == None``
The resource's ``created_by`` and ``team_id`` fields must be non-None
to match the caller's identity — guarding against the ``None == None``
bypass that previously let service-account keys read every keyless
resource.
"""
@ -89,8 +89,8 @@ def can_access_resource(
team_id = user_api_key_dict.team_id
if (
team_id is not None
and created_by_team_id is not None
and created_by_team_id == team_id
and resource_team_id is not None
and resource_team_id == team_id
):
return True

View File

@ -4539,7 +4539,7 @@ class LiteLLM_ManagedFileTable(LiteLLMPydanticObjectBase):
model_mappings: Dict[str, str]
flat_model_file_ids: List[str]
created_by: Optional[str] = None
created_by_team_id: Optional[str] = None
team_id: Optional[str] = None
updated_by: Optional[str] = None
storage_backend: Optional[str] = None
storage_url: Optional[str] = None
@ -4551,7 +4551,7 @@ class LiteLLM_ManagedObjectTable(LiteLLMPydanticObjectBase):
file_purpose: Literal["batch", "fine-tune", "response"]
file_object: Union[LiteLLMBatch, LiteLLMFineTuningJob, ResponsesAPIResponse]
created_by: Optional[str] = None
created_by_team_id: Optional[str] = None
team_id: Optional[str] = None
class LiteLLM_ManagedVectorStoreTable(LiteLLMPydanticObjectBase):
@ -4562,7 +4562,7 @@ class LiteLLM_ManagedVectorStoreTable(LiteLLMPydanticObjectBase):
model_mappings: Dict[str, str]
flat_model_resource_ids: List[str]
created_by: Optional[str] = None
created_by_team_id: Optional[str] = None
team_id: Optional[str] = None
updated_by: Optional[str] = None
storage_backend: Optional[str] = None
storage_url: Optional[str] = None

View File

@ -885,12 +885,12 @@ model LiteLLM_ManagedFileTable {
storage_url String? // The actual storage URL where the file is stored
created_at DateTime @default(now())
created_by String?
created_by_team_id String? // Team that owns the resource; populated for service-account keys without a user_id so listings can isolate by team.
team_id String? // Team that owns the resource; populated for service-account keys without a user_id so listings can isolate by team.
updated_at DateTime @updatedAt
updated_by String?
@@index([unified_file_id])
@@index([created_by_team_id, created_at(sort: Desc)])
@@index([team_id, created_at(sort: Desc)])
}
model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use the
@ -903,13 +903,13 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t
batch_processed Boolean @default(false) // set to true by CheckBatchCost after cost is computed
created_at DateTime @default(now())
created_by String?
created_by_team_id String?
team_id String?
updated_at DateTime @updatedAt
updated_by String?
@@index([unified_object_id])
@@index([model_object_id])
@@index([created_by_team_id, created_at(sort: Desc)])
@@index([team_id, created_at(sort: Desc)])
}
model LiteLLM_ManagedVectorStoreTable {
@ -922,12 +922,12 @@ model LiteLLM_ManagedVectorStoreTable {
storage_url String? // Storage URL (if applicable)
created_at DateTime @default(now())
created_by String?
created_by_team_id String?
team_id String?
updated_at DateTime @updatedAt
updated_by String?
@@index([unified_resource_id])
@@index([created_by_team_id, created_at(sort: Desc)])
@@index([team_id, created_at(sort: Desc)])
}
model LiteLLM_ManagedVectorStoresTable {

View File

@ -885,12 +885,12 @@ model LiteLLM_ManagedFileTable {
storage_url String? // The actual storage URL where the file is stored
created_at DateTime @default(now())
created_by String?
created_by_team_id String? // Team that owns the resource; populated for service-account keys without a user_id so listings can isolate by team.
team_id String? // Team that owns the resource; populated for service-account keys without a user_id so listings can isolate by team.
updated_at DateTime @updatedAt
updated_by String?
@@index([unified_file_id])
@@index([created_by_team_id, created_at(sort: Desc)])
@@index([team_id, created_at(sort: Desc)])
}
model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use the
@ -903,13 +903,13 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t
batch_processed Boolean @default(false) // set to true by CheckBatchCost after cost is computed
created_at DateTime @default(now())
created_by String?
created_by_team_id String?
team_id String?
updated_at DateTime @updatedAt
updated_by String?
@@index([unified_object_id])
@@index([model_object_id])
@@index([created_by_team_id, created_at(sort: Desc)])
@@index([team_id, created_at(sort: Desc)])
}
model LiteLLM_ManagedVectorStoreTable {
@ -922,12 +922,12 @@ model LiteLLM_ManagedVectorStoreTable {
storage_url String? // Storage URL (if applicable)
created_at DateTime @default(now())
created_by String?
created_by_team_id String?
team_id String?
updated_at DateTime @updatedAt
updated_by String?
@@index([unified_resource_id])
@@index([created_by_team_id, created_at(sort: Desc)])
@@index([team_id, created_at(sort: Desc)])
}
model LiteLLM_ManagedVectorStoresTable {

View File

@ -34,7 +34,7 @@ def _make_unified_file_id() -> str:
def _make_managed_files_instance(
file_created_by: str,
unified_file_id: str,
file_created_by_team_id=None,
file_team_id=None,
):
"""Create a _PROXY_LiteLLMManagedFiles with a mocked DB that returns a file owned by file_created_by."""
from litellm_enterprise.proxy.hooks.managed_files import (
@ -43,7 +43,7 @@ def _make_managed_files_instance(
mock_db_record = MagicMock()
mock_db_record.created_by = file_created_by
mock_db_record.created_by_team_id = file_created_by_team_id
mock_db_record.team_id = file_team_id
mock_prisma = MagicMock()
mock_prisma.db.litellm_managedfiletable.find_first = AsyncMock(
@ -110,7 +110,7 @@ async def test_should_block_default_user_id_access():
assert exc_info.value.status_code == 403
# --- Service-account isolation: created_by/created_by_team_id checks ---
# --- Service-account isolation: created_by/team_id checks ---
@pytest.mark.asyncio
@ -120,7 +120,7 @@ async def test_keyless_caller_cannot_access_keyless_file():
unified_file_id = _make_unified_file_id()
managed_files = _make_managed_files_instance(
file_created_by=None,
file_created_by_team_id=None,
file_team_id=None,
unified_file_id=unified_file_id,
)
keyless = UserAPIKeyAuth(api_key="sk-test", parent_otel_span=None)
@ -136,7 +136,7 @@ async def test_service_account_can_access_team_file():
unified_file_id = _make_unified_file_id()
managed_files = _make_managed_files_instance(
file_created_by=None,
file_created_by_team_id="team-eng",
file_team_id="team-eng",
unified_file_id=unified_file_id,
)
sa = UserAPIKeyAuth(api_key="sk-svc", team_id="team-eng", parent_otel_span=None)
@ -150,7 +150,7 @@ async def test_service_account_blocked_from_other_team_file():
unified_file_id = _make_unified_file_id()
managed_files = _make_managed_files_instance(
file_created_by=None,
file_created_by_team_id="team-sales",
file_team_id="team-sales",
unified_file_id=unified_file_id,
)
sa = UserAPIKeyAuth(api_key="sk-svc", team_id="team-eng", parent_otel_span=None)

View File

@ -58,7 +58,7 @@ async def test_list_admin_query_is_unscoped():
table = resource.prisma_client.db.litellm_test_resource_table
where = table.find_many.await_args.kwargs["where"]
assert "created_by" not in where
assert "created_by_team_id" not in where
assert "team_id" not in where
@pytest.mark.asyncio
@ -72,7 +72,7 @@ async def test_list_user_filters_by_user_id():
"where"
]
assert where["created_by"] == "alice"
assert "created_by_team_id" not in where
assert "team_id" not in where
@pytest.mark.asyncio
@ -85,7 +85,7 @@ async def test_list_service_account_filters_by_team_id():
where = resource.prisma_client.db.litellm_test_resource_table.find_many.await_args.kwargs[
"where"
]
assert where["created_by_team_id"] == "team-eng"
assert where["team_id"] == "team-eng"
assert "created_by" not in where
@ -118,7 +118,7 @@ async def test_can_access_uses_team_id_for_service_account(caller_team_id, expec
cache.async_get_cache = AsyncMock(
return_value={
"created_by": None,
"created_by_team_id": "team-eng",
"team_id": "team-eng",
}
)
prisma = MagicMock()

View File

@ -31,7 +31,7 @@ def test_owner_filter_user_scoped_to_user_id():
def test_owner_filter_service_account_scoped_to_team():
service_account = UserAPIKeyAuth(team_id="team-eng")
assert build_owner_filter(service_account) == {"created_by_team_id": "team-eng"}
assert build_owner_filter(service_account) == {"team_id": "team-eng"}
def test_owner_filter_user_with_team_returns_or_filter():
@ -42,7 +42,7 @@ def test_owner_filter_user_with_team_returns_or_filter():
assert build_owner_filter(user) == {
"OR": [
{"created_by": "alice"},
{"created_by_team_id": "team-eng"},
{"team_id": "team-eng"},
]
}
@ -64,14 +64,14 @@ def test_owner_filter_no_identity_returns_none():
[LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY],
)
@pytest.mark.parametrize(
"created_by,created_by_team_id",
"created_by,resource_team_id",
[("alice", "team-eng"), (None, None)],
)
def test_access_admin_can_read_any_resource(role, created_by, created_by_team_id):
def test_access_admin_can_read_any_resource(role, created_by, resource_team_id):
admin = UserAPIKeyAuth(user_role=role)
assert (
can_access_resource(
admin, created_by=created_by, created_by_team_id=created_by_team_id
admin, created_by=created_by, resource_team_id=resource_team_id
)
is True
)
@ -88,24 +88,26 @@ def test_access_admin_can_read_any_resource(role, created_by, created_by_team_id
def test_access_user_id_match(user_id, created_by, expected):
user = UserAPIKeyAuth(user_id=user_id)
assert (
can_access_resource(user, created_by=created_by, created_by_team_id=None)
can_access_resource(user, created_by=created_by, resource_team_id=None)
is expected
)
@pytest.mark.parametrize(
"team_id,created_by_team_id,expected",
"caller_team_id,resource_team_id,expected",
[
("team-eng", "team-eng", True),
("team-eng", "team-sales", False),
("team-eng", None, False),
],
)
def test_access_service_account_team_id_match(team_id, created_by_team_id, expected):
service_account = UserAPIKeyAuth(team_id=team_id)
def test_access_service_account_team_id_match(
caller_team_id, resource_team_id, expected
):
service_account = UserAPIKeyAuth(team_id=caller_team_id)
assert (
can_access_resource(
service_account, created_by=None, created_by_team_id=created_by_team_id
service_account, created_by=None, resource_team_id=resource_team_id
)
is expected
)
@ -117,9 +119,7 @@ def test_access_user_can_see_team_match_when_no_user_id_match():
same team."""
user = UserAPIKeyAuth(user_id="alice", team_id="team-eng")
assert (
can_access_resource(
user, created_by="service-bot", created_by_team_id="team-eng"
)
can_access_resource(user, created_by="service-bot", resource_team_id="team-eng")
is True
)
@ -128,14 +128,14 @@ def test_access_service_account_denied_user_resource_in_different_team():
service_account = UserAPIKeyAuth(team_id="team-eng")
assert (
can_access_resource(
service_account, created_by="bob", created_by_team_id="team-sales"
service_account, created_by="bob", resource_team_id="team-sales"
)
is False
)
@pytest.mark.parametrize(
"created_by,created_by_team_id",
"created_by,resource_team_id",
[
(None, None),
("anybody", None),
@ -143,14 +143,14 @@ def test_access_service_account_denied_user_resource_in_different_team():
("anybody", "any-team"),
],
)
def test_access_identity_less_caller_always_denied(created_by, created_by_team_id):
def test_access_identity_less_caller_always_denied(created_by, resource_team_id):
"""The original `None == None` bypass — a caller with no admin role and
no identifying ids is denied against every resource regardless of how
the resource was tagged."""
nobody = UserAPIKeyAuth()
assert (
can_access_resource(
nobody, created_by=created_by, created_by_team_id=created_by_team_id
nobody, created_by=created_by, resource_team_id=resource_team_id
)
is False
)