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:
@@ -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`
|
||||
|
||||
38
authentik/vanguard-profile-scope-mapping.py
Normal file
38
authentik/vanguard-profile-scope-mapping.py
Normal 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}
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user