fix(gateway): pairing is a grant that syncs to the allowlist (#23778) (#56381)

Consolidates the pairing/allowlist authorization model. Reverses the
read-side AND-ing from #56346 (which made a paired user require ALSO
being in the allowlist) and restores pairing as a first-class grant:

- authz_mixin: a pairing-store entry authorizes regardless of the
  allowlist (union). approve_code is reachable only by the trusted
  operator (CLI / authenticated dashboard), never by an inbound sender,
  so it is not an attacker-controlled path — the #23778 bypass was the
  inbound message/approval-button gate, fixed separately.
- pairing: when an allowlist IS already configured for the platform,
  operator approval also appends the user to that allowlist env var
  (option i) and revoke removes them, keeping a single operator-visible,
  editable source of truth instead of an opaque approved.json. On an
  open gateway (no allowlist) approval is a no-op on the env var so we
  never silently lock an open gateway; the pairing store remains the
  grant record, honored by the union.
- auto-resume authz (0de67ad60) now honors paired users automatically
  via the same union — a legitimately-paired session survives restart.

Replaces the now-incorrect AND-ing tests with union + mirror + revoke
coverage. E2E verified: locked-gateway approve/revoke round-trips
through the allowlist; open-gateway approval stays open.
This commit is contained in:
Teknium
2026-07-01 05:31:15 -07:00
committed by GitHub
parent 04b4310643
commit 1bfe08145c
3 changed files with 279 additions and 69 deletions
+13 -15
View File
@@ -417,17 +417,20 @@ class GatewayAuthorizationMixin:
if getattr(source, "role_authorized", False) is True:
return True
# Pairing store membership. Recorded here but NOT returned yet: if an
# allowlist is configured, a previously-paired user must STILL be in
# that allowlist to be honored. Otherwise a user who tapped "Always" on
# an approval button could permanently bypass TELEGRAM_ALLOWED_USERS (or
# equivalent) via the pairing store even after being removed from the
# allowlist (issue #23778). When no allowlist is configured, pairing is
# the intended access path and is honored in the no-allowlist branch
# below; when an allowlist IS configured, the pairing entry only counts
# if the user is also in it (folded into the final membership check).
# Check pairing store. A pairing entry is a first-class authorization
# grant, created only by a trusted operator approving a pairing code
# (hermes gateway pairing approve / the authenticated dashboard) — an
# inbound sender can never reach approve_code, so this is not an
# attacker-controlled path. Honored as a UNION with the allowlist: a
# paired user is authorized regardless of the allowlist, and when an
# 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
# fixed separately.)
platform_name = source.platform.value if source.platform else ""
is_paired = self.pairing_store.is_approved(platform_name, user_id)
if self.pairing_store.is_approved(platform_name, user_id):
return True
# Check platform-specific and global allowlists
platform_allowlist = os.getenv(platform_env_map.get(source.platform, ""), "").strip()
@@ -439,11 +442,6 @@ class GatewayAuthorizationMixin:
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
# No env allowlist configured. A pairing-store entry is the intended
# access path here (the user completed the pairing handshake), so
# honor it — there is no allowlist to re-validate against.
if is_paired:
return True
# No env allowlist configured. Adapters that own their own
# config-driven access policy (dm_policy / group_policy /
# allow_from / group_allow_from) gate access at intake, so for those
+115
View File
@@ -52,6 +52,112 @@ MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout
PAIRING_DIR = get_hermes_dir("platforms/pairing", "pairing")
# Platform value -> its per-platform allowlist env var. When an operator has
# already configured an allowlist for a platform, approving a pairing code also
# writes the user into that allowlist (and revoking removes them), so the
# operator's own list stays the single visible/editable source of truth instead
# of drifting from an opaque approved.json (#23778 consolidation, option i).
# Platforms absent from this map (or with no allowlist configured) keep the
# pairing store as the sole grant record, honored by the authz union.
_PLATFORM_ALLOWLIST_ENV = {
"telegram": "TELEGRAM_ALLOWED_USERS",
"discord": "DISCORD_ALLOWED_USERS",
"whatsapp": "WHATSAPP_ALLOWED_USERS",
"whatsapp_cloud": "WHATSAPP_CLOUD_ALLOWED_USERS",
"slack": "SLACK_ALLOWED_USERS",
"signal": "SIGNAL_ALLOWED_USERS",
"email": "EMAIL_ALLOWED_USERS",
"sms": "SMS_ALLOWED_USERS",
"mattermost": "MATTERMOST_ALLOWED_USERS",
"matrix": "MATRIX_ALLOWED_USERS",
"dingtalk": "DINGTALK_ALLOWED_USERS",
"feishu": "FEISHU_ALLOWED_USERS",
"wecom": "WECOM_ALLOWED_USERS",
"wecom_callback": "WECOM_CALLBACK_ALLOWED_USERS",
"weixin": "WEIXIN_ALLOWED_USERS",
"bluebubbles": "BLUEBUBBLES_ALLOWED_USERS",
"qqbot": "QQ_ALLOWED_USERS",
"yuanbao": "YUANBAO_ALLOWED_USERS",
}
def _allowlist_env_for_platform(platform: str) -> Optional[str]:
"""Return the per-platform allowlist env var name, or None.
Falls back to the platform registry for plugin platforms so a plugin's
own ``allowed_users_env`` is honored too.
"""
platform = (platform or "").lower().strip()
env_var = _PLATFORM_ALLOWLIST_ENV.get(platform)
if env_var:
return env_var
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(platform)
if entry and entry.allowed_users_env:
return entry.allowed_users_env
except Exception:
pass
return None
def _split_allowlist(raw: str) -> list:
return [uid.strip() for uid in raw.split(",") if uid.strip()]
def _sync_allowlist_add(platform: str, user_id: str) -> None:
"""Add ``user_id`` to the platform allowlist env var IF one is configured.
Option (i): only materialize the grant into the allowlist when the operator
already runs an allowlist for this platform. On an open gateway (no
allowlist) we do nothing — the pairing store remains the grant record and
the authz union honors it, so we never silently convert an open gateway into
a locked one on first pairing.
"""
env_var = _allowlist_env_for_platform(platform)
if not env_var:
return
current = os.getenv(env_var, "").strip()
if not current:
return # No allowlist configured — leave the gateway open (option i).
ids = _split_allowlist(current)
if "*" in ids or str(user_id) in ids:
return # Already covered.
ids.append(str(user_id))
try:
from hermes_cli.config import save_env_value
save_env_value(env_var, ",".join(ids))
except Exception:
# Best-effort: the pairing store grant still authorizes via the union,
# so a failure here degrades to "grant recorded but not mirrored".
pass
def _sync_allowlist_remove(platform: str, user_id: str) -> None:
"""Remove ``user_id`` from the platform allowlist env var if present."""
env_var = _allowlist_env_for_platform(platform)
if not env_var:
return
current = os.getenv(env_var, "").strip()
if not current:
return
ids = _split_allowlist(current)
remaining = [i for i in ids if i != str(user_id)]
if len(remaining) == len(ids):
return # Not present.
try:
from hermes_cli.config import save_env_value, remove_env_value
if remaining:
save_env_value(env_var, ",".join(remaining))
else:
remove_env_value(env_var)
except Exception:
pass
def _secure_write(path: Path, data: str) -> None:
"""Write data to file with restrictive permissions (owner read/write only).
@@ -177,6 +283,11 @@ class PairingStore:
}
self._save_json(self._approved_path(platform), approved)
# Mirror the grant into the operator's allowlist when one is configured
# (option i), so the pairing store and the allowlist stay a single
# visible source of truth. No-op on open gateways.
_sync_allowlist_add(platform, normalized_user_id)
def revoke(self, platform: str, user_id: str) -> bool:
"""Remove a user from the approved list. Returns True if found."""
path = self._approved_path(platform)
@@ -191,6 +302,10 @@ class PairingStore:
for approved_user_id in matching_ids:
del approved[approved_user_id]
self._save_json(path, approved)
# Keep the allowlist mirror in sync: revoking a paired user
# also removes the entry the approval added (option i). No-op if
# the user was added to the allowlist by other means.
_sync_allowlist_remove(platform, user_id)
return True
return False
+151 -54
View File
@@ -1,14 +1,19 @@
"""Regression guard: pairing store must not bypass a configured allowlist (#23778).
"""Pairing store <-> allowlist consolidation (#23778).
A user who tapped "Always" on an approval button gets a pairing-store entry.
Before the fix, ``_is_user_authorized()`` returned True from the pairing store
BEFORE the allowlist was ever consulted, so a paired-but-not-allowed user
permanently bypassed ``TELEGRAM_ALLOWED_USERS`` (or equivalent) even after being
removed from the allowlist. The fix records pairing membership but only honors
it when no allowlist is configured; when an allowlist IS configured, the paired
user must still appear in it.
Design (union + option-i mirror):
* A pairing-store entry is a first-class authorization grant. A paired user
is authorized regardless of any configured allowlist (union), because
``approve_code`` is reachable only by the trusted operator (CLI/dashboard),
never by an inbound sender.
* When an allowlist IS already configured for the platform, approving a
pairing code ALSO writes the user into that allowlist env var (and revoking
removes them), so the two stay a single operator-visible source of truth.
* On an open gateway (no allowlist configured) approval does NOT create an
allowlist — that would silently lock an open gateway. The pairing store
remains the grant record, honored by the authz union.
"""
import os
from types import SimpleNamespace
import pytest
@@ -29,6 +34,10 @@ def _isolate_env(monkeypatch):
monkeypatch.delenv(var, raising=False)
# --------------------------------------------------------------------------
# authz union: a paired user is authorized regardless of the allowlist
# --------------------------------------------------------------------------
def _make_runner(*, paired: bool):
from gateway.run import GatewayRunner
@@ -37,7 +46,7 @@ def _make_runner(*, paired: bool):
return runner
def _make_source(user_id: str = "attacker42", chat_type: str = "dm"):
def _make_source(user_id: str = "pairme", chat_type: str = "dm"):
return SessionSource(
platform=Platform.TELEGRAM,
chat_id="123",
@@ -48,61 +57,149 @@ def _make_source(user_id: str = "attacker42", chat_type: str = "dm"):
)
def test_paired_user_denied_when_not_in_platform_allowlist(monkeypatch):
"""The core bypass: paired but absent from TELEGRAM_ALLOWED_USERS → denied."""
def test_paired_user_authorized_even_when_not_in_allowlist(monkeypatch):
"""Union semantics: pairing is a grant, honored alongside the allowlist."""
runner = _make_runner(paired=True)
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1,owner2")
assert runner._is_user_authorized(_make_source("attacker42")) is False
assert runner._is_user_authorized(_make_source("pairme")) is True
def test_paired_user_denied_when_not_in_global_allowlist(monkeypatch):
runner = _make_runner(paired=True)
monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "owner1,owner2")
assert runner._is_user_authorized(_make_source("attacker42")) is False
def test_paired_user_allowed_when_still_in_platform_allowlist(monkeypatch):
"""A paired user who is also in the allowlist stays authorized."""
runner = _make_runner(paired=True)
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1,attacker42")
assert runner._is_user_authorized(_make_source("attacker42")) is True
def test_paired_user_allowed_when_still_in_global_allowlist(monkeypatch):
runner = _make_runner(paired=True)
monkeypatch.setenv("GATEWAY_ALLOWED_USERS", "owner1,attacker42")
assert runner._is_user_authorized(_make_source("attacker42")) is True
def test_paired_user_allowed_with_wildcard_allowlist(monkeypatch):
"""A "*" allowlist means everyone; a paired user is honored."""
runner = _make_runner(paired=True)
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "*")
assert runner._is_user_authorized(_make_source("attacker42")) is True
def test_paired_user_allowed_when_no_allowlist_configured(monkeypatch):
"""No allowlist configured → pairing is the intended access path, honored."""
def test_paired_user_authorized_with_no_allowlist(monkeypatch):
runner = _make_runner(paired=True)
assert runner._is_user_authorized(_make_source("attacker42")) is True
assert runner._is_user_authorized(_make_source("pairme")) is True
def test_unpaired_user_denied_when_no_allowlist_configured(monkeypatch):
"""No allowlist and not paired → default-deny (no fail-open)."""
runner = _make_runner(paired=False)
assert runner._is_user_authorized(_make_source("randouser")) is False
def test_unpaired_user_in_allowlist_still_allowed(monkeypatch):
"""Pairing changes did not regress the plain allowlist path."""
def test_unpaired_user_in_allowlist_still_authorized(monkeypatch):
runner = _make_runner(paired=False)
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1")
assert runner._is_user_authorized(_make_source("owner1")) is True
def test_unpaired_user_not_in_allowlist_denied(monkeypatch):
runner = _make_runner(paired=False)
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1")
assert runner._is_user_authorized(_make_source("stranger")) is False
def test_unpaired_user_no_allowlist_denied_no_failopen(monkeypatch):
runner = _make_runner(paired=False)
assert runner._is_user_authorized(_make_source("stranger")) is False
# --------------------------------------------------------------------------
# B2 mirror: approval writes into the allowlist iff one is configured
# --------------------------------------------------------------------------
@pytest.fixture
def store(tmp_path, monkeypatch):
"""A real PairingStore backed by a temp pairing dir."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / ".hermes").mkdir(parents=True, exist_ok=True)
import importlib
import gateway.pairing as pairing_mod
importlib.reload(pairing_mod)
return pairing_mod.PairingStore()
def _approve_new_user(store, platform, user_id, user_name=""):
code = store.generate_code(platform, user_id, user_name)
assert code is not None
return store.approve_code(platform, code)
def test_approval_adds_to_configured_allowlist(store, monkeypatch):
"""When an allowlist exists, approval appends the user to it (option i)."""
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1")
# save_env_value writes to .env under HERMES_HOME; patch it to capture.
captured = {}
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: (captured.__setitem__(k, v),
os.environ.__setitem__(k, v)))
_approve_new_user(store, "telegram", "newuser99")
assert captured.get("TELEGRAM_ALLOWED_USERS") == "owner1,newuser99"
def test_approval_no_allowlist_leaves_gateway_open(store, monkeypatch):
"""Open gateway: approval must NOT create an allowlist (option i)."""
called = {}
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: called.__setitem__(k, v))
_approve_new_user(store, "telegram", "newuser99")
assert "TELEGRAM_ALLOWED_USERS" not in called
assert os.getenv("TELEGRAM_ALLOWED_USERS", "") == ""
# The pairing store still records the grant (union honors it).
assert store.is_approved("telegram", "newuser99") is True
def test_approval_idempotent_when_already_in_allowlist(store, monkeypatch):
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1,newuser99")
called = {}
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: called.__setitem__(k, v))
_approve_new_user(store, "telegram", "newuser99")
# Already present — no rewrite.
assert "TELEGRAM_ALLOWED_USERS" not in called
def test_approval_skips_wildcard_allowlist(store, monkeypatch):
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "*")
called = {}
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: called.__setitem__(k, v))
_approve_new_user(store, "telegram", "newuser99")
assert "TELEGRAM_ALLOWED_USERS" not in called
def test_revoke_removes_from_allowlist(store, monkeypatch):
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "owner1,newuser99")
saved = {}
removed = []
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: (saved.__setitem__(k, v),
os.environ.__setitem__(k, v)))
monkeypatch.setattr(cfg, "remove_env_value", lambda k: removed.append(k))
# Seed the approved list directly so revoke has something to remove.
store._approve_user("telegram", "newuser99", "")
assert store.revoke("telegram", "newuser99") is True
assert saved.get("TELEGRAM_ALLOWED_USERS") == "owner1"
def test_revoke_removes_env_var_when_list_empties(store, monkeypatch):
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "newuser99")
removed = []
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "save_env_value",
lambda k, v: os.environ.__setitem__(k, v))
monkeypatch.setattr(cfg, "remove_env_value", lambda k: removed.append(k))
store._approve_user("telegram", "newuser99", "")
# _approve_user's own add is a no-op (already present); reset for the revoke.
os.environ["TELEGRAM_ALLOWED_USERS"] = "newuser99"
assert store.revoke("telegram", "newuser99") is True
assert "TELEGRAM_ALLOWED_USERS" in removed