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

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