diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index f84ca259ccb..166635df59a 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -246,6 +246,21 @@ class GatewayAuthorizationMixin: return any(str(item).strip() for item in sender_allow) return False + def _pairing_store_for(self, source: "SessionSource"): + """Pick the per-profile PairingStore for a source, falling back to global. + + In a multiplexing gateway, each profile owns its own pairing whitelist + so isolation is preserved. When the source has no profile (single- + profile gateway, or a path that hasn't stamped profile yet) or the + profile isn't registered, fall back to ``self.pairing_store`` (the + global default) so existing behavior is preserved. + """ + per_profile = getattr(self, "pairing_stores", None) or {} + profile = getattr(source, "profile", None) + if profile and profile in per_profile: + return per_profile[profile] + return getattr(self, "pairing_store", None) + def _is_user_authorized(self, source: SessionSource) -> bool: """ Check if a user is authorized to use the bot. @@ -430,10 +445,14 @@ class GatewayAuthorizationMixin: # allowlist IS configured, operator approval also writes the user into # that allowlist (see PairingStore._approve_user), keeping a single # operator-visible source of truth. (#23778: the original bypass was the - # inbound message/approval-button gate, not this grant; that gate is + # inbound message/approval-button gate, not this gate; that gate is # fixed separately.) + # In multiplex gateways, route to the per-profile PairingStore so each + # profile's whitelist is isolated; falls back to the global store when + # the source has no profile or the profile isn't registered. platform_name = source.platform.value if source.platform else "" - if self.pairing_store.is_approved(platform_name, user_id): + pairing_store = self._pairing_store_for(source) + if pairing_store is not None and pairing_store.is_approved(platform_name, user_id): return True # Check platform-specific and global allowlists diff --git a/gateway/pairing.py b/gateway/pairing.py index c7d3a8c7440..4e3712c117b 100644 --- a/gateway/pairing.py +++ b/gateway/pairing.py @@ -195,22 +195,41 @@ class PairingStore: - {platform}-pending.json : pending pairing requests - {platform}-approved.json : approved (paired) users - _rate_limits.json : rate limit tracking + + When constructed with ``profile=""``, storage lives under + ``/profiles//pairing/`` (per-profile, used by + multiplexing gateways so each profile has its own whitelist). + Without a profile, storage is the global ``/pairing/`` + directory (backward-compat for the ``hermes pairing`` CLI). """ - def __init__(self): - PAIRING_DIR.mkdir(parents=True, exist_ok=True) + def __init__(self, profile: Optional[str] = None): + # Resolve storage directory lazily — tests use a temp HERMES_HOME + # and PairingStore may be constructed before the env is set. + if profile: + from hermes_constants import get_hermes_home + self._dir = get_hermes_home() / "profiles" / profile / "pairing" + else: + self._dir = PAIRING_DIR + self._dir.mkdir(parents=True, exist_ok=True) # Protects all read-modify-write cycles. The gateway runs multiple # platform adapters concurrently in threads sharing one PairingStore. self._lock = threading.RLock() + self._profile = profile # for diagnostics / log lines + + @property + def profile(self) -> Optional[str]: + """Profile name this store is scoped to, or None for the global store.""" + return self._profile def _pending_path(self, platform: str) -> Path: - return PAIRING_DIR / f"{platform}-pending.json" + return self._dir / f"{platform}-pending.json" def _approved_path(self, platform: str) -> Path: - return PAIRING_DIR / f"{platform}-approved.json" + return self._dir / f"{platform}-approved.json" def _rate_limit_path(self) -> Path: - return PAIRING_DIR / "_rate_limits.json" + return self._dir / "_rate_limits.json" def _load_json(self, path: Path) -> dict: if path.exists(): diff --git a/gateway/run.py b/gateway/run.py index 6162f10b6fb..4541d646575 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3047,9 +3047,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception as exc: logger.debug("checkpoint auto-maintenance skipped: %s", exc) - # DM pairing store for code-based user authorization + # DM pairing store for code-based user authorization. + # ``pairing_store`` stays as the global/default store for the + # ``hermes pairing`` CLI and any caller without a profile context. + # ``pairing_stores`` is the per-profile map used by + # ``authz_mixin._is_user_authorized`` to route checks to the right + # whitelist (one per profile in multiplex mode). from gateway.pairing import PairingStore self.pairing_store = PairingStore() + self.pairing_stores: Dict[str, "PairingStore"] = {} # Event hook system from gateway.hooks import HookRegistry @@ -8335,6 +8341,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew try: from gateway.status import write_runtime_status served = [active] + sorted(self._profile_adapters.keys()) + # Per-profile PairingStores so authz_mixin can route pairing + # checks to the right whitelist. The active profile gets a store + # at its HERMES_HOME; additional served profiles get one under + # profiles//pairing/. See gateway.pairing.PairingStore. + for name in served: + if name and name not in self.pairing_stores: + self.pairing_stores[name] = PairingStore(profile=name) write_runtime_status(served_profiles=served) except Exception: logger.debug("could not record served_profiles", exc_info=True) diff --git a/tests/gateway/test_pairing.py b/tests/gateway/test_pairing.py index c08ac205577..aa4a9b1e9f4 100644 --- a/tests/gateway/test_pairing.py +++ b/tests/gateway/test_pairing.py @@ -720,3 +720,110 @@ class TestUnreadablePairingFile: # The warning must fire — otherwise this is the silent-failure bug. assert any(rec.levelno == logging.WARNING for rec in caplog.records), \ "PermissionError on approved.json must produce a WARNING log line" +# Profile-scoped storage (multiplexing gateway isolation) +# --------------------------------------------------------------------------- + + +class TestProfileScopedStorage: + """PairingStore(profile="") should isolate per-profile whitelists + under /profiles//pairing/ so a multiplexing gateway + can keep each profile's allowlist separate. + """ + + def test_default_store_uses_global_dir(self, tmp_path, monkeypatch): + """PairingStore() (no profile) keeps the legacy global path so the + ``hermes pairing`` CLI continues to work without a profile context.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + # Re-import PAIRING_DIR (it's a module-level constant resolved at + # import time) so the test exercises the right path. We patch it + # rather than re-importing so the assertion is unambiguous. + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + store = PairingStore() + assert store.profile is None + assert store._dir == tmp_path + assert store._approved_path("weixin") == tmp_path / "weixin-approved.json" + + def test_profile_store_uses_profiles_subdir(self, tmp_path, monkeypatch): + """PairingStore(profile="yangyang") puts files under + /profiles/yangyang/pairing/.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + store = PairingStore(profile="yangyang") + assert store.profile == "yangyang" + expected = tmp_path / "profiles" / "yangyang" / "pairing" + assert store._dir == expected + assert store._approved_path("weixin") == expected / "weixin-approved.json" + # Auto-creates the directory + assert expected.is_dir() + + def test_profile_approval_does_not_leak_to_global(self, tmp_path, monkeypatch): + """Approving in a profile-scoped store must not appear in the global + store — and vice versa. This is the whole point of the fix.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + global_store = PairingStore() + profile_store = PairingStore(profile="yangyang") + + # Approve in the profile store + profile_store._approve_user("weixin", "yangyang_user", "杨洋") + # And in the global store, a different user + global_store._approve_user("weixin", "global_user", "Default") + + # Cross-isolation: each store only sees its own user + assert profile_store.is_approved("weixin", "yangyang_user") is True + assert profile_store.is_approved("weixin", "global_user") is False + assert global_store.is_approved("weixin", "global_user") is True + assert global_store.is_approved("weixin", "yangyang_user") is False + + def test_profile_uses_distinct_rate_limit_file(self, tmp_path, monkeypatch): + """Rate-limit state is per-profile, not shared globally — otherwise + one profile's flood would lock out the other profile's users.""" + from hermes_constants import get_hermes_home + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + global_store = PairingStore() + profile_store = PairingStore(profile="yangyang") + + assert global_store._rate_limit_path() == tmp_path / "_rate_limits.json" + assert profile_store._rate_limit_path() == ( + tmp_path / "profiles" / "yangyang" / "pairing" / "_rate_limits.json" + ) + + def test_pairing_store_for_helper_routes_by_profile(self, tmp_path, monkeypatch): + """_pairing_store_for(source) on a gateway-like object picks the + per-profile store when source.profile is set, and falls back to + the global store when it isn't (defensive — single-profile + gateways, or any code path that hasn't stamped source.profile).""" + from gateway.session import SessionSource + from gateway.config import Platform + + class FakeGateway: + def __init__(self): + self.pairing_store = object() # sentinel + self.pairing_stores = { + "default": "default-store", + "yangyang": "yangyang-store", + } + + # Method under test — copy of the real helper so this test + # is self-contained even if the real one moves. + def _pairing_store_for(self, source): + per_profile = getattr(self, "pairing_stores", None) or {} + profile = getattr(source, "profile", None) + if profile and profile in per_profile: + return per_profile[profile] + return getattr(self, "pairing_store", None) + + g = FakeGateway() + # source with profile="yangyang" → per-profile store + s_yy = SessionSource(platform=Platform.WEIXIN, chat_id="c", profile="yangyang") + assert g._pairing_store_for(s_yy) == "yangyang-store" + # source with no profile → fallback to global + s_none = SessionSource(platform=Platform.WEIXIN, chat_id="c") + assert g._pairing_store_for(s_none) is g.pairing_store + # source with an unknown profile → fallback (defensive) + s_unknown = SessionSource(platform=Platform.WEIXIN, chat_id="c", profile="ghost") + assert g._pairing_store_for(s_unknown) is g.pairing_store +