Files
aiturk-hermes-ide/tests/gateway/test_multiplex_profile_authz.py
T

413 lines
15 KiB
Python

"""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