Repository: zulip/zulip

Issue:
LDAP synchronisation of disabled account not working on FreeIPA
Hi,

I am using zulip in combination with FreeIPA (LDAP-based authentication server). Unfortunately, there doesn't seem to be support to automatically detect disabled accounts and to automatically disable them in zulip as well. I only found something for Active Directory.

The corresponding field in FreeIPA is called `nsAccountLock`

I think this feature would be extremely useful.


Discussion:
@andreas-bulling have you read https://zulip.readthedocs.io/en/latest/production/authentication-methods.html#synchronizing-data?  There's a section on automatically disabling accounts.  I'm not sure whether we need code changes or just configuration to support `nsaccountlock`; do you have documentation on its format?
Yes, as I said - I found the instructions for AD but the flag/variable is different for FreeIPA. It's called `nsAccountLock` and seems to be either true/yes or false/no.

See here for more information:
https://auth.docs.cern.ch/freeipa/freeipa-attribute-tables/
https://docs.fedoraproject.org/en-US/Fedora/18/html/FreeIPA_Guide/about-sync-schema.html
Sorry for the slow reply -- I think it makes sense to support this parameter.  

@mateuszmandera would you be up for doing this one?  I think it should be pretty simple, since it's just a boolean for whether to deactivate the account, but we can configure it using this scheme (basically whatever you put as `deactivated` in the LHS will be mapped to marking the account as deactivated; `nsAccountLock` seems to have that True/False meaning)
```
    # "deactivated": "nsAccountLock",
    # "userAccountControl": "userAccountControl",                                          
```
Great, looking forward to this feature - this will make my (admin) life a lot easier. Let me know if I can help testing it but the change indeed seems rather trivial.

Resolving patch:
diff --git a/zproject/backends.py b/zproject/backends.py
--- a/zproject/backends.py
+++ b/zproject/backends.py
@@ -477,6 +477,23 @@ def check_ldap_config() -> None:
         # Email search needs to be configured in this case.
         assert settings.AUTH_LDAP_USERNAME_ATTR and settings.AUTH_LDAP_REVERSE_EMAIL_SEARCH
 
+    # These two are alternatives approaches to deactivating users based on an ldap attribute
+    # and thus don't make sense to have enabled together.
+    assert not (
+        settings.AUTH_LDAP_USER_ATTR_MAP.get("userAccountControl")
+        and settings.AUTH_LDAP_USER_ATTR_MAP.get("deactivated")
+    )
+
+
+def ldap_should_sync_active_status() -> bool:
+    if "userAccountControl" in settings.AUTH_LDAP_USER_ATTR_MAP:
+        return True
+
+    if "deactivated" in settings.AUTH_LDAP_USER_ATTR_MAP:
+        return True
+
+    return False
+
 
 def find_ldap_users_by_email(email: str) -> List[_LDAPUser]:
     """
@@ -715,15 +732,30 @@ def sync_avatar_from_ldap(self, user: UserProfile, ldap_user: _LDAPUser) -> None
             else:
                 logging.warning("Could not parse %s field for user %s", avatar_attr_name, user.id)
 
-    def is_account_control_disabled_user(self, ldap_user: _LDAPUser) -> bool:
-        """Implements the userAccountControl check for whether a user has been
-        disabled in an Active Directory server being integrated with
-        Zulip via LDAP."""
-        account_control_value = ldap_user.attrs[
-            settings.AUTH_LDAP_USER_ATTR_MAP["userAccountControl"]
-        ][0]
-        ldap_disabled = bool(int(account_control_value) & LDAP_USER_ACCOUNT_CONTROL_DISABLED_MASK)
-        return ldap_disabled
+    def is_user_disabled_in_ldap(self, ldap_user: _LDAPUser) -> bool:
+        """Implements checks for whether a user has been
+        disabled in the LDAP server being integrated with
+        Zulip."""
+        if "userAccountControl" in settings.AUTH_LDAP_USER_ATTR_MAP:
+            account_control_value = ldap_user.attrs[
+                settings.AUTH_LDAP_USER_ATTR_MAP["userAccountControl"]
+            ][0]
+            return bool(int(account_control_value) & LDAP_USER_ACCOUNT_CONTROL_DISABLED_MASK)
+
+        assert "deactivated" in settings.AUTH_LDAP_USER_ATTR_MAP
+        attr_value = ldap_user.attrs[settings.AUTH_LDAP_USER_ATTR_MAP["deactivated"]][0]
+
+        # In the LDAP specification, a Boolean attribute should be
+        # *exactly* either "TRUE" or "FALSE". However,
+        # https://www.freeipa.org/page/V4/User_Life-Cycle_Management suggests
+        # that FreeIPA at least documents using Yes/No for booleans.
+        true_values = ["TRUE", "YES"]
+        false_values = ["FALSE", "NO"]
+        attr_value_upper = attr_value.upper()
+        assert (
+            attr_value_upper in true_values or attr_value_upper in false_values
+        ), f"Invalid value '{attr_value}' in the LDAP attribute mapped to deactivated"
+        return attr_value_upper in true_values
 
     def is_account_realm_access_forbidden(self, ldap_user: _LDAPUser, realm: Realm) -> bool:
         # org_membership takes priority over AUTH_LDAP_ADVANCED_REALM_ACCESS_CONTROL.
@@ -881,8 +913,8 @@ def get_or_build_user(self, username: str, ldap_user: _LDAPUser) -> Tuple[UserPr
         if self.is_account_realm_access_forbidden(ldap_user, self._realm):
             raise ZulipLDAPException("User not allowed to access realm")
 
-        if "userAccountControl" in settings.AUTH_LDAP_USER_ATTR_MAP:  # nocoverage
-            ldap_disabled = self.is_account_control_disabled_user(ldap_user)
+        if ldap_should_sync_active_status():  # nocoverage
+            ldap_disabled = self.is_user_disabled_in_ldap(ldap_user)
             if ldap_disabled:
                 # Treat disabled users as deactivated in Zulip.
                 return_data["inactive_user"] = True
@@ -1012,8 +1044,8 @@ def get_or_build_user(
         user = get_user_by_delivery_email(username, ldap_user.realm)
         built = False
         # Synchronise the UserProfile with its LDAP attributes:
-        if "userAccountControl" in settings.AUTH_LDAP_USER_ATTR_MAP:
-            user_disabled_in_ldap = self.is_account_control_disabled_user(ldap_user)
+        if ldap_should_sync_active_status():
+            user_disabled_in_ldap = self.is_user_disabled_in_ldap(ldap_user)
             if user_disabled_in_ldap:
                 if user.is_active:
                     ldap_logger.info(
diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py
--- a/zproject/prod_settings_template.py
+++ b/zproject/prod_settings_template.py
@@ -239,6 +239,9 @@
     ## who are disabled in LDAP/Active Directory (and reactivate users who are not).
     ## See docs for usage details and precise semantics.
     # "userAccountControl": "userAccountControl",
+    ## Alternatively, you can map "deactivated" to a boolean attribute
+    ## that is "TRUE" for deactivated users and "FALSE" otherwise.
+    # "deactivated": "nsAccountLock",
     ## Restrict access to organizations using an LDAP attribute.
     ## See https://zulip.readthedocs.io/en/latest/production/authentication-methods.html#restricting-ldap-user-access-to-specific-organizations
     # "org_membership": "department",


Test patch:
diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py
--- a/zerver/tests/test_auth_backends.py
+++ b/zerver/tests/test_auth_backends.py
@@ -5877,7 +5877,7 @@ def test_too_short_name(self) -> None:
             ["WARNING:django_auth_ldap:Name too short! while authenticating hamlet"],
         )
 
-    def test_deactivate_user(self) -> None:
+    def test_deactivate_user_with_useraccountcontrol_attr(self) -> None:
         self.change_ldap_user_attr("hamlet", "userAccountControl", "2")
 
         with self.settings(
@@ -5893,6 +5893,50 @@ def test_deactivate_user(self) -> None:
             ],
         )
 
+    def test_deactivate_reactivate_user_with_deactivated_attr(self) -> None:
+        self.change_ldap_user_attr("hamlet", "someCustomAttr", "TRUE")
+
+        with self.settings(
+            AUTH_LDAP_USER_ATTR_MAP={"full_name": "cn", "deactivated": "someCustomAttr"}
+        ), self.assertLogs("zulip.ldap") as info_logs:
+            self.perform_ldap_sync(self.example_user("hamlet"))
+        hamlet = self.example_user("hamlet")
+        self.assertFalse(hamlet.is_active)
+        self.assertEqual(
+            info_logs.output,
+            [
+                "INFO:zulip.ldap:Deactivating user hamlet@zulip.com because they are disabled in LDAP."
+            ],
+        )
+
+        self.change_ldap_user_attr("hamlet", "someCustomAttr", "FALSE")
+        with self.settings(
+            AUTH_LDAP_USER_ATTR_MAP={"full_name": "cn", "deactivated": "someCustomAttr"}
+        ), self.assertLogs("zulip.ldap") as info_logs:
+            self.perform_ldap_sync(self.example_user("hamlet"))
+        hamlet.refresh_from_db()
+        self.assertTrue(hamlet.is_active)
+        self.assertEqual(
+            info_logs.output,
+            [
+                "INFO:zulip.ldap:Reactivating user hamlet@zulip.com because they are not disabled in LDAP."
+            ],
+        )
+
+        self.change_ldap_user_attr("hamlet", "someCustomAttr", "YESSS")
+        with self.settings(
+            AUTH_LDAP_USER_ATTR_MAP={"full_name": "cn", "deactivated": "someCustomAttr"}
+        ), self.assertLogs("django_auth_ldap") as ldap_logs, self.assertRaises(AssertionError):
+            self.perform_ldap_sync(self.example_user("hamlet"))
+        hamlet.refresh_from_db()
+        self.assertTrue(hamlet.is_active)
+        self.assertEqual(
+            ldap_logs.output,
+            [
+                "WARNING:django_auth_ldap:Invalid value 'YESSS' in the LDAP attribute mapped to deactivated while authenticating hamlet"
+            ],
+        )
+
     @mock.patch("zproject.backends.ZulipLDAPAuthBackendBase.sync_full_name_from_ldap")
     def test_dont_sync_disabled_ldap_user(self, fake_sync: mock.MagicMock) -> None:
         self.change_ldap_user_attr("hamlet", "userAccountControl", "2")