2026-05-12 00:46:12 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
from dataclasses import dataclass, field
|
2026-05-12 14:51:53 +08:00
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
|
from .pairing import MattermostPairingManager
|
2026-05-12 00:46:12 +08:00
|
|
|
|
|
|
|
|
|
|
DmPolicy = str
|
|
|
|
|
|
DM_POLICY_PAIRING: DmPolicy = "pairing"
|
|
|
|
|
|
DM_POLICY_OPEN: DmPolicy = "open"
|
|
|
|
|
|
DM_POLICY_ALLOWLIST: DmPolicy = "allowlist"
|
|
|
|
|
|
DM_POLICY_DISABLED: DmPolicy = "disabled"
|
|
|
|
|
|
DM_POLICIES = frozenset({DM_POLICY_PAIRING, DM_POLICY_OPEN, DM_POLICY_ALLOWLIST, DM_POLICY_DISABLED})
|
|
|
|
|
|
|
|
|
|
|
|
GroupPolicy = str
|
|
|
|
|
|
GROUP_POLICY_OPEN: GroupPolicy = "open"
|
|
|
|
|
|
GROUP_POLICY_ALLOWLIST: GroupPolicy = "allowlist"
|
|
|
|
|
|
GROUP_POLICY_DISABLED: GroupPolicy = "disabled"
|
|
|
|
|
|
GROUP_POLICIES = frozenset({GROUP_POLICY_OPEN, GROUP_POLICY_ALLOWLIST, GROUP_POLICY_DISABLED})
|
|
|
|
|
|
|
|
|
|
|
|
_PREFIX_RE = re.compile(r"^(?:mattermost:|user:|channel:)", re.IGNORECASE)
|
|
|
|
|
|
_AT_USERNAME_RE = re.compile(r"^@(\S+)$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_allow_entry(raw: str) -> str:
|
|
|
|
|
|
"""规范化白名单条目为纯 Mattermost User ID。
|
|
|
|
|
|
|
|
|
|
|
|
支持格式:
|
|
|
|
|
|
- 纯 User ID(如 "abc123")
|
|
|
|
|
|
- @username 格式(保留为 username,后续需要查表转换)
|
|
|
|
|
|
- user:<id> 或 mattermost:<id> 前缀
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not raw or not raw.strip():
|
|
|
|
|
|
return ""
|
|
|
|
|
|
entry = raw.strip()
|
|
|
|
|
|
entry = _PREFIX_RE.sub("", entry).strip()
|
|
|
|
|
|
return entry
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_allow_entry_match(entry: str, user_id: str, username: str = "") -> bool:
|
|
|
|
|
|
"""检查白名单条目是否匹配给定的用户 ID 或 username。"""
|
|
|
|
|
|
if not entry:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
m = _AT_USERNAME_RE.match(entry)
|
|
|
|
|
|
if m:
|
|
|
|
|
|
return m.group(1).casefold() == username.casefold()
|
|
|
|
|
|
return entry.casefold() == user_id.casefold()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class MattermostSecurityConfig:
|
|
|
|
|
|
dm_policy: DmPolicy = DM_POLICY_OPEN
|
|
|
|
|
|
group_policy: GroupPolicy = GROUP_POLICY_OPEN
|
|
|
|
|
|
allow_from: set[str] = field(default_factory=set)
|
|
|
|
|
|
group_allow_from: set[str] = field(default_factory=set)
|
|
|
|
|
|
dangerously_allow_name_matching: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_config(cls, config: dict) -> MattermostSecurityConfig:
|
|
|
|
|
|
dm_policy = config.get("dm_policy", DM_POLICY_OPEN)
|
|
|
|
|
|
if dm_policy not in DM_POLICIES:
|
|
|
|
|
|
dm_policy = DM_POLICY_OPEN
|
|
|
|
|
|
|
|
|
|
|
|
group_policy = config.get("group_policy", GROUP_POLICY_OPEN)
|
|
|
|
|
|
if group_policy not in GROUP_POLICIES:
|
|
|
|
|
|
group_policy = GROUP_POLICY_OPEN
|
|
|
|
|
|
|
|
|
|
|
|
allow_from = {normalize_allow_entry(e) for e in config.get("allow_from", []) if normalize_allow_entry(e)}
|
|
|
|
|
|
group_allow_from = {
|
|
|
|
|
|
normalize_allow_entry(e) for e in config.get("group_allow_from", []) if normalize_allow_entry(e)
|
|
|
|
|
|
}
|
|
|
|
|
|
dangerously_allow_name_matching = bool(config.get("dangerously_allow_name_matching", False))
|
|
|
|
|
|
|
|
|
|
|
|
return cls(
|
|
|
|
|
|
dm_policy=dm_policy,
|
|
|
|
|
|
group_policy=group_policy,
|
|
|
|
|
|
allow_from=frozenset(allow_from),
|
|
|
|
|
|
group_allow_from=frozenset(group_allow_from),
|
|
|
|
|
|
dangerously_allow_name_matching=dangerously_allow_name_matching,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class SecurityCheckResult:
|
|
|
|
|
|
allowed: bool
|
|
|
|
|
|
reason: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MattermostSecurity:
|
2026-05-12 14:51:53 +08:00
|
|
|
|
def __init__(self, config: dict | None = None, pairing_manager: MattermostPairingManager | None = None):
|
2026-05-12 00:46:12 +08:00
|
|
|
|
self._config = MattermostSecurityConfig.from_config(config or {})
|
|
|
|
|
|
self._group_configs: dict[str, dict] = (config or {}).get("groups", {})
|
2026-05-12 14:51:53 +08:00
|
|
|
|
self._pairing_manager = pairing_manager
|
2026-05-12 00:46:12 +08:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def dm_policy(self) -> str:
|
|
|
|
|
|
return self._config.dm_policy
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def group_policy(self) -> str:
|
|
|
|
|
|
return self._config.group_policy
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def allow_from_count(self) -> int:
|
|
|
|
|
|
return len(self._config.allow_from)
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_group_require_mention(self, channel_id: str) -> bool | None:
|
|
|
|
|
|
group_cfg = self._group_configs.get(channel_id, {})
|
|
|
|
|
|
if "requireMention" in group_cfg:
|
|
|
|
|
|
return bool(group_cfg["requireMention"])
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-05-12 14:51:53 +08:00
|
|
|
|
def reload_config(self, key: str, value) -> None:
|
|
|
|
|
|
"""增量更新安全配置,响应 ConfigWritesManager 的配置变更通知。"""
|
|
|
|
|
|
from dataclasses import replace
|
|
|
|
|
|
|
|
|
|
|
|
if key == "allow_from":
|
|
|
|
|
|
entries = {normalize_allow_entry(e) for e in (value if isinstance(value, list) else [])}
|
|
|
|
|
|
self._config = replace(self._config, allow_from=frozenset(e for e in entries if e))
|
|
|
|
|
|
elif key == "group_allow_from":
|
|
|
|
|
|
entries = {normalize_allow_entry(e) for e in (value if isinstance(value, list) else [])}
|
|
|
|
|
|
self._config = replace(self._config, group_allow_from=frozenset(e for e in entries if e))
|
|
|
|
|
|
elif key == "dm_policy" and value in DM_POLICIES:
|
|
|
|
|
|
self._config = replace(self._config, dm_policy=value)
|
|
|
|
|
|
elif key == "group_policy" and value in GROUP_POLICIES:
|
|
|
|
|
|
self._config = replace(self._config, group_policy=value)
|
|
|
|
|
|
elif key == "dangerously_allow_name_matching":
|
|
|
|
|
|
self._config = replace(self._config, dangerously_allow_name_matching=bool(value))
|
|
|
|
|
|
|
2026-05-12 00:46:12 +08:00
|
|
|
|
def check_dm(self, user_id: str, username: str = "") -> SecurityCheckResult:
|
|
|
|
|
|
"""检查 DM 消息是否允许通过。"""
|
|
|
|
|
|
policy = self._config.dm_policy
|
|
|
|
|
|
|
|
|
|
|
|
if policy == DM_POLICY_DISABLED:
|
|
|
|
|
|
return SecurityCheckResult(False, "DM is disabled")
|
|
|
|
|
|
if policy == DM_POLICY_OPEN:
|
|
|
|
|
|
return SecurityCheckResult(True)
|
|
|
|
|
|
if policy == DM_POLICY_ALLOWLIST:
|
|
|
|
|
|
return self._check_allowlist(user_id, username, self._config.allow_from)
|
|
|
|
|
|
if policy == DM_POLICY_PAIRING:
|
2026-05-12 14:51:53 +08:00
|
|
|
|
if self._pairing_manager is None:
|
|
|
|
|
|
return SecurityCheckResult(False, "Pairing manager not configured")
|
|
|
|
|
|
if not self._pairing_manager.is_approved(user_id):
|
|
|
|
|
|
return SecurityCheckResult(False, f"User {user_id} not paired")
|
2026-05-12 00:46:12 +08:00
|
|
|
|
return SecurityCheckResult(True)
|
|
|
|
|
|
|
|
|
|
|
|
return SecurityCheckResult(False, f"Unknown DM policy: {policy}")
|
|
|
|
|
|
|
|
|
|
|
|
def check_group(self, user_id: str, username: str = "") -> SecurityCheckResult:
|
|
|
|
|
|
"""检查组消息是否允许通过。"""
|
|
|
|
|
|
policy = self._config.group_policy
|
|
|
|
|
|
|
|
|
|
|
|
if policy == GROUP_POLICY_DISABLED:
|
|
|
|
|
|
return SecurityCheckResult(False, "Group is disabled")
|
|
|
|
|
|
if policy == GROUP_POLICY_OPEN:
|
|
|
|
|
|
return SecurityCheckResult(True)
|
|
|
|
|
|
if policy == GROUP_POLICY_ALLOWLIST:
|
|
|
|
|
|
return self._check_allowlist(user_id, username, self._config.group_allow_from)
|
|
|
|
|
|
|
|
|
|
|
|
return SecurityCheckResult(False, f"Unknown Group policy: {policy}")
|
|
|
|
|
|
|
|
|
|
|
|
def check_inbound(self, chat_type: str, user_id: str, username: str = "") -> SecurityCheckResult:
|
|
|
|
|
|
"""统一的入站安全检查入口。"""
|
|
|
|
|
|
if chat_type == "direct":
|
|
|
|
|
|
return self.check_dm(user_id, username)
|
|
|
|
|
|
if chat_type in ("group", "channel"):
|
|
|
|
|
|
return self.check_group(user_id, username)
|
|
|
|
|
|
return SecurityCheckResult(False, f"Unknown chat_type: {chat_type}")
|
|
|
|
|
|
|
|
|
|
|
|
def _check_allowlist(self, user_id: str, username: str, allowlist: frozenset[str]) -> SecurityCheckResult:
|
|
|
|
|
|
if not allowlist:
|
|
|
|
|
|
return SecurityCheckResult(False, "Allowlist is empty — no senders allowed")
|
|
|
|
|
|
|
|
|
|
|
|
allow_name_match = self._config.dangerously_allow_name_matching
|
|
|
|
|
|
|
|
|
|
|
|
for entry in allowlist:
|
|
|
|
|
|
match_username = username if allow_name_match else ""
|
|
|
|
|
|
if is_allow_entry_match(entry, user_id, match_username):
|
|
|
|
|
|
return SecurityCheckResult(True)
|
|
|
|
|
|
|
|
|
|
|
|
return SecurityCheckResult(False, f"User {user_id} not in allowlist")
|
|
|
|
|
|
|
|
|
|
|
|
def authorize_command_invocation(
|
|
|
|
|
|
self,
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
command: str,
|
|
|
|
|
|
channel_id: str = "",
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""授权用户调用特定命令。
|
|
|
|
|
|
|
|
|
|
|
|
当前仅允许白名单用户调用管理命令。
|
|
|
|
|
|
"""
|
|
|
|
|
|
managed_commands = {"restart", "sudo", "exec", "delete", "config"}
|
|
|
|
|
|
if command not in managed_commands:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
allowlist = self._config.allow_from
|
|
|
|
|
|
if not allowlist:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
return any(is_allow_entry_match(entry, user_id) for entry in allowlist)
|
|
|
|
|
|
|
|
|
|
|
|
def audit_event(
|
|
|
|
|
|
self,
|
|
|
|
|
|
event_type: str,
|
|
|
|
|
|
user_id: str = "",
|
|
|
|
|
|
detail: str = "",
|
|
|
|
|
|
channel_id: str = "",
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"[Mattermost][AUDIT] {event_type} user={user_id} channel={channel_id} detail={detail}")
|