Skip to content

Commit 8f3de8d

Browse files
committed
sources/ldap: add forward deletion option
1 parent c6333f9 commit 8f3de8d

12 files changed

+257
-5
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 5.1.9 on 2025-05-27 11:18
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("authentik_core", "0048_delete_oldauthenticatedsession_content_type"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="groupsourceconnection",
15+
name="validated_by",
16+
field=models.UUIDField(
17+
blank=True, help_text="Helper field for batch deletions", null=True
18+
),
19+
),
20+
migrations.AddField(
21+
model_name="usersourceconnection",
22+
name="validated_by",
23+
field=models.UUIDField(
24+
blank=True, help_text="Helper field for batch deletions", null=True
25+
),
26+
),
27+
]

authentik/core/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,9 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
841841
user = models.ForeignKey(User, on_delete=models.CASCADE)
842842
source = models.ForeignKey(Source, on_delete=models.CASCADE)
843843
identifier = models.TextField()
844+
validated_by = models.UUIDField(
845+
null=True, blank=True, help_text=_("Helper field for batch deletions")
846+
)
844847

845848
objects = InheritanceManager()
846849

@@ -866,6 +869,9 @@ class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
866869
group = models.ForeignKey(Group, on_delete=models.CASCADE)
867870
source = models.ForeignKey(Source, on_delete=models.CASCADE)
868871
identifier = models.TextField()
872+
validated_by = models.UUIDField(
873+
null=True, blank=True, help_text=_("Helper field for batch deletions")
874+
)
869875

870876
objects = InheritanceManager()
871877

authentik/sources/ldap/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class Meta:
111111
"sync_parent_group",
112112
"connectivity",
113113
"lookup_groups_from_user",
114+
"delete_not_found_objects",
114115
]
115116
extra_kwargs = {"bind_password": {"write_only": True}}
116117

@@ -147,6 +148,7 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
147148
"user_property_mappings",
148149
"group_property_mappings",
149150
"lookup_groups_from_user",
151+
"delete_not_found_objects",
150152
]
151153
search_fields = ["name", "slug"]
152154
ordering = ["name"]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 5.1.9 on 2025-05-27 12:10
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("authentik_sources_ldap", "0008_groupldapsourceconnection_userldapsourceconnection"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="ldapsource",
15+
name="delete_not_found_objects",
16+
field=models.BooleanField(
17+
blank=True,
18+
default=False,
19+
help_text="Delete authentik users and groups which were previously supplied by this source, but are now missing from it.",
20+
),
21+
),
22+
]

authentik/sources/ldap/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,15 @@ class LDAPSource(Source):
137137
),
138138
)
139139

140+
delete_not_found_objects = models.BooleanField(
141+
default=False,
142+
blank=True,
143+
help_text=_(
144+
"Delete authentik users and groups which were previously supplied by this source, "
145+
"but are now missing from it."
146+
),
147+
)
148+
140149
@property
141150
def component(self) -> str:
142151
return "ak-source-ldap-form"

authentik/sources/ldap/sync/base.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from authentik.core.sources.mapper import SourceMapper
1010
from authentik.lib.config import CONFIG
1111
from authentik.lib.sync.mapper import PropertyMappingManager
12-
from authentik.sources.ldap.models import LDAPSource
12+
from authentik.sources.ldap.models import LDAPSource, flatten
1313

1414

1515
class BaseLDAPSynchronizer:
@@ -77,6 +77,12 @@ def get_objects(self, **kwargs) -> Generator:
7777
"""Get objects from LDAP, implemented in subclass"""
7878
raise NotImplementedError()
7979

80+
def get_identifier(self, object):
81+
attributes = object.get("attributes", {})
82+
if not attributes.get(self._source.object_uniqueness_field):
83+
return
84+
return flatten(attributes[self._source.object_uniqueness_field])
85+
8086
def search_paginator( # noqa: PLR0913
8187
self,
8288
search_base,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from collections.abc import Generator
2+
from itertools import batched
3+
from uuid import uuid4
4+
5+
from ldap3 import SUBTREE
6+
7+
from authentik.core.models import Group, GroupSourceConnection
8+
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
9+
from authentik.sources.ldap.sync.forward_delete_users import DELETE_CHUNK_SIZE, UPDATE_CHUNK_SIZE
10+
11+
12+
class GroupLDAPForwardDeletion(BaseLDAPSynchronizer):
13+
"""Delete LDAP Groups from authentik"""
14+
15+
@staticmethod
16+
def name() -> str:
17+
return "group_deletions"
18+
19+
def get_objects(self, **kwargs) -> Generator:
20+
if not self._source.sync_groups:
21+
self.message("Group syncing is disabled for this Source")
22+
return iter(())
23+
24+
uuid = uuid4()
25+
groups = self._source.connection().extend.standard.paged_search(
26+
search_base=self.base_dn_groups,
27+
search_filter=self._source.group_object_filter,
28+
search_scope=SUBTREE,
29+
attributes=[self._source.object_uniqueness_field],
30+
generator=True,
31+
**kwargs,
32+
)
33+
for batch in batched(groups, UPDATE_CHUNK_SIZE, strict=False):
34+
identifiers = []
35+
for group in batch:
36+
if identifier := self.get_identifier(group):
37+
identifiers.append(identifier)
38+
GroupSourceConnection.objects.filter(identifier__in=identifiers).update(
39+
validated_by=uuid
40+
)
41+
42+
return batched(
43+
Group.objects.filter(groupsourceconnection__source=self._source)
44+
.exclude(groupsourceconnection__validated_by=uuid)
45+
.values_list("pk", flat=True)
46+
.iterator(chunk_size=DELETE_CHUNK_SIZE),
47+
DELETE_CHUNK_SIZE,
48+
strict=False,
49+
)
50+
51+
def sync(self, group_pks: tuple) -> int:
52+
"""Delete authentik groups"""
53+
if not self._source.sync_groups:
54+
self.message("Group syncing is disabled for this Source")
55+
return -1
56+
self._logger.debug("Deleting groups", group_pks=group_pks)
57+
_, deleted_per_type = Group.objects.filter(pk__in=group_pks).delete()
58+
return deleted_per_type.get(Group._meta.label, 0)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from collections.abc import Generator
2+
from itertools import batched
3+
from uuid import uuid4
4+
5+
from ldap3 import SUBTREE
6+
7+
from authentik.core.models import User, UserSourceConnection
8+
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
9+
10+
UPDATE_CHUNK_SIZE = 10_000
11+
DELETE_CHUNK_SIZE = 50
12+
13+
14+
class UserLDAPForwardDeletion(BaseLDAPSynchronizer):
15+
"""Delete LDAP Users from authentik"""
16+
17+
@staticmethod
18+
def name() -> str:
19+
return "user_deletions"
20+
21+
def get_objects(self, **kwargs) -> Generator:
22+
if not self._source.sync_users:
23+
self.message("User syncing is disabled for this Source")
24+
return iter(())
25+
26+
uuid = uuid4()
27+
users = self._source.connection().extend.standard.paged_search(
28+
search_base=self.base_dn_users,
29+
search_filter=self._source.user_object_filter,
30+
search_scope=SUBTREE,
31+
attributes=[self._source.object_uniqueness_field],
32+
generator=True,
33+
**kwargs,
34+
)
35+
for batch in batched(users, UPDATE_CHUNK_SIZE, strict=False):
36+
identifiers = []
37+
for user in batch:
38+
if identifier := self.get_identifier(user):
39+
identifiers.append(identifier)
40+
UserSourceConnection.objects.filter(identifier__in=identifiers).update(
41+
validated_by=uuid
42+
)
43+
44+
return batched(
45+
User.objects.filter(usersourceconnection__source=self._source)
46+
.exclude(usersourceconnection__validated_by=uuid)
47+
.values_list("pk", flat=True)
48+
.iterator(chunk_size=DELETE_CHUNK_SIZE),
49+
DELETE_CHUNK_SIZE,
50+
strict=False,
51+
)
52+
53+
def sync(self, user_pks: tuple) -> int:
54+
"""Delete authentik users"""
55+
if not self._source.sync_users:
56+
self.message("User syncing is disabled for this Source")
57+
return -1
58+
self._logger.debug("Deleting users", user_pks=user_pks)
59+
_, deleted_per_type = User.objects.filter(pk__in=user_pks).delete()
60+
return deleted_per_type.get(User._meta.label, 0)

authentik/sources/ldap/tasks.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from authentik.root.celery import CELERY_APP
1818
from authentik.sources.ldap.models import LDAPSource
1919
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
20+
from authentik.sources.ldap.sync.forward_delete_groups import GroupLDAPForwardDeletion
21+
from authentik.sources.ldap.sync.forward_delete_users import UserLDAPForwardDeletion
2022
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
2123
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
2224
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
@@ -52,11 +54,11 @@ def ldap_connectivity_check(pk: str | None = None):
5254

5355

5456
@CELERY_APP.task(
55-
# We take the configured hours timeout time by 2.5 as we run user and
56-
# group in parallel and then membership, so 2x is to cover the serial tasks,
57+
# We take the configured hours timeout time by 3.5 as we run user and
58+
# group in parallel and then membership, then deletions, so 3x is to cover the serial tasks,
5759
# and 0.5x on top of that to give some more leeway
58-
soft_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 2.5,
59-
task_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 2.5,
60+
soft_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 3.5,
61+
task_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 3.5,
6062
)
6163
def ldap_sync_single(source_pk: str):
6264
"""Sync a single source"""
@@ -79,6 +81,25 @@ def ldap_sync_single(source_pk: str):
7981
group(
8082
ldap_sync_paginator(source, MembershipLDAPSynchronizer),
8183
),
84+
# Finally, deletions. What we'd really like to do here is something like
85+
# ```
86+
# user_identifiers = <ldap query>
87+
# User.objects.exclude(
88+
# usersourceconnection__identifier__in=user_uniqueness_identifiers,
89+
# ).delete()
90+
# ```
91+
# This runs into performance issues in large installations. So instead we spread the
92+
# work out into three steps:
93+
# 1. Get every object from the LDAP source.
94+
# 2. Mark every object as "safe" in the database. This is quick, but any error could
95+
# mean deleting users which should not be deleted, so we do it immediately, in
96+
# large chunks, and only queue the deletion step afterwards.
97+
# 3. Delete every unmarked item. This is slow, so we spread it over many tasks in
98+
# small chunks.
99+
group(
100+
ldap_sync_paginator(source, UserLDAPForwardDeletion)
101+
+ ldap_sync_paginator(source, GroupLDAPForwardDeletion),
102+
),
82103
)
83104
task()
84105

blueprints/schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8180,6 +8180,11 @@
81808180
"type": "boolean",
81818181
"title": "Lookup groups from user",
81828182
"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"
8183+
},
8184+
"delete_not_found_objects": {
8185+
"type": "boolean",
8186+
"title": "Delete not found objects",
8187+
"description": "Delete authentik users and groups which were previously supplied by this source, but are now missing from it."
81838188
}
81848189
},
81858190
"required": []

schema.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28473,6 +28473,10 @@ paths:
2847328473
schema:
2847428474
type: string
2847528475
format: uuid
28476+
- in: query
28477+
name: delete_not_found_objects
28478+
schema:
28479+
type: boolean
2847628480
- in: query
2847728481
name: enabled
2847828482
schema:
@@ -47922,6 +47926,10 @@ components:
4792247926
description: Lookup group membership based on a user attribute instead of
4792347927
a group attribute. This allows nested group resolution on systems like
4792447928
FreeIPA and Active Directory
47929+
delete_not_found_objects:
47930+
type: boolean
47931+
description: Delete authentik users and groups which were previously supplied
47932+
by this source, but are now missing from it.
4792547933
required:
4792647934
- base_dn
4792747935
- component
@@ -48123,6 +48131,10 @@ components:
4812348131
description: Lookup group membership based on a user attribute instead of
4812448132
a group attribute. This allows nested group resolution on systems like
4812548133
FreeIPA and Active Directory
48134+
delete_not_found_objects:
48135+
type: boolean
48136+
description: Delete authentik users and groups which were previously supplied
48137+
by this source, but are now missing from it.
4812648138
required:
4812748139
- base_dn
4812848140
- name
@@ -53456,6 +53468,10 @@ components:
5345653468
description: Lookup group membership based on a user attribute instead of
5345753469
a group attribute. This allows nested group resolution on systems like
5345853470
FreeIPA and Active Directory
53471+
delete_not_found_objects:
53472+
type: boolean
53473+
description: Delete authentik users and groups which were previously supplied
53474+
by this source, but are now missing from it.
5345953475
PatchedLicenseRequest:
5346053476
type: object
5346153477
description: License Serializer

web/src/admin/sources/ldap/LDAPSourceForm.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,26 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
148148
<span class="pf-c-switch__label">${msg("Sync groups")}</span>
149149
</label>
150150
</ak-form-element-horizontal>
151+
<ak-form-element-horizontal name="deleteNotFoundObjects">
152+
<label class="pf-c-switch">
153+
<input
154+
class="pf-c-switch__input"
155+
type="checkbox"
156+
?checked=${this.instance?.deleteNotFoundObjects ?? false}
157+
/>
158+
<span class="pf-c-switch__toggle">
159+
<span class="pf-c-switch__toggle-icon">
160+
<i class="fas fa-check" aria-hidden="true"></i>
161+
</span>
162+
</span>
163+
<span class="pf-c-switch__label">${msg("Delete Not Found Objects")}</span>
164+
</label>
165+
<p class="pf-c-form__helper-text">
166+
${msg(
167+
"Delete authentik users and groups which were previously supplied by this source, but are now missing from it.",
168+
)}
169+
</p>
170+
</ak-form-element-horizontal>
151171
<ak-form-group .expanded=${true}>
152172
<span slot="header"> ${msg("Connection settings")} </span>
153173
<div slot="body" class="pf-c-form">

0 commit comments

Comments
 (0)