Skip to content

Commit 9afa387

Browse files
fix: RBAC bypass vulnerabilities in model access (backport #4270) (#4285)
Closes security gaps where RBAC checks could be bypassed: o Inference router: Added RBAC enforcement in the fallback path to ensure access control is applied consistently. o Model listing: Dynamic models fetched via provider_data were returned without RBAC checks. Added filtering to ensure users only see models they have permission to access. Both fixes create temporary ModelWithOwner objects for RBAC validation, maintaining security through consistent access control enforcement. Closes: #4269 <hr>This is an automatic backport of pull request #4270 done by [Mergify](https://mergify.com). Signed-off-by: Derek Higgins <[email protected]> Signed-off-by: Charlie Doern <[email protected]> Co-authored-by: Derek Higgins <[email protected]>
1 parent 01736b1 commit 9afa387

File tree

2 files changed

+130
-1
lines changed

2 files changed

+130
-1
lines changed

llama_stack/core/routers/inference.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,17 @@
4949
)
5050
from llama_stack.apis.models import Model, ModelType
5151
from llama_stack.apis.telemetry import MetricEvent, MetricInResponse, Telemetry
52+
from llama_stack.core.access_control.access_control import is_action_allowed
53+
from llama_stack.core.datatypes import ModelWithOwner
54+
from llama_stack.core.request_headers import get_authenticated_user
5255
from llama_stack.log import get_logger
5356
from llama_stack.models.llama.llama3.chat_format import ChatFormat
5457
from llama_stack.models.llama.llama3.tokenizer import Tokenizer
55-
from llama_stack.providers.datatypes import HealthResponse, HealthStatus, RoutingTable
58+
from llama_stack.providers.datatypes import (
59+
HealthResponse,
60+
HealthStatus,
61+
RoutingTable,
62+
)
5663
from llama_stack.providers.utils.inference.inference_store import InferenceStore
5764
from llama_stack.providers.utils.telemetry.tracing import enqueue_event, get_current_span
5865

@@ -186,15 +193,41 @@ async def _get_model_provider(self, model_id: str, expected_model_type: str) ->
186193
provider = await self.routing_table.get_provider_impl(model.identifier)
187194
return provider, model.provider_resource_id
188195

196+
# Handles cases where clients use the provider format directly
197+
return await self._get_provider_by_fallback(model_id, expected_model_type)
198+
199+
async def _get_provider_by_fallback(self, model_id: str, expected_model_type: str) -> tuple[Inference, str]:
200+
"""
201+
Handle fallback case where model_id is in provider_id/provider_resource_id format.
202+
"""
189203
splits = model_id.split("/", maxsplit=1)
190204
if len(splits) != 2:
191205
raise ModelNotFoundError(model_id)
192206

193207
provider_id, provider_resource_id = splits
208+
209+
# Check if provider exists
194210
if provider_id not in self.routing_table.impls_by_provider_id:
195211
logger.warning(f"Provider {provider_id} not found for model {model_id}")
196212
raise ModelNotFoundError(model_id)
197213

214+
# Create a temporary model object for RBAC check
215+
temp_model = ModelWithOwner(
216+
identifier=model_id,
217+
provider_id=provider_id,
218+
provider_resource_id=provider_resource_id,
219+
model_type=expected_model_type,
220+
metadata={}, # Empty metadata for temporary object
221+
)
222+
223+
# Perform RBAC check
224+
user = get_authenticated_user()
225+
if not is_action_allowed(self.routing_table.policy, "read", temp_model, user):
226+
logger.debug(
227+
f"Access denied to model '{model_id}' via fallback path for user {user.principal if user else 'anonymous'}"
228+
)
229+
raise ModelNotFoundError(model_id)
230+
198231
return self.routing_table.impls_by_provider_id[provider_id], provider_resource_id
199232

200233
async def openai_completion(

tests/unit/server/test_access_control.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
import yaml
1111
from pydantic import TypeAdapter, ValidationError
1212

13+
from llama_stack.apis.common.errors import ModelNotFoundError
1314
from llama_stack.apis.datatypes import Api
1415
from llama_stack.apis.models import ModelType
1516
from llama_stack.core.access_control.access_control import AccessDeniedError, is_action_allowed
1617
from llama_stack.core.datatypes import AccessRule, ModelWithOwner, User
18+
from llama_stack.core.routers.inference import InferenceRouter
1719
from llama_stack.core.routing_tables.models import ModelsRoutingTable
1820

1921

@@ -558,3 +560,97 @@ def test_condition_reprs(condition):
558560
from llama_stack.core.access_control.conditions import parse_condition
559561

560562
assert condition == str(parse_condition(condition))
563+
564+
565+
@pytest.fixture
566+
def restricted_user():
567+
"""User with limited access."""
568+
return User("restricted-user", {"roles": ["user"]})
569+
570+
571+
@pytest.fixture
572+
def admin_user():
573+
"""User with admin access."""
574+
return User("admin-user", {"roles": ["admin"]})
575+
576+
577+
@pytest.fixture
578+
def rbac_policy():
579+
"""RBAC policy that restricts access to certain models."""
580+
from llama_stack.core.access_control.datatypes import Action, Scope
581+
582+
return [
583+
# Admins get full access
584+
AccessRule(
585+
permit=Scope(actions=list(Action)),
586+
when=["user with admin in roles"],
587+
),
588+
# Regular users only get read access to their own resources
589+
AccessRule(
590+
permit=Scope(actions=[Action.READ]),
591+
when=["user is owner"],
592+
),
593+
]
594+
595+
596+
class TestInferenceRouterRBACBypass:
597+
"""Test RBAC bypass vulnerability in inference router fallback path."""
598+
599+
@pytest.fixture
600+
def mock_routing_table(self):
601+
"""Create a mock routing table for testing."""
602+
routing_table = AsyncMock()
603+
routing_table.impls_by_provider_id = {"test-provider": AsyncMock()}
604+
routing_table.policy = []
605+
return routing_table
606+
607+
@patch("llama_stack.core.routers.inference.get_authenticated_user")
608+
async def test_registry_path_and_fallback_path_consistent(
609+
self, mock_get_user, mock_routing_table, restricted_user, admin_user, rbac_policy
610+
):
611+
"""Test that registry path and fallback path have consistent RBAC enforcement."""
612+
mock_routing_table.policy = rbac_policy
613+
614+
# Create a model owned by admin
615+
admin_model = ModelWithOwner(
616+
identifier="admin-model",
617+
provider_id="test-provider",
618+
provider_resource_id="admin-resource",
619+
model_type=ModelType.llm,
620+
type="model",
621+
metadata={},
622+
owner=admin_user,
623+
)
624+
625+
# Setup router
626+
router = InferenceRouter(
627+
routing_table=mock_routing_table,
628+
store=None,
629+
)
630+
631+
# Test 1: Restricted user tries to access via registry (should fail)
632+
mock_get_user.return_value = restricted_user
633+
mock_routing_table.get_object_by_identifier.return_value = None # RBAC blocks it
634+
with pytest.raises(ModelNotFoundError):
635+
await router._get_model_provider("admin-model", "llm")
636+
637+
# Test 2: Restricted user tries to access via fallback path (should also fail)
638+
mock_routing_table.get_object_by_identifier.return_value = None
639+
with pytest.raises(ModelNotFoundError):
640+
await router._get_model_provider("test-provider/admin-resource", "llm")
641+
642+
# Test 3: Admin user can access via registry
643+
mock_get_user.return_value = admin_user
644+
mock_routing_table.get_object_by_identifier.return_value = admin_model
645+
provider_mock = AsyncMock()
646+
mock_routing_table.get_provider_impl.return_value = provider_mock
647+
648+
provider, resource_id = await router._get_model_provider("admin-model", "llm")
649+
assert provider == provider_mock
650+
assert resource_id == "admin-resource"
651+
652+
# Test 4: Admin user can also access via fallback path
653+
mock_routing_table.get_object_by_identifier.return_value = None
654+
provider, resource_id = await router._get_model_provider("test-provider/admin-resource", "llm")
655+
assert provider == mock_routing_table.impls_by_provider_id["test-provider"]
656+
assert resource_id == "admin-resource"

0 commit comments

Comments
 (0)