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:
user
2026-02-21 17:59:05 +01:00
parent 5bc59730c4
commit 2514aa777d
6 changed files with 480 additions and 35 deletions

View File

@@ -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}")

View File

@@ -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",

View File

@@ -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"):