Repository: goauthentik/authentik

Issue:
FreeIPA LDAP sync broken in 2023.8.*
**Describe the bug**
After upgrading Authentik to `2023.8.0` (and `2023.8.1`), the LDAP sync with FreeIPA seems to be broken. The `ldap_sync:freeipa:groups` tasks complete without issues, but the `ldap_sync:freeipa:users` task fails:

```
ldap_sync:freeipa:users:cd24b105-15d6-4d2b-85bc-29492f0f86a8

Task finished with errors
["“['TRUE']” value must be either True or False."]
```

**To Reproduce**
Steps to reproduce the behavior:

1. Go to 'if/admin/#/core/sources'
2. Click on the FreeIPA LDAP source
3. Click "Run Sync Again"

**Expected behavior**
It is expected that the sync completes without errors

**Screenshots**
![image](https://github.com/goauthentik/authentik/assets/9056525/864efc40-83e7-4fc4-9a0a-01cce4ba9a23)

**Logs**
Error log from `if/admin/#/events/log` --> `System task exception: authentik.events.monitored_tasks`:

```
Task ldap_sync encountered an error: Traceback (most recent call last):
  File "/usr/local/lib/python3.11/site-packages/celery/app/trace.py", line 477, in trace_task
    R = retval = fun(*args, **kwargs)
                 ^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py", line 275, in _inner
    reraise(*exc_info)
  File "/usr/local/lib/python3.11/site-packages/sentry_sdk/_compat.py", line 60, in reraise
    raise value
  File "/usr/local/lib/python3.11/site-packages/sentry_sdk/integrations/celery.py", line 270, in _inner
    return f(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/celery/app/trace.py", line 760, in __protected_call__
    return self.run(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/authentik/sources/ldap/tasks.py", line 97, in ldap_sync
    count = sync_inst.sync(page)
            ^^^^^^^^^^^^^^^^^^^^
  File "/authentik/sources/ldap/sync/users.py", line 73, in sync
    FreeIPA(self._source).sync(attributes, ak_user, created)
  File "/authentik/sources/ldap/sync/vendor/freeipa.py", line 23, in sync
    self.check_nsaccountlock(attributes, user)
  File "/authentik/sources/ldap/sync/vendor/freeipa.py", line 51, in check_nsaccountlock
    user.save()
  File "/usr/local/lib/python3.11/site-packages/django/contrib/auth/base_user.py", line 76, in save
    super().save(*args, **kwargs)
  File "/usr/local/lib/python3.11/site-packages/django/db/models/base.py", line 814, in save
    self.save_base(
  File "/usr/local/lib/python3.11/site-packages/django/db/models/base.py", line 877, in save_base
    updated = self._save_table(
              ^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/base.py", line 990, in _save_table
    updated = self._do_update(
              ^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/base.py", line 1054, in _do_update
    return filtered._update(values) > 0
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/query.py", line 1231, in _update
    return query.get_compiler(self.db).execute_sql(CURSOR)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 1984, in execute_sql
    cursor = super().execute_sql(result_type)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 1549, in execute_sql
    sql, params = self.as_sql()
                  ^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/sql/compiler.py", line 1947, in as_sql
    val = field.get_db_prep_save(val, connection=self.connection)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/fields/__init__.py", line 954, in get_db_prep_save
    return self.get_db_prep_value(value, connection=connection, prepared=False)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/fields/__init__.py", line 947, in get_db_prep_value
    value = self.get_prep_value(value)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/fields/__init__.py", line 1122, in get_prep_value
    return self.to_python(value)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/site-packages/django/db/models/fields/__init__.py", line 1112, in to_python
    raise exceptions.ValidationError(
django.core.exceptions.ValidationError: ["“['TRUE']” value must be either True or False."]
```

**Version and Deployment (please complete the following information):**

-   authentik version: `2023.8.0`, `2023.8.1`
-   Deployment: `docker-compose`

**Additional context**
N/A



Discussion:
caused by #6270
Also confirming I see this on my setup.

Resolving patch:
diff --git a/authentik/sources/ldap/sync/vendor/freeipa.py b/authentik/sources/ldap/sync/vendor/freeipa.py
--- a/authentik/sources/ldap/sync/vendor/freeipa.py
+++ b/authentik/sources/ldap/sync/vendor/freeipa.py
@@ -45,7 +45,11 @@ def check_nsaccountlock(self, attributes: dict[str, Any], user: User):
         # 389-ds and this will trigger regardless
         if "nsaccountlock" not in attributes:
             return
-        is_active = attributes.get("nsaccountlock", False)
+        # For some reason, nsaccountlock is not defined properly in the schema as bool
+        # hence we get it as a list of strings
+        _is_active = str(self._flatten(attributes.get("nsaccountlock", ["FALSE"])))
+        # So we have to attempt to convert it to a bool
+        is_active = _is_active.lower() == "true"
         if is_active != user.is_active:
             user.is_active = is_active
             user.save()


Test patch:
diff --git a/authentik/sources/ldap/tests/mock_freeipa.py b/authentik/sources/ldap/tests/mock_freeipa.py
new file mode 100644
--- /dev/null
+++ b/authentik/sources/ldap/tests/mock_freeipa.py
@@ -0,0 +1,111 @@
+"""ldap testing utils"""
+
+from ldap3 import MOCK_SYNC, OFFLINE_DS389_1_3_3, Connection, Server
+
+
+def mock_freeipa_connection(password: str) -> Connection:
+    """Create mock FreeIPA-ish connection"""
+    server = Server("my_fake_server", get_info=OFFLINE_DS389_1_3_3)
+    _pass = "foo"  # noqa # nosec
+    connection = Connection(
+        server,
+        user="cn=my_user,dc=goauthentik,dc=io",
+        password=_pass,
+        client_strategy=MOCK_SYNC,
+    )
+    # Entry for password checking
+    connection.strategy.add_entry(
+        "cn=user,ou=users,dc=goauthentik,dc=io",
+        {
+            "name": "test-user",
+            "uid": "unique-test-group",
+            "objectClass": "person",
+            "displayName": "Erin M. Hagens",
+        },
+    )
+    connection.strategy.add_entry(
+        "cn=group1,ou=groups,dc=goauthentik,dc=io",
+        {
+            "cn": "group1",
+            "uid": "unique-test-group",
+            "objectClass": "groupOfNames",
+            "member": ["cn=user0,ou=users,dc=goauthentik,dc=io"],
+        },
+    )
+    # Group without SID
+    connection.strategy.add_entry(
+        "cn=group2,ou=groups,dc=goauthentik,dc=io",
+        {
+            "cn": "group2",
+            "objectClass": "groupOfNames",
+        },
+    )
+    connection.strategy.add_entry(
+        "cn=user0,ou=users,dc=goauthentik,dc=io",
+        {
+            "userPassword": password,
+            "name": "user0_sn",
+            "uid": "user0_sn",
+            "objectClass": "person",
+        },
+    )
+    # User without SID
+    connection.strategy.add_entry(
+        "cn=user1,ou=users,dc=goauthentik,dc=io",
+        {
+            "userPassword": "test1111",
+            "name": "user1_sn",
+            "objectClass": "person",
+        },
+    )
+    # Duplicate users
+    connection.strategy.add_entry(
+        "cn=user2,ou=users,dc=goauthentik,dc=io",
+        {
+            "userPassword": "test2222",
+            "name": "user2_sn",
+            "uid": "unique-test2222",
+            "objectClass": "person",
+        },
+    )
+    connection.strategy.add_entry(
+        "cn=user3,ou=users,dc=goauthentik,dc=io",
+        {
+            "userPassword": "test2222",
+            "name": "user2_sn",
+            "uid": "unique-test2222",
+            "objectClass": "person",
+        },
+    )
+    # Group with posixGroup and memberUid
+    connection.strategy.add_entry(
+        "cn=group-posix,ou=groups,dc=goauthentik,dc=io",
+        {
+            "cn": "group-posix",
+            "objectClass": "posixGroup",
+            "memberUid": ["user-posix"],
+        },
+    )
+    # User with posixAccount
+    connection.strategy.add_entry(
+        "cn=user-posix,ou=users,dc=goauthentik,dc=io",
+        {
+            "userPassword": password,
+            "uid": "user-posix",
+            "cn": "user-posix",
+            "objectClass": "posixAccount",
+        },
+    )
+    # Locked out user
+    connection.strategy.add_entry(
+        "cn=user-nsaccountlock,ou=users,dc=goauthentik,dc=io",
+        {
+            "userPassword": password,
+            "uid": "user-nsaccountlock",
+            "cn": "user-nsaccountlock",
+            "objectClass": "person",
+            "nsaccountlock": ["TRUE"],
+        },
+    )
+    connection.bind()
+    return connection
diff --git a/authentik/sources/ldap/tests/mock_slapd.py b/authentik/sources/ldap/tests/mock_slapd.py
--- a/authentik/sources/ldap/tests/mock_slapd.py
+++ b/authentik/sources/ldap/tests/mock_slapd.py
@@ -4,7 +4,7 @@
 
 
 def mock_slapd_connection(password: str) -> Connection:
-    """Create mock AD connection"""
+    """Create mock SLAPD connection"""
     server = Server("my_fake_server", get_info=OFFLINE_SLAPD_2_4)
     _pass = "foo"  # noqa # nosec
     connection = Connection(
diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py
--- a/authentik/sources/ldap/tests/test_sync.py
+++ b/authentik/sources/ldap/tests/test_sync.py
@@ -17,6 +17,7 @@
 from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
 from authentik.sources.ldap.tasks import ldap_sync, ldap_sync_all
 from authentik.sources.ldap.tests.mock_ad import mock_ad_connection
+from authentik.sources.ldap.tests.mock_freeipa import mock_freeipa_connection
 from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection
 
 LDAP_PASSWORD = generate_key()
@@ -120,6 +121,23 @@ def test_sync_users_openldap(self):
             self.assertTrue(User.objects.filter(username="user0_sn").exists())
             self.assertFalse(User.objects.filter(username="user1_sn").exists())
 
+    def test_sync_users_freeipa_ish(self):
+        """Test user sync (FreeIPA-ish), mainly testing vendor quirks"""
+        self.source.object_uniqueness_field = "uid"
+        self.source.property_mappings.set(
+            LDAPPropertyMapping.objects.filter(
+                Q(managed__startswith="goauthentik.io/sources/ldap/default")
+                | Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
+            )
+        )
+        self.source.save()
+        connection = MagicMock(return_value=mock_freeipa_connection(LDAP_PASSWORD))
+        with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
+            user_sync = UserLDAPSynchronizer(self.source)
+            user_sync.sync_full()
+            self.assertTrue(User.objects.filter(username="user0_sn").exists())
+            self.assertFalse(User.objects.filter(username="user1_sn").exists())
+
     def test_sync_groups_ad(self):
         """Test group sync"""
         self.source.property_mappings.set(