From a06d7b6e7159b0c0497e1a41260b1236939dcbaf Mon Sep 17 00:00:00 2001 From: Alexander Pitkin Date: Sat, 16 May 2026 12:53:57 +0300 Subject: [PATCH 1/3] Feat: on_shared_thread_view is only used to verify that thread can be viewved --- backend/chainlit/callbacks.py | 3 ++- backend/chainlit/server.py | 13 ++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/chainlit/callbacks.py b/backend/chainlit/callbacks.py index 0cdd209d3b..d552419ba7 100644 --- a/backend/chainlit/callbacks.py +++ b/backend/chainlit/callbacks.py @@ -546,7 +546,8 @@ def on_shared_thread_view( """Hook to authorize viewing a shared thread. Users must implement and return True to allow a non-author to view a thread. - Thread metadata contains "is_shared" boolean flag and "shared_at" timestamp for custom thread sharing. + This callback is the sole gatekeeper for the GET /project/share/{thread_id} endpoint. + Thread metadata may contain "is_shared" boolean flag and "shared_at" timestamp for custom thread sharing. Signature: async (thread: ThreadDict, viewer: Optional[User]) -> bool """ config.code.on_shared_thread_view = wrap_user_function(func) diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index cab8fef9e6..86d4c4c7b1 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -992,8 +992,9 @@ async def get_shared_thread( """Get a shared thread (read-only for everyone). This endpoint is separate from the resume endpoint and does not require the caller - to be the author of the thread. It only returns the thread if its metadata - contains is_shared=True. Otherwise, it returns 404 to avoid leaking existence. + to be the author of the thread. Access is authorized by the app-defined + on_shared_thread_view callback — if the callback returns True the thread is + returned; otherwise a 404 is raised to avoid leaking existence. """ data_layer = get_data_layer() @@ -1001,7 +1002,7 @@ async def get_shared_thread( if not data_layer: raise HTTPException(status_code=400, detail="Data persistence is not enabled") - # No auth required: allow anonymous access to shared threads + # Retrieve thread from data layer; authorization is handled by the on_shared_thread_view callback below thread = await data_layer.get_thread(thread_id) if not thread: @@ -1025,10 +1026,8 @@ async def get_shared_thread( except Exception: user_can_view = False - is_shared = bool(metadata.get("is_shared")) - - # Proceed only raise an error if both conditions are False. - if (not user_can_view) and (not is_shared): + # Proceed only raise an error if user_can_view return False or exception + if not user_can_view: raise HTTPException(status_code=404, detail="Thread not found") metadata.pop("chat_profile", None) From e2c88f91d0b65a3b7da372968cd6ac35d88f4abd Mon Sep 17 00:00:00 2001 From: Alexander Pitkin Date: Sat, 16 May 2026 13:22:58 +0300 Subject: [PATCH 2/3] Chore: Add tests for get shared thread permissions --- backend/tests/test_server.py | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/backend/tests/test_server.py b/backend/tests/test_server.py index 1441a3d8b2..0482e7795a 100644 --- a/backend/tests/test_server.py +++ b/backend/tests/test_server.py @@ -1152,3 +1152,64 @@ def test_health_check(test_client: TestClient): response = test_client.get("/health") assert response.status_code == 200 assert response.json() == {"status": "ok"} + + +@pytest.mark.parametrize(("is_shared", "on_shared_thread_view_result", "allowed"), [ + (True, True, True), + (True, False, False), + (True, ValueError("error"), False), + (False, True, True), + (False, False, False), +]) +def test_get_shared_thread_access( + test_client: TestClient, + test_config: ChainlitConfig, + is_shared: bool, + on_shared_thread_view_result: bool | Exception, + allowed: bool, +): + """Check if shared thread access is allowed based on is_shared and on_shared_thread_view result.""" + import chainlit.data as data_mod + from chainlit.server import app as _app, get_current_user as _get_current_user + + viewer = PersistedUser( + id="viewer1", + createdAt=datetime.datetime.now().isoformat(), + identifier="viewer", + ) + _app.dependency_overrides[_get_current_user] = lambda: viewer + + dl = AsyncMock() + dl.get_thread.return_value = { + "id": "shared-thread-1", + "name": "Shared Thread", + "userIdentifier": "author", + "metadata": {"is_shared": is_shared, "chat_profile": "pro"}, + } + dl.get_thread_author.return_value = "author" + dl.build_debug_url.return_value = "" + + data_mod._data_layer = dl + data_mod._data_layer_initialized = True + + async def deny_cb(thread, user): + if isinstance(on_shared_thread_view_result, Exception): + raise on_shared_thread_view_result + return on_shared_thread_view_result + + test_config.code.on_shared_thread_view = deny_cb + + r = test_client.get("/project/share/shared-thread-1") + + if allowed: + assert r.status_code == 200 + assert r.json()["id"] == "shared-thread-1" + else: + assert r.status_code == 404 + assert r.json() == {"detail": "Thread not found"} + + # Cleanup + del _app.dependency_overrides[_get_current_user] + data_mod._data_layer = None + data_mod._data_layer_initialized = False + test_config.code.on_shared_thread_view = None From e90f7520e2b8d197e409079343a6c3c39ecdb79f Mon Sep 17 00:00:00 2001 From: Alexander Pitkin Date: Thu, 28 May 2026 01:26:16 +0300 Subject: [PATCH 3/3] Feat: Add on_shared_thread_access_allowed callback to restrict access to threads --- backend/chainlit/__init__.py | 2 + backend/chainlit/callbacks.py | 18 ++++++- backend/chainlit/config.py | 3 ++ backend/chainlit/server.py | 23 ++++++--- backend/tests/test_server.py | 90 ++++++++++++++++++++++++++++++++--- 5 files changed, 121 insertions(+), 15 deletions(-) diff --git a/backend/chainlit/__init__.py b/backend/chainlit/__init__.py index 99dd9d15b2..8b83cb0a5d 100644 --- a/backend/chainlit/__init__.py +++ b/backend/chainlit/__init__.py @@ -84,6 +84,7 @@ on_message, on_settings_edit, on_settings_update, + on_shared_thread_access_allowed, on_shared_thread_view, on_slack_reaction_added, on_stop, @@ -205,6 +206,7 @@ def acall(self): "on_message", "on_settings_edit", "on_settings_update", + "on_shared_thread_access_allowed", "on_shared_thread_view", "on_slack_reaction_added", "on_stop", diff --git a/backend/chainlit/callbacks.py b/backend/chainlit/callbacks.py index d552419ba7..de021f443d 100644 --- a/backend/chainlit/callbacks.py +++ b/backend/chainlit/callbacks.py @@ -546,9 +546,23 @@ def on_shared_thread_view( """Hook to authorize viewing a shared thread. Users must implement and return True to allow a non-author to view a thread. - This callback is the sole gatekeeper for the GET /project/share/{thread_id} endpoint. - Thread metadata may contain "is_shared" boolean flag and "shared_at" timestamp for custom thread sharing. + Thread metadata contains "is_shared" boolean flag and "shared_at" timestamp for custom thread sharing. Signature: async (thread: ThreadDict, viewer: Optional[User]) -> bool """ config.code.on_shared_thread_view = wrap_user_function(func) return func + + +def on_shared_thread_access_allowed( + func: Callable[[ThreadDict, Optional[User]], Awaitable[bool]], +) -> Callable[[ThreadDict, Optional[User]], Awaitable[bool]]: + """Hook to add extra permission check for viewing a shared thread. + + Unlike on_shared_thread_view, this callback can only deny access further. + If defined and returns False, the viewer is blocked regardless of other checks. + If undefined or returns True, normal authorization flow proceeds. + + Signature: async (thread: ThreadDict, viewer: Optional[User]) -> bool + """ + config.code.on_shared_thread_access_allowed = wrap_user_function(func) + return func diff --git a/backend/chainlit/config.py b/backend/chainlit/config.py index a540752b33..b33950a6fe 100644 --- a/backend/chainlit/config.py +++ b/backend/chainlit/config.py @@ -422,6 +422,9 @@ class CodeSettings(BaseModel): on_shared_thread_view: Optional[ Callable[["ThreadDict", Optional["User"]], Awaitable[bool]] ] = None + on_shared_thread_access_allowed: Optional[ + Callable[["ThreadDict", Optional["User"]], Awaitable[bool]] + ] = None # Auth callbacks password_auth_callback: Optional[ Callable[[str, str], Awaitable[Optional["User"]]] diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index 86d4c4c7b1..cd7aa4ce55 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -992,9 +992,8 @@ async def get_shared_thread( """Get a shared thread (read-only for everyone). This endpoint is separate from the resume endpoint and does not require the caller - to be the author of the thread. Access is authorized by the app-defined - on_shared_thread_view callback — if the callback returns True the thread is - returned; otherwise a 404 is raised to avoid leaking existence. + to be the author of the thread. It only returns the thread if its metadata + contains is_shared=True. Otherwise, it returns 404 to avoid leaking existence. """ data_layer = get_data_layer() @@ -1002,7 +1001,7 @@ async def get_shared_thread( if not data_layer: raise HTTPException(status_code=400, detail="Data persistence is not enabled") - # Retrieve thread from data layer; authorization is handled by the on_shared_thread_view callback below + # No auth required: allow anonymous access to shared threads thread = await data_layer.get_thread(thread_id) if not thread: @@ -1025,11 +1024,23 @@ async def get_shared_thread( ) except Exception: user_can_view = False + is_shared = bool(metadata.get("is_shared")) - # Proceed only raise an error if user_can_view return False or exception - if not user_can_view: + if (not user_can_view) and (not is_shared): raise HTTPException(status_code=404, detail="Thread not found") + if getattr(config.code, "on_shared_thread_access_allowed", None): + try: + access_allowed = await config.code.on_shared_thread_access_allowed( + thread, current_user + ) + if not access_allowed: + raise HTTPException(status_code=404, detail="Thread not found") + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=404, detail="Thread not found") + metadata.pop("chat_profile", None) metadata.pop("chat_settings", None) metadata.pop("env", None) diff --git a/backend/tests/test_server.py b/backend/tests/test_server.py index 0482e7795a..468550d173 100644 --- a/backend/tests/test_server.py +++ b/backend/tests/test_server.py @@ -1154,13 +1154,16 @@ def test_health_check(test_client: TestClient): assert response.json() == {"status": "ok"} -@pytest.mark.parametrize(("is_shared", "on_shared_thread_view_result", "allowed"), [ - (True, True, True), - (True, False, False), - (True, ValueError("error"), False), - (False, True, True), - (False, False, False), -]) +@pytest.mark.parametrize( + ("is_shared", "on_shared_thread_view_result", "allowed"), + [ + (True, True, True), + (True, False, True), + (True, ValueError("error"), True), + (False, True, True), + (False, False, False), + ], +) def test_get_shared_thread_access( test_client: TestClient, test_config: ChainlitConfig, @@ -1213,3 +1216,76 @@ async def deny_cb(thread, user): data_mod._data_layer = None data_mod._data_layer_initialized = False test_config.code.on_shared_thread_view = None + + +@pytest.mark.parametrize( + ("is_shared", "access_allowed_result", "allowed"), + [ + (True, None, True), # No callback → falls through to is_shared (=200) + (True, True, True), # Callback allows → proceeds to return thread (=200) + (True, False, False), # Callback denies → 404 + (True, ValueError("err"), False), # Callback raises → 404 + (False, True, False), # is_shared=False blocks + ], +) +def test_get_shared_thread_access_allowed( + test_client: TestClient, + test_config: ChainlitConfig, + is_shared: bool, + access_allowed_result: bool | Exception | None, + allowed: bool, +): + """Check if shared thread access respects on_shared_thread_access_allowed callback.""" + import chainlit.data as data_mod + from chainlit.server import app as _app, get_current_user as _get_current_user + + viewer = PersistedUser( + id="viewer1", + createdAt=datetime.datetime.now().isoformat(), + identifier="viewer", + ) + _app.dependency_overrides[_get_current_user] = lambda: viewer + + dl = AsyncMock() + dl.get_thread.return_value = { + "id": "shared-thread-1", + "name": "Shared Thread", + "userIdentifier": "author", + "metadata": {"is_shared": is_shared, "chat_profile": "pro"}, + } + dl.get_thread_author.return_value = "author" + dl.build_debug_url.return_value = "" + + data_mod._data_layer = dl + data_mod._data_layer_initialized = True + + # Ensure on_shared_thread_view is not set (Tier 1+2 relies on is_shared fallback) + test_config.code.on_shared_thread_view = None + + if access_allowed_result is not None: + + async def access_cb(thread, user): + if isinstance(access_allowed_result, Exception): + raise access_allowed_result + return access_allowed_result + + test_config.code.on_shared_thread_access_allowed = access_cb + else: + # Callback not defined — Tier 3 is skipped + test_config.code.on_shared_thread_access_allowed = None + + r = test_client.get("/project/share/shared-thread-1") + + if allowed: + assert r.status_code == 200 + assert r.json()["id"] == "shared-thread-1" + else: + assert r.status_code == 404 + assert r.json() == {"detail": "Thread not found"} + + # Cleanup + del _app.dependency_overrides[_get_current_user] + data_mod._data_layer = None + data_mod._data_layer_initialized = False + test_config.code.on_shared_thread_view = None + test_config.code.on_shared_thread_access_allowed = None