From 5a77be5e8bec1600ad0a865638d88c1368497559 Mon Sep 17 00:00:00 2001 From: Sreekara Yachamaneni Date: Thu, 27 Aug 2026 16:28:59 -0400 Subject: [PATCH 1/2] Merge commit from fork * authz fix for custom auth * add back resource param * auth on multiple resources --- libs/sdk-py/CHANGELOG.md | 9 ++ libs/sdk-py/langgraph_sdk/auth/__init__.py | 83 ++++++++++--- libs/sdk-py/tests/test_auth.py | 132 +++++++++++++++++++++ 3 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 libs/sdk-py/tests/test_auth.py diff --git a/libs/sdk-py/CHANGELOG.md b/libs/sdk-py/CHANGELOG.md index 32c29f0e1..f9048bc9d 100644 --- a/libs/sdk-py/CHANGELOG.md +++ b/libs/sdk-py/CHANGELOG.md @@ -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 diff --git a/libs/sdk-py/langgraph_sdk/auth/__init__.py b/libs/sdk-py/langgraph_sdk/auth/__init__.py index 8c8c6ad72..5f812718a 100644 --- a/libs/sdk-py/langgraph_sdk/auth/__init__.py +++ b/libs/sdk-py/langgraph_sdk/auth/__init__.py @@ -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[ diff --git a/libs/sdk-py/tests/test_auth.py b/libs/sdk-py/tests/test_auth.py new file mode 100644 index 000000000..82b243ce5 --- /dev/null +++ b/libs/sdk-py/tests/test_auth.py @@ -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]} From d5f4b2aa960940effc8430165ab3604038e817af Mon Sep 17 00:00:00 2001 From: Sreekara Yachamaneni Date: Thu, 27 Aug 2026 17:14:54 -0400 Subject: [PATCH 2/2] release(sdk-py): 0.4.4 (#8738) Bumps the Python SDK version from 0.4.3 to 0.4.4. --- libs/sdk-py/langgraph_sdk/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/sdk-py/langgraph_sdk/__init__.py b/libs/sdk-py/langgraph_sdk/__init__.py index a56e0b3b3..ca87e5ebe 100644 --- a/libs/sdk-py/langgraph_sdk/__init__.py +++ b/libs/sdk-py/langgraph_sdk/__init__.py @@ -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",