diff --git a/CHANGELOG.md b/CHANGELOG.md index d2dc0ad..9b26d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [recycler] -- 2026-06-02 + +### Changes +- **Least-privilege invite everywhere.** The leftover `.help` "Add to server" + button (`cogs/_help_view.py`) still built an Administrator (`permissions=8`) + invite; it now uses the single source of truth in `clanklib/permissions.py` + like `.invite`/`.about`/`.setup`, so every invite asks only for the + permissions the bot uses (`permissions=805391380`). The manifest now declares + this set under `discord` in `auren.json` so the Auren platform's Invite + button matches, and a new test pins the manifest value to + `required_bot_permissions()` so they can never drift. Deployment docs updated. + ## [recycler] -- 2026-06-01 ### Changes diff --git a/auren.json b/auren.json index 9752548..5c9ea96 100644 --- a/auren.json +++ b/auren.json @@ -9,6 +9,10 @@ "repo": "hilleywyn/recycler" }, "channels": ["discord"], + "discord": { + "permissions": "805391380", + "scopes": ["bot", "applications.commands"] + }, "runtime": { "dockerfile": "Dockerfile", "entrypoint": "main.py" diff --git a/cogs/_help_view.py b/cogs/_help_view.py index fd2131d..06e8286 100644 --- a/cogs/_help_view.py +++ b/cogs/_help_view.py @@ -17,12 +17,12 @@ def _invite_url(bot: Any) -> str: + # Single source of truth: clanklib.permissions builds an invite that asks + # for exactly the permissions the bot uses -- never Administrator. + from clanklib.permissions import invite_url from clanklib.settings import setting cid = getattr(bot.user, "id", None) or setting(bot, "DISCORD_CLIENT_ID", "") - return ( - f"https://discord.com/oauth2/authorize?client_id={cid}" - "&permissions=8&scope=bot%20applications.commands" - ) + return invite_url(cid) def _build_panel(bot: Any, prefix: str, chosen_keys: list[str], author_id: int): diff --git a/docs/deployment.md b/docs/deployment.md index b250988..73120c3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -41,17 +41,25 @@ install pulls that package from git. ### 1.2 Inviting the bot -Recycler creates and deletes roles, channels and webhooks (that is the whole -job), so it needs the **Administrator** permission. Build an invite URL: +Recycler asks for only the permissions it actually uses -- **never +Administrator**. The set is defined once in `clanklib/permissions.py` and drives +the invite link, the `.setup` audit, and the Auren platform's Invite button: + +- View Channels, Send Messages, Embed Links, Read Message History (core) +- Manage Channels, Manage Roles (recreate channels/roles on backup/template restore) +- Manage Webhooks (replay archived messages for chatlog/sync) +- Ban Members (propagate bans between synced guilds) + +Build an invite URL (the `permissions` value is the union of the above): ``` -https://discord.com/oauth2/authorize?client_id=&permissions=8&scope=bot%20applications.commands +https://discord.com/oauth2/authorize?client_id=&permissions=805391380&scope=bot%20applications.commands ``` Once the bot is running you can also just type `.invite` (or `.about`) and it -prints this URL for you. Administrator (`permissions=8`) is required for -backup/template restores; without it, restores will partially fail with -permission errors. +prints this exact URL for you, or `.setup` to audit what is missing. Make sure +the bot's role sits **above** the roles/channels it manages, or restores will +partially fail with permission errors. ### 1.3 A PostgreSQL database diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 1677591..ec877cd 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -47,3 +47,18 @@ def __init__(self, gp): def test_pretty_perm_label(): assert perms.pretty_perm("manage_roles") == "Manage Roles" + + +def test_manifest_permissions_match_required(): + """auren.json's declared invite permissions (which the Auren platform's + Invite button reads) must equal the code's required set, so the two can + never drift apart.""" + import json + from pathlib import Path + + manifest = json.loads( + (Path(__file__).resolve().parent.parent / "auren.json").read_text() + ) + declared = int(manifest["discord"]["permissions"]) + assert declared == perms.required_bot_permissions().value + assert declared != 8 # never Administrator