deer-flow/backend/app/channels/allowed_users.py
Zhengxu (Joshua) Jin 47b6755ce9
fix(qq): parse allowed_users with the shared fail-closed allowlist (#6730)
* fix(qq): parse allowed_users with the shared fail-closed allowlist

QQChannel built its allowlist with set(config.get("allowed_users") or []).
A single OpenID, which is what an `allowed_users: $QQ_ALLOWED_USERS`
reference resolves to, became a set of its characters, so the configured
user was silently dropped. A mapping authorized every key, including one
mapped to false, and a numeric scalar raised TypeError while the channel
was being constructed.

Parse it with the shared parse_allowed_users helper that Telegram and
WeChat already use: unset, null, empty collections and a blank string
allow everyone; a scalar OpenID is one entry; entries that are not
non-blank strings (such as numeric QQ account numbers) are dropped with a
warning, and a mapping is rejected as a whole; a configured value with no
valid OpenID denies every sender and logs an error. A scalar containing commas or whitespace warns
that it stays one literal ID. Binding codes still precede the allowlist.

* test(qq): gate group messages by member OpenID in allowlist tests

Every allowlist test sent a C2C frame, and the shared event() helper puts
the sender in both user_openid and member_openid, so a group-path
regression in sender resolution would pass unnoticed. admits() now takes
a kind; group frames carry only member_openid and a group_openid that
differs from the sender. The listed-OpenID test runs for both kinds, and a
new test shows that listing the group's OpenID does not admit its members.

Document that a blank or whitespace-only allowed_users string allows every
sender, like an unset value or an empty list.

* refactor(channels): share scalar allowlist separator warnings

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-12 22:19:11 +08:00

51 lines
2.1 KiB
Python

"""Shared fail-closed parsing for channel user allowlists."""
from __future__ import annotations
import logging
from collections.abc import Callable, Hashable
from typing import Any
def parse_allowed_users[UserId: Hashable](
allowed_users: Any,
*,
parse_user_id: Callable[[Any], UserId | None],
logger: logging.Logger,
channel_name: str,
expected_id: str,
valid_id_name: str = "user ID",
warn_on_scalar_separators: bool = False,
) -> frozenset[UserId] | None:
"""Return ``None`` for an unset/empty allowlist, or its valid user IDs.
Scalars are single entries; mappings must not authorize their keys. The
channel's coercer validates each entry. Invalid entries warn and are dropped,
but a configured value with no valid entry returns an empty set and logs an
error, so callers deny everyone instead of silently removing the restriction.
String-ID channels can opt into a scalar-separator warning without splitting
the ID; surrounding whitespace alone does not trigger it.
"""
if allowed_users is None or (isinstance(allowed_users, str) and not allowed_users.strip()):
return None
if warn_on_scalar_separators and isinstance(allowed_users, str) and ("," in allowed_users or any(char.isspace() for char in allowed_users.strip())):
logger.warning(
"[%s] allowed_users is a scalar string containing separators; treating it as one literal %s. Use a YAML list for multiple IDs",
channel_name,
valid_id_name,
)
entries = list(allowed_users) if isinstance(allowed_users, (list, tuple, set, frozenset)) else [allowed_users]
if not entries:
return None
user_ids: set[UserId] = set()
for entry in entries:
user_id = parse_user_id(entry)
if user_id is None:
logger.warning("[%s] Ignoring allowed_users entry %r: expected %s; list several IDs as a YAML list", channel_name, entry, expected_id)
else:
user_ids.add(user_id)
if not user_ids:
logger.error("[%s] allowed_users has no valid %s; denying every user until it is fixed", channel_name, valid_id_name)
return frozenset(user_ids)