ForcePilot/backend/package/yuxi/channels/adapters/synologychat/security.py
Kris 3ad218e537 feat(synologychat): 新增群晖Chat适配器完整实现
新增群晖Chat渠道适配器的全套实现,包括:
1. 基础适配器与导出接口定义
2. DSM API认证、探测与会话管理
3. 轮询与Webhook两种消息接收方式
4. 消息去重、格式化与规范化处理
5. 多账号支持与权限安全策略
6. 目录用户/群组发现功能
7. 审批配对与流量控制机制
8. 安全审计与配置检查功能
2026-05-12 00:49:16 +08:00

186 lines
7.5 KiB
Python

"""Security policy for Synology Chat access control.
Controls which users can interact with the bot via DM policy, group policy,
and allowlist mechanisms. Includes rate limiting, security warnings
collection, wildcard support, and pairing mode.
"""
from __future__ import annotations
import time
from collections import defaultdict
from dataclasses import dataclass
from typing import Any
from yuxi.channels.models import ChannelMessage, ChatType
from yuxi.utils.logging_config import logger
_DEFAULT_RATE_LIMIT_PER_MINUTE = 30
_DEFAULT_RATE_WINDOW_SECONDS = 60
@dataclass
class _RateWindow:
count: int = 0
window_start: float = 0.0
class RateLimiter:
def __init__(
self, max_requests: int = _DEFAULT_RATE_LIMIT_PER_MINUTE, window_seconds: int = _DEFAULT_RATE_WINDOW_SECONDS
):
self._max_requests = max_requests
self._window_seconds = window_seconds
self._buckets: dict[str, _RateWindow] = defaultdict(_RateWindow)
def allow(self, key: str) -> bool:
now = time.monotonic()
bucket = self._buckets[key]
if now - bucket.window_start > self._window_seconds:
bucket.count = 1
bucket.window_start = now
return True
if bucket.count < self._max_requests:
bucket.count += 1
return True
return False
def remaining(self, key: str) -> int:
now = time.monotonic()
bucket = self._buckets[key]
if now - bucket.window_start > self._window_seconds:
return self._max_requests
return max(0, self._max_requests - bucket.count)
class SynologyChatSecurityPolicy:
def __init__(self, config: dict[str, Any], account_id: str = ""):
sec = config.get("security", {})
if not isinstance(sec, dict):
sec = {}
self._dm_policy = sec.get("dm_policy", "open")
self._group_policy = sec.get("group_policy", "allowlist")
self._allow_from: list[str] = sec.get("allow_from", [])
self._group_allow_from: list[str] = sec.get("group_allow_from", [])
self._pending_pairing: set[str] = set()
rate_cfg = config.get("rate_limit", {})
if not isinstance(rate_cfg, dict):
rate_cfg = {}
self._rate_limiter = RateLimiter(
max_requests=rate_cfg.get("max_per_minute", _DEFAULT_RATE_LIMIT_PER_MINUTE),
window_seconds=rate_cfg.get("window_seconds", _DEFAULT_RATE_WINDOW_SECONDS),
)
self._account_id = account_id
@property
def dm_policy(self) -> str:
return self._dm_policy
@property
def group_policy(self) -> str:
return self._group_policy
def check(self, msg: ChannelMessage) -> bool:
user_id = msg.identity.channel_user_id
rate_key = f"{self._account_id}:{user_id}" if self._account_id else user_id
if not self._rate_limiter.allow(rate_key):
logger.warning(f"[SynologyChat] Rate limit exceeded for user {user_id}")
return False
if msg.chat_type == ChatType.DIRECT:
return self._check_dm(msg)
return self._check_group(msg)
def check_rate(self, user_id: str) -> bool:
rate_key = f"{self._account_id}:{user_id}" if self._account_id else user_id
return self._rate_limiter.allow(rate_key)
def _check_dm(self, msg: ChannelMessage) -> bool:
user_id = msg.identity.channel_user_id
if self._dm_policy == "open":
return True
if self._dm_policy == "disabled":
logger.debug(f"[SynologyChat] DM blocked (policy=disabled) for user {user_id}")
return False
if self._dm_policy == "allowlist":
allowed = self._is_allowed(user_id, self._allow_from)
if not allowed:
logger.debug(f"[SynologyChat] DM blocked (allowlist) for user {user_id}")
return allowed
if self._dm_policy == "pairing":
if self._is_allowed(user_id, self._allow_from):
return True
self._pending_pairing.add(user_id)
logger.info(f"[SynologyChat] DM pairing required for user {user_id}")
return False
logger.warning(f"[SynologyChat] Unknown dm_policy '{self._dm_policy}', falling back to open")
return True
def _check_group(self, msg: ChannelMessage) -> bool:
user_id = msg.identity.channel_user_id
chat_id = msg.identity.channel_chat_id
if self._group_policy == "open":
return True
if self._group_policy == "disabled":
logger.debug(f"[SynologyChat] Group message blocked (policy=disabled) from user {user_id}")
return False
if self._group_policy == "allowlist":
if self._is_allowed(user_id, self._group_allow_from):
return True
logger.debug(f"[SynologyChat] Group message blocked (allowlist) from user {user_id} in chat {chat_id}")
return False
logger.warning(f"[SynologyChat] Unknown group_policy '{self._group_policy}', falling back to open")
return True
def _is_allowed(self, user_id: str, allowlist: list[str]) -> bool:
if "*" in allowlist:
return True
return user_id in allowlist
def is_pairing_required(self, user_id: str) -> bool:
return self._dm_policy == "pairing" and user_id in self._pending_pairing
def approve_pairing(self, user_id: str) -> None:
self._pending_pairing.discard(user_id)
if user_id not in self._allow_from:
self._allow_from.append(user_id)
def deny_pairing(self, user_id: str) -> None:
self._pending_pairing.discard(user_id)
def add_allowed_user(self, user_id: str, scope: str = "dm") -> bool:
if scope == "dm":
target = self._allow_from
elif scope == "group":
target = self._group_allow_from
else:
return False
if user_id not in target:
target.append(user_id)
logger.info(f"[SynologyChat] Added user {user_id} to {scope} allow_from")
return True
return False
def collect_security_warnings(self) -> list[str]:
warnings: list[str] = []
if self._dm_policy == "open":
warnings.append("dm_policy is 'open': any user can DM the bot without restriction")
if self._group_policy == "open":
warnings.append("group_policy is 'open': any group member can trigger the bot")
if not self._allow_from and self._dm_policy == "allowlist":
warnings.append("dm_policy is 'allowlist' but allow_from is empty — no users permitted")
if not self._group_allow_from and self._group_policy == "allowlist":
warnings.append("group_policy is 'allowlist' but group_allow_from is empty — no users permitted")
if self._dm_policy == "pairing" and not self._allow_from:
warnings.append("pairing mode active but initial allow_from is empty — all users require approval")
return warnings
def rebuild_rate_limiter(self, max_per_minute: int | None = None, window_seconds: int | None = None) -> None:
if max_per_minute is not None or window_seconds is not None:
new_max = max_per_minute if max_per_minute is not None else self._rate_limiter._max_requests
new_window = window_seconds if window_seconds is not None else self._rate_limiter._window_seconds
self._rate_limiter = RateLimiter(max_requests=new_max, window_seconds=new_window)
logger.info(f"[SynologyChat] Rate limiter rebuilt: max_per_minute={new_max}, window_seconds={new_window}")