Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
"""Tests for config-driven platform access policies at the gateway layer.
|
||||
|
||||
Background (#34515): WeCom, Weixin, Yuanbao, QQBot, and WhatsApp expose a
|
||||
documented config-driven access surface (``dm_policy`` / ``group_policy`` /
|
||||
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra``) and enforce
|
||||
it at intake —
|
||||
a message is dropped inside the adapter and never reaches the gateway unless it
|
||||
already passed that policy.
|
||||
|
||||
The gateway's env-based allowlist check (``_is_user_authorized``) runs *after*
|
||||
the adapter. Adapters that own their access policy declare
|
||||
``enforces_own_access_policy`` (a ``BasePlatformAdapter`` property, default
|
||||
``False``) so the gateway can honor a config-only ``dm_policy: allowlist`` /
|
||||
``allow_from`` (which the adapter already enforced) instead of double-denying it
|
||||
when no ``PLATFORM_ALLOWED_USERS`` env var is set.
|
||||
|
||||
Crucially, the flag is NOT a blanket "already authorized" pass. These adapters
|
||||
default ``dm_policy`` / ``group_policy`` to ``"open"``, which forwards *every*
|
||||
sender, so the gateway trusts the adapter only when its effective policy for the
|
||||
chat type is an actual ``"allowlist"`` restriction. Trusting ``"open"`` here
|
||||
admitted the whole external network with no operator-configured allowlist — the
|
||||
fail-open SECURITY.md §2.6 forbids for network-exposed adapters ("an allowlist
|
||||
is required for every enabled network-exposed adapter ... code paths that fail
|
||||
open when no allowlist is configured are code bugs"). Open access requires an
|
||||
explicit ``{PLATFORM}_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS`` opt-in.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
# Platforms whose adapters own their access policy at intake.
|
||||
_OWN_POLICY_PLATFORMS = [
|
||||
Platform.WECOM,
|
||||
Platform.WEIXIN,
|
||||
Platform.YUANBAO,
|
||||
Platform.QQBOT,
|
||||
Platform.WHATSAPP,
|
||||
]
|
||||
|
||||
|
||||
def _clear_auth_env(monkeypatch) -> None:
|
||||
for key in (
|
||||
"WECOM_ALLOWED_USERS",
|
||||
"WEIXIN_ALLOWED_USERS",
|
||||
"YUANBAO_ALLOWED_USERS",
|
||||
"QQ_ALLOWED_USERS",
|
||||
"QQ_GROUP_ALLOWED_USERS",
|
||||
"WHATSAPP_ALLOWED_USERS",
|
||||
"TELEGRAM_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOW_ALL_USERS",
|
||||
"WECOM_ALLOW_ALL_USERS",
|
||||
"WEIXIN_ALLOW_ALL_USERS",
|
||||
"YUANBAO_ALLOW_ALL_USERS",
|
||||
"QQ_ALLOW_ALL_USERS",
|
||||
"WHATSAPP_ALLOW_ALL_USERS",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def _make_runner(platform: Platform, config: GatewayConfig, *, enforces: bool):
|
||||
"""Build a bare GatewayRunner with one adapter for *platform*.
|
||||
|
||||
``enforces`` controls whether the adapter declares
|
||||
``enforces_own_access_policy`` — i.e. whether it owns its access gate.
|
||||
"""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = config
|
||||
adapter = SimpleNamespace(send=AsyncMock(), enforces_own_access_policy=enforces)
|
||||
runner.adapters = {platform: adapter}
|
||||
runner.pairing_store = MagicMock()
|
||||
runner.pairing_store.is_approved.return_value = False
|
||||
runner.pairing_store._is_rate_limited.return_value = False
|
||||
return runner, adapter
|
||||
|
||||
|
||||
def _source(platform: Platform, *, chat_type: str = "dm") -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=platform,
|
||||
user_id="some-user",
|
||||
chat_id="some-chat",
|
||||
user_name="tester",
|
||||
chat_type=chat_type,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 1: the base-class contract and per-adapter overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_base_adapter_defaults_to_not_owning_access_policy():
|
||||
"""Adapters that don't override the property delegate to the gateway."""
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
# The default lives on the base property descriptor.
|
||||
assert BasePlatformAdapter.enforces_own_access_policy.fget(object()) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module_path, class_name",
|
||||
[
|
||||
("plugins.platforms.wecom.adapter", "WeComAdapter"),
|
||||
("gateway.platforms.weixin", "WeixinAdapter"),
|
||||
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
|
||||
("gateway.platforms.qqbot.adapter", "QQAdapter"),
|
||||
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"),
|
||||
],
|
||||
)
|
||||
def test_own_policy_adapters_declare_the_flag(module_path, class_name):
|
||||
"""The config-policy adapters override the flag to True."""
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(module_path)
|
||||
adapter_cls = getattr(module, class_name)
|
||||
# Property is overridden on the subclass and returns True regardless of
|
||||
# instance state (it reflects a static capability, not runtime config).
|
||||
value = adapter_cls.enforces_own_access_policy.fget(object.__new__(adapter_cls))
|
||||
assert value is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 2: gateway trusts the adapter-enforced flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_allowlist_authorized_without_env_allowlist(monkeypatch, platform):
|
||||
"""A config-only ``dm_policy: allowlist`` is trusted without an env allowlist.
|
||||
|
||||
The adapter only forwards an allowlisted sender under ``allowlist`` policy,
|
||||
so a message reaching the gateway *was* authorized for this specific sender.
|
||||
The gateway must honor that instead of double-denying (the #34515 case).
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "allowlist"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(platform)) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_stored_route_cannot_reuse_prior_adapter_authorization(monkeypatch, platform):
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
platform: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"dm_policy": "allowlist"},
|
||||
)
|
||||
}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(
|
||||
_source(platform),
|
||||
allow_adapter_delegation=False,
|
||||
) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_open_dm_authorized_with_gateway_allow_all(monkeypatch, platform):
|
||||
"""Explicit ``GATEWAY_ALLOW_ALL_USERS`` unlocks ``dm_policy: open``."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
|
||||
config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(platform)) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_open_dm_not_authorized_without_allowlist(monkeypatch, platform):
|
||||
"""``dm_policy: open`` forwards everyone → NOT authorization (SECURITY.md §2.6).
|
||||
|
||||
With no env allowlist and no per-platform allow-all flag, an own-policy
|
||||
adapter running ``open`` (the default) must NOT fail open: the gateway falls
|
||||
through to default-deny so the whole external network can't reach the agent.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(platform)) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_default_open_dm_is_fail_closed(monkeypatch, platform):
|
||||
"""The adapters' *default* ``open`` policy (no config at all) fails closed.
|
||||
|
||||
Operators who enable an own-policy adapter with only credentials get
|
||||
``dm_policy = "open"`` resolved on the live adapter. Simulate that resolved
|
||||
state (empty config.extra, adapter ``_dm_policy = "open"``) and confirm the
|
||||
gateway denies — the do-nothing default must not be open to the world.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(platforms={platform: PlatformConfig(enabled=True, extra={})})
|
||||
runner, adapter = _make_runner(platform, config, enforces=True)
|
||||
adapter._dm_policy = "open" # as the live adapter resolves the default
|
||||
|
||||
assert runner._is_user_authorized(_source(platform)) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_allowlist_authorized_for_group_chat(monkeypatch, platform):
|
||||
"""A config-only ``group_policy: allowlist`` is trusted for group traffic."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"group_policy": "allowlist"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(platform, chat_type="group")) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_open_group_not_authorized_without_allowlist(monkeypatch, platform):
|
||||
"""``group_policy: open`` is the same fail-open class as DM open → deny."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"group_policy": "open"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(platform, chat_type="group")) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module_path, class_name, dm_helper",
|
||||
[
|
||||
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter", "_is_dm_allowed"),
|
||||
("plugins.platforms.wecom.adapter", "WeComAdapter", "_is_dm_allowed"),
|
||||
("gateway.platforms.weixin", "WeixinAdapter", "_is_dm_allowed"),
|
||||
("gateway.platforms.qqbot.adapter", "QQAdapter", "_is_dm_allowed"),
|
||||
],
|
||||
)
|
||||
def test_pairing_dm_policy_strict_intake_auth_denies_unknown(
|
||||
monkeypatch, module_path, class_name, dm_helper,
|
||||
):
|
||||
"""Default ``dm_policy: pairing`` must not admit senders via strict auth."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
import importlib
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
module = importlib.import_module(module_path)
|
||||
adapter_cls = getattr(module, class_name)
|
||||
adapter = adapter_cls(PlatformConfig(enabled=True, extra={"dm_policy": "pairing"}))
|
||||
assert getattr(adapter, dm_helper)("unknown-user") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank_sender", ["", " ", None])
|
||||
def test_yuanbao_pairing_dm_intake_denies_blank_principal(monkeypatch, blank_sender):
|
||||
"""Yuanbao pairing intake must not forward senderless C2C callbacks."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
from gateway.platforms.yuanbao import AccessPolicy
|
||||
|
||||
policy = AccessPolicy(
|
||||
dm_policy="pairing",
|
||||
dm_allow_from=[],
|
||||
group_policy="pairing",
|
||||
group_allow_from=[],
|
||||
)
|
||||
assert policy.is_dm_intake_allowed(blank_sender) is False
|
||||
assert policy.is_dm_intake_allowed("user-1") is True
|
||||
|
||||
|
||||
def test_wecom_open_group_with_per_group_sender_allowlist_is_authorized(monkeypatch):
|
||||
"""WeCom ``groups.<id>.allow_from`` is an adapter-enforced restriction.
|
||||
|
||||
The top-level group policy is still ``open`` for the chat ID, but the
|
||||
adapter has already checked the sender allowlist before dispatching to the
|
||||
gateway. That is not the fail-open case and must not be double-denied.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.WECOM: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"group_policy": "open",
|
||||
"groups": {"some-chat": {"allow_from": ["some-user"]}},
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(Platform.WECOM, chat_type="group")) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 2b: `dm_policy: pairing` is NOT blanket-trusted
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Regression: WeCom/Weixin document ``dm_policy: pairing`` and declare
|
||||
# ``enforces_own_access_policy=True``, but their intake helper only special-cases
|
||||
# ``disabled`` / ``allowlist`` — ``pairing`` falls through and forwards the DM so
|
||||
# the gateway can run its pairing handshake. With no env allowlist, the
|
||||
# adapter-trust shortcut above then authorized *every* unpaired sender, silently
|
||||
# degrading pairing mode to open access. The shortcut must skip pairing-mode DMs
|
||||
# so an unpaired sender falls through to default-deny (and gets a pairing code).
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 3: unauthorized-DM behavior reads config dm_policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dm_policy, expected",
|
||||
[
|
||||
("allowlist", "ignore"),
|
||||
("disabled", "ignore"),
|
||||
("pairing", "pair"),
|
||||
],
|
||||
)
|
||||
def test_unauthorized_dm_behavior_follows_config_dm_policy(monkeypatch, dm_policy, expected):
|
||||
"""A restrictive dm_policy drops unauthorized DMs; pairing opts back in."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": dm_policy})}
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
|
||||
|
||||
assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == expected
|
||||
|
||||
|
||||
def test_unauthorized_dm_behavior_open_policy_keeps_default(monkeypatch):
|
||||
"""``dm_policy: open`` is not restrictive → falls through to the default."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
|
||||
|
||||
# No allowlist + no restrictive policy → open-gateway pairing default.
|
||||
assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == "pair"
|
||||
Reference in New Issue
Block a user