feat: add granular ACL tiers (trusted/oper/admin)
4-tier permission model: user < trusted < oper < admin. Commands specify a required tier via tier= parameter. Backward compatible: admin=True maps to tier="admin". - TIERS constant and Handler.tier field in plugin.py - _get_tier() method in bot.py with pattern matching - _is_admin() preserved as thin wrapper - operators/trusted config lists in config.py - whoami shows tier, admins shows all configured tiers - 32 test cases in test_acl.py
This commit is contained in:
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
|
||||
from derp import __version__
|
||||
from derp.irc import _MAX_IRC_LINE, IRCConnection, Message, format_msg, parse
|
||||
from derp.plugin import Handler, PluginRegistry
|
||||
from derp.plugin import TIERS, Handler, PluginRegistry
|
||||
from derp.state import StateStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -93,6 +93,8 @@ class Bot:
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
self._reconnect_delay: float = 5.0
|
||||
self._admins: list[str] = config.get("bot", {}).get("admins", [])
|
||||
self._operators: list[str] = config.get("bot", {}).get("operators", [])
|
||||
self._trusted: list[str] = config.get("bot", {}).get("trusted", [])
|
||||
self._opers: set[str] = set() # hostmasks of known IRC operators
|
||||
self._caps: set[str] = set() # negotiated IRCv3 caps
|
||||
self._who_pending: dict[str, asyncio.Task] = {} # debounced WHO per channel
|
||||
@@ -359,20 +361,33 @@ class Bot:
|
||||
return True
|
||||
return plugin_name in allowed
|
||||
|
||||
def _get_tier(self, msg: Message) -> str:
|
||||
"""Determine the permission tier of the message sender.
|
||||
|
||||
Checks (in priority order): IRC operator, admin pattern,
|
||||
operator pattern, trusted pattern. Falls back to ``"user"``.
|
||||
"""
|
||||
if not msg.prefix:
|
||||
return "user"
|
||||
if msg.prefix in self._opers:
|
||||
return "admin"
|
||||
for pattern in self._admins:
|
||||
if fnmatch.fnmatch(msg.prefix, pattern):
|
||||
return "admin"
|
||||
for pattern in self._operators:
|
||||
if fnmatch.fnmatch(msg.prefix, pattern):
|
||||
return "oper"
|
||||
for pattern in self._trusted:
|
||||
if fnmatch.fnmatch(msg.prefix, pattern):
|
||||
return "trusted"
|
||||
return "user"
|
||||
|
||||
def _is_admin(self, msg: Message) -> bool:
|
||||
"""Check if the message sender is a bot admin.
|
||||
|
||||
Returns True if the sender is a known IRC operator or matches
|
||||
a configured hostmask pattern (fnmatch-style).
|
||||
Thin wrapper around ``_get_tier`` for backward compatibility.
|
||||
"""
|
||||
if not msg.prefix:
|
||||
return False
|
||||
if msg.prefix in self._opers:
|
||||
return True
|
||||
for pattern in self._admins:
|
||||
if fnmatch.fnmatch(msg.prefix, pattern):
|
||||
return True
|
||||
return False
|
||||
return self._get_tier(msg) == "admin"
|
||||
|
||||
def _dispatch_command(self, msg: Message) -> None:
|
||||
"""Check if a PRIVMSG is a bot command and spawn it."""
|
||||
@@ -396,10 +411,13 @@ class Bot:
|
||||
if not self._plugin_allowed(handler.plugin, channel):
|
||||
return
|
||||
|
||||
if handler.admin and not self._is_admin(msg):
|
||||
deny = f"Permission denied: {self.prefix}{cmd_name} requires admin"
|
||||
self._spawn(self.reply(msg, deny), name=f"cmd:{cmd_name}:denied")
|
||||
return
|
||||
required = handler.tier
|
||||
if required != "user":
|
||||
sender = self._get_tier(msg)
|
||||
if TIERS.index(sender) < TIERS.index(required):
|
||||
deny = f"Permission denied: {self.prefix}{cmd_name} requires {required}"
|
||||
self._spawn(self.reply(msg, deny), name=f"cmd:{cmd_name}:denied")
|
||||
return
|
||||
|
||||
self._spawn(self._run_command(handler, cmd_name, msg), name=f"cmd:{cmd_name}")
|
||||
|
||||
|
||||
@@ -29,8 +29,16 @@ DEFAULTS: dict = {
|
||||
"rate_burst": 5,
|
||||
"paste_threshold": 4,
|
||||
"admins": [],
|
||||
"operators": [],
|
||||
"trusted": [],
|
||||
},
|
||||
"channels": {},
|
||||
"webhook": {
|
||||
"enabled": False,
|
||||
"host": "127.0.0.1",
|
||||
"port": 8080,
|
||||
"secret": "",
|
||||
},
|
||||
"logging": {
|
||||
"level": "info",
|
||||
"format": "text",
|
||||
|
||||
@@ -12,6 +12,8 @@ from typing import Any, Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TIERS: tuple[str, ...] = ("user", "trusted", "oper", "admin")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Handler:
|
||||
@@ -22,9 +24,10 @@ class Handler:
|
||||
help: str = ""
|
||||
plugin: str = ""
|
||||
admin: bool = False
|
||||
tier: str = "user"
|
||||
|
||||
|
||||
def command(name: str, help: str = "", admin: bool = False) -> Callable:
|
||||
def command(name: str, help: str = "", admin: bool = False, tier: str = "") -> Callable:
|
||||
"""Decorator to register an async function as a bot command.
|
||||
|
||||
Usage::
|
||||
@@ -36,12 +39,17 @@ def command(name: str, help: str = "", admin: bool = False) -> Callable:
|
||||
@command("reload", help="Reload a plugin", admin=True)
|
||||
async def cmd_reload(bot, message):
|
||||
...
|
||||
|
||||
@command("trusted_cmd", help="Trusted-only", tier="trusted")
|
||||
async def cmd_trusted(bot, message):
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
func._derp_command = name # type: ignore[attr-defined]
|
||||
func._derp_help = help # type: ignore[attr-defined]
|
||||
func._derp_admin = admin # type: ignore[attr-defined]
|
||||
func._derp_tier = tier if tier else ("admin" if admin else "user") # type: ignore[attr-defined]
|
||||
return func
|
||||
|
||||
return decorator
|
||||
@@ -74,12 +82,14 @@ class PluginRegistry:
|
||||
self._paths: dict[str, Path] = {}
|
||||
|
||||
def register_command(self, name: str, callback: Callable, help: str = "",
|
||||
plugin: str = "", admin: bool = False) -> None:
|
||||
plugin: str = "", admin: bool = False,
|
||||
tier: str = "user") -> None:
|
||||
"""Register a command handler."""
|
||||
if name in self.commands:
|
||||
log.warning("command '%s' already registered, overwriting", name)
|
||||
self.commands[name] = Handler(
|
||||
name=name, callback=callback, help=help, plugin=plugin, admin=admin,
|
||||
name=name, callback=callback, help=help, plugin=plugin,
|
||||
admin=admin, tier=tier,
|
||||
)
|
||||
log.debug("registered command: %s (%s)", name, plugin)
|
||||
|
||||
@@ -102,6 +112,7 @@ class PluginRegistry:
|
||||
help=getattr(obj, "_derp_help", ""),
|
||||
plugin=plugin_name,
|
||||
admin=getattr(obj, "_derp_admin", False),
|
||||
tier=getattr(obj, "_derp_tier", "user"),
|
||||
)
|
||||
count += 1
|
||||
if hasattr(obj, "_derp_event"):
|
||||
|
||||
Reference in New Issue
Block a user