diff --git a/README.md b/README.md index e250fc4..2c9f7d4 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,61 @@ own file to not leak secrets into your configuration: Please note that every trailing `\n` in the password file will be stripped automatically. + +## User Mapping + +The `user_mapping` option allows you to transform LDAP user identifiers into Matrix user identifiers +using a customizable template. This is useful when you want to normalize or modify the local part +of user IDs based on LDAP attributes and when you have numeric IDs in LDAP because Synapse does not +accept numeric usernames since they are reserved for the guest account. + +### Configuration + +```yaml + modules: + - module: "ldap_auth_provider.LdapAuthProviderModule" + config: + enabled: true + uri: "ldap://ldap.example.com:389" + start_tls: true + base: "ou=users,dc=example,dc=com" + attributes: + uid: "cn" + mail: "mail" + name: "givenName" + bind_dn: "cn=hacker,ou=svcaccts,dc=example,dc=com" + bind_password: "ch33kym0nk3y" + # User mapping configuration + user_mapping: + localpart_template: "u{localpart}" +``` + +### How it works + +When `user_mapping` is configured with a `localpart_template`: + +1. **LDAP Authentication**: User authenticates against LDAP with their original LDAP username +2. **Template Application**: The LDAP username is transformed using the template + - Example: LDAP username `123456 + template `u{localpart}` → Matrix username `u123456` +3. **Matrix Registration/Login**: The user's Matrix account uses the transformed localpart +4. **Storage**: The original LDAP localpart is stored in the `user_external_ids` table for future reference + +### Example scenarios + +**Scenario 1: Prefix usernames with department code** +```yaml +user_mapping: + localpart_template: "emp_{localpart}" +# LDAP user "john.smith" → Matrix user "@emp_john.smith:example.com" +``` + +**Scenario 2: Prefix numeric employee IDs** +```yaml +user_mapping: + localpart_template: "u{localpart}" +# LDAP user "123456" → Matrix user "@u123456:example.com" +``` + ### Simple vs search mode, and attribute mapping The module behaves quite differently depending on the configured `mode`: @@ -143,6 +198,7 @@ credentials, but existing Matrix accounts keep the profile data stored in Synapse. Therefore logging in again will not refresh the display name or email address. + ## Active Directory forest support If the ``active_directory`` flag is set to `true`, an Active Directory forest will be diff --git a/ldap_auth_provider.py b/ldap_auth_provider.py index 25dd81b..9205253 100644 --- a/ldap_auth_provider.py +++ b/ldap_auth_provider.py @@ -59,6 +59,7 @@ class _LdapConfig: filter: Optional[str] = None active_directory: Optional[str] = None default_domain: Optional[str] = None + user_mapping: Optional[Dict[str, str]] = None SUPPORTED_LOGIN_TYPE: str = "m.login.password" @@ -92,9 +93,69 @@ def __init__(self, config: _LdapConfig, account_handler: ModuleApi): # of error; or None if there was no attempt to fetch root domain yet self.ldap_root_domain = None # type: Optional[str] + # User mapping configuration + self.user_mapping = config.user_mapping + def get_supported_login_types(self) -> Dict[str, Tuple[str, ...]]: return {SUPPORTED_LOGIN_TYPE: SUPPORTED_LOGIN_FIELDS} + def _apply_user_mapping(self, localpart: str) -> str: + """Apply user mapping configuration to transform localpart. + + Args: + localpart: Original localpart from LDAP authentication + + Returns: + Transformed localpart according to user_mapping configuration + """ + if not self.user_mapping: + return localpart + + localpart_template = self.user_mapping.get("localpart_template") + if not localpart_template: + return localpart + + try: + # Apply template transformation + mapped_localpart = localpart_template.format(localpart=localpart) + logger.debug("Mapped localpart '%s' to '%s' using template '%s'", + localpart, mapped_localpart, localpart_template) + return mapped_localpart + except (KeyError, ValueError) as e: + logger.warning("Failed to apply user mapping template '%s' to localpart '%s': %s", + localpart_template, localpart, e) + return localpart + + async def _reverse_user_mapping(self, mapped_localpart: str) -> str: + """Reverse user mapping to get original localpart for LDAP queries. + + Uses user_external_ids table to find the original LDAP localpart. + + Args: + mapped_localpart: Mapped localpart (e.g., 'u790159') + + Returns: + Original localpart for LDAP queries (e.g., '790159') + """ + if not self.user_mapping: + return mapped_localpart + + # Get original localpart from database + try: + original_from_db = await self._get_original_localpart(mapped_localpart) + if original_from_db: + logger.debug("Found original localpart '%s' in database for '%s'", + original_from_db, mapped_localpart) + return original_from_db + except Exception as e: + logger.warning("Failed to get original localpart from database for '%s': %s", + mapped_localpart, e) + + # If not found in database, assume it's already the original + logger.debug("No original localpart found for '%s', assuming it's already original", + mapped_localpart) + return mapped_localpart + async def check_auth( self, username: str, login_type: str, login_dict: Dict[str, Any] ) -> Optional[str]: @@ -118,12 +179,15 @@ async def check_auth( # username is of the form @foo:bar.com username = username.split(":", 1)[0][1:] + # If username is already mapped (from previous login), reverse it for LDAP queries + original_username = await self._reverse_user_mapping(username) + # Used in LDAP queries as value of ldap_attributes['uid'] attribute. - uid_value = username + uid_value = original_username # Default display name for the user, if a new account is registered. - default_display_name = username + default_display_name = original_username # Local part of Matrix ID which will be used in registration process - localpart = username + localpart = original_username if self.ldap_active_directory: try: @@ -182,10 +246,22 @@ async def check_auth( ) return None - # Get full user id from localpart - user_id = self.account_handler.get_qualified_user_id(localpart) + # Apply user mapping to localpart before checking existence + mapped_localpart = self._apply_user_mapping(localpart) + + # First try to find existing user by original LDAP localpart + existing_user_id = await self._find_user_by_original_localpart(localpart) + + if existing_user_id: + # User exists with this original LDAP ID, return existing user + logger.debug("Found existing user '%s' for original localpart '%s'", + existing_user_id, localpart) + return existing_user_id - # check if user with user_id exists + # Get full user id from mapped localpart + user_id = self.account_handler.get_qualified_user_id(mapped_localpart) + + # check if user with mapped user_id exists (fallback for users without stored original ID) canonical_user_id = await self.account_handler.check_user_exists(user_id) if canonical_user_id: # exists, authentication complete @@ -225,9 +301,10 @@ async def check_auth( display_name = default_display_name mail = None - # Register the user + # Register the user with mapped localpart user_id = await self.register_user( - localpart.lower(), display_name, mail + mapped_localpart.lower(), display_name, mail, + already_mapped=True, original_localpart=localpart.lower() ) return user_id @@ -317,19 +394,31 @@ async def check_3pid_auth( logger.warning("Error during ldap authentication: %s", e) raise - async def register_user(self, localpart: str, name: str, email_address: str) -> str: + async def register_user(self, localpart: str, name: str, email_address: str, already_mapped: bool = False, original_localpart: str = None) -> str: """Register a Synapse user, first checking if they exist. Args: localpart: Localpart of the user to register on this homeserver. name: Full name of the user. email_address: Email address of the user. + already_mapped: If True, localpart is already mapped and won't be mapped again. + original_localpart: Original LDAP localpart (for storing in user_external_ids). Returns: user_id: User ID of the newly registered user. """ - # Get full user id from localpart - user_id = self.account_handler.get_qualified_user_id(localpart) + # Apply user mapping to localpart before registration (unless already mapped) + if already_mapped: + mapped_localpart = localpart + # If original_localpart not provided, we can't store it + if original_localpart is None: + original_localpart = localpart # This might not be correct, but it's our best guess + else: + original_localpart = localpart + mapped_localpart = self._apply_user_mapping(localpart) + + # Get full user id from mapped localpart + user_id = self.account_handler.get_qualified_user_id(mapped_localpart) if await self.account_handler.check_user_exists(user_id): # exists, authentication complete @@ -343,17 +432,22 @@ async def register_user(self, localpart: str, name: str, email_address: str) -> # from password providers if parse_version(synapse.__version__) <= parse_version("0.99.3"): user_id, access_token = await self.account_handler.register( - localpart=localpart, + localpart=mapped_localpart, displayname=name, ) else: # If Synapse has support, bind emails user_id, access_token = await self.account_handler.register( - localpart=localpart, + localpart=mapped_localpart, displayname=name, emails=emails, ) + # Store original LDAP localpart in user_external_ids for future reference + # Only store if we applied mapping (original localpart != mapped localpart) + if original_localpart and original_localpart != mapped_localpart: + await self._store_original_localpart(user_id, original_localpart) + logger.info( "Registration based on LDAP data was successful: %s", user_id, @@ -361,6 +455,142 @@ async def register_user(self, localpart: str, name: str, email_address: str) -> return user_id + async def _store_original_localpart(self, user_id: str, original_localpart: str) -> None: + """Store original LDAP localpart in user_external_ids table. + + Args: + user_id: Full Matrix user ID (e.g., '@u790159:domain.com') + original_localpart: Original LDAP localpart (e.g., '790159') + """ + try: + # Use a consistent auth_provider_id for LDAP original localparts + auth_provider_id = "ldap_original" + + # First check if user already has an external ID for this auth provider + # Try to access internal store directly + existing_ldap_original = None + + if hasattr(self.account_handler, '_store'): + try: + store = self.account_handler._store + # Use the exact method name from Synapse store + existing_external_ids = await store.get_external_ids_by_user(user_id) + + for auth_provider, external_id in existing_external_ids: + if auth_provider == auth_provider_id: + existing_ldap_original = external_id + break + except Exception as e: + logger.debug("Could not check existing external IDs via store: %s", e) + + if existing_ldap_original: + if existing_ldap_original == original_localpart: + logger.debug("Original localpart '%s' already stored for user '%s'", + original_localpart, user_id) + return + else: + logger.info("User '%s' already has different original localpart '%s', not updating to '%s'", + user_id, existing_ldap_original, original_localpart) + return + + # Store the mapping in user_external_ids table + await self.account_handler.record_user_external_id( + auth_provider_id, original_localpart, user_id + ) + + logger.debug("Stored original localpart '%s' for user '%s'", + original_localpart, user_id) + except Exception as e: + logger.warning("Failed to store original localpart '%s' for user '%s': %s", + original_localpart, user_id, e) + + async def _get_original_localpart(self, mapped_localpart: str) -> Optional[str]: + """Retrieve original LDAP localpart from user_external_ids table. + + Args: + mapped_localpart: Mapped localpart (e.g., 'u790159') + + Returns: + Original LDAP localpart if found, None otherwise + """ + try: + # Construct the full user_id from mapped localpart + user_id = self.account_handler.get_qualified_user_id(mapped_localpart) + + # Check if user exists + if not await self.account_handler.check_user_exists(user_id): + return None + + # Get external IDs for this user via internal store + auth_provider_id = "ldap_original" + + if hasattr(self.account_handler, '_store'): + try: + store = self.account_handler._store + external_ids = await store.get_external_ids_by_user(user_id) + + # Look for our stored original localpart + for auth_provider, external_id in external_ids: + if auth_provider == auth_provider_id: + logger.debug("Found original localpart '%s' for mapped localpart '%s'", + external_id, mapped_localpart) + return external_id + except Exception as e: + logger.debug("Could not get external IDs via store: %s", e) + + return None + except Exception as e: + logger.warning("Failed to retrieve original localpart for '%s': %s", + mapped_localpart, e) + return None + + async def _find_user_by_original_localpart(self, original_localpart: str) -> Optional[str]: + """Find existing user by original LDAP localpart. + + Uses Synapse's internal store to efficiently find user by external ID. + + Args: + original_localpart: Original LDAP localpart (e.g., '790159') + + Returns: + Full Matrix user ID if found, None otherwise + """ + try: + auth_provider_id = "ldap_original" + + # Try to access the internal store through ModuleApi + if hasattr(self.account_handler, '_store'): + store = self.account_handler._store + + # Implement our own get_user_by_external_id using SQL query + try: + # Use the internal db_pool to query user_external_ids table + result = await store.db_pool.simple_select_one_onecol( + table="user_external_ids", + keyvalues={ + "auth_provider": auth_provider_id, + "external_id": original_localpart, + }, + retcol="user_id", + allow_none=True, + desc="get_user_by_external_id_ldap", + ) + + if result: + logger.debug("Found user '%s' by original localpart '%s' using SQL query", + result, original_localpart) + return result + except Exception as e: + logger.debug("SQL query failed: %s", e) + + logger.debug("No user found for original localpart '%s'", original_localpart) + return None + + except Exception as e: + logger.debug("Error searching for user with original localpart '%s': %s", + original_localpart, e) + return None + @staticmethod def parse_config(config) -> "_LdapConfig": # verify config sanity @@ -423,6 +653,22 @@ def parse_config(config) -> "_LdapConfig": if ldap_config.active_directory: ldap_config.default_domain = config.get("default_domain", None) + # Parse user_mapping configuration + user_mapping = config.get("user_mapping") + if user_mapping: + if not isinstance(user_mapping, dict): + raise ValueError("user_mapping must be a dictionary") + + localpart_template = user_mapping.get("localpart_template") + if localpart_template and not isinstance(localpart_template, str): + raise ValueError("localpart_template must be a string") + + # Validate template contains {localpart} placeholder + if localpart_template and "{localpart}" not in localpart_template: + raise ValueError("localpart_template must contain {localpart} placeholder") + + ldap_config.user_mapping = user_mapping + if "validate_cert" in config and "tls_options" in config: raise Exception( "You cannot include both validate_cert and tls_options in the config"