feat(gateway): plugin platforms declare group-chat allowlist and pairing suppression

Two authz behaviours were hard-coded on Platform.QQBOT/TELEGRAM/... in
gateway/authz_mixin.py (_GROUP_CHAT_ENV, _ALLOWLIST_SUPPRESSES_PAIRING), so a
plugin-registered platform could not get them and removing the bundled QQ
adapter would silently drop QQ_GROUP_ALLOWED_USERS gating and change pairing.

PlatformEntry gains group_allowed_chats_env and allowlist_suppresses_pairing;
authz_mixin reads them through authz_field (live entry, else the bundled
gateway_env.AUTHZ). Every bundled platform that was in the old tables declares
them in its AUTHZ, so behaviour is unchanged.
This commit is contained in:
Teknium committed 2026-10-17 14:51:46 -07:00
1 parent e6f57427c1
commit 47d9efe490
19 files changed
+70 -17

No files matched your search

+17 -17
View File
@@ -28,19 +28,11 @@ _TRUTHY = frozenset({"true", "1", "yes"})
_BOT_LOOP_GUARD_INIT_LOCK = threading.Lock()
logger = logging.getLogger(__name__)
# ``<PLATFORM>_ALLOWED_USERS`` / ``<PLATFORM>_ALLOW_ALL_USERS`` come from each platform's
# ``PlatformEntry.allowed_users_env`` / ``allow_all_env`` (shared with the pairing allowlist mirror).
# Platforms whose env allowlist turns the unauthorized-DM default from "pair" into "ignore". Names
# only (the env var is still the plugin's): this is the historical set the allowlist-aware default
# covered, so adding a platform here is a behaviour change (Yuanbao was never in it).
_ALLOWLIST_SUPPRESSES_PAIRING = frozenset({
Platform.TELEGRAM, Platform.DISCORD, Platform.WHATSAPP, Platform.WHATSAPP_CLOUD, Platform.SLACK,
Platform.SIGNAL, Platform.EMAIL, Platform.SMS, Platform.MATTERMOST, Platform.MATRIX, Platform.DINGTALK,
Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES,
Platform.QQBOT,
})
# ``<PLATFORM>_ALLOWED_USERS`` / ``<PLATFORM>_ALLOW_ALL_USERS``, the group-chat allowlist env and
# whether an allowlist suppresses pairing come from each platform's ``PlatformEntry``
# (``allowed_users_env`` / ``allow_all_env`` / ``group_allowed_chats_env`` /
# ``allowlist_suppresses_pairing``), so a plugin platform declares them like a bundled one.
_GROUP_USER_ENV = {Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS"}
_GROUP_CHAT_ENV = {Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS", Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS"}
# Gate reads use the shared per-profile isolated reader (allowlist leak under multiplex, #72348).
@@ -59,6 +51,13 @@ def authz_field(name, field, entry):
return _field(name, field, entry)
def _group_chat_env(platform, entry=None) -> str:
"""The platform's group-chat allowlist env name (``PlatformEntry.group_allowed_chats_env``), or ""."""
if platform is None:
return ""
return authz_field(platform.value, "group_allowed_chats_env", entry or _registry_entry(platform)) or ""
def _registry_entry(platform):
"""Platform-registry entry for a (plugin) platform, or None. Discovers first (idempotent): the
allowlist env names live only on the entry, so an undiscovered process must not read "no list"."""
@@ -553,7 +552,7 @@ class GatewayAuthorizationMixin:
# Chat-scoped group allowlists must work with ``user_id is None`` (anonymous admins,
# sender_chat posts, channel broadcasts).
if is_group and source.chat_id:
chat_allowlist_env = _GROUP_CHAT_ENV.get(source.platform, "")
chat_allowlist_env = _group_chat_env(source.platform)
if chat_allowlist_env and _allows(_coerce_allow_set(_auth_env(chat_allowlist_env)), source.chat_id):
return True
# config.yaml fallback (``extra.group_allowed_chats``): Telegram observe-unmentioned mode
@@ -681,7 +680,7 @@ class GatewayAuthorizationMixin:
platform_allowlist = _auth_env(platform_allow_env)
group_user_allowlist = _auth_env(_GROUP_USER_ENV.get(source.platform, "")) if is_group_or_forum else ""
group_chat_allowlist = _auth_env(_GROUP_CHAT_ENV.get(source.platform, "")) if is_group_or_forum else ""
group_chat_allowlist = _auth_env(_group_chat_env(source.platform, entry)) if is_group_or_forum else ""
global_allowlist = _auth_env("GATEWAY_ALLOWED_USERS")
if not (platform_allowlist or group_user_allowlist or group_chat_allowlist or global_allowlist):
@@ -749,9 +748,10 @@ class GatewayAuthorizationMixin:
return "pair"
if dm_policy in {"allowlist", "disabled"}:
return "ignore"
env_key = (authz_field(platform.value, "allowed_users_env", _registry_entry(platform)) or ""
if platform in _ALLOWLIST_SUPPRESSES_PAIRING else "")
allowlist_keys = [env_key, _GROUP_USER_ENV.get(platform), _GROUP_CHAT_ENV.get(platform), *allowlist_keys]
entry = _registry_entry(platform)
env_key = (authz_field(platform.value, "allowed_users_env", entry) or ""
if authz_field(platform.value, "allowlist_suppresses_pairing", entry) else "")
allowlist_keys = [env_key, _GROUP_USER_ENV.get(platform), _group_chat_env(platform, entry), *allowlist_keys]
if any(key and _auth_env(key).strip() for key in allowlist_keys):
return "ignore"
return "pair"
+6
View File
@@ -138,6 +138,12 @@ class PlatformEntry:
# either resolves "open" without ``allow_all_env`` / GATEWAY_ALLOW_ALL_USERS, startup is refused.
# A "" member = that policy is YAML-only (QQ Bot). None = no own policy, never checked.
own_policy_env: Optional[tuple] = None
# Env var listing group/forum CHAT ids whose traffic is authorized by chat (any sender, user_id
# optional): Telegram ``TELEGRAM_GROUP_ALLOWED_CHATS``, QQ ``QQ_GROUP_ALLOWED_USERS``. "" = none.
group_allowed_chats_env: str = ""
# True: a configured env allowlist turns the unauthorized-DM default from "pair" into "ignore"
# (the owner restricted access; unknown senders get no pairing code).
allowlist_suppresses_pairing: bool = False
# --- send ---
# send_message native target syntax, see ``gateway.platforms.send_targets``. A hint, not an
# authority like ``parse_target_ref_fn``: None / NOT_EXPLICIT fall back to core's generic id
@@ -27,4 +27,5 @@ ENV_OVERRIDES = (
AUTHZ = {
"allowed_users_env": "BLUEBUBBLES_ALLOWED_USERS",
"allow_all_env": "BLUEBUBBLES_ALLOW_ALL_USERS",
"allowlist_suppresses_pairing": True,
}
@@ -17,4 +17,5 @@ ENV_OVERRIDES = (
AUTHZ = {
"allowed_users_env": "DINGTALK_ALLOWED_USERS",
"allow_all_env": "DINGTALK_ALLOW_ALL_USERS",
"allowlist_suppresses_pairing": True,
}
+1
View File
@@ -17,4 +17,5 @@ AUTHZ = {
"allowed_users_env": "DISCORD_ALLOWED_USERS",
"allow_all_env": "DISCORD_ALLOW_ALL_USERS",
"allow_bots_env": "DISCORD_ALLOW_BOTS",
"allowlist_suppresses_pairing": True,
}
+1
View File
@@ -17,4 +17,5 @@ ENV_OVERRIDES = (
AUTHZ = {
"allowed_users_env": "EMAIL_ALLOWED_USERS",
"allow_all_env": "EMAIL_ALLOW_ALL_USERS",
"allowlist_suppresses_pairing": True,
}
+1
View File
@@ -22,4 +22,5 @@ AUTHZ = {
"allowed_users_env": "FEISHU_ALLOWED_USERS",
"allow_all_env": "FEISHU_ALLOW_ALL_USERS",
"allow_bots_env": "FEISHU_ALLOW_BOTS",
"allowlist_suppresses_pairing": True,
}
+1
View File
@@ -30,4 +30,5 @@ ENV_OVERRIDES = (
AUTHZ = {
"allowed_users_env": "MATRIX_ALLOWED_USERS",
"allow_all_env": "MATRIX_ALLOW_ALL_USERS",
"allowlist_suppresses_pairing": True,
}
@@ -18,4 +18,5 @@ ENV_OVERRIDES = (
AUTHZ = {
"allowed_users_env": "MATTERMOST_ALLOWED_USERS",
"allow_all_env": "MATTERMOST_ALLOW_ALL_USERS",
"allowlist_suppresses_pairing": True,
}
+2
View File
@@ -43,4 +43,6 @@ AUTHZ = {
"allowed_users_env": "QQ_ALLOWED_USERS",
"allow_all_env": "QQ_ALLOW_ALL_USERS",
"own_policy_env": ("", ""),
"group_allowed_chats_env": "QQ_GROUP_ALLOWED_USERS",
"allowlist_suppresses_pairing": True,
}
+1
View File
@@ -21,4 +21,5 @@ ENV_OVERRIDES = (
AUTHZ = {
"allowed_users_env": "SIGNAL_ALLOWED_USERS",
"allow_all_env": "SIGNAL_ALLOW_ALL_USERS",
"allowlist_suppresses_pairing": True,
}
+1
View File
@@ -28,4 +28,5 @@ AUTHZ = {
"allowed_users_env": "SLACK_ALLOWED_USERS",
"allow_all_env": "SLACK_ALLOW_ALL_USERS",
"allow_bots_env": "SLACK_ALLOW_BOTS",
"allowlist_suppresses_pairing": True,
}
+1
View File
@@ -19,4 +19,5 @@ ENV_OVERRIDES = (
AUTHZ = {
"allowed_users_env": "SMS_ALLOWED_USERS",
"allow_all_env": "SMS_ALLOW_ALL_USERS",
"allowlist_suppresses_pairing": True,
}
@@ -24,4 +24,6 @@ AUTHZ = {
"allowed_users_env": "TELEGRAM_ALLOWED_USERS",
"allow_all_env": "TELEGRAM_ALLOW_ALL_USERS",
"allow_bots_env": "TELEGRAM_ALLOW_BOTS",
"group_allowed_chats_env": "TELEGRAM_GROUP_ALLOWED_CHATS",
"allowlist_suppresses_pairing": True,
}
+2
View File
@@ -33,8 +33,10 @@ AUTHZ = {
"allowed_users_env": "WECOM_ALLOWED_USERS",
"allow_all_env": "WECOM_ALLOW_ALL_USERS",
"own_policy_env": ("WECOM_DM_POLICY", "WECOM_GROUP_POLICY"),
"allowlist_suppresses_pairing": True,
}
CALLBACK_AUTHZ = {
"allowed_users_env": "WECOM_CALLBACK_ALLOWED_USERS",
"allow_all_env": "WECOM_CALLBACK_ALLOW_ALL_USERS",
"allowlist_suppresses_pairing": True,
}
+1
View File
@@ -24,4 +24,5 @@ AUTHZ = {
"allowed_users_env": "WEIXIN_ALLOWED_USERS",
"allow_all_env": "WEIXIN_ALLOW_ALL_USERS",
"own_policy_env": ("WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY"),
"allowlist_suppresses_pairing": True,
}
@@ -27,4 +27,5 @@ AUTHZ = {
"allowed_users_env": "WHATSAPP_ALLOWED_USERS",
"allow_all_env": "WHATSAPP_ALLOW_ALL_USERS",
"own_policy_env": ("WHATSAPP_DM_POLICY", "WHATSAPP_GROUP_POLICY"),
"allowlist_suppresses_pairing": True,
}
@@ -24,4 +24,5 @@ ENV_OVERRIDES = (
AUTHZ = {
"allowed_users_env": "WHATSAPP_CLOUD_ALLOWED_USERS",
"allow_all_env": "WHATSAPP_CLOUD_ALLOW_ALL_USERS",
"allowlist_suppresses_pairing": True,
}
@@ -51,3 +51,31 @@ def test_declared_own_policy_env_refuses_open_policy_without_allow_all(probe_pla
assert _own_policy_open_startup_violation(cfg) == "authzprobe: open policy without allow-all opt-in"
monkeypatch.setenv("AUTHZPROBE_ALLOW_ALL_USERS", "true")
assert _own_policy_open_startup_violation(cfg) is None
@pytest.mark.parametrize("declared", [True, False])
def test_declared_group_chat_env_and_pairing_suppression(monkeypatch, declared):
"""A plugin platform declaring ``group_allowed_chats_env`` / ``allowlist_suppresses_pairing``
gets chat-id group authorization and the allowlist-aware "ignore" default; undeclared, neither."""
from gateway.platform_registry import PlatformEntry, platform_registry
from gateway.run import GatewayRunner
for var in ("GRPPROBE_ALLOWED_USERS", "GRPPROBE_GROUP_CHATS", "GATEWAY_ALLOW_ALL_USERS", "GATEWAY_ALLOWED_USERS"):
monkeypatch.delenv(var, raising=False)
fields = dict(group_allowed_chats_env="GRPPROBE_GROUP_CHATS", allowlist_suppresses_pairing=True) if declared else {}
platform_registry.register(PlatformEntry(
name="grpprobe", label="Group Probe", adapter_factory=lambda cfg: None, check_fn=lambda: True,
allowed_users_env="GRPPROBE_ALLOWED_USERS", **fields))
try:
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(platforms={Platform("grpprobe"): PlatformConfig(enabled=True)})
runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False)
group = SessionSource(platform=Platform("grpprobe"), chat_id="g1", chat_type="group", user_id=None,
user_name="")
monkeypatch.setenv("GRPPROBE_GROUP_CHATS", "g1")
assert runner._is_user_authorized(group) is declared
monkeypatch.delenv("GRPPROBE_GROUP_CHATS")
monkeypatch.setenv("GRPPROBE_ALLOWED_USERS", "owner")
assert runner._get_unauthorized_dm_behavior(Platform("grpprobe")) == ("ignore" if declared else "pair")
finally:
platform_registry.unregister("grpprobe")