sources/ldap: add forward deletion option (#14718)
* sources/ldap: add forward deletion option * remove unnecessary `blank=True` * clarify `validated_by` `help_text` * add indices to `validated_by` * factor out `get_identifier` everywhere and `get_attributes` I don't know what that additional `in` check is for, but I'm not about to find out. * add tests for known good user and group * fixup! add tests for known good user and group * fixup! add tests for known good user and group
This commit is contained in:
@ -111,6 +111,7 @@ class LDAPSourceSerializer(SourceSerializer):
|
|||||||
"sync_parent_group",
|
"sync_parent_group",
|
||||||
"connectivity",
|
"connectivity",
|
||||||
"lookup_groups_from_user",
|
"lookup_groups_from_user",
|
||||||
|
"delete_not_found_objects",
|
||||||
]
|
]
|
||||||
extra_kwargs = {"bind_password": {"write_only": True}}
|
extra_kwargs = {"bind_password": {"write_only": True}}
|
||||||
|
|
||||||
@ -147,6 +148,7 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"user_property_mappings",
|
"user_property_mappings",
|
||||||
"group_property_mappings",
|
"group_property_mappings",
|
||||||
"lookup_groups_from_user",
|
"lookup_groups_from_user",
|
||||||
|
"delete_not_found_objects",
|
||||||
]
|
]
|
||||||
search_fields = ["name", "slug"]
|
search_fields = ["name", "slug"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
# Generated by Django 5.1.9 on 2025-05-28 08:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_core", "0048_delete_oldauthenticatedsession_content_type"),
|
||||||
|
("authentik_sources_ldap", "0008_groupldapsourceconnection_userldapsourceconnection"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="groupldapsourceconnection",
|
||||||
|
name="validated_by",
|
||||||
|
field=models.UUIDField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Unique ID used while checking if this object still exists in the directory.",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ldapsource",
|
||||||
|
name="delete_not_found_objects",
|
||||||
|
field=models.BooleanField(
|
||||||
|
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="Unique ID used while checking if this object still exists in the directory.",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="groupldapsourceconnection",
|
||||||
|
index=models.Index(fields=["validated_by"], name="authentik_s_validat_b70447_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="userldapsourceconnection",
|
||||||
|
index=models.Index(fields=["validated_by"], name="authentik_s_validat_ff2ebc_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -137,6 +137,14 @@ class LDAPSource(Source):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
delete_not_found_objects = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text=_(
|
||||||
|
"Delete authentik users and groups which were previously supplied by this source, "
|
||||||
|
"but are now missing from it."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def component(self) -> str:
|
def component(self) -> str:
|
||||||
return "ak-source-ldap-form"
|
return "ak-source-ldap-form"
|
||||||
@ -321,6 +329,12 @@ class LDAPSourcePropertyMapping(PropertyMapping):
|
|||||||
|
|
||||||
|
|
||||||
class UserLDAPSourceConnection(UserSourceConnection):
|
class UserLDAPSourceConnection(UserSourceConnection):
|
||||||
|
validated_by = models.UUIDField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Unique ID used while checking if this object still exists in the directory."),
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> type[Serializer]:
|
||||||
from authentik.sources.ldap.api import (
|
from authentik.sources.ldap.api import (
|
||||||
@ -332,9 +346,18 @@ class UserLDAPSourceConnection(UserSourceConnection):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("User LDAP Source Connection")
|
verbose_name = _("User LDAP Source Connection")
|
||||||
verbose_name_plural = _("User LDAP Source Connections")
|
verbose_name_plural = _("User LDAP Source Connections")
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["validated_by"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class GroupLDAPSourceConnection(GroupSourceConnection):
|
class GroupLDAPSourceConnection(GroupSourceConnection):
|
||||||
|
validated_by = models.UUIDField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Unique ID used while checking if this object still exists in the directory."),
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> type[Serializer]:
|
||||||
from authentik.sources.ldap.api import (
|
from authentik.sources.ldap.api import (
|
||||||
@ -346,3 +369,6 @@ class GroupLDAPSourceConnection(GroupSourceConnection):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Group LDAP Source Connection")
|
verbose_name = _("Group LDAP Source Connection")
|
||||||
verbose_name_plural = _("Group LDAP Source Connections")
|
verbose_name_plural = _("Group LDAP Source Connections")
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["validated_by"]),
|
||||||
|
]
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from structlog.stdlib import BoundLogger, get_logger
|
|||||||
from authentik.core.sources.mapper import SourceMapper
|
from authentik.core.sources.mapper import SourceMapper
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||||
from authentik.sources.ldap.models import LDAPSource
|
from authentik.sources.ldap.models import LDAPSource, flatten
|
||||||
|
|
||||||
|
|
||||||
class BaseLDAPSynchronizer:
|
class BaseLDAPSynchronizer:
|
||||||
@ -77,6 +77,16 @@ class BaseLDAPSynchronizer:
|
|||||||
"""Get objects from LDAP, implemented in subclass"""
|
"""Get objects from LDAP, implemented in subclass"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def get_attributes(self, object):
|
||||||
|
if "attributes" not in object:
|
||||||
|
return
|
||||||
|
return object.get("attributes", {})
|
||||||
|
|
||||||
|
def get_identifier(self, attributes: dict):
|
||||||
|
if not attributes.get(self._source.object_uniqueness_field):
|
||||||
|
return
|
||||||
|
return flatten(attributes[self._source.object_uniqueness_field])
|
||||||
|
|
||||||
def search_paginator( # noqa: PLR0913
|
def search_paginator( # noqa: PLR0913
|
||||||
self,
|
self,
|
||||||
search_base,
|
search_base,
|
||||||
|
|||||||
61
authentik/sources/ldap/sync/forward_delete_groups.py
Normal file
61
authentik/sources/ldap/sync/forward_delete_groups.py
Normal file
@ -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 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 not (attributes := self.get_attributes(group)):
|
||||||
|
continue
|
||||||
|
if identifier := self.get_identifier(attributes):
|
||||||
|
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)
|
||||||
63
authentik/sources/ldap/sync/forward_delete_users.py
Normal file
63
authentik/sources/ldap/sync/forward_delete_users.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
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 not (attributes := self.get_attributes(user)):
|
||||||
|
continue
|
||||||
|
if identifier := self.get_identifier(attributes):
|
||||||
|
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)
|
||||||
@ -58,18 +58,16 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
|||||||
return -1
|
return -1
|
||||||
group_count = 0
|
group_count = 0
|
||||||
for group in page_data:
|
for group in page_data:
|
||||||
if "attributes" not in group:
|
if (attributes := self.get_attributes(group)) is None:
|
||||||
continue
|
continue
|
||||||
attributes = group.get("attributes", {})
|
|
||||||
group_dn = flatten(flatten(group.get("entryDN", group.get("dn"))))
|
group_dn = flatten(flatten(group.get("entryDN", group.get("dn"))))
|
||||||
if not attributes.get(self._source.object_uniqueness_field):
|
if not (uniq := self.get_identifier(attributes)):
|
||||||
self.message(
|
self.message(
|
||||||
f"Uniqueness field not found/not set in attributes: '{group_dn}'",
|
f"Uniqueness field not found/not set in attributes: '{group_dn}'",
|
||||||
attributes=attributes.keys(),
|
attributes=attributes.keys(),
|
||||||
dn=group_dn,
|
dn=group_dn,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
uniq = flatten(attributes[self._source.object_uniqueness_field])
|
|
||||||
try:
|
try:
|
||||||
defaults = {
|
defaults = {
|
||||||
k: flatten(v)
|
k: flatten(v)
|
||||||
|
|||||||
@ -63,9 +63,9 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
|
|||||||
group_member_dn = group_member.get("dn", {})
|
group_member_dn = group_member.get("dn", {})
|
||||||
members.append(group_member_dn)
|
members.append(group_member_dn)
|
||||||
else:
|
else:
|
||||||
if "attributes" not in group:
|
if (attributes := self.get_attributes(group)) is None:
|
||||||
continue
|
continue
|
||||||
members = group.get("attributes", {}).get(self._source.group_membership_field, [])
|
members = attributes.get(self._source.group_membership_field, [])
|
||||||
|
|
||||||
ak_group = self.get_group(group)
|
ak_group = self.get_group(group)
|
||||||
if not ak_group:
|
if not ak_group:
|
||||||
|
|||||||
@ -60,18 +60,16 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
|
|||||||
return -1
|
return -1
|
||||||
user_count = 0
|
user_count = 0
|
||||||
for user in page_data:
|
for user in page_data:
|
||||||
if "attributes" not in user:
|
if (attributes := self.get_attributes(user)) is None:
|
||||||
continue
|
continue
|
||||||
attributes = user.get("attributes", {})
|
|
||||||
user_dn = flatten(user.get("entryDN", user.get("dn")))
|
user_dn = flatten(user.get("entryDN", user.get("dn")))
|
||||||
if not attributes.get(self._source.object_uniqueness_field):
|
if not (uniq := self.get_identifier(attributes)):
|
||||||
self.message(
|
self.message(
|
||||||
f"Uniqueness field not found/not set in attributes: '{user_dn}'",
|
f"Uniqueness field not found/not set in attributes: '{user_dn}'",
|
||||||
attributes=attributes.keys(),
|
attributes=attributes.keys(),
|
||||||
dn=user_dn,
|
dn=user_dn,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
uniq = flatten(attributes[self._source.object_uniqueness_field])
|
|
||||||
try:
|
try:
|
||||||
defaults = {
|
defaults = {
|
||||||
k: flatten(v)
|
k: flatten(v)
|
||||||
|
|||||||
@ -17,6 +17,8 @@ from authentik.lib.utils.reflection import class_to_path, path_to_class
|
|||||||
from authentik.root.celery import CELERY_APP
|
from authentik.root.celery import CELERY_APP
|
||||||
from authentik.sources.ldap.models import LDAPSource
|
from authentik.sources.ldap.models import LDAPSource
|
||||||
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
|
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.groups import GroupLDAPSynchronizer
|
||||||
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
||||||
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||||
@ -52,11 +54,11 @@ def ldap_connectivity_check(pk: str | None = None):
|
|||||||
|
|
||||||
|
|
||||||
@CELERY_APP.task(
|
@CELERY_APP.task(
|
||||||
# We take the configured hours timeout time by 2.5 as we run user and
|
# We take the configured hours timeout time by 3.5 as we run user and
|
||||||
# group in parallel and then membership, so 2x is to cover the serial tasks,
|
# 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
|
# 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,
|
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")) * 2.5,
|
task_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 3.5,
|
||||||
)
|
)
|
||||||
def ldap_sync_single(source_pk: str):
|
def ldap_sync_single(source_pk: str):
|
||||||
"""Sync a single source"""
|
"""Sync a single source"""
|
||||||
@ -79,6 +81,25 @@ def ldap_sync_single(source_pk: str):
|
|||||||
group(
|
group(
|
||||||
ldap_sync_paginator(source, MembershipLDAPSynchronizer),
|
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()
|
task()
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,33 @@
|
|||||||
|
|
||||||
from ldap3 import MOCK_SYNC, OFFLINE_SLAPD_2_4, Connection, Server
|
from ldap3 import MOCK_SYNC, OFFLINE_SLAPD_2_4, Connection, Server
|
||||||
|
|
||||||
|
# The mock modifies these in place, so we have to define them per string
|
||||||
|
user_in_slapd_dn = "cn=user_in_slapd_cn,ou=users,dc=goauthentik,dc=io"
|
||||||
|
user_in_slapd_cn = "user_in_slapd_cn"
|
||||||
|
user_in_slapd_uid = "user_in_slapd_uid"
|
||||||
|
user_in_slapd_object_class = "person"
|
||||||
|
user_in_slapd = {
|
||||||
|
"dn": user_in_slapd_dn,
|
||||||
|
"attributes": {
|
||||||
|
"cn": user_in_slapd_cn,
|
||||||
|
"uid": user_in_slapd_uid,
|
||||||
|
"objectClass": user_in_slapd_object_class,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
group_in_slapd_dn = "cn=user_in_slapd_cn,ou=groups,dc=goauthentik,dc=io"
|
||||||
|
group_in_slapd_cn = "group_in_slapd_cn"
|
||||||
|
group_in_slapd_uid = "group_in_slapd_uid"
|
||||||
|
group_in_slapd_object_class = "groupOfNames"
|
||||||
|
group_in_slapd = {
|
||||||
|
"dn": group_in_slapd_dn,
|
||||||
|
"attributes": {
|
||||||
|
"cn": group_in_slapd_cn,
|
||||||
|
"uid": group_in_slapd_uid,
|
||||||
|
"objectClass": group_in_slapd_object_class,
|
||||||
|
"member": [user_in_slapd["dn"]],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def mock_slapd_connection(password: str) -> Connection:
|
def mock_slapd_connection(password: str) -> Connection:
|
||||||
"""Create mock SLAPD connection"""
|
"""Create mock SLAPD connection"""
|
||||||
@ -96,5 +123,14 @@ def mock_slapd_connection(password: str) -> Connection:
|
|||||||
"objectClass": "posixAccount",
|
"objectClass": "posixAccount",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# Known user and group
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
user_in_slapd["dn"],
|
||||||
|
user_in_slapd["attributes"],
|
||||||
|
)
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
group_in_slapd["dn"],
|
||||||
|
group_in_slapd["attributes"],
|
||||||
|
)
|
||||||
connection.bind()
|
connection.bind()
|
||||||
return connection
|
return connection
|
||||||
|
|||||||
@ -13,14 +13,26 @@ from authentik.events.system_tasks import TaskStatus
|
|||||||
from authentik.lib.generators import generate_id, generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.lib.sync.outgoing.exceptions import StopSync
|
from authentik.lib.sync.outgoing.exceptions import StopSync
|
||||||
from authentik.lib.utils.reflection import class_to_path
|
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.groups import GroupLDAPSynchronizer
|
||||||
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
||||||
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||||
from authentik.sources.ldap.tasks import ldap_sync, ldap_sync_all
|
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_ad import mock_ad_connection
|
||||||
from authentik.sources.ldap.tests.mock_freeipa import mock_freeipa_connection
|
from authentik.sources.ldap.tests.mock_freeipa import mock_freeipa_connection
|
||||||
from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection
|
from authentik.sources.ldap.tests.mock_slapd import (
|
||||||
|
group_in_slapd_cn,
|
||||||
|
group_in_slapd_uid,
|
||||||
|
mock_slapd_connection,
|
||||||
|
user_in_slapd_cn,
|
||||||
|
user_in_slapd_uid,
|
||||||
|
)
|
||||||
|
|
||||||
LDAP_PASSWORD = generate_key()
|
LDAP_PASSWORD = generate_key()
|
||||||
|
|
||||||
@ -308,3 +320,160 @@ class LDAPSyncTests(TestCase):
|
|||||||
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
|
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
|
||||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||||
ldap_sync_all.delay().get()
|
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_still_in_source(self):
|
||||||
|
"""Test that user is not deleted if it's still in the source"""
|
||||||
|
username = user_in_slapd_cn
|
||||||
|
identifier = user_in_slapd_uid
|
||||||
|
user = User.objects.create_user(username=username)
|
||||||
|
UserLDAPSourceConnection.objects.create(
|
||||||
|
user=user, source=self.source, identifier=identifier
|
||||||
|
)
|
||||||
|
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.assertTrue(User.objects.filter(username=username).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_still_in_source(self):
|
||||||
|
"""Test that group is not deleted if it's still in the source"""
|
||||||
|
groupname = group_in_slapd_cn
|
||||||
|
identifier = group_in_slapd_uid
|
||||||
|
group = Group.objects.create(name=groupname)
|
||||||
|
GroupLDAPSourceConnection.objects.create(
|
||||||
|
group=group, source=self.source, identifier=identifier
|
||||||
|
)
|
||||||
|
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.assertTrue(Group.objects.filter(name=groupname).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())
|
||||||
|
|||||||
@ -8180,6 +8180,11 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Lookup groups from user",
|
"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"
|
"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": []
|
"required": []
|
||||||
|
|||||||
16
schema.yml
16
schema.yml
@ -28473,6 +28473,10 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
- in: query
|
||||||
|
name: delete_not_found_objects
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
- in: query
|
- in: query
|
||||||
name: enabled
|
name: enabled
|
||||||
schema:
|
schema:
|
||||||
@ -47922,6 +47926,10 @@ components:
|
|||||||
description: Lookup group membership based on a user attribute instead of
|
description: Lookup group membership based on a user attribute instead of
|
||||||
a group attribute. This allows nested group resolution on systems like
|
a group attribute. This allows nested group resolution on systems like
|
||||||
FreeIPA and Active Directory
|
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:
|
required:
|
||||||
- base_dn
|
- base_dn
|
||||||
- component
|
- component
|
||||||
@ -48123,6 +48131,10 @@ components:
|
|||||||
description: Lookup group membership based on a user attribute instead of
|
description: Lookup group membership based on a user attribute instead of
|
||||||
a group attribute. This allows nested group resolution on systems like
|
a group attribute. This allows nested group resolution on systems like
|
||||||
FreeIPA and Active Directory
|
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:
|
required:
|
||||||
- base_dn
|
- base_dn
|
||||||
- name
|
- name
|
||||||
@ -53456,6 +53468,10 @@ components:
|
|||||||
description: Lookup group membership based on a user attribute instead of
|
description: Lookup group membership based on a user attribute instead of
|
||||||
a group attribute. This allows nested group resolution on systems like
|
a group attribute. This allows nested group resolution on systems like
|
||||||
FreeIPA and Active Directory
|
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:
|
PatchedLicenseRequest:
|
||||||
type: object
|
type: object
|
||||||
description: License Serializer
|
description: License Serializer
|
||||||
|
|||||||
@ -148,6 +148,26 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
|
|||||||
<span class="pf-c-switch__label">${msg("Sync groups")}</span>
|
<span class="pf-c-switch__label">${msg("Sync groups")}</span>
|
||||||
</label>
|
</label>
|
||||||
</ak-form-element-horizontal>
|
</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}>
|
<ak-form-group .expanded=${true}>
|
||||||
<span slot="header"> ${msg("Connection settings")} </span>
|
<span slot="header"> ${msg("Connection settings")} </span>
|
||||||
<div slot="body" class="pf-c-form">
|
<div slot="body" class="pf-c-form">
|
||||||
|
|||||||
Reference in New Issue
Block a user