fix(gateway): username-based DISCORD_ALLOWED_USERS no longer locks out the operator after one turn
The Discord adapter resolves username allowlist entries to numeric IDs at connect and mirrors them into os.environ — but the gateway's per-turn .env hot-reload (load_hermes_dotenv(override=True)) restores the raw usernames from the file. From the second agent turn onward, _is_user_authorized compared numeric user_ids against username strings and dropped every message from the operator as 'Unauthorized user' while the adapter layer still admitted them (bot reacted, never replied). Fix: gateway authz unions the adapter's resolved numeric IDs (DiscordAdapter.resolved_allowlist_user_ids()) into the env-derived allowlist. Union only fires when an env allowlist is configured (never a widening; fail-closed branch unchanged), is duck-typed + isinstance-guarded against mock adapters, and filters non-numeric entries so unresolved usernames and '*' can't leak through adapter memory. Live repro: symptom fired on origin/main (authorized=False after reload), passes with fix; stranger + empty-allowlist + raising-resolver negatives hold. Sabotage run: incident test fails on unfixed authz_mixin.
This commit is contained in:
@@ -751,6 +751,43 @@ class GatewayAuthorizationMixin:
|
||||
if global_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
|
||||
|
||||
# Adapters that resolve username-shaped allowlist entries to numeric
|
||||
# IDs at connect time (Discord's ``_resolve_allowed_usernames``) keep
|
||||
# the authoritative resolved set in adapter memory and mirror it into
|
||||
# the process env. The gateway's per-turn .env hot-reload
|
||||
# (``load_hermes_dotenv(override=True)`` in
|
||||
# ``_reload_runtime_env_preserving_config_authority``) restores the
|
||||
# RAW username strings from the .env file into the env, so from the
|
||||
# second agent turn onward ``platform_allowlist`` holds usernames
|
||||
# while ``source.user_id`` is numeric — the operator is admitted by
|
||||
# the adapter but dropped here as "Unauthorized user" (Aug 2026:
|
||||
# responded once, then silence). Union in the adapter's resolved IDs
|
||||
# so runtime resolution survives env reloads. This is a UNION of the
|
||||
# resolution of entries already present in the configured allowlist —
|
||||
# never a widening: the empty-allowlist fail-closed branch above has
|
||||
# already returned, and adapters only resolve entries the operator
|
||||
# wrote. Guarded on ``platform_allowlist`` so group/global-only
|
||||
# configurations never consult adapter memory, and duck-typed +
|
||||
# type-checked so bare-runner test fixtures with mock adapters
|
||||
# (pitfall #17) cannot auto-truthy their way into an authorization.
|
||||
if platform_allowlist:
|
||||
try:
|
||||
adapter = self._adapter_for_source(source)
|
||||
except Exception:
|
||||
adapter = None
|
||||
resolver = getattr(adapter, "resolved_allowlist_user_ids", None)
|
||||
if callable(resolver):
|
||||
try:
|
||||
resolved_ids = resolver()
|
||||
except Exception:
|
||||
resolved_ids = None
|
||||
if isinstance(resolved_ids, (set, frozenset, list, tuple)):
|
||||
allowed_ids.update(
|
||||
str(entry).strip()
|
||||
for entry in resolved_ids
|
||||
if isinstance(entry, (str, int)) and str(entry).strip()
|
||||
)
|
||||
|
||||
# "*" in any allowlist means allow everyone (consistent with
|
||||
# SIGNAL_GROUP_ALLOWED_USERS precedent)
|
||||
if "*" in allowed_ids:
|
||||
|
||||
@@ -6701,6 +6701,27 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if str(entry).strip().isdigit()
|
||||
}
|
||||
|
||||
def resolved_allowlist_user_ids(self) -> set:
|
||||
"""Numeric user IDs from the connect-time username resolution.
|
||||
|
||||
``_resolve_allowed_usernames`` turns username-shaped
|
||||
``DISCORD_ALLOWED_USERS`` entries into numeric IDs and keeps the
|
||||
authoritative result in ``self._allowed_user_ids``. The env-var
|
||||
mirror of that result does NOT survive the gateway's per-turn .env
|
||||
hot-reload (``load_hermes_dotenv(override=True)`` restores the raw
|
||||
usernames from the file), so the gateway authz layer
|
||||
(``GatewayAuthorizationMixin._is_user_authorized``) calls this to
|
||||
union the resolved IDs back into the allowlist it builds from env.
|
||||
|
||||
Returns only numeric entries: usernames are not comparable to
|
||||
``source.user_id`` and the ``"*"`` wildcard is env-file-persistent
|
||||
already (resolution preserves it in the file's value), so passing it
|
||||
through here would only widen access on the gateway layer beyond
|
||||
what the operator's current .env says.
|
||||
"""
|
||||
allowed = getattr(self, "_allowed_user_ids", None) or set()
|
||||
return {str(uid) for uid in allowed if str(uid).isdigit()}
|
||||
|
||||
def _discord_allow_all_users(self) -> bool:
|
||||
"""Per-profile DISCORD_ALLOW_ALL_USERS flag."""
|
||||
raw = self._gate_raw("allow_all_users", "DISCORD_ALLOW_ALL_USERS")
|
||||
|
||||
172
tests/gateway/test_discord_username_allowlist_env_reload.py
Normal file
172
tests/gateway/test_discord_username_allowlist_env_reload.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Gateway authz must survive the per-turn .env hot-reload when
|
||||
DISCORD_ALLOWED_USERS contains usernames (Aug 2026 incident).
|
||||
|
||||
Sequence under test:
|
||||
1. Operator writes usernames into DISCORD_ALLOWED_USERS in ``.env``.
|
||||
2. Discord adapter connect resolves usernames -> numeric IDs into adapter
|
||||
memory (``_allowed_user_ids``) and mirrors them into ``os.environ``.
|
||||
3. The gateway's per-turn env hot-reload
|
||||
(``_reload_runtime_env_preserving_config_authority`` ->
|
||||
``load_hermes_dotenv(override=True)``) restores the RAW username strings
|
||||
from the file into the process env.
|
||||
4. ``GatewayAuthorizationMixin._is_user_authorized`` compares the sender's
|
||||
numeric ``user_id`` against the env allowlist.
|
||||
|
||||
Before the fix, step 4 found usernames, never matched a numeric ID, and
|
||||
dropped the operator as "Unauthorized user" from the second agent turn
|
||||
onward — the bot answered exactly once per reconnect. The fix unions the
|
||||
adapter's resolved numeric IDs (``resolved_allowlist_user_ids()``) into the
|
||||
gateway-layer allowlist, so runtime resolution survives env reloads.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.session import Platform, SessionSource
|
||||
|
||||
OPERATOR_ID = "387972437901312000"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_auth_env(monkeypatch):
|
||||
for var in (
|
||||
"DISCORD_ALLOWED_USERS",
|
||||
"DISCORD_ALLOWED_ROLES",
|
||||
"DISCORD_ALLOW_ALL_USERS",
|
||||
"DISCORD_ALLOW_BOTS",
|
||||
"GATEWAY_ALLOW_ALL_USERS",
|
||||
"GATEWAY_ALLOWED_USERS",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
def _make_runner(adapter=None):
|
||||
"""Bare GatewayRunner (object.__new__ pattern, AGENTS.md pitfall #17)."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False)
|
||||
runner.adapters = {Platform.DISCORD: adapter} if adapter is not None else {}
|
||||
return runner
|
||||
|
||||
|
||||
def _discord_source(user_id: str = OPERATOR_ID):
|
||||
return SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id="1543941724672368680",
|
||||
chat_type="thread",
|
||||
user_id=user_id,
|
||||
user_name="Teknium",
|
||||
is_bot=False,
|
||||
)
|
||||
|
||||
|
||||
def _resolved_adapter(ids=frozenset({OPERATOR_ID, "111222333444555666"})):
|
||||
"""Stand-in for a connected DiscordAdapter after username resolution.
|
||||
|
||||
Mirrors the real accessor's contract: a plain method returning a set of
|
||||
numeric-ID strings. SimpleNamespace (not MagicMock) so no unrelated
|
||||
attribute can auto-truthy through other authz branches.
|
||||
"""
|
||||
return SimpleNamespace(resolved_allowlist_user_ids=lambda: set(ids))
|
||||
|
||||
|
||||
class TestResolvedAllowlistSurvivesEnvReload:
|
||||
def test_username_env_plus_resolved_adapter_authorizes(self, monkeypatch):
|
||||
"""THE incident shape: env holds usernames (post-reload), adapter holds
|
||||
resolved numeric IDs — the operator must stay authorized."""
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "teknium,123mikeyd")
|
||||
runner = _make_runner(_resolved_adapter())
|
||||
assert runner._is_user_authorized(_discord_source()) is True
|
||||
|
||||
def test_username_env_without_adapter_still_denies(self, monkeypatch):
|
||||
"""No live adapter (or resolution never ran): usernames cannot match a
|
||||
numeric user_id — deny, exactly as before the fix."""
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "teknium,123mikeyd")
|
||||
runner = _make_runner(adapter=None)
|
||||
assert runner._is_user_authorized(_discord_source()) is False
|
||||
|
||||
def test_stranger_denied_despite_resolved_adapter(self, monkeypatch):
|
||||
"""The union must not widen access: a sender in neither the env list
|
||||
nor the resolved set stays denied."""
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "teknium,123mikeyd")
|
||||
runner = _make_runner(_resolved_adapter())
|
||||
assert runner._is_user_authorized(_discord_source("666000666000666000")) is False
|
||||
|
||||
def test_empty_env_allowlist_never_consults_adapter(self, monkeypatch):
|
||||
"""Fail-closed invariant: with NO configured allowlist, adapter memory
|
||||
must not become an authorization source (the union is a resolution of
|
||||
configured entries, not an independent grant)."""
|
||||
consulted = []
|
||||
|
||||
def _resolver():
|
||||
consulted.append(True)
|
||||
return {OPERATOR_ID}
|
||||
|
||||
adapter = SimpleNamespace(resolved_allowlist_user_ids=_resolver)
|
||||
runner = _make_runner(adapter)
|
||||
assert runner._is_user_authorized(_discord_source()) is False
|
||||
assert consulted == [], (
|
||||
"adapter resolved set must not be consulted when no env allowlist "
|
||||
"is configured — that would turn stale adapter memory into a grant"
|
||||
)
|
||||
|
||||
def test_numeric_env_ids_still_work_without_adapter(self, monkeypatch):
|
||||
"""Plain numeric-ID configuration is untouched by the fix."""
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", OPERATOR_ID)
|
||||
runner = _make_runner(adapter=None)
|
||||
assert runner._is_user_authorized(_discord_source()) is True
|
||||
|
||||
def test_non_set_resolver_return_is_ignored(self, monkeypatch):
|
||||
"""A resolver returning a non-collection (e.g. a MagicMock in a test
|
||||
fixture) must be discarded by the isinstance guard, not iterated or
|
||||
truthy-tested into an authorization."""
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "teknium")
|
||||
adapter = SimpleNamespace(resolved_allowlist_user_ids=lambda: MagicMock())
|
||||
runner = _make_runner(adapter)
|
||||
assert runner._is_user_authorized(_discord_source("666000666000666000")) is False
|
||||
|
||||
def test_raising_resolver_fails_closed(self, monkeypatch):
|
||||
"""An adapter whose accessor raises must not break authz for senders
|
||||
the env list already covers, nor authorize anyone else."""
|
||||
def _boom():
|
||||
raise RuntimeError("adapter mid-reconnect")
|
||||
|
||||
monkeypatch.setenv("DISCORD_ALLOWED_USERS", f"teknium,{OPERATOR_ID}")
|
||||
adapter = SimpleNamespace(resolved_allowlist_user_ids=_boom)
|
||||
runner = _make_runner(adapter)
|
||||
# numeric entry in env still authorizes
|
||||
assert runner._is_user_authorized(_discord_source()) is True
|
||||
# stranger still denied
|
||||
assert runner._is_user_authorized(_discord_source("666000666000666000")) is False
|
||||
|
||||
|
||||
class TestDiscordAdapterResolvedAccessor:
|
||||
def _adapter(self, allowed_ids):
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter._allowed_user_ids = allowed_ids
|
||||
return adapter
|
||||
|
||||
def test_returns_numeric_ids_as_strings(self):
|
||||
adapter = self._adapter({OPERATOR_ID, 111222333444555666})
|
||||
assert adapter.resolved_allowlist_user_ids() == {
|
||||
OPERATOR_ID,
|
||||
"111222333444555666",
|
||||
}
|
||||
|
||||
def test_filters_usernames_and_wildcard(self):
|
||||
"""Unresolved usernames and the '*' wildcard must not pass through:
|
||||
usernames can't match numeric user_ids, and '*' would widen the
|
||||
gateway layer to allow-everyone from adapter memory alone."""
|
||||
adapter = self._adapter({"teknium", "*", OPERATOR_ID})
|
||||
assert adapter.resolved_allowlist_user_ids() == {OPERATOR_ID}
|
||||
|
||||
def test_missing_attribute_yields_empty_set(self):
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
adapter = object.__new__(DiscordAdapter) # no _allowed_user_ids at all
|
||||
assert adapter.resolved_allowlist_user_ids() == set()
|
||||
Reference in New Issue
Block a user