mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-30 05:25:05 +02:00
Merge branch 'main' into fix/store-put-value-mapping
This commit is contained in:
@@ -39,6 +39,15 @@
|
||||
- `client.threads.stream()` now accepts `transport="sse"` (default) or
|
||||
`transport="websocket"` in place of the previous transport-agnostic default.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resource-scoped auth decorators now honor `actions=` and reject empty or
|
||||
invalid action lists. Because unmatched custom-auth paths remain allowed,
|
||||
deployments using action-scoped handlers should configure a global
|
||||
default-deny handler; `langgraph-api` 0.10+ warns about uncovered paths at
|
||||
startup. Resource-specific decorators retain matching `resources=` selectors
|
||||
for backward compatibility; use `@auth.on(resources=...)` for other resources.
|
||||
|
||||
### Notes
|
||||
|
||||
- The v3 streaming surface (`AsyncThreadStream`, `SyncThreadStream`, and all
|
||||
|
||||
@@ -3,7 +3,7 @@ from langgraph_sdk.client import get_client, get_sync_client
|
||||
from langgraph_sdk.encryption import Encryption
|
||||
from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
|
||||
|
||||
__version__ = "0.4.3"
|
||||
__version__ = "0.4.4"
|
||||
|
||||
__all__ = [
|
||||
"Auth",
|
||||
|
||||
@@ -341,9 +341,15 @@ VUpdate = typing.TypeVar("VUpdate", covariant=True)
|
||||
VRead = typing.TypeVar("VRead", covariant=True)
|
||||
VDelete = typing.TypeVar("VDelete", covariant=True)
|
||||
VSearch = typing.TypeVar("VSearch", covariant=True)
|
||||
ResourceActionT = typing.TypeVar("ResourceActionT", bound=str)
|
||||
|
||||
_ResourceAction = typing.Literal["create", "read", "update", "delete", "search"]
|
||||
_ThreadAction = _ResourceAction | typing.Literal["create_run"]
|
||||
|
||||
|
||||
class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
|
||||
class _ResourceOn(
|
||||
typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch, ResourceActionT]
|
||||
):
|
||||
"""
|
||||
Generic base class for resource-specific handlers.
|
||||
"""
|
||||
@@ -392,8 +398,8 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
resources: str | Sequence[str],
|
||||
actions: str | Sequence[str] | None = None,
|
||||
resources: str | Sequence[str] | None = None,
|
||||
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
|
||||
) -> Callable[
|
||||
[_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]],
|
||||
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
||||
@@ -408,7 +414,7 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
|
||||
) = None,
|
||||
*,
|
||||
resources: str | Sequence[str] | None = None,
|
||||
actions: str | Sequence[str] | None = None,
|
||||
actions: ResourceActionT | Sequence[ResourceActionT] | None = None,
|
||||
) -> (
|
||||
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]
|
||||
| Callable[
|
||||
@@ -416,24 +422,66 @@ class _ResourceOn(typing.Generic[VCreate, VRead, VUpdate, VDelete, VSearch]):
|
||||
_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
||||
]
|
||||
):
|
||||
if fn is not None:
|
||||
_validate_handler(fn)
|
||||
return typing.cast(
|
||||
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
|
||||
_register_handler(self.auth, self.resource, "*", fn),
|
||||
)
|
||||
|
||||
def decorator(
|
||||
handler: _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch],
|
||||
) -> _ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]:
|
||||
_validate_handler(handler)
|
||||
return typing.cast(
|
||||
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
|
||||
_register_handler(self.auth, self.resource, "*", handler),
|
||||
if resources is None:
|
||||
resource_list = [self.resource]
|
||||
elif isinstance(resources, str):
|
||||
resource_list = [resources]
|
||||
elif isinstance(resources, Sequence):
|
||||
resource_list = list(resources)
|
||||
else:
|
||||
raise TypeError("resources must be a string or sequence of strings")
|
||||
if resource_list != [self.resource]:
|
||||
raise ValueError(
|
||||
f"Resource-specific decorator for {self.resource!r} cannot "
|
||||
f"register handlers for {resource_list!r}. Use @auth.on(...) "
|
||||
"for other or multiple resources."
|
||||
)
|
||||
if actions is None:
|
||||
action_list = ["*"]
|
||||
elif isinstance(actions, str):
|
||||
action_list = [actions]
|
||||
elif isinstance(actions, Sequence):
|
||||
action_list = list(actions)
|
||||
else:
|
||||
raise TypeError("actions must be a string or sequence of strings")
|
||||
if not action_list:
|
||||
raise ValueError("actions must not be empty")
|
||||
if not all(isinstance(action, str) for action in action_list):
|
||||
raise TypeError("actions must be a string or sequence of strings")
|
||||
valid_actions = {
|
||||
value.action
|
||||
for value in vars(self).values()
|
||||
if isinstance(value, _ResourceActionOn)
|
||||
}
|
||||
invalid_actions = (
|
||||
sorted(set(action_list) - valid_actions) if actions is not None else []
|
||||
)
|
||||
if invalid_actions:
|
||||
raise ValueError(
|
||||
f"Invalid action(s) for {self.resource}: {', '.join(invalid_actions)}"
|
||||
)
|
||||
if len(action_list) != len(set(action_list)):
|
||||
raise ValueError("actions must not contain duplicates")
|
||||
for action in action_list:
|
||||
if (self.resource, action) in self.auth._handlers:
|
||||
raise ValueError(
|
||||
f"types.Handler already set for {self.resource}, {action}."
|
||||
)
|
||||
for action in action_list:
|
||||
_register_handler(self.auth, self.resource, action, handler)
|
||||
return handler
|
||||
|
||||
# Accept keyword-only parameters for future filtering behavior; referenced to satisfy linters.
|
||||
_ = resources, actions
|
||||
if fn is not None:
|
||||
return decorator(
|
||||
typing.cast(
|
||||
"_ActionHandler[VCreate | VUpdate | VRead | VDelete | VSearch]",
|
||||
fn,
|
||||
)
|
||||
)
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -444,6 +492,7 @@ class _AssistantsOn(
|
||||
types.AssistantsUpdate,
|
||||
types.AssistantsDelete,
|
||||
types.AssistantsSearch,
|
||||
_ResourceAction,
|
||||
]
|
||||
):
|
||||
value = (
|
||||
@@ -467,6 +516,7 @@ class _ThreadsOn(
|
||||
types.ThreadsUpdate,
|
||||
types.ThreadsDelete,
|
||||
types.ThreadsSearch,
|
||||
_ThreadAction,
|
||||
]
|
||||
):
|
||||
value = (
|
||||
@@ -502,6 +552,7 @@ class _CronsOn(
|
||||
types.CronsUpdate,
|
||||
types.CronsDelete,
|
||||
types.CronsSearch,
|
||||
_ResourceAction,
|
||||
]
|
||||
):
|
||||
value = type[
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
|
||||
def test_handler_multiple_resources_and_actions() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on(resources=["threads", "assistants"], actions=["read", "search"])
|
||||
async def allow_reads(ctx, value):
|
||||
del value
|
||||
return {"owner": ctx.user.identity}
|
||||
|
||||
assert auth._handlers == {
|
||||
("threads", "read"): [allow_reads],
|
||||
("threads", "search"): [allow_reads],
|
||||
("assistants", "read"): [allow_reads],
|
||||
("assistants", "search"): [allow_reads],
|
||||
}
|
||||
|
||||
|
||||
def test_resource_handler_actions_are_scoped() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on
|
||||
async def deny_all(ctx, value):
|
||||
del ctx, value
|
||||
return False
|
||||
|
||||
@auth.on.threads(actions=["create", "search"])
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
@auth.on.threads(actions="create_run")
|
||||
async def run_handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
assert auth._handlers == {
|
||||
("threads", "create"): [handler],
|
||||
("threads", "search"): [handler],
|
||||
("threads", "create_run"): [run_handler],
|
||||
}
|
||||
assert auth._global_handlers == [deny_all]
|
||||
|
||||
|
||||
def test_resource_handler_preserves_wildcard() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on.threads
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
assert auth._handlers == {("threads", "*"): [handler]}
|
||||
|
||||
|
||||
def test_resource_handler_preserves_wildcard_with_parentheses() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on.threads()
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
assert auth._handlers == {("threads", "*"): [handler]}
|
||||
|
||||
|
||||
def test_resource_handler_accepts_matching_resource() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on.threads(resources=["threads"], actions="read")
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
assert auth._handlers == {("threads", "read"): [handler]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resources", [["assistants"], ["threads", "assistants"], [], [1]]
|
||||
)
|
||||
def test_resource_handler_rejects_nonmatching_resources(resources) -> None:
|
||||
auth = Auth()
|
||||
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
with pytest.raises(ValueError, match=r"Use @auth\.on"):
|
||||
auth.on.threads(resources=resources)(handler)
|
||||
assert auth._handlers == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("resource", "actions", "error"),
|
||||
[
|
||||
("threads", [], ValueError),
|
||||
("threads", ["reed"], ValueError),
|
||||
("threads", ["create", "create"], ValueError),
|
||||
("threads", {"create": True}, TypeError),
|
||||
("crons", ["create_run"], ValueError),
|
||||
],
|
||||
)
|
||||
def test_resource_handler_rejects_invalid_actions(resource, actions, error) -> None:
|
||||
auth = Auth()
|
||||
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
with pytest.raises(error):
|
||||
getattr(auth.on, resource)(actions=actions)(handler)
|
||||
assert auth._handlers == {}
|
||||
|
||||
|
||||
def test_resource_handler_registration_is_atomic() -> None:
|
||||
auth = Auth()
|
||||
|
||||
@auth.on.threads.read
|
||||
async def read_handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
async def handler(ctx, value):
|
||||
del ctx, value
|
||||
return None
|
||||
|
||||
with pytest.raises(ValueError, match="already set"):
|
||||
auth.on.threads(actions=["create", "read"])(handler)
|
||||
assert auth._handlers == {("threads", "read"): [read_handler]}
|
||||
Reference in New Issue
Block a user