"""Regression tests for multiplex profile-aware own-policy authorization.""" from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.session import SessionSource def _clear_auth_env(monkeypatch) -> None: for key in ( "WECOM_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS", "GATEWAY_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS", ): monkeypatch.delenv(key, raising=False) def _make_multiplex_runner(monkeypatch): """Runner with default allowlist WeCom and secondary open-policy WeCom.""" from gateway.run import GatewayRunner _clear_auth_env(monkeypatch) runner = object.__new__(GatewayRunner) runner.config = GatewayConfig(multiplex_profiles=True) default_adapter = SimpleNamespace( send=AsyncMock(), enforces_own_access_policy=True, _dm_policy="allowlist", _group_policy="pairing", ) secondary_adapter = SimpleNamespace( send=AsyncMock(), enforces_own_access_policy=True, _dm_policy="open", _group_policy="open", ) runner.adapters = {Platform.WECOM: default_adapter} runner._profile_adapters = { "coder": {Platform.WECOM: secondary_adapter}, } runner.pairing_store = MagicMock() runner.pairing_store.is_approved.return_value = False return runner, default_adapter, secondary_adapter def test_default_profile_still_trusts_own_allowlist(monkeypatch): """Default-profile allowlist trust is unchanged when profile is unstamped.""" runner, _default_adapter, _secondary_adapter = _make_multiplex_runner(monkeypatch) source = SessionSource( platform=Platform.WECOM, user_id="allowed-user", chat_id="dm-chat", user_name="allowed-user", chat_type="dm", profile=None, ) assert runner._is_user_authorized(source) is True def test_active_profile_stamp_resolves_primary_adapter(monkeypatch): """A single-profile gateway stamps its active profile but stores adapters as primary.""" runner, default_adapter, _secondary_adapter = _make_multiplex_runner(monkeypatch) runner._active_profile_name = lambda: "dev" assert runner._authorization_adapter(Platform.WECOM, profile="dev") is default_adapter def test_scoped_secondary_profile_still_uses_profile_adapters(monkeypatch): """Runtime scope must not redirect secondary authz to primary adapters. ``_make_profile_message_handler`` wraps ``_handle_message`` in ``_profile_runtime_scope``, which overrides HERMES_HOME so ``get_active_profile_name()`` equals the secondary profile for that turn. Authorization must still read ``_profile_adapters[profile]``, not the empty primary ``self.adapters`` map — otherwise upstream-auth platforms such as A2A default-deny an already-authenticated peer (#80884). A secondary profile with NO registry entry still fails closed. """ from gateway.run import GatewayRunner _clear_auth_env(monkeypatch) runner = object.__new__(GatewayRunner) runner.config = GatewayConfig(multiplex_profiles=True) runner.adapters = {} runner.pairing_store = MagicMock() runner.pairing_store.is_approved.return_value = False secondary = SimpleNamespace( authorization_is_upstream=True, enforces_own_access_policy=False, ) runner._profile_adapters = {"beta": {Platform("a2a"): secondary}} # Simulate the scoped turn: active profile name collapses to the secondary. runner._active_profile_name = lambda: "beta" assert runner._authorization_adapter(Platform("a2a"), profile="beta") is secondary source = SessionSource( platform=Platform("a2a"), chat_id="a2a-context", user_id="alpha", user_name="alpha", chat_type="dm", profile="beta", ) assert runner._is_user_authorized(source) is True # Fail-closed guard is untouched: no registry entry -> no default fallback. runner._profile_adapters = {"beta": {}} assert runner._authorization_adapter(Platform("a2a"), profile="beta") is None def test_secondary_allowlist_dm_behavior_ignores_unauthorized(monkeypatch): """Unauthorized-DM behavior must read the secondary adapter's dm_policy.""" runner, _default_adapter, secondary_adapter = _make_multiplex_runner(monkeypatch) secondary_adapter._dm_policy = "allowlist" assert runner._get_unauthorized_dm_behavior( Platform.WECOM, profile="coder", ) == "ignore" 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_startup_guard_gateway_allow_all_reads_scope_not_environ(monkeypatch): """The GATEWAY_ALLOW_ALL_USERS opt-in check inside the startup guard must honor the active profile secret scope (#93522): the default profile's env-only opt-in must not leak into a secondary profile that never opted in, and a secondary profile's own scoped opt-in must be honored.""" from agent import secret_scope from gateway.run import _own_policy_open_startup_violation _clear_auth_env(monkeypatch) cfg = GatewayConfig(multiplex_profiles=True) cfg.platforms = { Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": "open"}), } previous_multiplex = secret_scope.is_multiplex_active() secret_scope.set_multiplex_active(True) monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true") try: token = secret_scope.set_secret_scope({"SOMETHING_ELSE": "x"}) try: violation = _own_policy_open_startup_violation(cfg) finally: secret_scope.reset_secret_scope(token) assert violation is not None, "default profile's env opt-in must not leak into the scoped secondary profile" token = secret_scope.set_secret_scope({"GATEWAY_ALLOW_ALL_USERS": "true"}) try: violation = _own_policy_open_startup_violation(cfg) finally: secret_scope.reset_secret_scope(token) assert violation is None, "the secondary profile's own scoped opt-in must be honored" finally: secret_scope.set_multiplex_active(previous_multiplex) 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 _clear_auth_env(monkeypatch) secondary_cfg = GatewayConfig(multiplex_profiles=True) secondary_cfg.platforms = { Platform.WECOM: PlatformConfig( enabled=True, extra={"dm_policy": "open"}, ), } violation = _own_policy_open_startup_violation(secondary_cfg) assert violation is not None assert "wecom" in violation assert "open policy" in violation # ───────────────────────────────────────────────────────────────────── # Plugin-platform extra.allowed_users fallback (#98738 / #82871) # ───────────────────────────────────────────────────────────────────── # Buzz has no static Platform member: plugin platforms get a dynamic # member created on demand by Platform._missing_ (value lookup). Resolve # it that way — attribute access only works after an earlier lookup in # the same process, which a fresh CI shard cannot rely on. _BUZZ = Platform("buzz") def _make_buzz_multiplex_runner(monkeypatch, extra): """Runner whose secondary 'coder' profile runs a live Buzz adapter.""" from gateway.run import GatewayRunner from tests.gateway.test_buzz_adapter import _normalize_user_ref for key in ( "BUZZ_ALLOWED_USERS", "BUZZ_ALLOW_ALL_USERS", "GATEWAY_ALLOWED_USERS", "GATEWAY_ALLOW_ALL_USERS", ): monkeypatch.delenv(key, raising=False) runner = object.__new__(GatewayRunner) runner.config = GatewayConfig(multiplex_profiles=True) adapter = SimpleNamespace( config=PlatformConfig(enabled=True, extra=extra), # The Buzz adapter exposes this hook so npub allowlist entries match # the hex-pubkey user ids the gateway authorizes. normalize_user_id=_normalize_user_ref, ) runner.adapters = {} runner._profile_adapters = {"coder": {_BUZZ: adapter}} runner.pairing_store = MagicMock() runner.pairing_store.is_approved.return_value = False return runner def _buzz_source(user_id): return SessionSource( platform=_BUZZ, user_id=user_id, chat_id="chat-1", user_name="member", chat_type="dm", profile="coder", ) def _patch_buzz_registry(monkeypatch, allowed_users_env="BUZZ_ALLOWED_USERS"): from gateway.platform_registry import platform_registry real_get = platform_registry.get def _get(key): if key == "buzz": return SimpleNamespace(allowed_users_env=allowed_users_env) return real_get(key) monkeypatch.setattr(platform_registry, "get", _get) def test_secondary_buzz_extra_allowed_users_authorizes_listed_user(monkeypatch): """A secondary profile's extra.allowed_users must authorize its users when the env var only ever carried the default profile's list (#98738/#82871).""" from tests.gateway.test_buzz_adapter import SELF_NPUB, SELF_PUBKEY runner = _make_buzz_multiplex_runner( monkeypatch, extra={"allowed_users": [SELF_NPUB]} ) _patch_buzz_registry(monkeypatch) # user_id arrives as the hex pubkey while the allowlist entry is an npub. assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is True def test_secondary_buzz_extra_allowed_users_denies_unlisted_sender(monkeypatch): """Default-deny is preserved: a sender not in the profile's allowlist stays denied even though the adapter-level list admitted the message.""" from tests.gateway.test_buzz_adapter import SELF_PUBKEY runner = _make_buzz_multiplex_runner( monkeypatch, extra={"allowed_users": ["npub1" + "b" * 56]} ) _patch_buzz_registry(monkeypatch) assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is False def test_secondary_buzz_without_extra_allowlist_stays_default_deny(monkeypatch): """No extra.allowed_users configured: nothing changes, the default-deny path applies (no fail-open via an empty list).""" from tests.gateway.test_buzz_adapter import SELF_PUBKEY runner = _make_buzz_multiplex_runner(monkeypatch, extra={}) _patch_buzz_registry(monkeypatch) assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is False def test_extra_allowed_users_not_consulted_without_registry_declaration(monkeypatch): """The fallback is gated on the platform's registry entry declaring allowed_users_env — a platform without that contract keeps the previous behavior even if its extra happens to hold an allowed_users key.""" from tests.gateway.test_buzz_adapter import SELF_PUBKEY runner = _make_buzz_multiplex_runner( monkeypatch, extra={"allowed_users": ["someone"]} ) _patch_buzz_registry(monkeypatch, allowed_users_env="") assert runner._is_user_authorized(_buzz_source("someone")) is False def test_extra_allowed_users_wildcard_authorizes_any_sender(monkeypatch): """\"*\" in the profile's extra.allowed_users keeps the env-var wildcard semantics: any sender is authorized (still gated on the registry declaration).""" from tests.gateway.test_buzz_adapter import SELF_PUBKEY runner = _make_buzz_multiplex_runner(monkeypatch, extra={"allowed_users": ["*"]}) _patch_buzz_registry(monkeypatch) assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is True def test_extra_allowed_users_blank_entries_are_dropped_not_denials(monkeypatch): """Blank/whitespace entries are dropped at parse; an otherwise-empty list behaves like the absent case (default-deny), not like a wildcard.""" from tests.gateway.test_buzz_adapter import SELF_PUBKEY runner = _make_buzz_multiplex_runner( monkeypatch, extra={"allowed_users": ["", " ", ","]} ) _patch_buzz_registry(monkeypatch) assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is False def test_extra_allowed_users_case_insensitive_hex_and_uppercase_npub(monkeypatch): """Entry spellings normalize to the same principal: upper-case hex and upper-case npub entries both match the hex user id Buzz dispatches (entries are normalized; the inbound id is already hex).""" from tests.gateway.test_buzz_adapter import SELF_NPUB, SELF_PUBKEY runner = _make_buzz_multiplex_runner( monkeypatch, extra={"allowed_users": [SELF_PUBKEY.upper(), SELF_NPUB.upper()]} ) _patch_buzz_registry(monkeypatch) assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is True def test_adapter_intake_and_central_authz_agree_on_the_same_list(monkeypatch): """Policy-layer agreement (#98738): a sender admitted by the adapter's construction-time intake allowlist (extra.allowed_users normalized to hex) is exactly the sender the central check authorizes, and an unlisted sender is rejected at BOTH layers.""" from gateway.session import SessionSource as _SessionSource from tests.gateway.test_buzz_adapter import SELF_NPUB, SELF_PUBKEY monkeypatch.delenv("BUZZ_ALLOWED_USERS", raising=False) monkeypatch.delenv("BUZZ_ALLOW_ALL_USERS", raising=False) other_hex = "b" * 64 adapter_extra = {"allowed_users": [SELF_NPUB]} # Layer 1 — adapter intake: construction normalizes npub entries to hex. from tests.gateway.test_buzz_adapter import _make_adapter as _base_adapter adapter = _base_adapter(adapter_extra) assert adapter._allowed_pubkeys == {SELF_PUBKEY} # Layer 2 — central authz over the same adapter config. runner = _make_buzz_multiplex_runner(monkeypatch, extra=adapter_extra) _patch_buzz_registry(monkeypatch) for sender, admitted in ((SELF_PUBKEY, True), (other_hex, False)): # Adapter layer: intake filter admits/denies... assert (sender in adapter._allowed_pubkeys) is admitted # ...and central authz returns the SAME verdict for that sender. assert runner._is_user_authorized( _SessionSource( platform=_BUZZ, user_id=sender, chat_id="chat-1", user_name="member", chat_type="dm", profile="coder", ) ) is admitted