feat(conversational): implement route permissions and access control

- Added  to  for defining access control on routes.
- Introduced  decorator to specify permissions needed for route access.
- Enhanced  with methods to check route access based on user permissions.
- Updated documentation across multiple languages to reflect changes in route permissions and usage examples.
- Added tests to verify permission handling and redirection to  for unauthorized access.
This commit is contained in:
Lorenze Jay
2026-06-06 15:09:07 -07:00
parent 913a3abead
commit 9b251b1de4
9 changed files with 462 additions and 14 deletions

View File

@@ -54,8 +54,11 @@ class RouterConfig:
``route_descriptions`` overrides the per-route descriptions used to build
the router LLM's "available routes" catalog. Routes without an entry fall
back to the handler's docstring first line (or, for built-in routes, the
framework's canned description). ``prompt`` is reserved for domain
policy/voice, not the route catalog — that's auto-built.
framework's canned description). ``route_permissions`` maps protected route
labels to one or more permission names; alternatively, pass
``required_permissions`` to ``@listen(...)``. Denied turns redirect to
``permission_denied_route``. ``prompt`` is reserved for domain policy/voice,
not the route catalog — that's auto-built.
"""
prompt: str | None = None
@@ -63,6 +66,8 @@ class RouterConfig:
llm: Any | None = None
routes: Sequence[str] | None = None
route_descriptions: dict[str, str] | None = None
route_permissions: dict[str, str | Sequence[str]] | None = None
permission_denied_route: str = "permission_denied"
default_intent: str | None = "converse"
fallback_intent: str | None = "converse"
intent_field: str = "intent"

View File

@@ -358,8 +358,8 @@ class _ConversationalMixin:
Pass an explicit ``RouterConfig`` only to override the routing prompt,
supply per-route descriptions, or change the default/fallback intent.
Override this method to bypass the LLM router entirely (e.g.,
permission gates before the LLM decision).
Use ``RouterConfig.route_permissions`` for route-level access control.
Override this method to bypass the LLM router entirely.
"""
config = self._conversation_config
if config is None:
@@ -539,6 +539,24 @@ class _ConversationalMixin:
cast("Flow[Any]", self), text, outcomes=outcomes, llm=llm
)
def can_access_route(
self,
required_permissions: Sequence[str],
) -> bool:
"""Return whether the current turn may use a protected route.
By default, this checks for a ``permissions`` collection on state. Apps
with richer auth models can override this method instead of overriding
``route_turn``.
"""
state_permissions = getattr(cast(Any, self.state), "permissions", None)
if state_permissions is None and isinstance(self.state, dict):
state_permissions = self.state.get("permissions")
if state_permissions is None:
return False
return all(permission in state_permissions for permission in required_permissions)
def classify_intent(
self,
text: str,
@@ -659,8 +677,42 @@ class _ConversationalMixin:
if valid_labels and intent not in valid_labels:
return router_config.fallback_intent or router_config.default_intent
if not self._can_access_router_intent(intent, router_config):
return router_config.permission_denied_route
return intent
def _can_access_router_intent(
self,
intent: str,
router_config: RouterConfig,
) -> bool:
required_permissions = self._route_required_permissions(
intent,
router_config,
)
if not required_permissions:
return True
return self.can_access_route(required_permissions)
def _route_required_permissions(
self,
route: str,
router_config: RouterConfig,
) -> tuple[str, ...]:
permissions = (router_config.route_permissions or {}).get(route)
if permissions is None:
handler_name = self._listener_methods_by_route().get(route)
if handler_name is None:
return ()
handler = getattr(type(self), handler_name, None)
permissions = getattr(handler, "__route_permissions__", None)
if permissions is None:
return ()
if isinstance(permissions, str):
return (permissions,)
return tuple(permissions)
def _default_router_llm(self, router_config: RouterConfig) -> Any | None:
config = self._conversation_config
return (
@@ -732,13 +784,7 @@ class _ConversationalMixin:
self,
router_config: RouterConfig | None,
) -> dict[str, str]:
label_to_method: dict[str, str] = {}
for listener_name, condition in self._listeners.items():
if isinstance(condition, tuple):
_, trigger_labels = condition
for trigger_label in trigger_labels:
label_to_method.setdefault(str(trigger_label), str(listener_name))
label_to_method = self._listener_methods_by_route()
routes = self._effective_routes(router_config)
overrides = (
router_config.route_descriptions
@@ -794,6 +840,15 @@ class _ConversationalMixin:
labels.update(str(method) for method in methods)
return labels
def _listener_methods_by_route(self) -> dict[str, str]:
label_to_method: dict[str, str] = {}
for listener_name, condition in self._listeners.items():
if isinstance(condition, tuple):
_, trigger_labels = condition
for trigger_label in trigger_labels:
label_to_method.setdefault(str(trigger_label), str(listener_name))
return label_to_method
def _effective_routes(self, router_config: RouterConfig | None = None) -> set[str]:
custom_routes = set(router_config.routes or ()) if router_config else set()
if not custom_routes:
@@ -802,6 +857,8 @@ class _ConversationalMixin:
- set(self.builtin_routes)
- set(self.internal_routes)
)
if router_config is not None:
custom_routes.discard(router_config.permission_denied_route)
return custom_routes | set(self.builtin_routes)
def _default_conversation_llm(self) -> Any | None:

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Sequence
from typing import cast
from crewai.flow.dsl._conditions import _definition_condition_from_runtime
@@ -15,7 +15,11 @@ from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import ListenMethod
def listen(condition: FlowTrigger) -> FlowMethodDecorator:
def listen(
condition: FlowTrigger,
*,
required_permissions: str | Sequence[str] | None = None,
) -> FlowMethodDecorator:
"""Creates a listener that executes when specified conditions are met.
This decorator sets up a method to execute in response to other method
@@ -25,6 +29,8 @@ def listen(condition: FlowTrigger) -> FlowMethodDecorator:
Args:
condition: Route label, method reference, or condition returned by
or_() / and_() that triggers the listener.
required_permissions: Optional permission name or names required to
access this route in conversational flows.
Returns:
A flow method decorator that preserves the decorated method's static signature.
@@ -50,6 +56,15 @@ def listen(condition: FlowTrigger) -> FlowMethodDecorator:
FlowMethodDefinition(listen=_definition_condition_from_runtime(condition)),
)
_set_trigger_metadata(wrapper, condition)
if required_permissions is not None:
permissions = (
(required_permissions,)
if isinstance(required_permissions, str)
else tuple(required_permissions)
)
if not permissions:
raise ValueError("required_permissions must not be empty")
wrapper.__route_permissions__ = permissions
return wrapper
return cast(FlowMethodDecorator, decorator)

View File

@@ -87,6 +87,7 @@ class FlowMethod(Generic[P, R]):
"__router_emit__",
"__human_feedback_config__",
"__conversational_only__", # gates registration on Flow.conversational
"__route_permissions__", # conversational route access control
"__flow_persistence_config__",
"__flow_method_definition__",
"_human_feedback_llm", # Live LLM object for HITL resume

View File

@@ -2,11 +2,12 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any, Literal
from unittest.mock import MagicMock, patch
from uuid import uuid4
from pydantic import BaseModel
from pydantic import BaseModel, Field
from crewai.events.event_bus import crewai_event_bus
from crewai.events.listeners.tracing.trace_listener import TraceCollectionListener
@@ -858,6 +859,154 @@ class TestConversationalFlow:
assert result == "researched"
assert flow.state.messages[-1].content == "researched"
def test_router_route_permissions_redirect_denied_intent(self) -> None:
class Route(BaseModel):
intent: Literal["research", "permission_denied", "converse", "end"]
class PermissionState(ConversationState):
permissions: set[str] = Field(default_factory=set)
router_llm = MagicMock()
router_llm.call.return_value = Route(intent="research")
@ConversationConfig(
router=RouterConfig(
response_format=Route,
llm=router_llm,
routes=["research"],
route_permissions={"research": "web_search"},
),
)
class PermissionFlow(Flow[PermissionState]):
conversational = True
@listen("research")
def run_research(self) -> str:
self.append_assistant_message("researched")
return "researched"
@listen("permission_denied")
def deny(self) -> str:
self.append_assistant_message("denied")
return "denied"
flow = PermissionFlow()
result = flow.handle_turn("research CrewAI")
assert result == "denied"
assert flow.state.last_intent == "permission_denied"
assert flow.state.messages[-1].content == "denied"
def test_router_route_permissions_allow_when_state_has_permission(self) -> None:
class Route(BaseModel):
intent: Literal["research", "permission_denied", "converse", "end"]
class PermissionState(ConversationState):
permissions: set[str] = Field(default_factory=lambda: {"web_search"})
router_llm = MagicMock()
router_llm.call.return_value = Route(intent="research")
@ConversationConfig(
router=RouterConfig(
response_format=Route,
llm=router_llm,
routes=["research"],
route_permissions={"research": "web_search"},
),
)
class PermissionFlow(Flow[PermissionState]):
conversational = True
@listen("research")
def run_research(self) -> str:
self.append_assistant_message("researched")
return "researched"
@listen("permission_denied")
def deny(self) -> str:
self.append_assistant_message("denied")
return "denied"
flow = PermissionFlow()
result = flow.handle_turn("research CrewAI")
assert result == "researched"
assert flow.state.last_intent == "research"
assert flow.state.messages[-1].content == "researched"
def test_router_route_permissions_can_use_custom_authorizer(self) -> None:
class Route(BaseModel):
intent: Literal["admin", "permission_denied", "converse", "end"]
router_llm = MagicMock()
router_llm.call.return_value = Route(intent="admin")
@ConversationConfig(
router=RouterConfig(
response_format=Route,
llm=router_llm,
routes=["admin"],
route_permissions={"admin": ("audit", "admin")},
),
)
class PermissionFlow(ConversationalFlow):
def can_access_route(
self,
required_permissions: Sequence[str],
) -> bool:
assert tuple(required_permissions) == ("audit", "admin")
assert self.state.current_user_message == "show audit"
return True
@listen("admin")
def run_admin(self) -> str:
self.append_assistant_message("admin report")
return "admin report"
flow = PermissionFlow()
result = flow.handle_turn("show audit")
assert result == "admin report"
assert flow.state.last_intent == "admin"
def test_router_infers_permissions_from_listener_metadata(self) -> None:
class PermissionState(ConversationState):
permissions: set[str] = Field(default_factory=set)
router_llm = MagicMock()
@ConversationConfig(
router=RouterConfig(
llm=router_llm,
),
)
class PermissionFlow(Flow[PermissionState]):
conversational = True
@listen("research", required_permissions=["web_search"])
def run_research(self) -> str:
"""Fresh web research."""
self.append_assistant_message("researched")
return "researched"
@listen("permission_denied")
def deny(self) -> str:
self.append_assistant_message("denied")
return "denied"
flow = PermissionFlow()
response_format = flow._router_response_format(flow.conversational_config.router)
assert "permission_denied" not in response_format.model_fields[
"intent"
].description
router_llm.call.return_value = response_format(intent="research")
result = flow.handle_turn("research CrewAI")
assert result == "denied"
assert flow.state.last_intent == "permission_denied"
def test_conversational_flow_auto_defaults_to_conversation_state(self) -> None:
"""``class C(Flow): conversational = True`` resolves state to ConversationState.