aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Joe Banks <[email protected]>2024-07-26 14:55:07 +0100
committerGravatar Joe Banks <[email protected]>2024-07-26 18:06:02 +0100
commit32b672447a57295d6606ecc27d2463180993cd02 (patch)
treeced434cada6c19e051b16e097622327fc6473654
parentAdd LDAP module (diff)
Add new LDAP cog
-rw-r--r--arthur/exts/directory/__init__.py1
-rw-r--r--arthur/exts/directory/ldap.py426
2 files changed, 427 insertions, 0 deletions
diff --git a/arthur/exts/directory/__init__.py b/arthur/exts/directory/__init__.py
new file mode 100644
index 0000000..f2fbac3
--- /dev/null
+++ b/arthur/exts/directory/__init__.py
@@ -0,0 +1 @@
+"""Extensions related to Cloudflare."""
diff --git a/arthur/exts/directory/ldap.py b/arthur/exts/directory/ldap.py
new file mode 100644
index 0000000..1960fb8
--- /dev/null
+++ b/arthur/exts/directory/ldap.py
@@ -0,0 +1,426 @@
+"""The LDAP cog is used to interact with our directory services, FreeIPA & Keycloak."""
+
+import secrets
+from collections import Counter
+from dataclasses import dataclass
+from enum import StrEnum
+
+import discord
+from discord import ui
+from discord.ext import commands, tasks
+
+from arthur.apis.directory import freeipa, keycloak, ldap
+from arthur.bot import KingArthur
+from arthur.config import CONFIG
+from arthur.constants import LDAP_BASE_STAFF_ROLE, LDAP_ROLE_MAPPING
+from arthur.log import logger
+
+PASSWORD_RESET_LENGTH = 16
+
+NOTIFICATIONS_ENABLED = False
+
+BOOTSTRAP_CHANNEL_TOPIC = """
+This channel is used for Python Discord LDAP enrollment. If you have been added to the LDAP directory, you will receive a message here with instructions on how to create your login credentials.
+
+If you have any questions or need help, feel free to ask in the <#{devops_channel_id}> channel.
+
+You can login to your account at <https://id.pydis.wtf/realms/pydis/account>.
+"""
+
+BOOTSTRAP_MESSAGE = """
+# Python Discord LDAP enrollment
+Hello! :wave:
+
+You have been added to the Python Discord LDAP directory. You can now log in to Python Discord managed services using a newly created `@pydis.wtf` account.
+
+**Please press the button below to generate your login credentials.**
+
+You will be prompted to change your password on first login. You will then be prompted to optionally update your name and forwarding email address.
+
+Once you have set these credentials, hold onto them as they will be used to access various services provided by Python Discord.
+
+**We will gradually be phasing out the use of GitHub logins for our services in favor of this new system, which mirrors accesses directly from Discord.**
+
+If you have any questions or need help, feel free to ask in the <#{devops_channel_id}> channel.
+## Why `pydis.wtf`?
+`pydis.wtf` is our internal tooling and systems domain, which is the primary address for our managed services.
+
+Over time, forwarding will be configured to accept email to `[email protected]` and `[email protected]` addresses as well.
+
+These addresses are by design not public or intended for public usage, and should be used only for Python Discord managed services.
+## Important Information
+- Your username will be set to your Discord account name. If you would like to change this, please let DevOps know.
+- Once you have logged into the account console, you can add a forwarding email allowing you to receive email to `@pydis.wtf` addresses.
+## Supported Services
+For now, on most of the below, old login methods will continue to work, but we encourage you to switch to the new system.
+- [Grafana](https://grafana.pydis.wtf/)
+- [Metabase](https://metabase.pydis.wtf/)
+- [PyDis ID Self-Service](<https://id.pydis.wtf/>)
+- [ModMail](https://modmail.pydis.wtf/)
+- Anti-Spam Message Deletions
+If you require data from the old system, please let us know and we can assist you in migrating it over.
+"""
+
+CREDENTIALS_SECTION = """
+## {title}
+
+To get started, you will need to login [here](<https://id.pydis.wtf/realms/pydis/account> ) using the following credentials:
+
+- **Username:** `{username}@pydis.wtf`
+- **Password:** ||`{password}`||
+
+You will be prompted to reset your password after logging in.
+"""
+
+ELIGIBLE_MESSAGE = """
+Hi {mention}! You have roles that make you eligible for a new LDAP account. Please read the message above to get started!
+"""
+
+
+class LDAPSyncAction(StrEnum):
+ """Represents an action that will be performed against the LDAP directory."""
+
+ ADD = "add"
+ REMOVE = "remove"
+ KEEP = "keep"
+ CHANGE = "change"
+
+
+@dataclass
+class DiffedUser:
+ """Represents a user with their Discord and LDAP records, as well as if modification is required."""
+
+ discord_user: discord.Member
+ ldap_user: ldap.LDAPUser | None
+ groups: list[str]
+ action: LDAPSyncAction
+
+
+class BootstrapType(StrEnum):
+ """Represents the type of bootstrap operation."""
+
+ CREATION = "creation"
+ RESET = "reset"
+
+
+class BootstrapView(ui.View):
+ """View for the LDAP bootstrap command."""
+
+ def __init__(self, cog: "LDAP") -> None:
+ super().__init__(timeout=None)
+ self.cog = cog
+
+ @ui.button(
+ label="Create or Reset Login", style=discord.ButtonStyle.primary, custom_id="generate_creds"
+ )
+ async def generate_creds(self, interaction: discord.Interaction, _button: ui.Button) -> None:
+ """Generate credentials for the user."""
+ user = interaction.user
+
+ if LDAP_BASE_STAFF_ROLE not in [role.id for role in user.roles]:
+ await interaction.response.send_message(
+ "You are not eligible for LDAP enrollment.", ephemeral=True
+ )
+ return
+
+ bootstrap_type, password = await self.cog.bootstrap(user)
+
+ if bootstrap_type == BootstrapType.CREATION:
+ title = "Account Creation"
+ else:
+ title = "Password Reset"
+
+ content = CREDENTIALS_SECTION.format(title=title, username=user.name, password=password)
+
+ await interaction.response.send_message(content, ephemeral=True)
+
+
+class LDAP(commands.Cog):
+ """Commands for working with the LDAP Directory."""
+
+ def __init__(self, bot: KingArthur) -> None:
+ self.bot = bot
+ self.sync_users.start()
+
+ @tasks.loop(minutes=10)
+ async def sync_users(self) -> None:
+ """Sync users with the LDAP directory."""
+ logger.info("Syncing users with the LDAP directory.")
+
+ diff, missing_emp, counts = await self.get_user_diff()
+
+ add_users = counts[LDAPSyncAction.ADD]
+ remove_users = counts[LDAPSyncAction.REMOVE]
+ keep_users = counts[LDAPSyncAction.KEEP]
+ change_users = counts[LDAPSyncAction.CHANGE]
+
+ logger.info(
+ f"LDAP: {add_users} missing users, removing {remove_users} users, "
+ f"keeping {keep_users} users, and changing {change_users} users."
+ )
+
+ if len(missing_emp) > 0:
+ logger.error(
+ "LDAP: Some users are missing an employee number. This may lead to duplicated users being created."
+ )
+
+ await self.bot.get_channel(CONFIG.devops_channel_id).send(
+ ":x: LDAP Sync: Some users are missing an employee number. This may lead to duplicate users, please rectify."
+ )
+
+ notified_users = []
+
+ async for message in self.bot.get_channel(CONFIG.ldap_bootstrap_channel_id).history(
+ limit=None, oldest_first=True
+ ):
+ if (
+ "Python Discord LDAP enrollment" in message.content
+ or len(message.mentions) == 0
+ or message.author != self.bot.user
+ ):
+ continue
+
+ notified_users.append(message.mentions[0])
+
+ for user in diff:
+ if user.action == LDAPSyncAction.ADD:
+ if user.discord_user in notified_users:
+ continue
+
+ if NOTIFICATIONS_ENABLED:
+ await self.bot.get_channel(CONFIG.ldap_bootstrap_channel_id).send(
+ ELIGIBLE_MESSAGE.format(mention=user.discord_user.mention)
+ )
+ if user.action == LDAPSyncAction.REMOVE:
+ freeipa.deactivate_user(user.ldap_user.uid)
+ elif user.action == LDAPSyncAction.CHANGE:
+ freeipa.set_user_groups(user.ldap_user.uid, user.groups)
+
+ logger.info("LDAP: Sync complete.")
+
+ async def cleanup_bootstrap(self, user: discord.Member) -> None:
+ """Clear up the bootstrap message for a user."""
+ channel = self.bot.get_channel(CONFIG.ldap_bootstrap_channel_id)
+
+ async for message in channel.history(limit=None, oldest_first=True):
+ if message.author == self.bot.user and user.mention in message.content:
+ await message.delete()
+ break
+
+ @commands.Cog.listener()
+ async def on_member_update(self, before: discord.Member, after: discord.Member) -> None:
+ """Handle member updates."""
+ if before.roles == after.roles:
+ return
+
+ before_roles = {role.id for role in before.roles}
+ after_roles = {role.id for role in after.roles}
+
+ if LDAP_BASE_STAFF_ROLE in before_roles or LDAP_BASE_STAFF_ROLE in after_roles:
+ self.sync_users()
+
+ async def bootstrap(self, user: discord.Member) -> tuple[BootstrapType, str]:
+ """Bootstrap a user into the LDAP directory, either creating or resetting the password."""
+ if ldap_user := await ldap.find_by_discord_id(user.id):
+ password = secrets.token_urlsafe(20)
+
+ keycloak.force_password_reset(ldap_user.uid, password)
+
+ return BootstrapType.RESET, password
+
+ generated_pw = freeipa.create_user(
+ user.name,
+ user.display_name,
+ self._user_groups(user),
+ user.id,
+ )
+
+ await self.cleanup_bootstrap(user)
+
+ return BootstrapType.CREATION, generated_pw
+
+ async def cog_load(self) -> None: # noqa: C901, PLR0912
+ """Verify the bootstrap channel is setup as intended."""
+ self.bot.add_view(BootstrapView(self))
+ bootstrap_message = BOOTSTRAP_MESSAGE.format(devops_channel_id=CONFIG.devops_channel_id)
+
+ channel = self.bot.get_channel(CONFIG.ldap_bootstrap_channel_id)
+
+ logger.info("LDAP: Checking bootstrap channel.")
+
+ if not channel:
+ logger.error("LDAP: Bootstrap channel not found.")
+ return
+
+ await channel.edit(
+ topic=BOOTSTRAP_CHANNEL_TOPIC.format(devops_channel_id=CONFIG.devops_channel_id)
+ )
+
+ found_message = None
+ other_messages = []
+
+ async for message in channel.history(limit=None, oldest_first=True):
+ if (
+ message.author == self.bot.user
+ and "Python Discord LDAP enrollment" in message.content
+ ):
+ found_message = message
+
+ if message.author == self.bot.user and len(message.mentions) > 0:
+ target_user = message.mentions[0]
+
+ if await ldap.find_by_discord_id(target_user.id):
+ other_messages.append(message)
+
+ for message in other_messages:
+ await message.delete()
+
+ if found_message:
+ logger.info("LDAP: Found bootstrap message.")
+ if found_message.content != bootstrap_message:
+ await found_message.edit(content=bootstrap_message, view=BootstrapView(self))
+ else:
+ logger.info("LDAP: Creating bootstrap message.")
+ await channel.send(bootstrap_message, view=BootstrapView(self))
+
+ # Validate all enrolled roles can see the channel
+ for role_id in LDAP_ROLE_MAPPING.values():
+ role = channel.guild.get_role(role_id)
+
+ if not role:
+ continue
+
+ try:
+ if role.id == CONFIG.devops_role:
+ await channel.set_permissions(
+ role,
+ read_messages=True,
+ send_messages=True,
+ manage_channels=True,
+ manage_messages=True,
+ manage_permissions=True,
+ )
+ else:
+ await channel.set_permissions(role, read_messages=True, send_messages=False)
+ except discord.Forbidden:
+ logger.error(f"Could not set permissions for role: {role}")
+
+ @commands.group(name="directory", invoke_without_command=True, aliases=["ldap"])
+ async def ldap_group(self, ctx: commands.Context) -> None:
+ """Commands for working with the Python Discord directory."""
+ await ctx.send_help(ctx.command)
+
+ @staticmethod
+ def _user_groups(user: discord.Member) -> list[str]:
+ """Return the groups a user is enrolled in."""
+ return [
+ role
+ for role, discord_role_id in LDAP_ROLE_MAPPING.items()
+ if discord_role_id in [r.id for r in user.roles]
+ ]
+
+ async def get_user_diff(
+ self,
+ ) -> tuple[list[DiffedUser], list[ldap.LDAPUser], Counter[LDAPSyncAction]]:
+ """Calculate and return the diff of users against LDAP from the guild."""
+ guild = self.bot.get_guild(CONFIG.guild_id)
+ users = await ldap.find_users()
+ ldap_discord_id_map = {user.employee_number: user for user in users}
+
+ enrolled_roles = set(LDAP_ROLE_MAPPING.values())
+
+ base_role = guild.get_role(LDAP_BASE_STAFF_ROLE)
+
+ diff = []
+ missing_emp = [user for user in users if user.employee_number is None]
+
+ for user in guild.members:
+ if user.bot:
+ continue
+
+ if base_role not in user.roles:
+ if user.id in ldap_discord_id_map:
+ diff.append(
+ DiffedUser(user, ldap_discord_id_map[user.id], [], LDAPSyncAction.REMOVE)
+ )
+ continue
+
+ user_role_ids = {r.id for r in user.roles}
+
+ if enrolled_roles & user_role_ids:
+ roles = self._user_groups(user)
+ if user.id in ldap_discord_id_map:
+ diff.append(
+ DiffedUser(user, ldap_discord_id_map[user.id], roles, LDAPSyncAction.KEEP)
+ )
+ if set(roles) != set(ldap_discord_id_map[user.id].groups):
+ diff[-1].action = LDAPSyncAction.CHANGE
+ else:
+ diff.append(DiffedUser(user, None, roles, LDAPSyncAction.ADD))
+ elif user.id in ldap_discord_id_map:
+ diff.append(
+ DiffedUser(user, ldap_discord_id_map[user.id], [], LDAPSyncAction.REMOVE)
+ )
+
+ counter = Counter([user.action for user in diff])
+
+ return diff, missing_emp, counter
+
+ @staticmethod
+ def _format_user(discord_user: discord.Member, ldap_user: ldap.LDAPUser | None) -> str:
+ """Format the user for display."""
+ if not ldap_user or ldap_user.uid == discord_user.name:
+ return discord_user.name
+
+ return f"{discord_user.name} (LDAP: {ldap_user.uid})"
+
+ @ldap_group.command(name="sync")
+ async def sync(self, ctx: commands.Context) -> None:
+ """List users found in the LDAP directory."""
+ diff, missing_emp, counts = await self.get_user_diff()
+
+ add_users = counts[LDAPSyncAction.ADD]
+ remove_users = counts[LDAPSyncAction.REMOVE]
+ keep_users = counts[LDAPSyncAction.KEEP]
+ change_users = counts[LDAPSyncAction.CHANGE]
+
+ diff_message = "# LDAP Sync Overview\n"
+
+ diff_message += f"**Adding Users:** {add_users}\n"
+ diff_message += f"**Removing Users:** {remove_users}\n"
+ diff_message += f"**Keeping Users:** {keep_users}\n"
+ diff_message += f"**Changing Users:** {change_users}\n"
+
+ diff_message += "```diff\n"
+
+ diff_sorted = sorted(diff, key=lambda user: (user.action, user.discord_user.name))
+
+ prefixes = {
+ LDAPSyncAction.ADD: "+",
+ LDAPSyncAction.REMOVE: "-",
+ LDAPSyncAction.KEEP: " ",
+ LDAPSyncAction.CHANGE: "~",
+ }
+
+ for user in diff_sorted:
+ prefix = prefixes[user.action]
+ diff_message += f"{prefix} {self._format_user(user.discord_user, user.ldap_user)}"
+ diff_message += f" ({", ".join(user.groups)})\n"
+
+ diff_message += "```\n"
+
+ if len(missing_emp) > 0:
+ diff_message += (
+ ":warning: **Warning: Some LDAP users are missing an employee number. "
+ "This may lead to duplicated users being created.**\n\n"
+ )
+
+ diff_message += "Users missing employee numbers:\n"
+ diff_message += "\n".join(f"- `{user.uid}`" for user in missing_emp)
+
+ await ctx.reply(diff_message)
+
+
+async def setup(bot: KingArthur) -> None:
+ """Add the extension to the bot."""
+ await bot.add_cog(LDAP(bot))