From ba110747b7df8a94e02e8d9258101b6b9f3fe39d Mon Sep 17 00:00:00 2001 From: Brandyn / Techy Date: Tue, 3 Nov 2026 00:13:14 -0600 Subject: [PATCH] Identify the bot's web callers by Discord id, and isolate the bot's network authentik/vanguard-profile-scope-mapping.py (new, and the first time that mapping is version-controlled rather than pasted from the docs) emits the Discord snowflake from the account's source connection alongside the inherited groups, so the bot never has to match a name. docker-compose.yml: discord-bot moves to its own network. It trusts the identity headers Caddy injects, and it shared the default bridge with jellyfin, copyparty, syncthing, gitea, act_runner and cadvisor, any of which could reach :8000 directly and forge them. It needs no sibling container: Caddy uses the published loopback port and MariaDB is reached through host.docker.internal. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 +++++ authentik/vanguard-profile-scope-mapping.py | 38 +++++++++++++++++++++ bot/AGENT.md | 7 ++-- docker-compose.yml | 8 +++++ 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 authentik/vanguard-profile-scope-mapping.py diff --git a/README.md b/README.md index 4bb2116..9a42e61 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,14 @@ that stamped today's date is undone; latest activity and current rank/presence w is idempotent, and refuses rather than guessing if a column has no rule or a table is missing from the destination. Run it with the gateway stopped, report first, then `--apply`. +**Identity, exactly:** the bot gets the caller's Discord snowflake from the `discord_id` claim the proxy puts in +`X-Authentik-Jwt`, emitted by the scope mapping in `authentik/vanguard-profile-scope-mapping.py` (the same mapping +that carries inherited groups). Authentik already stores that id on the account's source connection, so nothing +has to be matched by name. Without the claim the bot falls back to matching the Discord **username** only, never a +server nickname: nicknames are self-chosen and not unique, so matching one would let a member be mistaken for +another or make another ambiguous. `discord-bot` also runs on **its own Docker network** (`botnet`), because it +trusts the headers Caddy injects and nothing else should be able to reach `:8000` to forge them. + The bot serves **both** its API and its web pages on 127.0.0.1:8000, published as `https://bot.HOST/` behind **Authentik forward-auth**: Caddy asks Authentik, then injects `X-Authentik-Username/-Groups/-Email`, and the bot authorises per request from those headers (`WEB_ALLOWED_GROUPS=bot-users,admins`; `WEB_SERVE_UI=true` diff --git a/authentik/vanguard-profile-scope-mapping.py b/authentik/vanguard-profile-scope-mapping.py new file mode 100644 index 0000000..362b1f9 --- /dev/null +++ b/authentik/vanguard-profile-scope-mapping.py @@ -0,0 +1,38 @@ +# Authentik SCOPE MAPPING (not a policy): adds two things the apps behind the proxy need and the built-in +# mappings do not provide. +# +# Paste into: Customization -> Property Mappings -> Create -> Scope Mapping +# Name: Vanguard profile (nested groups + discord id) +# Scope name: profile +# Then tick it, NEXT TO the managed "authentik default OAuth Mapping: OpenID 'profile'", on every provider the +# community uses: the "Vanguard sites" proxy provider, Jellyfin and Gitea (Advanced protocol settings -> Scopes). +# Claims from several mappings are merged, so both run and neither needs removing. Proxy providers re-add the +# managed one on every save, which is why this adds to it rather than replacing it. +# +# 1. groups: the managed mapping emits ak_groups, i.e. DIRECT memberships only, so a group inherited through a +# parent never reaches the apps. This emits only the inherited ones; merged with the managed mapping's direct +# ones the apps see the full set, with no duplicates. +# +# 2. discord_id: the Discord snowflake, taken from the source connection Authentik already stores when it links +# an account. It reaches the apps in X-Authentik-Jwt. The Discord bot uses it to identify the caller exactly, +# instead of matching an Authentik username against Discord names — names are mutable, reusable, and server +# nicknames are not unique, so name matching can both lock the right person out and let the wrong one in. +# +# Takes effect on the next login. + +DISCORD_SOURCE_SLUG = "discord" + +direct = set(request.user.ak_groups.all()) +inherited = sorted(group.name for group in request.user.all_groups() if group not in direct) + +discord_id = None +for attribute in ("usersourceconnection_set", "source_connections"): # the related name differs by version + related = getattr(request.user, attribute, None) + if related is None: + continue + connection = (related.filter(source__slug=DISCORD_SOURCE_SLUG).first() or related.first()) + if connection is not None: + discord_id = getattr(connection, "identifier", None) or None + break + +return {"groups": inherited, "discord_id": discord_id} diff --git a/bot/AGENT.md b/bot/AGENT.md index 521f51e..336181c 100644 --- a/bot/AGENT.md +++ b/bot/AGENT.md @@ -94,8 +94,11 @@ Rules: 3. Authorize by group, read from the header each time. Don't cache roles in your DB. 4. Serve under `/`; the proxy does no path rewriting for this host. 5. Build absolute links from `WEB_PUBLIC_URL`. -6. The Authentik username may differ from the Discord username. Use the header value as the user key; - if you need the Discord snowflake, resolve it through your gateway client and cache it in your DB. +6. **The Discord snowflake arrives in `X-Authentik-Jwt`** as the `discord_id` claim (a scope mapping on the + provider supplies it; Authentik takes it from the account's source connection). Decode the JWT payload and + use that as the user key. Do not match the Authentik username against Discord names: usernames are mutable + and reusable, and server nicknames are self-chosen and not unique, so name matching can mistake one member + for another. The signature needs no checking because the header can only come from the proxy. Until the admin creates the matching Authentik application, the hostname returns an error, never an open page. Test against `http://127.0.0.1:8000/` on the server with the headers set by hand. diff --git a/docker-compose.yml b/docker-compose.yml index 0745c82..fc0e816 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -236,6 +236,11 @@ services: - /srv/appdata/discord-bot:/data # persistent scratch (sqlite, caches) if the bot wants files extra_hosts: - "host.docker.internal:host-gateway" + networks: + - botnet # its OWN network: the bot trusts the identity headers Caddy injects, so + # nothing else must be able to reach :8000 and forge them. Caddy is on the + # host network and uses the published 127.0.0.1:8000; MariaDB is reached + # through host.docker.internal. The bot needs no sibling container. depends_on: mariadb: { condition: service_healthy } restart: unless-stopped @@ -340,6 +345,9 @@ services: privileged: true restart: unless-stopped +networks: + botnet: # isolates discord-bot from the shared default network (see above) + volumes: copyparty_hist: caddy_data: