diff options
author | 2024-07-26 14:55:07 +0100 | |
---|---|---|
committer | 2024-07-26 18:06:02 +0100 | |
commit | 32b672447a57295d6606ecc27d2463180993cd02 (patch) | |
tree | ced434cada6c19e051b16e097622327fc6473654 | |
parent | Add LDAP module (diff) |
Add new LDAP cog
-rw-r--r-- | arthur/exts/directory/__init__.py | 1 | ||||
-rw-r--r-- | arthur/exts/directory/ldap.py | 426 |
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)) |