feat: add admin/owner permission system

Hostmask-based admin controls with automatic IRCOP detection via WHO.
Permission enforcement in the central dispatch path denies restricted
commands to non-admins. Includes !whoami and !admins commands, marks
load/reload/unload as admin-only.

Also lands previously-implemented SASL PLAIN auth, token-bucket rate
limiting, and CTCP VERSION/TIME/PING responses that were staged but
uncommitted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 02:24:56 +01:00
parent 36b21e2463
commit f96224afb1
9 changed files with 408 additions and 18 deletions

View File

@@ -4,6 +4,7 @@ import textwrap
from pathlib import Path
from derp.bot import _AMBIGUOUS, Bot
from derp.irc import Message
from derp.plugin import PluginRegistry, command, event
@@ -33,6 +34,22 @@ class TestDecorators:
assert handler._derp_event == "PRIVMSG"
def test_command_decorator_admin(self):
@command("secret", help="admin only", admin=True)
async def handler(bot, msg):
pass
assert handler._derp_command == "secret"
assert handler._derp_admin is True
def test_command_decorator_admin_default(self):
@command("public", help="everyone")
async def handler(bot, msg):
pass
assert getattr(handler, "_derp_admin", False) is False
class TestRegistry:
"""Test the plugin registry."""
@@ -46,6 +63,24 @@ class TestRegistry:
assert "test" in registry.commands
assert registry.commands["test"].help == "test help"
def test_register_command_admin(self):
registry = PluginRegistry()
async def handler(bot, msg):
pass
registry.register_command("secret", handler, help="admin", admin=True)
assert registry.commands["secret"].admin is True
def test_register_command_admin_default(self):
registry = PluginRegistry()
async def handler(bot, msg):
pass
registry.register_command("public", handler, help="public")
assert registry.commands["public"].admin is False
def test_register_event(self):
registry = PluginRegistry()
@@ -150,6 +185,27 @@ class TestRegistry:
count = registry.load_plugin(plugin_file)
assert count == 3
def test_load_plugin_admin_flag(self, tmp_path: Path):
plugin_code = textwrap.dedent("""\
from derp.plugin import command
@command("secret", help="Admin only", admin=True)
async def cmd_secret(bot, msg):
pass
@command("public", help="Everyone")
async def cmd_public(bot, msg):
pass
""")
plugin_file = tmp_path / "mixed.py"
plugin_file.write_text(plugin_code)
registry = PluginRegistry()
registry.load_plugin(plugin_file)
assert registry.commands["secret"].admin is True
assert registry.commands["public"].admin is False
def test_load_plugin_stores_path(self, tmp_path: Path):
plugin_file = tmp_path / "pathed.py"
plugin_file.write_text(textwrap.dedent("""\
@@ -366,3 +422,57 @@ class TestPrefixMatch:
handler = bot._resolve_command("v")
assert handler is not None
assert handler.name == "version"
class TestIsAdmin:
"""Test admin permission checks."""
@staticmethod
def _make_bot(admins: list[str] | None = None, opers: set[str] | None = None) -> Bot:
"""Create a Bot with optional admin patterns and oper set."""
config = {
"server": {"host": "localhost", "port": 6667, "tls": False,
"nick": "test", "user": "test", "realname": "test"},
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins",
"admins": admins or []},
}
bot = Bot(config, PluginRegistry())
if opers:
bot._opers = opers
return bot
@staticmethod
def _msg(prefix: str) -> Message:
"""Create a minimal Message with a given prefix."""
return Message(raw="", prefix=prefix, nick=prefix.split("!")[0],
command="PRIVMSG", params=["#test", "!test"])
def test_no_prefix_not_admin(self):
bot = self._make_bot()
msg = Message(raw="", prefix=None, nick=None, command="PRIVMSG", params=[])
assert bot._is_admin(msg) is False
def test_oper_is_admin(self):
bot = self._make_bot(opers={"alice!~alice@host"})
msg = self._msg("alice!~alice@host")
assert bot._is_admin(msg) is True
def test_hostmask_pattern_match(self):
bot = self._make_bot(admins=["*!~user@trusted.host"])
msg = self._msg("bob!~user@trusted.host")
assert bot._is_admin(msg) is True
def test_hostmask_pattern_no_match(self):
bot = self._make_bot(admins=["*!~user@trusted.host"])
msg = self._msg("bob!~other@untrusted.host")
assert bot._is_admin(msg) is False
def test_wildcard_pattern(self):
bot = self._make_bot(admins=["ops!*@*.ops.net"])
msg = self._msg("ops!~ident@server.ops.net")
assert bot._is_admin(msg) is True
def test_no_patterns_no_opers(self):
bot = self._make_bot()
msg = self._msg("nobody!~user@host")
assert bot._is_admin(msg) is False