Repository: goauthentik/authentik
Pull request: sources/ldap: add forward deletion option
Closes #1644 

---

## Checklist

-   [x] Local tests pass (`ak test authentik/`)
-   [x] The code has been formatted (`make lint-fix`)

If an API change has been made

-   [x] The API schema has been updated (`make gen-build`)

If changes to the frontend have been made

-   [x] The code has been formatted (`make web`)

If applicable

-   [ ] The documentation has been updated
-   [ ] The documentation has been formatted (`make website`)


Problem statement:
LDAP Users and groups not removed from Authentik when removed from LDAP
**Describe the bug**
Users and groups continue to exist after deleting them from LDAP

**To Reproduce**
Steps to reproduce the behavior:
1. Create User in LDAP
2. Sync LDAP source
3. Delete User in LDAP
4. Sync LDAP source again

**Expected behavior**
User and or Group should be removed from Authentik when the LDAP provider no longer returns them from the query


**Version and Deployment (please complete the following information):**
 - authentik version: 2021.9.8
 - Deployment: docker-compose

**Additional context**
Initially discovered when altering LDAP user filter to remove some unneeded accounts

Hints from issue discussion:
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

Any update about this ?
Is this by design? Is the LDAP synchronization only a one-time import process? 
> Is this by design? Is the LDAP synchronization only a one-time import process?

LDAP keeps syncing so new users and changes are reflected. It is not a one-time process. The issue is about removing users.
To solve this Authentik would have to store the source where a user came from in the attributes. After each sync Authentik would then have to check every user with that attribute whether it is still included in the new set of users synced from LDAP. With Postgres jsonb which is used for attributes this should not be too big of a performance impact. Otherwise this might require a separate table.

There is also the question what to do when a user which was manually created is then also existing in an LDAP sync? Should that be persisted once it is no longer part of the LDAP sync or should it then be removed?

Regardless, this is an important issue and one we are currently debating how we feel about it before we roll out Authentik to production. In our case all users come from LDAP so a simplified version or in the worst case some hacky cron job might do the trick for us.
I personally have a simple script that runs periodically and checks the list of users in a given LDAP provider (by slug) against the users in a Authentik user path. Authentik users not found in the LDAP server are disabled.

My use case and setup are very basic, where LDAP is the source of true. Only one LDAP server and a fixed user path for them in Authentik.

Would love to retire my script and have an option in Authentik to 'disable' (or delete) users from that LDAP server when they no longer exists at the source
would you mind to share how you realized the script to point my into the right direction to apply this workaround myself.
Is it running in authentik or a cronjob?

Im also very much in need of that feature. if someone has a working script to do that via cron or anything else in the meantime i would be very grateful.

after spending some time with the ldap sync and then realizing that i can't implement it because of that missing feature is a bummer.

as of right now i can limit everything to groups and once a user is deleted in the ldap backend, the user is getting removed from all groups, but is left in authentik. so access is now denied but i have no way of finding and deleting all these orphans now...

Commit message: sources/ldap: add forward deletion option

Patch to review:
diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py
index 167bb2059aa1..cc37ef77ef8e 100644
--- a/authentik/sources/ldap/api.py
+++ b/authentik/sources/ldap/api.py
@@ -111,6 +111,7 @@ class Meta:
             "sync_parent_group",
             "connectivity",
             "lookup_groups_from_user",
+            "delete_not_found_objects",
         ]
         extra_kwargs = {"bind_password": {"write_only": True}}
 
@@ -147,6 +148,7 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
         "user_property_mappings",
         "group_property_mappings",
         "lookup_groups_from_user",
+        "delete_not_found_objects",
     ]
     search_fields = ["name", "slug"]
     ordering = ["name"]
diff --git a/authentik/sources/ldap/migrations/0009_groupldapsourceconnection_validated_by_and_more.py b/authentik/sources/ldap/migrations/0009_groupldapsourceconnection_validated_by_and_more.py
new file mode 100644
index 000000000000..4bdedd702d56
--- /dev/null
+++ b/authentik/sources/ldap/migrations/0009_groupldapsourceconnection_validated_by_and_more.py
@@ -0,0 +1,36 @@
+# Generated by Django 5.1.9 on 2025-05-27 13:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("authentik_sources_ldap", "0008_groupldapsourceconnection_userldapsourceconnection"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="groupldapsourceconnection",
+            name="validated_by",
+            field=models.UUIDField(
+                blank=True, help_text="Helper field for batch deletions", null=True
+            ),
+        ),
+        migrations.AddField(
+            model_name="ldapsource",
+            name="delete_not_found_objects",
+            field=models.BooleanField(
+                blank=True,
+                default=False,
+                help_text="Delete authentik users and groups which were previously supplied by this source, but are now missing from it.",
+            ),
+        ),
+        migrations.AddField(
+            model_name="userldapsourceconnection",
+            name="validated_by",
+            field=models.UUIDField(
+                blank=True, help_text="Helper field for batch deletions", null=True
+            ),
+        ),
+    ]
diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py
index da4e30898224..c6a2a8aded79 100644
--- a/authentik/sources/ldap/models.py
+++ b/authentik/sources/ldap/models.py
@@ -137,6 +137,15 @@ class LDAPSource(Source):
         ),
     )
 
+    delete_not_found_objects = models.BooleanField(
+        default=False,
+        blank=True,
+        help_text=_(
+            "Delete authentik users and groups which were previously supplied by this source, "
+            "but are now missing from it."
+        ),
+    )
+
     @property
     def component(self) -> str:
         return "ak-source-ldap-form"
@@ -321,6 +330,10 @@ class Meta:
 
 
 class UserLDAPSourceConnection(UserSourceConnection):
+    validated_by = models.UUIDField(
+        null=True, blank=True, help_text=_("Helper field for batch deletions")
+    )
+
     @property
     def serializer(self) -> type[Serializer]:
         from authentik.sources.ldap.api import (
@@ -335,6 +348,10 @@ class Meta:
 
 
 class GroupLDAPSourceConnection(GroupSourceConnection):
+    validated_by = models.UUIDField(
+        null=True, blank=True, help_text=_("Helper field for batch deletions")
+    )
+
     @property
     def serializer(self) -> type[Serializer]:
         from authentik.sources.ldap.api import (
diff --git a/authentik/sources/ldap/sync/base.py b/authentik/sources/ldap/sync/base.py
index 5fa7d699bf1a..872bfb3171e5 100644
--- a/authentik/sources/ldap/sync/base.py
+++ b/authentik/sources/ldap/sync/base.py
@@ -9,7 +9,7 @@
 from authentik.core.sources.mapper import SourceMapper
 from authentik.lib.config import CONFIG
 from authentik.lib.sync.mapper import PropertyMappingManager
-from authentik.sources.ldap.models import LDAPSource
+from authentik.sources.ldap.models import LDAPSource, flatten
 
 
 class BaseLDAPSynchronizer:
@@ -77,6 +77,12 @@ def get_objects(self, **kwargs) -> Generator:
         """Get objects from LDAP, implemented in subclass"""
         raise NotImplementedError()
 
+    def get_identifier(self, object):
+        attributes = object.get("attributes", {})
+        if not attributes.get(self._source.object_uniqueness_field):
+            return
+        return flatten(attributes[self._source.object_uniqueness_field])
+
     def search_paginator(  # noqa: PLR0913
         self,
         search_base,
diff --git a/authentik/sources/ldap/sync/forward_delete_groups.py b/authentik/sources/ldap/sync/forward_delete_groups.py
new file mode 100644
index 000000000000..70aa3f6a50b7
--- /dev/null
+++ b/authentik/sources/ldap/sync/forward_delete_groups.py
@@ -0,0 +1,59 @@
+from collections.abc import Generator
+from itertools import batched
+from uuid import uuid4
+
+from ldap3 import SUBTREE
+
+from authentik.core.models import Group
+from authentik.sources.ldap.models import GroupLDAPSourceConnection
+from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
+from authentik.sources.ldap.sync.forward_delete_users import DELETE_CHUNK_SIZE, UPDATE_CHUNK_SIZE
+
+
+class GroupLDAPForwardDeletion(BaseLDAPSynchronizer):
+    """Delete LDAP Groups from authentik"""
+
+    @staticmethod
+    def name() -> str:
+        return "group_deletions"
+
+    def get_objects(self, **kwargs) -> Generator:
+        if not self._source.sync_groups or not self._source.delete_not_found_objects:
+            self.message("Group syncing is disabled for this Source")
+            return iter(())
+
+        uuid = uuid4()
+        groups = self._source.connection().extend.standard.paged_search(
+            search_base=self.base_dn_groups,
+            search_filter=self._source.group_object_filter,
+            search_scope=SUBTREE,
+            attributes=[self._source.object_uniqueness_field],
+            generator=True,
+            **kwargs,
+        )
+        for batch in batched(groups, UPDATE_CHUNK_SIZE, strict=False):
+            identifiers = []
+            for group in batch:
+                if identifier := self.get_identifier(group):
+                    identifiers.append(identifier)
+            GroupLDAPSourceConnection.objects.filter(identifier__in=identifiers).update(
+                validated_by=uuid
+            )
+
+        return batched(
+            GroupLDAPSourceConnection.objects.filter(source=self._source)
+            .exclude(validated_by=uuid)
+            .values_list("group", flat=True)
+            .iterator(chunk_size=DELETE_CHUNK_SIZE),
+            DELETE_CHUNK_SIZE,
+            strict=False,
+        )
+
+    def sync(self, group_pks: tuple) -> int:
+        """Delete authentik groups"""
+        if not self._source.sync_groups or not self._source.delete_not_found_objects:
+            self.message("Group syncing is disabled for this Source")
+            return -1
+        self._logger.debug("Deleting groups", group_pks=group_pks)
+        _, deleted_per_type = Group.objects.filter(pk__in=group_pks).delete()
+        return deleted_per_type.get(Group._meta.label, 0)
diff --git a/authentik/sources/ldap/sync/forward_delete_users.py b/authentik/sources/ldap/sync/forward_delete_users.py
new file mode 100644
index 000000000000..b38718ed0cfa
--- /dev/null
+++ b/authentik/sources/ldap/sync/forward_delete_users.py
@@ -0,0 +1,61 @@
+from collections.abc import Generator
+from itertools import batched
+from uuid import uuid4
+
+from ldap3 import SUBTREE
+
+from authentik.core.models import User
+from authentik.sources.ldap.models import UserLDAPSourceConnection
+from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
+
+UPDATE_CHUNK_SIZE = 10_000
+DELETE_CHUNK_SIZE = 50
+
+
+class UserLDAPForwardDeletion(BaseLDAPSynchronizer):
+    """Delete LDAP Users from authentik"""
+
+    @staticmethod
+    def name() -> str:
+        return "user_deletions"
+
+    def get_objects(self, **kwargs) -> Generator:
+        if not self._source.sync_users or not self._source.delete_not_found_objects:
+            self.message("User syncing is disabled for this Source")
+            return iter(())
+
+        uuid = uuid4()
+        users = self._source.connection().extend.standard.paged_search(
+            search_base=self.base_dn_users,
+            search_filter=self._source.user_object_filter,
+            search_scope=SUBTREE,
+            attributes=[self._source.object_uniqueness_field],
+            generator=True,
+            **kwargs,
+        )
+        for batch in batched(users, UPDATE_CHUNK_SIZE, strict=False):
+            identifiers = []
+            for user in batch:
+                if identifier := self.get_identifier(user):
+                    identifiers.append(identifier)
+            UserLDAPSourceConnection.objects.filter(identifier__in=identifiers).update(
+                validated_by=uuid
+            )
+
+        return batched(
+            UserLDAPSourceConnection.objects.filter(source=self._source)
+            .exclude(validated_by=uuid)
+            .values_list("user", flat=True)
+            .iterator(chunk_size=DELETE_CHUNK_SIZE),
+            DELETE_CHUNK_SIZE,
+            strict=False,
+        )
+
+    def sync(self, user_pks: tuple) -> int:
+        """Delete authentik users"""
+        if not self._source.sync_users or not self._source.delete_not_found_objects:
+            self.message("User syncing is disabled for this Source")
+            return -1
+        self._logger.debug("Deleting users", user_pks=user_pks)
+        _, deleted_per_type = User.objects.filter(pk__in=user_pks).delete()
+        return deleted_per_type.get(User._meta.label, 0)
diff --git a/authentik/sources/ldap/tasks.py b/authentik/sources/ldap/tasks.py
index 2f0547a6ab9e..38517804056c 100644
--- a/authentik/sources/ldap/tasks.py
+++ b/authentik/sources/ldap/tasks.py
@@ -17,6 +17,8 @@
 from authentik.root.celery import CELERY_APP
 from authentik.sources.ldap.models import LDAPSource
 from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
+from authentik.sources.ldap.sync.forward_delete_groups import GroupLDAPForwardDeletion
+from authentik.sources.ldap.sync.forward_delete_users import UserLDAPForwardDeletion
 from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
 from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
 from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
@@ -52,11 +54,11 @@ def ldap_connectivity_check(pk: str | None = None):
 
 
 @CELERY_APP.task(
-    # We take the configured hours timeout time by 2.5 as we run user and
-    # group in parallel and then membership, so 2x is to cover the serial tasks,
+    # We take the configured hours timeout time by 3.5 as we run user and
+    # group in parallel and then membership, then deletions, so 3x is to cover the serial tasks,
     # and 0.5x on top of that to give some more leeway
-    soft_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 2.5,
-    task_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 2.5,
+    soft_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 3.5,
+    task_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 3.5,
 )
 def ldap_sync_single(source_pk: str):
     """Sync a single source"""
@@ -79,6 +81,25 @@ def ldap_sync_single(source_pk: str):
             group(
                 ldap_sync_paginator(source, MembershipLDAPSynchronizer),
             ),
+            # Finally, deletions. What we'd really like to do here is something like
+            # ```
+            # user_identifiers = <ldap query>
+            # User.objects.exclude(
+            #     usersourceconnection__identifier__in=user_uniqueness_identifiers,
+            # ).delete()
+            # ```
+            # This runs into performance issues in large installations. So instead we spread the
+            # work out into three steps:
+            # 1. Get every object from the LDAP source.
+            # 2. Mark every object as "safe" in the database. This is quick, but any error could
+            #    mean deleting users which should not be deleted, so we do it immediately, in
+            #    large chunks, and only queue the deletion step afterwards.
+            # 3. Delete every unmarked item. This is slow, so we spread it over many tasks in
+            #    small chunks.
+            group(
+                ldap_sync_paginator(source, UserLDAPForwardDeletion)
+                + ldap_sync_paginator(source, GroupLDAPForwardDeletion),
+            ),
         )
         task()
 
diff --git a/authentik/sources/ldap/tests/test_sync.py b/authentik/sources/ldap/tests/test_sync.py
index 951b5805b80d..f64727548fea 100644
--- a/authentik/sources/ldap/tests/test_sync.py
+++ b/authentik/sources/ldap/tests/test_sync.py
@@ -13,7 +13,13 @@
 from authentik.lib.generators import generate_id, generate_key
 from authentik.lib.sync.outgoing.exceptions import StopSync
 from authentik.lib.utils.reflection import class_to_path
-from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
+from authentik.sources.ldap.models import (
+    GroupLDAPSourceConnection,
+    LDAPSource,
+    LDAPSourcePropertyMapping,
+    UserLDAPSourceConnection,
+)
+from authentik.sources.ldap.sync.forward_delete_users import DELETE_CHUNK_SIZE
 from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
 from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
 from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
@@ -308,3 +314,124 @@ def test_tasks_openldap(self):
         connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
         with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
             ldap_sync_all.delay().get()
+
+    def test_user_deletion(self):
+        """Test user deletion"""
+        user = User.objects.create_user(username="not-in-the-source")
+        UserLDAPSourceConnection.objects.create(
+            user=user, source=self.source, identifier="not-in-the-source"
+        )
+        self.source.object_uniqueness_field = "uid"
+        self.source.group_object_filter = "(objectClass=groupOfNames)"
+        self.source.delete_not_found_objects = True
+        self.source.save()
+
+        connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
+        with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
+            ldap_sync_all.delay().get()
+        self.assertFalse(User.objects.filter(username="not-in-the-source").exists())
+
+    def test_user_deletion_no_sync(self):
+        """Test that user is not deleted if sync_users is False"""
+        user = User.objects.create_user(username="not-in-the-source")
+        UserLDAPSourceConnection.objects.create(
+            user=user, source=self.source, identifier="not-in-the-source"
+        )
+        self.source.object_uniqueness_field = "uid"
+        self.source.group_object_filter = "(objectClass=groupOfNames)"
+        self.source.delete_not_found_objects = True
+        self.source.sync_users = False
+        self.source.save()
+
+        connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
+        with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
+            ldap_sync_all.delay().get()
+        self.assertTrue(User.objects.filter(username="not-in-the-source").exists())
+
+    def test_user_deletion_no_delete(self):
+        """Test that user is not deleted if delete_not_found_objects is False"""
+        user = User.objects.create_user(username="not-in-the-source")
+        UserLDAPSourceConnection.objects.create(
+            user=user, source=self.source, identifier="not-in-the-source"
+        )
+        self.source.object_uniqueness_field = "uid"
+        self.source.group_object_filter = "(objectClass=groupOfNames)"
+        self.source.save()
+
+        connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
+        with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
+            ldap_sync_all.delay().get()
+        self.assertTrue(User.objects.filter(username="not-in-the-source").exists())
+
+    def test_group_deletion(self):
+        """Test group deletion"""
+        group = Group.objects.create(name="not-in-the-source")
+        GroupLDAPSourceConnection.objects.create(
+            group=group, source=self.source, identifier="not-in-the-source"
+        )
+        self.source.object_uniqueness_field = "uid"
+        self.source.group_object_filter = "(objectClass=groupOfNames)"
+        self.source.delete_not_found_objects = True
+        self.source.save()
+
+        connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
+        with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
+            ldap_sync_all.delay().get()
+        self.assertFalse(Group.objects.filter(name="not-in-the-source").exists())
+
+    def test_group_deletion_no_sync(self):
+        """Test that group is not deleted if sync_groups is False"""
+        group = Group.objects.create(name="not-in-the-source")
+        GroupLDAPSourceConnection.objects.create(
+            group=group, source=self.source, identifier="not-in-the-source"
+        )
+        self.source.object_uniqueness_field = "uid"
+        self.source.group_object_filter = "(objectClass=groupOfNames)"
+        self.source.delete_not_found_objects = True
+        self.source.sync_groups = False
+        self.source.save()
+
+        connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
+        with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
+            ldap_sync_all.delay().get()
+        self.assertTrue(Group.objects.filter(name="not-in-the-source").exists())
+
+    def test_group_deletion_no_delete(self):
+        """Test that group is not deleted if delete_not_found_objects is False"""
+        group = Group.objects.create(name="not-in-the-source")
+        GroupLDAPSourceConnection.objects.create(
+            group=group, source=self.source, identifier="not-in-the-source"
+        )
+        self.source.object_uniqueness_field = "uid"
+        self.source.group_object_filter = "(objectClass=groupOfNames)"
+        self.source.save()
+
+        connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
+        with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
+            ldap_sync_all.delay().get()
+        self.assertTrue(Group.objects.filter(name="not-in-the-source").exists())
+
+    def test_batch_deletion(self):
+        """Test batch deletion"""
+        BATCH_SIZE = DELETE_CHUNK_SIZE + 1
+        for i in range(BATCH_SIZE):
+            user = User.objects.create_user(username=f"not-in-the-source-{i}")
+            group = Group.objects.create(name=f"not-in-the-source-{i}")
+            group.users.add(user)
+            UserLDAPSourceConnection.objects.create(
+                user=user, source=self.source, identifier=f"not-in-the-source-{i}-user"
+            )
+            GroupLDAPSourceConnection.objects.create(
+                group=group, source=self.source, identifier=f"not-in-the-source-{i}-group"
+            )
+        self.source.object_uniqueness_field = "uid"
+        self.source.group_object_filter = "(objectClass=groupOfNames)"
+        self.source.delete_not_found_objects = True
+        self.source.save()
+
+        connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
+        with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
+            ldap_sync_all.delay().get()
+
+        self.assertFalse(User.objects.filter(username__startswith="not-in-the-source").exists())
+        self.assertFalse(Group.objects.filter(name__startswith="not-in-the-source").exists())
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 437f23172e32..f4a87c9c4622 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -8180,6 +8180,11 @@
                     "type": "boolean",
                     "title": "Lookup groups from user",
                     "description": "Lookup group membership based on a user attribute instead of a group attribute. This allows nested group resolution on systems like FreeIPA and Active Directory"
+                },
+                "delete_not_found_objects": {
+                    "type": "boolean",
+                    "title": "Delete not found objects",
+                    "description": "Delete authentik users and groups which were previously supplied by this source, but are now missing from it."
                 }
             },
             "required": []
diff --git a/schema.yml b/schema.yml
index 8974c0f0dca8..84687dadd509 100644
--- a/schema.yml
+++ b/schema.yml
@@ -28473,6 +28473,10 @@ paths:
         schema:
           type: string
           format: uuid
+      - in: query
+        name: delete_not_found_objects
+        schema:
+          type: boolean
       - in: query
         name: enabled
         schema:
@@ -47922,6 +47926,10 @@ components:
           description: Lookup group membership based on a user attribute instead of
             a group attribute. This allows nested group resolution on systems like
             FreeIPA and Active Directory
+        delete_not_found_objects:
+          type: boolean
+          description: Delete authentik users and groups which were previously supplied
+            by this source, but are now missing from it.
       required:
       - base_dn
       - component
@@ -48123,6 +48131,10 @@ components:
           description: Lookup group membership based on a user attribute instead of
             a group attribute. This allows nested group resolution on systems like
             FreeIPA and Active Directory
+        delete_not_found_objects:
+          type: boolean
+          description: Delete authentik users and groups which were previously supplied
+            by this source, but are now missing from it.
       required:
       - base_dn
       - name
@@ -53456,6 +53468,10 @@ components:
           description: Lookup group membership based on a user attribute instead of
             a group attribute. This allows nested group resolution on systems like
             FreeIPA and Active Directory
+        delete_not_found_objects:
+          type: boolean
+          description: Delete authentik users and groups which were previously supplied
+            by this source, but are now missing from it.
     PatchedLicenseRequest:
       type: object
       description: License Serializer
diff --git a/web/src/admin/sources/ldap/LDAPSourceForm.ts b/web/src/admin/sources/ldap/LDAPSourceForm.ts
index fe4ba4dd91e4..69d4d9d318d1 100644
--- a/web/src/admin/sources/ldap/LDAPSourceForm.ts
+++ b/web/src/admin/sources/ldap/LDAPSourceForm.ts
@@ -148,6 +148,26 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
                     <span class="pf-c-switch__label">${msg("Sync groups")}</span>
                 </label>
             </ak-form-element-horizontal>
+            <ak-form-element-horizontal name="deleteNotFoundObjects">
+                <label class="pf-c-switch">
+                    <input
+                        class="pf-c-switch__input"
+                        type="checkbox"
+                        ?checked=${this.instance?.deleteNotFoundObjects ?? false}
+                    />
+                    <span class="pf-c-switch__toggle">
+                        <span class="pf-c-switch__toggle-icon">
+                            <i class="fas fa-check" aria-hidden="true"></i>
+                        </span>
+                    </span>
+                    <span class="pf-c-switch__label">${msg("Delete Not Found Objects")}</span>
+                </label>
+                <p class="pf-c-form__helper-text">
+                    ${msg(
+                        "Delete authentik users and groups which were previously supplied by this source, but are now missing from it.",
+                    )}
+                </p>
+            </ak-form-element-horizontal>
             <ak-form-group .expanded=${true}>
                 <span slot="header"> ${msg("Connection settings")} </span>
                 <div slot="body" class="pf-c-form">


Code review comments:
diff_hunk: @@ -77,6 +77,12 @@ def get_objects(self, **kwargs) -> Generator:
         """Get objects from LDAP, implemented in subclass"""
         raise NotImplementedError()
 
+    def get_identifier(self, object):
+        attributes = object.get("attributes", {})
+        if not attributes.get(self._source.object_uniqueness_field):
+            return
+        return flatten(attributes[self._source.object_uniqueness_field])
path: authentik/sources/ldap/sync/base.py
text: @user1:
This should also be used in other places, like authentik.sources.ldap.sync.users.UserLDAPSynchronizer.sync

@author:
Yeah, maybe? I did my best, see if you like it.

I'm not sure what that additional `in` check is for (untested code introduced in deb91bd12bdf3fc11807f5d76ea81e883739f358 -- should it be something like an `isinstance(object, dict)` check instead?), but I'm not about to find out, so I did my best to keep the exact same logic for already existing code.

diff_hunk: @@ -137,6 +137,15 @@ class LDAPSource(Source):
         ),
     )
 
+    delete_not_found_objects = models.BooleanField(
+        default=False,
+        blank=True,
path: authentik/sources/ldap/models.py
text: @user1:
```suggestion
```

I don't think we need this for a boolean field?

@author:
Thank you, removed.

diff_hunk: @@ -335,6 +348,10 @@ class Meta:
 
 
 class GroupLDAPSourceConnection(GroupSourceConnection):
+    validated_by = models.UUIDField(
path: authentik/sources/ldap/models.py
text: @user1:
should be indexed

@author:
Thank you, done!

diff_hunk: @@ -321,6 +330,10 @@ class Meta:
 
 
 class UserLDAPSourceConnection(UserSourceConnection):
+    validated_by = models.UUIDField(
+        null=True, blank=True, help_text=_("Helper field for batch deletions")
path: authentik/sources/ldap/models.py
text: @user1:
```suggestion
        null=True, blank=True, help_text=_("Unique ID used while checking if this object still exists in the directory.")
```

@author:
Done here and in `GroupLDAPSourceConnection`.

diff_hunk: @@ -321,6 +330,10 @@ class Meta:
 
 
 class UserLDAPSourceConnection(UserSourceConnection):
+    validated_by = models.UUIDField(
path: authentik/sources/ldap/models.py
text: @user1:
should be indexed

@author:
Thank you, done!