Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 44 additions & 23 deletions gateway/authz_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,39 @@ def _coerce_allow_set(raw) -> set[str]:
return {part.strip() for part in str(raw).split(",") if part.strip()}


def _telegram_config_authorizes_source(source: SessionSource, extra: object) -> Optional[bool]:
"""Apply Telegram's config allowlists with their documented scopes.

``allow_from`` applies in every chat type; ``group_allow_from`` and
``group_allowed_chats`` only add grants for group-like sources. Returning
``None`` distinguishes an absent Telegram config allowlist from a sender
that was explicitly rejected by one.
"""
if not isinstance(extra, dict):
return None

allowlist_keys = ("allow_from", "group_allow_from", "group_allowed_chats")
if not any(extra.get(key) is not None for key in allowlist_keys):
return None

user_id = str(source.user_id or "").strip()
global_allowed = _coerce_allow_set(extra.get("allow_from"))
if user_id and ("*" in global_allowed or user_id in global_allowed):
return True

if source.chat_type in {"group", "forum", "channel"}:
group_users = _coerce_allow_set(extra.get("group_allow_from"))
if user_id and ("*" in group_users or user_id in group_users):
return True

group_chats = _coerce_allow_set(extra.get("group_allowed_chats"))
chat_id = str(source.chat_id or "").strip()
if chat_id and ("*" in group_chats or chat_id in group_chats):
return True

return False


class GatewayAuthorizationMixin:
"""User/chat authorization methods for ``GatewayRunner``."""

Expand Down Expand Up @@ -414,21 +447,15 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
if "*" in allowed_group_ids or source.chat_id in allowed_group_ids:
return True

# Fallback: also check adapter-level config (config.yaml)
# for platforms.<platform>.extra.group_allowed_chats.
# The Telegram observe-unmentioned mode strips user_id from
# triggered group messages (_apply_telegram_group_observe_attribution),
# so the env-var-only check above misses config.yaml-configured
# allowlists. Read the live adapter's config.extra as a fallback.
# Fallback: also check adapter-level config (config.yaml). The
# Telegram observe-unmentioned mode strips user_id from triggered
# group messages, so this must happen before the no-user-id guard.
try:
adapter = self._adapter_for_source(source)
if adapter is not None:
extra = getattr(getattr(adapter, "config", None), "extra", None) or {}
adapter_group_allowed = extra.get("group_allowed_chats")
if adapter_group_allowed:
allowed = _coerce_allow_set(adapter_group_allowed)
if "*" in allowed or source.chat_id in allowed:
return True
if _telegram_config_authorizes_source(source, extra) is True:
return True
except Exception:
pass

Expand Down Expand Up @@ -600,21 +627,15 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
)
if effective_policy == "allowlist":
return True
# Some adapters (e.g. Telegram) gate access via config.extra.allow_from /
# group_allow_from at intake but do not override enforces_own_access_policy.
# Check their allowlist here so config.yaml-configured allow_from works
# without requiring a separate {PLATFORM}_ALLOWED_USERS env var.
# Some adapters (e.g. Telegram) gate access via config allowlists
# at intake but do not override enforces_own_access_policy. Keep
# the global and group-scoped Telegram grants as an OR-union here
# so the runner agrees with the intake gate for config-only setups.
adapter = self._adapter_for_source(source)
if adapter is not None:
extra = getattr(getattr(adapter, "config", None), "extra", None) or {}
if source.chat_type in {"group", "forum", "channel"}:
adapter_allow = extra.get("group_allow_from")
else:
adapter_allow = extra.get("allow_from")
if adapter_allow:
allowed = _coerce_allow_set(adapter_allow)
if user_id in allowed or "*" in allowed:
return True
if _telegram_config_authorizes_source(source, extra) is True:
return True
# No allowlists configured -- check global allow-all flag
return _auth_env("GATEWAY_ALLOW_ALL_USERS").lower() in {"true", "1", "yes"}

Expand Down
18 changes: 7 additions & 11 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ class _MockContextTypes:
from pathlib import Path as _Path
sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))

from gateway.authz_mixin import _coerce_allow_set
from gateway.authz_mixin import _telegram_config_authorizes_source
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
Expand Down Expand Up @@ -1014,16 +1014,12 @@ def _is_user_authorized_from_message(self, message: Message) -> bool:
if not user_id:
return True

# Adapter-level allow_from / group_allow_from: when set, they are the
# sole authority. Group chats use group_allow_from; DMs use allow_from.
chat_type = source.chat_type or ""
if chat_type in ("group", "forum", "channel"):
adapter_allow_from = self.config.extra.get("group_allow_from")
else:
adapter_allow_from = self.config.extra.get("allow_from")
if adapter_allow_from is not None:
allowed = _coerce_allow_set(adapter_allow_from)
return user_id in allowed or "*" in allowed
# Config allowlists use the same union as runner authorization: global
# users work everywhere, while group users and allowed group chats add
# scoped grants without widening direct-message access.
config_authorized = _telegram_config_authorizes_source(source, self.config.extra)
if config_authorized is not None:
return config_authorized

# Test/custom injection only. The class method named
# _is_callback_user_authorized is for inline button callbacks and must
Expand Down
85 changes: 82 additions & 3 deletions tests/gateway/test_telegram_auth_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

Expand Down Expand Up @@ -227,6 +227,85 @@ def test_is_user_authorized_from_message_group_allow_from():
assert adapter._is_user_authorized_from_message(msg) is False


def test_config_allowlists_authorize_the_documented_group_union():
"""Global users, group users, and allowed chats are independent grants."""
adapter = _make_adapter(
allow_from=["global-user"],
group_allow_from=["group-user"],
group_allowed_chats=["-100"],
)

assert adapter._is_user_authorized_from_message(
_make_message(from_user_id="global-user", chat_id=-200, chat_type="group")
) is True
assert adapter._is_user_authorized_from_message(
_make_message(from_user_id="group-user", chat_id=-200, chat_type="group")
) is True
assert adapter._is_user_authorized_from_message(
_make_message(from_user_id="unlisted-user", chat_id=-100, chat_type="group")
) is True
assert adapter._is_user_authorized_from_message(
_make_message(from_user_id="unlisted-user", chat_id=-200, chat_type="group")
) is False
assert adapter._is_user_authorized_from_message(
_make_message(from_user_id="group-user", chat_id=123, chat_type="private")
) is False


def test_runner_config_authorization_matches_telegram_intake_union(monkeypatch):
"""YAML-config and environment allowlists produce the same intake result."""
from gateway.run import GatewayRunner

for key in (
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
"TELEGRAM_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS",
):
monkeypatch.delenv(key, raising=False)

adapter = _make_adapter(
allow_from=["global-user"],
group_allow_from=["group-user"],
group_allowed_chats=["-100"],
)
runner = object.__new__(GatewayRunner)
runner.adapters = {Platform.TELEGRAM: adapter}
runner.pairing_store = MagicMock()
runner.pairing_store.is_approved.return_value = False

cases = (
(_make_message(from_user_id="global-user", chat_id=-200, chat_type="group"), True),
(_make_message(from_user_id="group-user", chat_id=-200, chat_type="group"), True),
(_make_message(from_user_id="unlisted-user", chat_id=-100, chat_type="group"), True),
(_make_message(from_user_id="unlisted-user", chat_id=-200, chat_type="group"), False),
(_make_message(from_user_id="group-user", chat_id=123, chat_type="private"), False),
)
config_intake = []
for message, expected in cases:
source = adapter._source_from_message_for_auth(message)
config_intake.append(adapter._is_user_authorized_from_message(message))
assert runner._is_user_authorized(source) is expected

monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "global-user")
monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_USERS", "group-user")
monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_CHATS", "-100")
env_adapter = _make_adapter()
env_runner = object.__new__(GatewayRunner)
env_runner.adapters = {Platform.TELEGRAM: env_adapter}
env_runner.pairing_store = MagicMock()
env_runner.pairing_store.is_approved.return_value = False
env_adapter._message_handler = env_runner._is_user_authorized

env_intake = [
env_adapter._is_user_authorized_from_message(message)
for message, _expected in cases
]
assert config_intake == env_intake


def test_is_user_authorized_from_message_wildcard():
"""_is_user_authorized_from_message should accept wildcard '*'."""
adapter = _make_adapter(allow_from=["*"])
Expand Down Expand Up @@ -370,7 +449,7 @@ async def test_unmentioned_group_text_from_removed_user_not_observed():
adapter = _make_adapter(
group_allow_from=["222"],
allowed_chats=["-100"],
group_allowed_chats=["-100"],
group_allowed_chats=["-200"],
require_mention=True,
observe_unmentioned_group_messages=True,
)
Expand All @@ -391,7 +470,7 @@ async def test_unmentioned_group_location_from_removed_user_not_observed():
adapter = _make_adapter(
group_allow_from=["222"],
allowed_chats=["-100"],
group_allowed_chats=["-100"],
group_allowed_chats=["-200"],
require_mention=True,
observe_unmentioned_group_messages=True,
)
Expand Down
Loading