fix(gateway): honour allow_all_users from config.yaml by bridging it to GATEWAY_ALLOW_ALL_USERS

`gateway.allow_all_users: true` (and the top-level spelling) in config.yaml
was a silent no-op: `_TOPLEVEL_BRIDGE` never forwarded it, GatewayConfig has
no field, and every allow-all reader (authz mixin default-deny branch,
startup access check, own-policy adapters, Discord/Matrix/Email plugin
gates) consults the GATEWAY_ALLOW_ALL_USERS env var only.

Bridge the YAML key into that env var in `bridge_core_env_settings`, the
one seam every reader already shares, instead of threading a new config
attribute through ten readers:
- first-writer-wins: an explicit env var beats YAML (matches every other
  {PLATFORM}_* gate);
- skipped inside a multiplexed secondary profile's scope (#80099 class):
  the secondary's config.yaml must not become the default profile's policy,
  and the isolation test now asserts GATEWAY_ALLOW_ALL_USERS stays unset;
- a startup warning names config.yaml as the grant source, because the key
  was inert until now and a forgotten `true` flips the posture to open.

Tests: both spellings authorize a stranger through `_is_user_authorized`;
`false`, absent key, and env=false over YAML=true all stay denied.
Docs: security guide, env-var reference, gateway internals.

Fixes #110690
This commit is contained in:
teknium1
2026-09-14 18:04:53 -07:00
committed by Teknium
parent 23bdb546cc
commit 5083d5f78e
6 changed files with 73 additions and 8 deletions
+22 -5
View File
@@ -297,20 +297,37 @@ def apply_plugin_yaml_hooks(yaml_cfg: dict, gateway_platforms: Any, platforms_da
def bridge_core_env_settings(yaml_cfg: dict, platforms_data: dict) -> None:
"""The two YAML→env bridges that stay in core (per-platform ones live in plugin hooks).
"""The YAML→env bridges that stay in core (per-platform ones live in plugin hooks).
Top-level ``require_mention`` → Telegram when the ``telegram:`` section has none: users write it
alongside ``group_sessions_per_user`` expecting it to work, and the telegram plugin's hook only
runs when a telegram block exists. Signal ``require_mention`` → ``SIGNAL_REQUIRE_MENTION`` (env wins).
``allow_all_users`` (top-level or ``gateway.allow_all_users``) → ``GATEWAY_ALLOW_ALL_USERS``: every
allow-all reader (authz mixin, startup access check, own-policy adapters, plugin gates) consults
that env var, so the bridge is the one seam that makes the YAML key reach all of them (#110690).
Both values are ALWAYS seeded into the owning platform's ``extra`` (the adapters read extra first);
the process-env write is skipped while a multiplexed secondary profile's scope is active — the
loader runs inside ``_profile_runtime_scope`` for every secondary, and a first-writer-wins write
there would make the secondary's mention policy the DEFAULT profile's (#80099 class).
Platform values are ALWAYS seeded into the owning platform's ``extra`` (the adapters read extra
first); the process-env write is skipped while a multiplexed secondary profile's scope is active —
the loader runs inside ``_profile_runtime_scope`` for every secondary, and a first-writer-wins write
there would make the secondary's policy the DEFAULT profile's (#80099 class). A secondary profile
sets ``GATEWAY_ALLOW_ALL_USERS`` in its own ``.env`` like every other scoped authorization gate.
"""
from gateway.platforms._shared import profile_scoped
skip_env_bridge = profile_scoped()
gateway_section = yaml_cfg.get("gateway")
allow_all = yaml_cfg.get("allow_all_users")
if allow_all is None and isinstance(gateway_section, dict):
allow_all = gateway_section.get("allow_all_users")
if allow_all is not None and not skip_env_bridge and not os.getenv("GATEWAY_ALLOW_ALL_USERS"):
os.environ["GATEWAY_ALLOW_ALL_USERS"] = str(allow_all).lower()
if os.environ["GATEWAY_ALLOW_ALL_USERS"] in {"true", "1", "yes"}:
# The key was inert before it was bridged, so a forgotten line silently flips the
# posture to open — name the grant source at startup.
logger.warning(
"config.yaml allow_all_users: true grants every sender on every platform access "
"(bridged to GATEWAY_ALLOW_ALL_USERS; an explicit env var wins)."
)
tl_require_mention = yaml_cfg.get("require_mention")
if tl_require_mention is not None and "require_mention" not in (yaml_cfg.get("telegram") or {}):
tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {})
+44
View File
@@ -844,6 +844,50 @@ class TestLoadGatewayConfig:
]
assert os.environ.get("DINGTALK_ALLOWED_USERS") == "user-id-1,user-id-2"
@pytest.mark.parametrize("yaml_text", ["gateway:\n allow_all_users: true\n", "allow_all_users: true\n"])
def test_allow_all_users_yaml_reaches_the_authz_gate(self, tmp_path, monkeypatch, yaml_text):
"""Both spellings must open the gate for an unknown sender; before #110690 the key was inert
because every allow-all reader consults GATEWAY_ALLOW_ALL_USERS only."""
from gateway.authz_mixin import GatewayAuthorizationMixin
from gateway.session import SessionSource
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(yaml_text, encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
runner = object.__new__(GatewayAuthorizationMixin)
runner.config = load_gateway_config()
runner.adapters = {}
stranger = SessionSource(platform=Platform.TELEGRAM, user_id="999", chat_id="999", chat_type="dm")
assert runner._is_user_authorized(stranger) is True
@pytest.mark.parametrize("yaml_text, env, expected", [
("gateway:\n allow_all_users: false\n", None, "false"),
("gateway:\n allow_all_users: true\n", "false", "false"), # explicit env wins over YAML
("gateway: {}\n", None, None),
])
def test_allow_all_users_yaml_never_widens_past_env(self, tmp_path, monkeypatch, yaml_text, env, expected):
from gateway.authz_mixin import GatewayAuthorizationMixin
from gateway.session import SessionSource
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(yaml_text, encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
if env is None:
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
else:
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", env)
runner = object.__new__(GatewayAuthorizationMixin)
runner.config = load_gateway_config()
runner.adapters = {}
assert os.environ.get("GATEWAY_ALLOW_ALL_USERS") == expected
stranger = SessionSource(platform=Platform.TELEGRAM, user_id="999", chat_id="999", chat_type="dm")
assert runner._is_user_authorized(stranger) is False
def test_top_level_platforms_override_nested_gateway_platforms(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
@@ -20,6 +20,8 @@ from hermes_constants import reset_hermes_home_override, set_hermes_home_overrid
_SECONDARY_YAML = """\
require_mention: false
gateway:
allow_all_users: true
telegram:
mention_patterns: ['^bot2']
reactions: false
@@ -49,7 +51,7 @@ _BRIDGED_ENV = (
"MATRIX_REQUIRE_MENTION", "MATRIX_ALLOWED_USERS", "MATRIX_SESSION_SCOPE",
"WHATSAPP_DM_POLICY", "WHATSAPP_ALLOWED_USERS", "FEISHU_ALLOW_BOTS",
"SLACK_ALLOW_BOTS", "SLACK_IGNORED_CHANNELS", "DINGTALK_ALLOWED_USERS",
"DISCORD_AUTO_THREAD", "DISCORD_REACTIONS", "SIGNAL_REQUIRE_MENTION",
"DISCORD_AUTO_THREAD", "DISCORD_REACTIONS", "SIGNAL_REQUIRE_MENTION", "GATEWAY_ALLOW_ALL_USERS",
)
_EXPECTED_EXTRA = (
@@ -96,7 +96,7 @@ The gateway uses a multi-layer authorization check, evaluated in order:
1. **Per-platform allow-all flag** (e.g., `TELEGRAM_ALLOW_ALL_USERS`) — if set, all users on that platform are authorized
2. **Platform allowlist** (e.g., `TELEGRAM_ALLOWED_USERS`) — comma-separated user IDs
3. **DM pairing** — authenticated users can pair new users via a pairing code
4. **Global allow-all** (`GATEWAY_ALLOW_ALL_USERS`) — if set, all users across all platforms are authorized
4. **Global allow-all** (`GATEWAY_ALLOW_ALL_USERS`, or `gateway.allow_all_users` in `config.yaml`, bridged to the env var by `gateway/config_loader.py::bridge_core_env_settings`) — if set, all users across all platforms are authorized
5. **Default: deny** — unauthorized users are rejected
### DM Pairing Flow
@@ -546,7 +546,7 @@ These are set automatically by the Docker terminal backend when `proxy.enabled:
| `GATEWAY_PROXY_KEY` | Bearer token for authenticating with the remote API server in proxy mode. Must match `API_SERVER_KEY` on the remote host. |
| `MESSAGING_CWD` | Deprecated compatibility fallback for gateway working directory. Prefer `terminal.cwd` in `config.yaml`. |
| `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms |
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlists (`true`/`false`, default: `false`) |
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlists (`true`/`false`, default: `false`). Also configurable via `gateway.allow_all_users` in `config.yaml`; the env var wins when both are set. |
### Web Dashboard & Hermes Desktop
+2
View File
@@ -410,6 +410,8 @@ DISCORD_ALLOW_ALL_USERS=true
GATEWAY_ALLOW_ALL_USERS=true
```
The global allow-all can also live in `config.yaml` as `gateway.allow_all_users: true` (or top-level `allow_all_users: true`); it is bridged to `GATEWAY_ALLOW_ALL_USERS` at gateway startup, an explicit env var wins, and the gateway logs a warning naming `config.yaml` as the grant source. In a multi-profile gateway a secondary profile sets `GATEWAY_ALLOW_ALL_USERS` in its own `.env` (its `config.yaml` is never bridged into the process environment).
:::warning
If **no allowlists are configured** and `GATEWAY_ALLOW_ALL_USERS` is not set, **all users are denied**. The gateway logs a warning at startup: