ForcePilot/backend/package/yuxi/channel/security/policy.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

184 lines
6.8 KiB
Python

"""多渠道网关安全策略编排。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from yuxi.channel.constants import channel_rate_limit_key
from yuxi.channel.plugins.protocol import OutboundMessage
from yuxi.channel.security.models import DmPolicy
if TYPE_CHECKING:
from yuxi.channel.message.models import InboundMessage
from yuxi.channel.plugins.protocol import ChannelPlugin
from yuxi.storage.postgres.model_channel import ChannelPairingRecord
from .identity import IdentityLinkResolver
from .registry import SecurityCheckerRegistry, SecurityContext
@dataclass
class SecurityCheckResult:
allowed: bool
reason: str | None = None
pairing_code: str | None = None
qr_content: str | None = None
qr_reply: OutboundMessage | None = None
class SecurityPolicy:
def __init__(
self,
checker_registry: SecurityCheckerRegistry,
identity_link_resolver: IdentityLinkResolver | None = None,
):
self.checker_registry = checker_registry
self.identity = identity_link_resolver
async def check(
self,
config: dict,
plugin: ChannelPlugin,
inbound: InboundMessage,
) -> SecurityCheckResult:
chat_type = inbound.chat_type or "private"
resolved_sender_id: str | None = None
if self.identity is not None and inbound.sender_id:
resolved_sender_id = self.identity.resolve(inbound.channel_type, inbound.sender_id)
chain = self.checker_registry.resolve_chain(config)
if config.get("security_checkers"):
return await self._run_custom_chain(config, plugin, inbound, chat_type, resolved_sender_id, chain)
return await self._run_default_chain(config, plugin, inbound, chat_type, resolved_sender_id, chain)
async def _run_default_chain(
self,
config: dict,
plugin: ChannelPlugin,
inbound: InboundMessage,
chat_type: str,
resolved_sender_id: str | None,
chain: list,
) -> SecurityCheckResult:
allowlist = self.checker_registry.get("allowlist")
if allowlist is None:
return await self._run_custom_chain(config, plugin, inbound, chat_type, resolved_sender_id, chain)
ctx = self._make_context(config, plugin, inbound, chat_type, resolved_sender_id, allowlist.name)
result = await allowlist.check(ctx)
if result is not None and not result.allowed:
if chat_type == "private":
dm_policy = self._resolve_dm_policy(config, plugin, inbound)
if dm_policy.mode != "deny":
pairing = self.checker_registry.get("pairing")
if pairing is not None:
pairing_ctx = self._make_context(
config, plugin, inbound, chat_type, resolved_sender_id, pairing.name
)
return await pairing.check(pairing_ctx)
return result
for checker in chain:
if checker.name in ("allowlist", "pairing"):
continue
ctx = self._make_context(config, plugin, inbound, chat_type, resolved_sender_id, checker.name)
result = await checker.check(ctx)
if result is not None and not result.allowed:
return result
return SecurityCheckResult(allowed=True)
async def _run_custom_chain(
self,
config: dict,
plugin: ChannelPlugin,
inbound: InboundMessage,
chat_type: str,
resolved_sender_id: str | None,
chain: list,
) -> SecurityCheckResult:
for checker in chain:
ctx = self._make_context(config, plugin, inbound, chat_type, resolved_sender_id, checker.name)
result = await checker.check(ctx)
if result is not None and not result.allowed:
return result
return SecurityCheckResult(allowed=True)
def _make_context(
self,
config: dict,
plugin: ChannelPlugin,
inbound: InboundMessage,
chat_type: str,
resolved_sender_id: str | None,
checker_name: str,
) -> SecurityContext:
from .registry import SecurityContext
config_checker: dict = {}
for entry in config.get("security_checkers", []):
if isinstance(entry, dict) and entry.get("name") == checker_name:
config_checker = entry.get("config") or {}
break
return SecurityContext(
config=config,
config_checker=config_checker,
plugin=plugin,
inbound=inbound,
chat_type=chat_type,
resolved_sender_id=resolved_sender_id,
)
def _resolve_dm_policy(self, config: dict, plugin: ChannelPlugin, inbound: InboundMessage) -> DmPolicy:
resolve = getattr(plugin, "resolve_dm_policy", None)
if resolve is not None:
policy = resolve(config, inbound.account_id)
if policy is not None:
return policy
return DmPolicy()
def check_bot_loop(self, actor_id: str, is_bot: bool) -> bool:
"""供扫码事件使用的 bot_loop 检查快捷方法。"""
checker = self.checker_registry.get("bot_loop")
if checker is None:
raise RuntimeError("bot_loop checker not registered")
return checker.is_allowed(actor_id, is_bot)
async def check_rate_limit(
self,
config: dict,
plugin: ChannelPlugin,
inbound: InboundMessage,
actor_id: str,
) -> bool:
"""供扫码事件使用的 rate_limit 检查快捷方法。"""
checker = self.checker_registry.get("rate_limit")
if checker is None:
raise RuntimeError("rate_limit checker not registered")
resolve = getattr(plugin, "resolve_rate_limit_policy", None)
rate_policy = resolve(config, inbound.account_id) if resolve is not None else None
max_requests = (
rate_policy.max_requests_per_minute
if rate_policy
else config.get("rate_limit", {}).get("max_requests_per_minute", 60)
)
rate_key = channel_rate_limit_key(inbound.channel_type, inbound.account_id or "", actor_id)
return await checker.is_allowed(rate_key, max_requests, window_seconds=60)
async def verify_pairing_code_record(
self,
channel_type: str,
account_id: str,
peer_id: str,
code: str,
) -> ChannelPairingRecord | None:
"""供扫码事件使用的配对码验证快捷方法。"""
checker = self.checker_registry.get("pairing")
if checker is None:
raise RuntimeError("pairing checker not registered")
return await checker.manager.verify_pairing_code_record(channel_type, account_id, peer_id, code)