diff --git a/gateway/run.py b/gateway/run.py index 751a9ace8..430704fbe 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8875,7 +8875,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew adapter.set_session_store(self.session_store) adapter.set_busy_session_handler(self._handle_active_session_busy_message) adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id) - adapter.set_authorization_check(self._make_adapter_auth_check(adapter.platform)) + adapter.set_authorization_check( + self._make_adapter_auth_check(adapter.platform, profile_name=profile_name) + ) adapter._busy_text_mode = self._busy_text_mode try: @@ -9092,6 +9094,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew def _make_adapter_auth_check( self, platform: Platform, + profile_name: Optional[str] = None, ) -> Callable[[str, Optional[str], Optional[str]], bool]: """Build a platform-bound auth callback for adapter use. @@ -9104,6 +9107,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew The returned callback delegates to :meth:`_is_user_authorized` so the full auth chain — platform allowlists, group allowlists, pairing store, allow-all flags — stays the single source of truth. + + ``profile_name`` binds the callback to the secondary adapter's own + multiplex profile, so its ``SessionSource`` resolves that profile's + secret scope instead of falling back to the active profile. """ def check( user_id: str, @@ -9117,6 +9124,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew chat_id=chat_id or "", chat_type=chat_type or "group", user_id=user_id, + profile=profile_name, ) return self._is_user_authorized(source) return check diff --git a/tests/gateway/test_multiplex_profile_authz.py b/tests/gateway/test_multiplex_profile_authz.py index 514bda7cc..fca5060b5 100644 --- a/tests/gateway/test_multiplex_profile_authz.py +++ b/tests/gateway/test_multiplex_profile_authz.py @@ -139,6 +139,56 @@ def test_secondary_allowlist_dm_behavior_ignores_unauthorized(monkeypatch): assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == "ignore" +def test_adapter_auth_check_stamps_secondary_profile(monkeypatch): + """The adapter auth-check callback must stamp its own secondary profile. + + Regression for the gap where ``_make_adapter_auth_check`` built a + profile-less ``SessionSource``, so a secondary adapter's external-context + authorization (e.g. Slack/Discord thread-reply lookups) silently + resolved the *active* profile's allowlist scope instead of its own. + """ + from gateway.run import GatewayRunner + + _clear_auth_env(monkeypatch) + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + + captured: dict = {} + + def fake_is_user_authorized(source): + captured["profile"] = source.profile + return True + + runner._is_user_authorized = fake_is_user_authorized + + check = runner._make_adapter_auth_check(Platform.WECOM, profile_name="coder") + assert check("some-user", "dm", "dm-chat") is True + assert captured["profile"] == "coder" + + +def test_adapter_auth_check_defaults_to_active_profile(monkeypatch): + """Primary-adapter callbacks (no profile_name) still resolve the active profile.""" + from gateway.run import GatewayRunner + + _clear_auth_env(monkeypatch) + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + + captured: dict = {} + + def fake_is_user_authorized(source): + captured["profile"] = source.profile + return True + + runner._is_user_authorized = fake_is_user_authorized + + check = runner._make_adapter_auth_check(Platform.WECOM) + assert check("some-user", "dm", "dm-chat") is True + assert captured["profile"] is None + + def test_secondary_open_policy_fails_startup_guard(monkeypatch): """Secondary profiles must pass the same open-policy startup guard.""" from gateway.run import _own_policy_open_startup_violation