Files
ragflow/api/db/joint_services/tenant_model_service.py
jony376 7f699d1202 Fix: enforce tenant authorization for tenant_rerank_id in retrieval flows (#14782)
### Related issues

Closes #14781 

### What problem does this PR solve?

Some retrieval endpoints accepted caller-supplied `tenant_rerank_id` and
resolved it through `get_model_config_by_id(...)`. That helper loaded
`TenantLLM` rows by global database id and returned decoded model
configuration without checking whether the model belonged to the
authenticated tenant or the dataset owner tenant.

This meant dataset access was validated, but rerank-model selection was
not. A caller who knew or could guess another tenant's
`tenant_rerank_id` could attempt retrieval with a foreign rerank model
config, creating a cross-tenant authorization gap for model usage.

This PR closes that gap by making `tenant_rerank_id` resolution
tenant-aware across the retrieval paths that accept it.

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
- [ ] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):

### Solution

- Extend `get_model_config_by_id(...)` to accept an optional
`allowed_tenant_ids` set and reject `TenantLLM` rows whose `tenant_id`
is outside that set.
- Pass the allowed tenant scope from retrieval endpoints that accept
`tenant_rerank_id`:
  - `api/apps/sdk/doc.py`
  - `api/apps/sdk/session.py`
  - `api/apps/services/dataset_api_service.py`
- Use the authenticated tenant plus dataset-owner tenant ids already
derived by each retrieval flow as the authorization boundary for rerank
model selection.
- Add focused unit coverage to assert unauthorized `tenant_rerank_id`
values are rejected and that the allowed tenant set is propagated
correctly.

### Testing

- `python -m py_compile` on:
  - `api/db/joint_services/tenant_model_service.py`
  - `api/apps/services/dataset_api_service.py`
  - `api/apps/sdk/doc.py`
  - `api/apps/sdk/session.py`
- Added unit tests in:
-
`test/testcases/test_http_api/test_file_management_within_dataset/test_doc_sdk_routes_unit.py`
-
`test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py`

### Notes for reviewers

- This change is intentionally narrow: it affects only the
`tenant_rerank_id` path, not the normal `rerank_id` name-based
resolution path.
- Local lint/syntax checks passed.
- Full pytest execution could not be completed in this environment
because the local test runtime is missing `strenum`, so the route-test
files fail during collection before exercising the updated cases.

---------

Co-authored-by: jony376 <jony376@gmail.com>
2026-05-13 19:53:08 +08:00

171 lines
8.2 KiB
Python

#
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import logging
import os
import enum
from common import settings
from common.constants import LLMType
from api.db.services.llm_service import LLMService
from api.db.services.tenant_llm_service import TenantLLMService, TenantService
logger = logging.getLogger(__name__)
def get_model_config_by_id(
tenant_model_id: int,
allowed_tenant_ids: str | list[str] | set[str] | tuple[str, ...] | None = None,
requester_tenant_id: str | None = None,
) -> dict:
found, model_config = TenantLLMService.get_by_id(tenant_model_id)
if not found:
raise LookupError(f"Tenant Model with id {tenant_model_id} not found")
if allowed_tenant_ids is not None:
if isinstance(allowed_tenant_ids, str):
allowed_tenant_ids = {allowed_tenant_ids}
else:
allowed_tenant_ids = {str(tenant_id) for tenant_id in allowed_tenant_ids if tenant_id}
if str(model_config.tenant_id) not in allowed_tenant_ids:
logger.warning(
"Denied tenant model access: tenant_model_id=%s model_tenant_id=%s "
"allowed_tenant_ids=%s requester_tenant_id=%s",
tenant_model_id,
model_config.tenant_id,
sorted(allowed_tenant_ids),
requester_tenant_id,
)
raise LookupError(f"Tenant Model with id {tenant_model_id} not authorized")
config_dict = model_config.to_dict()
api_key, is_tools, api_key_payload = TenantLLMService._decode_api_key_config(config_dict.get("api_key", ""))
config_dict["api_key"] = api_key
if api_key_payload is not None:
config_dict["api_key_payload"] = api_key_payload
if is_tools is not None:
config_dict["is_tools"] = is_tools
llm = LLMService.query(llm_name=config_dict["llm_name"])
if "is_tools" not in config_dict and llm:
config_dict["is_tools"] = llm[0].is_tools
return config_dict
def get_model_config_by_type_and_name(tenant_id: str, model_type: str, model_name: str):
if not model_name:
raise Exception("Model Name is required")
model_type_val = model_type.value if hasattr(model_type, "value") else model_type
model_config = TenantLLMService.get_api_key(tenant_id, model_name, model_type_val)
if not model_config:
# model_name in format 'name@factory', split model_name and try again
pure_model_name, fid = TenantLLMService.split_model_name_and_factory(model_name)
compose_profiles = os.getenv("COMPOSE_PROFILES", "")
is_tei_builtin_embedding = (
model_type_val == LLMType.EMBEDDING.value
and "tei-" in compose_profiles
and pure_model_name == os.getenv("TEI_MODEL", "")
and (fid == "Builtin" or fid is None)
)
if is_tei_builtin_embedding:
# configured local embedding model
embedding_cfg = settings.EMBEDDING_CFG
config_dict = {
"llm_factory": "Builtin",
"api_key": embedding_cfg["api_key"],
"llm_name": pure_model_name,
"api_base": embedding_cfg["base_url"],
"model_type": LLMType.EMBEDDING.value,
}
elif model_type_val == LLMType.CHAT.value:
# Retry as CHAT with pure_model_name first; then fall back to a multimodal model registered under IMAGE2TEXT.
model_config = TenantLLMService.get_api_key(tenant_id, pure_model_name, LLMType.CHAT.value)
if not model_config:
model_config = TenantLLMService.get_api_key(tenant_id, pure_model_name, LLMType.IMAGE2TEXT.value)
if not model_config:
raise LookupError(f"Tenant Model with name {model_name} and type {model_type_val} not found")
config_dict = model_config.to_dict()
elif model_type_val == LLMType.IMAGE2TEXT.value:
model_config = TenantLLMService.get_api_key(tenant_id, pure_model_name, LLMType.IMAGE2TEXT.value)
if not model_config:
# Fall back to a chat model only if it has declared IMAGE2TEXT capability (tag check via llm table)
chat_config = TenantLLMService.get_api_key(tenant_id, pure_model_name, LLMType.CHAT.value)
logger.debug("IMAGE2TEXT config not found for %s; chat_config found: %s", pure_model_name, chat_config is not None)
if chat_config:
llm_entry = LLMService.query(fid=chat_config.llm_factory, llm_name=chat_config.llm_name)
tags = [t.strip() for t in (llm_entry[0].tags or "").split(",")] if llm_entry else []
logger.debug("LLM tags for %s/%s: %s", chat_config.llm_factory, chat_config.llm_name, tags)
if "IMAGE2TEXT" in tags:
logger.debug("Promoting chat config to IMAGE2TEXT for %s", pure_model_name)
model_config = chat_config
if not model_config:
raise LookupError(f"Tenant Model with name {model_name} and type {model_type_val} not found")
config_dict = model_config.to_dict()
config_dict["model_type"] = LLMType.IMAGE2TEXT.value
else:
model_config = TenantLLMService.get_api_key(tenant_id, pure_model_name, model_type_val)
if not model_config:
raise LookupError(f"Tenant Model with name {model_name} and type {model_type_val} not found")
config_dict = model_config.to_dict()
else:
# model_name without @factory
config_dict = model_config.to_dict()
api_key, is_tools, api_key_payload = TenantLLMService._decode_api_key_config(config_dict.get("api_key", ""))
config_dict["api_key"] = api_key
if api_key_payload is not None:
config_dict["api_key_payload"] = api_key_payload
if is_tools is not None:
config_dict["is_tools"] = is_tools
config_model_type = config_dict.get("model_type")
config_model_type = config_model_type.value if hasattr(config_model_type, "value") else config_model_type
if config_model_type != model_type_val and not (
model_type_val == LLMType.CHAT.value
and config_model_type == LLMType.IMAGE2TEXT.value
) and not (
model_type_val == LLMType.IMAGE2TEXT.value
and config_model_type == LLMType.CHAT.value
):
raise LookupError(
f"Tenant Model with name {model_name} has type {config_model_type}, expected {model_type_val}"
)
llm = LLMService.query(llm_name=config_dict["llm_name"])
if "is_tools" not in config_dict and llm:
config_dict["is_tools"] = llm[0].is_tools
return config_dict
def get_tenant_default_model_by_type(tenant_id: str, model_type: str|enum.Enum):
exist, tenant = TenantService.get_by_id(tenant_id)
if not exist:
raise LookupError("Tenant not found")
model_type_val = model_type if isinstance(model_type, str) else model_type.value
model_name: str = ""
match model_type_val:
case LLMType.EMBEDDING.value:
model_name = tenant.embd_id
case LLMType.SPEECH2TEXT.value:
model_name = tenant.asr_id
case LLMType.IMAGE2TEXT.value:
model_name = tenant.img2txt_id
case LLMType.CHAT.value:
model_name = tenant.llm_id
case LLMType.RERANK.value:
model_name = tenant.rerank_id
case LLMType.TTS.value:
model_name = tenant.tts_id
case LLMType.OCR.value:
raise Exception("OCR model name is required")
case _:
raise Exception(f"Unknown model type {model_type}")
if not model_name:
raise Exception(f"No default {model_type} model is set.")
return get_model_config_by_type_and_name(tenant_id, model_type, model_name)