Roles policy: log the whole decision, and let one Discord role grant several groups

The table was keyed by role id with a repeated key, so Discord Admins silently mapped to admins only and
never bot-users. It is now role id -> (label, [groups]). With execution logging on, the policy records the
role ids Discord returned, each mapped role and the groups it grants, the roles it ignored, the groups it
withheld, any table group missing from Authentik, and what it added or removed - plus which mechanism the
enrollment branch used, which is the open question on first logins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 23:34:29 -06:00
parent f7c96dd8e2
commit be12a34f8c

View File

@@ -1,6 +1,8 @@
# Authentik expression policy: sync a member's Discord ROLES into Authentik GROUPS at every login. # Authentik expression policy: sync a member's Discord ROLES into Authentik GROUPS at every login.
# Paste into: Customization -> Policies -> Create -> Expression Policy (name: discord-roles-to-groups) # Paste into: Customization -> Policies -> Create -> Expression Policy (name: discord-roles-to-groups)
# Bind to BOTH flows (after discord-guild-member): default-source-enrollment and default-source-authentication. # Bind to BOTH flows (after discord-guild-member): default-source-enrollment and default-source-authentication.
# Tick "Execution logging" on the policy: everything below shows up in Events -> Logs -> policy_execution
# under result.log_messages, and in `docker logs authentik-server`.
# Requires the "guilds.members.read" scope on the Discord source (source -> Protocol settings -> Scopes: # Requires the "guilds.members.read" scope on the Discord source (source -> Protocol settings -> Scopes:
# guilds guilds.members.read ). Existing users re-consent once at Discord. # guilds guilds.members.read ). Existing users re-consent once at Discord.
# The Authentik groups must already exist (Directory -> Groups). Role IDs: Discord Developer Mode -> # The Authentik groups must already exist (Directory -> Groups). Role IDs: Discord Developer Mode ->
@@ -8,13 +10,18 @@
# Manual memberships are safe: the policy only removes groups it granted itself (user attribute discord_role_groups). # Manual memberships are safe: the policy only removes groups it granted itself (user attribute discord_role_groups).
GUILD_ID = "1387203044084551690" # <- your Discord server ID GUILD_ID = "1387203044084551690" # <- your Discord server ID
ROLE_TO_GROUP = { # <- Discord role id : Authentik group name
"1387239222070612110": "bot-users", # Discord role id : (label used in the log, [Authentik groups this role grants])
"1515901670943490129": "bot-users", # One role may grant several groups, and several roles may grant the same group. Do NOT repeat a role id:
# "222222222222222222": "streamers", # this is a dict, so a second entry for the same id silently replaces the first.
# "333333333333333333": "editors", ROLES = {
# "444444444444444444": "admins", "1387239222070612110": ("Discord Admins", ["bot-users", "admins"]),
"555555555555555555": "git-users", "1515901670943490129": ("Lead Dev", ["bot-users"]),
"1566623319027880086": ("Dev", ["bot-users"]),
# "222222222222222222": ("Streamer", ["streamers"]),
# "333333333333333333": ("Editor", ["editors"]),
# "444444444444444444": ("Media admin", ["media-admins"]),
# "555555555555555555": ("Git user", ["git-users"]),
} }
from authentik.core.models import Group from authentik.core.models import Group
@@ -25,6 +32,7 @@ if not source or getattr(source, "provider_type", "") != "discord":
connection = context.get("goauthentik.io/sources/connection") connection = context.get("goauthentik.io/sources/connection")
if not connection: if not connection:
ak_logger.info("discord-roles: no source connection in context; nothing to sync")
return True return True
resp = requests.get( resp = requests.get(
@@ -33,13 +41,35 @@ resp = requests.get(
timeout=10, timeout=10,
) )
if resp.status_code != 200: if resp.status_code != 200:
ak_logger.info(f"discord-roles: member lookup failed, HTTP {resp.status_code}: {resp.text[:200]}")
ak_message(f"Could not read your Discord roles ({resp.status_code}); groups unchanged.") ak_message(f"Could not read your Discord roles ({resp.status_code}); groups unchanged.")
return True # don't block login on a Discord hiccup return True # don't block login on a Discord hiccup
roles = set(resp.json().get("roles", [])) roles = set(resp.json().get("roles", []))
wanted = {g for r, g in ROLE_TO_GROUP.items() if r in roles}
managed = set(ROLE_TO_GROUP.values())
# ---- what Discord says, what we map, what we ignore ------------------------------------------------
matched = {rid: ROLES[rid] for rid in roles if rid in ROLES}
unmapped = sorted(roles - set(ROLES))
wanted = {group for _, groups in matched.values() for group in groups}
managed = {group for _, groups in ROLES.values() for group in groups}
ak_logger.info(f"discord-roles: discord returned {len(roles)} role(s): {sorted(roles)}")
for rid, (label, groups) in sorted(matched.items(), key=lambda item: item[1][0]):
ak_logger.info(f"discord-roles: MAPPED role {rid} ({label}) -> {groups}")
if unmapped:
ak_logger.info(f"discord-roles: ignored {len(unmapped)} role(s) not in the table: {unmapped}")
if not matched:
ak_logger.info(f"discord-roles: no mapped roles; the table knows {sorted(ROLES)}")
ak_logger.info(f"discord-roles: groups wanted {sorted(wanted)}; managed by this policy {sorted(managed)}; "
f"withheld {sorted(managed - wanted)}")
# A group named in the table but absent from Authentik can never be granted: say so instead of failing quietly.
existing = {group.name: group for group in Group.objects.filter(name__in=managed)}
absent = sorted(managed - set(existing))
if absent:
ak_logger.info(f"discord-roles: WARNING these groups do not exist in Authentik and were skipped: {absent}")
# ---- apply ------------------------------------------------------------------------------------------
# Authentication flow: the user exists -> edit memberships directly. # Authentication flow: the user exists -> edit memberships directly.
# Only memberships THIS policy granted earlier are ever removed (tracked in user.attributes["discord_role_groups"]), # Only memberships THIS policy granted earlier are ever removed (tracked in user.attributes["discord_role_groups"]),
# so a group someone was added to by hand in Authentik survives role changes and re-logins. # so a group someone was added to by hand in Authentik survives role changes and re-logins.
@@ -47,23 +77,31 @@ user = context.get("pending_user") or (request.user if request.user.is_authentic
if user and user.pk: if user and user.pk:
attrs = user.attributes or {} attrs = user.attributes or {}
granted = set(attrs.get("discord_role_groups", [])) granted = set(attrs.get("discord_role_groups", []))
for group in Group.objects.filter(name__in=managed): added, removed = [], []
if group.name in wanted: for name, group in existing.items():
group.users.add(user) if name in wanted and name not in granted:
granted.add(group.name) group.users.add(user); granted.add(name); added.append(name)
elif group.name in granted: elif name in wanted:
group.users.add(user) # idempotent: make sure the membership is really there
elif name in granted:
group.users.remove(user) # role gone -> take back what the role had given group.users.remove(user) # role gone -> take back what the role had given
granted.discard(group.name) granted.discard(name); removed.append(name)
attrs["discord_role_groups"] = sorted(granted) attrs["discord_role_groups"] = sorted(granted)
user.attributes = attrs user.attributes = attrs
user.save() user.save()
ak_logger.info(f"discord-roles: authentication flow, user {user.username} (pk {user.pk}): "
f"added {added or '[]'}, removed {removed or '[]'}, role-granted now {sorted(granted)}")
return True return True
# Enrollment flow: the user is created later by the user-write stage; hand it the groups to attach and # Enrollment flow: the user is created later by the user-write stage; hand it the groups to attach and
# record them as role-granted so a later role removal can revoke exactly these. # record them as role-granted so a later role removal can revoke exactly these. The flow plan is reached
# through context["flow_plan"] on some versions and IS context on others, so write to whichever is there.
attach = [group for name, group in existing.items() if name in wanted]
plan = context.get("flow_plan") plan = context.get("flow_plan")
if plan is not None: target = plan.context if plan is not None else context
plan.context["groups"] = list(Group.objects.filter(name__in=wanted)) target["groups"] = attach
prompt = plan.context.setdefault("prompt_data", {}) prompt = target.setdefault("prompt_data", {})
prompt.setdefault("attributes", {})["discord_role_groups"] = sorted(wanted) prompt.setdefault("attributes", {})["discord_role_groups"] = sorted(wanted)
ak_logger.info(f"discord-roles: enrollment flow via {'flow_plan' if plan is not None else 'policy context'}: "
f"attaching {[group.name for group in attach]}, recording {sorted(wanted)}")
return True return True