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) <noreply@anthropic.com>
This commit is contained in:
2026-11-03 00:13:14 -06:00
parent 31bec6e770
commit ba110747b7
4 changed files with 59 additions and 2 deletions

View File

@@ -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 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`. 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 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 **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` bot authorises per request from those headers (`WEB_ALLOWED_GROUPS=bot-users,admins`; `WEB_SERVE_UI=true`

View File

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

View File

@@ -94,8 +94,11 @@ Rules:
3. Authorize by group, read from the header each time. Don't cache roles in your DB. 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. 4. Serve under `/`; the proxy does no path rewriting for this host.
5. Build absolute links from `WEB_PUBLIC_URL`. 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; 6. **The Discord snowflake arrives in `X-Authentik-Jwt`** as the `discord_id` claim (a scope mapping on the
if you need the Discord snowflake, resolve it through your gateway client and cache it in your DB. 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 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. open page. Test against `http://127.0.0.1:8000/` on the server with the headers set by hand.

View File

@@ -236,6 +236,11 @@ services:
- /srv/appdata/discord-bot:/data # persistent scratch (sqlite, caches) if the bot wants files - /srv/appdata/discord-bot:/data # persistent scratch (sqlite, caches) if the bot wants files
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "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: depends_on:
mariadb: { condition: service_healthy } mariadb: { condition: service_healthy }
restart: unless-stopped restart: unless-stopped
@@ -340,6 +345,9 @@ services:
privileged: true privileged: true
restart: unless-stopped restart: unless-stopped
networks:
botnet: # isolates discord-bot from the shared default network (see above)
volumes: volumes:
copyparty_hist: copyparty_hist:
caddy_data: caddy_data: