sources/ldap: rewrite group membership syncing
This commit is contained in:
parent
89dc4db30b
commit
14dc420747
|
@ -15,6 +15,7 @@ from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
|||
from authentik.providers.saml.processors.request_parser import AuthNRequest
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||
from authentik.sources.saml.exceptions import UnsupportedNameIDFormat
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
|
@ -173,7 +174,7 @@ class AssertionProcessor:
|
|||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_X509:
|
||||
# This attribute is statically set by the LDAP source
|
||||
name_id.text = self.http_request.user.attributes.get(
|
||||
"distinguishedName", persistent
|
||||
LDAP_DISTINGUISHED_NAME, persistent
|
||||
)
|
||||
return name_id
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_WINDOWS:
|
||||
|
|
|
@ -371,7 +371,6 @@ structlog.configure_once(
|
|||
structlog.processors.format_exc_info,
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
context_class=structlog.threadlocal.wrap_dict(dict),
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.make_filtering_bound_logger(
|
||||
getattr(logging, LOG_LEVEL, logging.WARNING)
|
||||
|
|
|
@ -22,7 +22,7 @@ class LDAPSourceSerializer(ModelSerializer, MetaNameSerializer):
|
|||
"additional_group_dn",
|
||||
"user_object_filter",
|
||||
"group_object_filter",
|
||||
"user_group_membership_field",
|
||||
"group_membership_field",
|
||||
"object_uniqueness_field",
|
||||
"sync_users",
|
||||
"sync_users_password",
|
||||
|
|
|
@ -10,6 +10,7 @@ from authentik.core.models import User
|
|||
from authentik.sources.ldap.models import LDAPSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
LDAP_DISTINGUISHED_NAME = "distinguishedName"
|
||||
|
||||
|
||||
class LDAPBackend(ModelBackend):
|
||||
|
@ -35,7 +36,7 @@ class LDAPBackend(ModelBackend):
|
|||
if not users.exists():
|
||||
return None
|
||||
user: User = users.first()
|
||||
if "distinguishedName" not in user.attributes:
|
||||
if LDAP_DISTINGUISHED_NAME not in user.attributes:
|
||||
LOGGER.debug(
|
||||
"User doesn't have DN set, assuming not LDAP imported.", user=user
|
||||
)
|
||||
|
@ -63,7 +64,7 @@ class LDAPBackend(ModelBackend):
|
|||
try:
|
||||
temp_connection = ldap3.Connection(
|
||||
source.connection.server,
|
||||
user=user.attributes.get("distinguishedName"),
|
||||
user=user.attributes.get(LDAP_DISTINGUISHED_NAME),
|
||||
password=password,
|
||||
raise_exceptions=True,
|
||||
)
|
||||
|
|
|
@ -37,7 +37,7 @@ class LDAPSourceForm(forms.ModelForm):
|
|||
"additional_group_dn",
|
||||
"user_object_filter",
|
||||
"group_object_filter",
|
||||
"user_group_membership_field",
|
||||
"group_membership_field",
|
||||
"object_uniqueness_field",
|
||||
"sync_parent_group",
|
||||
]
|
||||
|
@ -51,7 +51,7 @@ class LDAPSourceForm(forms.ModelForm):
|
|||
"additional_group_dn": forms.TextInput(),
|
||||
"user_object_filter": forms.TextInput(),
|
||||
"group_object_filter": forms.TextInput(),
|
||||
"user_group_membership_field": forms.TextInput(),
|
||||
"group_membership_field": forms.TextInput(),
|
||||
"object_uniqueness_field": forms.TextInput(),
|
||||
}
|
||||
|
||||
|
|
24
authentik/sources/ldap/migrations/0009_auto_20210204_1834.py
Normal file
24
authentik/sources/ldap/migrations/0009_auto_20210204_1834.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 3.1.6 on 2021-02-04 18:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_ldap", "0008_managed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="ldapsource",
|
||||
name="user_group_membership_field",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ldapsource",
|
||||
name="group_membership_field",
|
||||
field=models.TextField(
|
||||
default="member", help_text="Field which contains members of a group."
|
||||
),
|
||||
),
|
||||
]
|
|
@ -41,8 +41,8 @@ class LDAPSource(Source):
|
|||
default="(objectCategory=Person)",
|
||||
help_text=_("Consider Objects matching this filter to be Users."),
|
||||
)
|
||||
user_group_membership_field = models.TextField(
|
||||
default="memberOf", help_text=_("Field which contains Groups of user.")
|
||||
group_membership_field = models.TextField(
|
||||
default="member", help_text=_("Field which contains members of a group.")
|
||||
)
|
||||
group_object_filter = models.TextField(
|
||||
default="(objectCategory=Group)",
|
||||
|
|
|
@ -8,6 +8,7 @@ import ldap3.core.exceptions
|
|||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
@ -74,9 +75,9 @@ class LDAPPasswordChanger:
|
|||
|
||||
def change_password(self, user: User, password: str):
|
||||
"""Change user's password"""
|
||||
user_dn = user.attributes.get("distinguishedName", None)
|
||||
user_dn = user.attributes.get(LDAP_DISTINGUISHED_NAME, None)
|
||||
if not user_dn:
|
||||
raise AttributeError("User has no distinguishedName set.")
|
||||
raise AttributeError(f"User has no {LDAP_DISTINGUISHED_NAME} set.")
|
||||
self._source.connection.extend.microsoft.modify_password(user_dn, password)
|
||||
|
||||
def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
|
||||
|
@ -117,9 +118,9 @@ class LDAPPasswordChanger:
|
|||
"""
|
||||
if user:
|
||||
# Check if password contains sAMAccountName or displayNames
|
||||
if "distinguishedName" in user.attributes:
|
||||
if LDAP_DISTINGUISHED_NAME in user.attributes:
|
||||
existing_user_check = self._ad_check_password_existing(
|
||||
password, user.attributes.get("distinguishedName")
|
||||
password, user.attributes.get(LDAP_DISTINGUISHED_NAME)
|
||||
)
|
||||
if not existing_user_check:
|
||||
LOGGER.debug("Password failed name check", user=user)
|
||||
|
|
|
@ -1,194 +0,0 @@
|
|||
"""Sync LDAP Users and groups into authentik"""
|
||||
from typing import Any, Dict
|
||||
|
||||
import ldap3
|
||||
import ldap3.core.exceptions
|
||||
from django.db.utils import IntegrityError
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class LDAPSynchronizer:
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
|
||||
_source: LDAPSource
|
||||
|
||||
def __init__(self, source: LDAPSource):
|
||||
self._source = source
|
||||
|
||||
@property
|
||||
def base_dn_users(self) -> str:
|
||||
"""Shortcut to get full base_dn for user lookups"""
|
||||
if self._source.additional_user_dn:
|
||||
return f"{self._source.additional_user_dn},{self._source.base_dn}"
|
||||
return self._source.base_dn
|
||||
|
||||
@property
|
||||
def base_dn_groups(self) -> str:
|
||||
"""Shortcut to get full base_dn for group lookups"""
|
||||
if self._source.additional_group_dn:
|
||||
return f"{self._source.additional_group_dn},{self._source.base_dn}"
|
||||
return self._source.base_dn
|
||||
|
||||
def sync_groups(self) -> int:
|
||||
"""Iterate over all LDAP Groups and create authentik_core.Group instances"""
|
||||
if not self._source.sync_groups:
|
||||
LOGGER.warning("Group syncing is disabled for this Source")
|
||||
return -1
|
||||
groups = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_groups,
|
||||
search_filter=self._source.group_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES],
|
||||
)
|
||||
group_count = 0
|
||||
for group in groups:
|
||||
attributes = group.get("attributes", {})
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
LOGGER.warning(
|
||||
"Cannot find uniqueness Field in attributes", user=attributes.keys()
|
||||
)
|
||||
continue
|
||||
uniq = attributes[self._source.object_uniqueness_field]
|
||||
_, created = Group.objects.update_or_create(
|
||||
attributes__ldap_uniq=uniq,
|
||||
parent=self._source.sync_parent_group,
|
||||
defaults={
|
||||
"name": attributes.get("name", ""),
|
||||
"attributes": {
|
||||
"ldap_uniq": uniq,
|
||||
"distinguishedName": attributes.get("distinguishedName"),
|
||||
},
|
||||
},
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Synced group", group=attributes.get("name", ""), created=created
|
||||
)
|
||||
group_count += 1
|
||||
return group_count
|
||||
|
||||
def sync_users(self) -> int:
|
||||
"""Iterate over all LDAP Users and create authentik_core.User instances"""
|
||||
if not self._source.sync_users:
|
||||
LOGGER.warning("User syncing is disabled for this Source")
|
||||
return -1
|
||||
users = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_users,
|
||||
search_filter=self._source.user_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES],
|
||||
)
|
||||
user_count = 0
|
||||
for user in users:
|
||||
attributes = user.get("attributes", {})
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
LOGGER.warning(
|
||||
"Cannot find uniqueness Field in attributes", user=user.keys()
|
||||
)
|
||||
continue
|
||||
uniq = attributes[self._source.object_uniqueness_field]
|
||||
try:
|
||||
defaults = self._build_object_properties(attributes)
|
||||
user, created = User.objects.update_or_create(
|
||||
attributes__ldap_uniq=uniq,
|
||||
defaults=defaults,
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
LOGGER.warning("Failed to create user", exc=exc)
|
||||
LOGGER.warning(
|
||||
(
|
||||
"To merge new User with existing user, set the User's "
|
||||
f"Attribute 'ldap_uniq' to '{uniq}'"
|
||||
)
|
||||
)
|
||||
else:
|
||||
if created:
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
LOGGER.debug(
|
||||
"Synced User", user=attributes.get("name", ""), created=created
|
||||
)
|
||||
user_count += 1
|
||||
return user_count
|
||||
|
||||
def sync_membership(self):
|
||||
"""Iterate over all Users and assign Groups using memberOf Field"""
|
||||
users = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_users,
|
||||
search_filter=self._source.user_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=[
|
||||
self._source.user_group_membership_field,
|
||||
self._source.object_uniqueness_field,
|
||||
],
|
||||
)
|
||||
group_cache: Dict[str, Group] = {}
|
||||
for user in users:
|
||||
member_of = user.get("attributes", {}).get(
|
||||
self._source.user_group_membership_field, []
|
||||
)
|
||||
uniq = user.get("attributes", {}).get(
|
||||
self._source.object_uniqueness_field, []
|
||||
)
|
||||
for group_dn in member_of:
|
||||
# Check if group_dn is within our base_dn_groups, and skip if not
|
||||
if not group_dn.endswith(self.base_dn_groups):
|
||||
continue
|
||||
# Check if we fetched the group already, and if not cache it for later
|
||||
if group_dn not in group_cache:
|
||||
groups = Group.objects.filter(
|
||||
attributes__distinguishedName=group_dn
|
||||
)
|
||||
if not groups.exists():
|
||||
LOGGER.warning(
|
||||
"Group does not exist in our DB yet, run sync_groups first.",
|
||||
group=group_dn,
|
||||
)
|
||||
return
|
||||
group_cache[group_dn] = groups.first()
|
||||
group = group_cache[group_dn]
|
||||
users = User.objects.filter(attributes__ldap_uniq=uniq)
|
||||
group.users.add(*list(users))
|
||||
# Now that all users are added, lets write everything
|
||||
for _, group in group_cache.items():
|
||||
group.save()
|
||||
LOGGER.debug("Successfully updated group membership")
|
||||
|
||||
def _build_object_properties(
|
||||
self, attributes: Dict[str, Any]
|
||||
) -> Dict[str, Dict[Any, Any]]:
|
||||
properties = {"attributes": {}}
|
||||
for mapping in self._source.property_mappings.all().select_subclasses():
|
||||
if not isinstance(mapping, LDAPPropertyMapping):
|
||||
continue
|
||||
mapping: LDAPPropertyMapping
|
||||
try:
|
||||
value = mapping.evaluate(user=None, request=None, ldap=attributes)
|
||||
if value is None:
|
||||
continue
|
||||
object_field = mapping.object_field
|
||||
if object_field.startswith("attributes."):
|
||||
properties["attributes"][
|
||||
object_field.replace("attributes.", "")
|
||||
] = value
|
||||
else:
|
||||
properties[object_field] = value
|
||||
except PropertyMappingExpressionException as exc:
|
||||
LOGGER.warning("Mapping failed to evaluate", exc=exc, mapping=mapping)
|
||||
continue
|
||||
if self._source.object_uniqueness_field in attributes:
|
||||
properties["attributes"]["ldap_uniq"] = attributes.get(
|
||||
self._source.object_uniqueness_field
|
||||
)
|
||||
distinguished_name = attributes.get("distinguishedName", attributes.get("dn"))
|
||||
if not distinguished_name:
|
||||
raise IntegrityError(
|
||||
"Object does not have a distinguishedName or dn field."
|
||||
)
|
||||
properties["attributes"]["distinguishedName"] = distinguished_name
|
||||
return properties
|
0
authentik/sources/ldap/sync/__init__.py
Normal file
0
authentik/sources/ldap/sync/__init__.py
Normal file
35
authentik/sources/ldap/sync/base.py
Normal file
35
authentik/sources/ldap/sync/base.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
"""Sync LDAP Users and groups into authentik"""
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
|
||||
LDAP_UNIQUENESS = "ldap_uniq"
|
||||
|
||||
|
||||
class BaseLDAPSynchronizer:
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
|
||||
_source: LDAPSource
|
||||
_logger: BoundLogger
|
||||
|
||||
def __init__(self, source: LDAPSource):
|
||||
self._source = source
|
||||
self._logger = get_logger().bind(source=source)
|
||||
|
||||
@property
|
||||
def base_dn_users(self) -> str:
|
||||
"""Shortcut to get full base_dn for user lookups"""
|
||||
if self._source.additional_user_dn:
|
||||
return f"{self._source.additional_user_dn},{self._source.base_dn}"
|
||||
return self._source.base_dn
|
||||
|
||||
@property
|
||||
def base_dn_groups(self) -> str:
|
||||
"""Shortcut to get full base_dn for group lookups"""
|
||||
if self._source.additional_group_dn:
|
||||
return f"{self._source.additional_group_dn},{self._source.base_dn}"
|
||||
return self._source.base_dn
|
||||
|
||||
def sync(self):
|
||||
"""Sync function, implemented in subclass"""
|
||||
raise NotImplementedError()
|
54
authentik/sources/ldap/sync/groups.py
Normal file
54
authentik/sources/ldap/sync/groups.py
Normal file
|
@ -0,0 +1,54 @@
|
|||
"""Sync LDAP Users and groups into authentik"""
|
||||
import ldap3
|
||||
import ldap3.core.exceptions
|
||||
|
||||
from authentik.core.models import Group
|
||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
|
||||
|
||||
|
||||
class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
|
||||
def sync(self) -> int:
|
||||
"""Iterate over all LDAP Groups and create authentik_core.Group instances"""
|
||||
if not self._source.sync_groups:
|
||||
self._logger.warning("Group syncing is disabled for this Source")
|
||||
return -1
|
||||
groups = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_groups,
|
||||
search_filter=self._source.group_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES],
|
||||
)
|
||||
group_count = 0
|
||||
for group in groups:
|
||||
attributes = group.get("attributes", {})
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
self._logger.warning(
|
||||
"Cannot find uniqueness Field in attributes",
|
||||
attributes=attributes.keys(),
|
||||
dn=attributes.get(LDAP_DISTINGUISHED_NAME, ""),
|
||||
)
|
||||
continue
|
||||
uniq = attributes[self._source.object_uniqueness_field]
|
||||
_, created = Group.objects.update_or_create(
|
||||
**{
|
||||
f"attributes__{LDAP_UNIQUENESS}": uniq,
|
||||
"parent": self._source.sync_parent_group,
|
||||
"defaults": {
|
||||
"name": attributes.get("name", ""),
|
||||
"attributes": {
|
||||
LDAP_UNIQUENESS: uniq,
|
||||
LDAP_DISTINGUISHED_NAME: attributes.get(
|
||||
"distinguishedName"
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
self._logger.debug(
|
||||
"Synced group", group=attributes.get("name", ""), created=created
|
||||
)
|
||||
group_count += 1
|
||||
return group_count
|
63
authentik/sources/ldap/sync/membership.py
Normal file
63
authentik/sources/ldap/sync/membership.py
Normal file
|
@ -0,0 +1,63 @@
|
|||
"""Sync LDAP Users and groups into authentik"""
|
||||
from typing import Any, Optional
|
||||
|
||||
import ldap3
|
||||
import ldap3.core.exceptions
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
|
||||
|
||||
|
||||
class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
|
||||
group_cache: dict[str, Group]
|
||||
|
||||
def __init__(self, source: LDAPSource):
|
||||
super().__init__(source)
|
||||
self.group_cache: dict[str, Group] = {}
|
||||
|
||||
def sync(self):
|
||||
"""Iterate over all Users and assign Groups using memberOf Field"""
|
||||
groups = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_groups,
|
||||
search_filter=self._source.group_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=[
|
||||
self._source.group_membership_field,
|
||||
self._source.object_uniqueness_field,
|
||||
],
|
||||
)
|
||||
for group in groups:
|
||||
members = group.get("attributes", {}).get(
|
||||
self._source.group_membership_field, []
|
||||
)
|
||||
users = User.objects.filter(
|
||||
**{f"attributes__{LDAP_DISTINGUISHED_NAME}__in": members}
|
||||
)
|
||||
|
||||
ak_group = self.get_group(group)
|
||||
if not ak_group:
|
||||
continue
|
||||
ak_group.users.set(users)
|
||||
ak_group.save()
|
||||
self._logger.debug("Successfully updated group membership")
|
||||
|
||||
def get_group(self, group_dict: dict[str, Any]) -> Optional[Group]:
|
||||
"""Check if we fetched the group already, and if not cache it for later"""
|
||||
group_uniq = group_dict.get("attributes", {}).get(LDAP_UNIQUENESS, "")
|
||||
group_dn = group_dict.get("attributes", {}).get(LDAP_DISTINGUISHED_NAME, "")
|
||||
if group_uniq not in self.group_cache:
|
||||
groups = Group.objects.filter(
|
||||
**{f"attributes__{LDAP_UNIQUENESS}": group_uniq}
|
||||
)
|
||||
if not groups.exists():
|
||||
self._logger.warning(
|
||||
"Group does not exist in our DB yet, run sync_groups first.",
|
||||
group=group_dn,
|
||||
)
|
||||
return None
|
||||
self.group_cache[group_uniq] = groups.first()
|
||||
return self.group_cache[group_uniq]
|
97
authentik/sources/ldap/sync/users.py
Normal file
97
authentik/sources/ldap/sync/users.py
Normal file
|
@ -0,0 +1,97 @@
|
|||
"""Sync LDAP Users into authentik"""
|
||||
from typing import Any
|
||||
|
||||
import ldap3
|
||||
import ldap3.core.exceptions
|
||||
from django.db.utils import IntegrityError
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import User
|
||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping
|
||||
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
|
||||
|
||||
|
||||
class UserLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
"""Sync LDAP Users into authentik"""
|
||||
|
||||
def sync(self) -> int:
|
||||
"""Iterate over all LDAP Users and create authentik_core.User instances"""
|
||||
if not self._source.sync_users:
|
||||
self._logger.warning("User syncing is disabled for this Source")
|
||||
return -1
|
||||
users = self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_users,
|
||||
search_filter=self._source.user_object_filter,
|
||||
search_scope=ldap3.SUBTREE,
|
||||
attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES],
|
||||
)
|
||||
user_count = 0
|
||||
for user in users:
|
||||
attributes = user.get("attributes", {})
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
self._logger.warning(
|
||||
"Cannot find uniqueness Field in attributes",
|
||||
attributes=attributes.keys(),
|
||||
dn=attributes.get(LDAP_DISTINGUISHED_NAME, ""),
|
||||
)
|
||||
continue
|
||||
uniq = attributes[self._source.object_uniqueness_field]
|
||||
try:
|
||||
defaults = self._build_object_properties(attributes)
|
||||
user, created = User.objects.update_or_create(
|
||||
**{
|
||||
f"attributes__{LDAP_UNIQUENESS}": uniq,
|
||||
"defaults": defaults,
|
||||
}
|
||||
)
|
||||
except IntegrityError as exc:
|
||||
self._logger.warning("Failed to create user", exc=exc)
|
||||
self._logger.warning(
|
||||
(
|
||||
"To merge new User with existing user, set the User's "
|
||||
f"Attribute '{LDAP_UNIQUENESS}' to '{uniq}'"
|
||||
)
|
||||
)
|
||||
else:
|
||||
if created:
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
self._logger.debug(
|
||||
"Synced User", user=attributes.get("name", ""), created=created
|
||||
)
|
||||
user_count += 1
|
||||
return user_count
|
||||
|
||||
def _build_object_properties(
|
||||
self, attributes: dict[str, Any]
|
||||
) -> dict[str, dict[Any, Any]]:
|
||||
properties = {"attributes": {}}
|
||||
for mapping in self._source.property_mappings.all().select_subclasses():
|
||||
if not isinstance(mapping, LDAPPropertyMapping):
|
||||
continue
|
||||
mapping: LDAPPropertyMapping
|
||||
try:
|
||||
value = mapping.evaluate(user=None, request=None, ldap=attributes)
|
||||
if value is None:
|
||||
continue
|
||||
object_field = mapping.object_field
|
||||
if object_field.startswith("attributes."):
|
||||
properties["attributes"][
|
||||
object_field.replace("attributes.", "")
|
||||
] = value
|
||||
else:
|
||||
properties[object_field] = value
|
||||
except PropertyMappingExpressionException as exc:
|
||||
self._logger.warning(
|
||||
"Mapping failed to evaluate", exc=exc, mapping=mapping
|
||||
)
|
||||
continue
|
||||
if self._source.object_uniqueness_field in attributes:
|
||||
properties["attributes"][LDAP_UNIQUENESS] = attributes.get(
|
||||
self._source.object_uniqueness_field
|
||||
)
|
||||
properties["attributes"][LDAP_DISTINGUISHED_NAME] = attributes.get(
|
||||
"distinguishedName", attributes.get("dn")
|
||||
)
|
||||
return properties
|
|
@ -8,7 +8,9 @@ from ldap3.core.exceptions import LDAPException
|
|||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
from authentik.sources.ldap.sync import LDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
|
@ -29,16 +31,21 @@ def ldap_sync(self: MonitoredTask, source_pk: int):
|
|||
return
|
||||
self.set_uid(slugify(source.name))
|
||||
try:
|
||||
syncer = LDAPSynchronizer(source)
|
||||
user_count = syncer.sync_users()
|
||||
group_count = syncer.sync_groups()
|
||||
syncer.sync_membership()
|
||||
messages = []
|
||||
for sync_class in [
|
||||
UserLDAPSynchronizer,
|
||||
GroupLDAPSynchronizer,
|
||||
MembershipLDAPSynchronizer,
|
||||
]:
|
||||
sync_inst = sync_class(source)
|
||||
count = sync_inst.sync()
|
||||
messages.append(f"Synced {count} objects from {sync_class.__name__}")
|
||||
cache_key = source.state_cache_prefix("last_sync")
|
||||
cache.set(cache_key, time(), timeout=60 * 60)
|
||||
self.set_status(
|
||||
TaskResult(
|
||||
TaskResultStatus.SUCCESSFUL,
|
||||
[f"Synced {user_count} users", f"Synced {group_count} groups"],
|
||||
messages,
|
||||
)
|
||||
)
|
||||
except LDAPException as exc:
|
||||
|
|
|
@ -8,11 +8,11 @@ from authentik.managed.manager import ObjectManager
|
|||
from authentik.providers.oauth2.generators import generate_client_secret
|
||||
from authentik.sources.ldap.auth import LDAPBackend
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
from authentik.sources.ldap.sync import LDAPSynchronizer
|
||||
from authentik.sources.ldap.tests.utils import _build_mock_connection
|
||||
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||
from authentik.sources.ldap.tests.utils import mock_ad_connection
|
||||
|
||||
LDAP_PASSWORD = generate_client_secret()
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||
|
||||
|
||||
class LDAPSyncTests(TestCase):
|
||||
|
@ -33,8 +33,8 @@ class LDAPSyncTests(TestCase):
|
|||
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_auth_synced_user(self):
|
||||
"""Test Cached auth"""
|
||||
syncer = LDAPSynchronizer(self.source)
|
||||
syncer.sync_users()
|
||||
user_sync = UserLDAPSynchronizer(self.source)
|
||||
user_sync.sync()
|
||||
|
||||
user = User.objects.get(username="user0_sn")
|
||||
auth_user_by_bind = Mock(return_value=user)
|
||||
|
|
|
@ -7,10 +7,10 @@ from authentik.core.models import User
|
|||
from authentik.providers.oauth2.generators import generate_client_secret
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
from authentik.sources.ldap.password import LDAPPasswordChanger
|
||||
from authentik.sources.ldap.tests.utils import _build_mock_connection
|
||||
from authentik.sources.ldap.tests.utils import mock_ad_connection
|
||||
|
||||
LDAP_PASSWORD = generate_client_secret()
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||
|
||||
|
||||
class LDAPPasswordTests(TestCase):
|
||||
|
|
|
@ -7,12 +7,14 @@ from authentik.core.models import Group, User
|
|||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.providers.oauth2.generators import generate_client_secret
|
||||
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
from authentik.sources.ldap.sync import LDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||
from authentik.sources.ldap.tasks import ldap_sync_all
|
||||
from authentik.sources.ldap.tests.utils import _build_mock_connection
|
||||
from authentik.sources.ldap.tests.utils import mock_ad_connection
|
||||
|
||||
LDAP_PASSWORD = generate_client_secret()
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
|
||||
|
||||
|
||||
class LDAPSyncTests(TestCase):
|
||||
|
@ -33,17 +35,18 @@ class LDAPSyncTests(TestCase):
|
|||
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_sync_users(self):
|
||||
"""Test user sync"""
|
||||
syncer = LDAPSynchronizer(self.source)
|
||||
syncer.sync_users()
|
||||
user_sync = UserLDAPSynchronizer(self.source)
|
||||
user_sync.sync()
|
||||
self.assertTrue(User.objects.filter(username="user0_sn").exists())
|
||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||
|
||||
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_sync_groups(self):
|
||||
"""Test group sync"""
|
||||
syncer = LDAPSynchronizer(self.source)
|
||||
syncer.sync_groups()
|
||||
syncer.sync_membership()
|
||||
group_sync = GroupLDAPSynchronizer(self.source)
|
||||
group_sync.sync()
|
||||
membership_sync = MembershipLDAPSynchronizer(self.source)
|
||||
membership_sync.sync()
|
||||
group = Group.objects.filter(name="test-group")
|
||||
self.assertTrue(group.exists())
|
||||
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
|
||||
|
||||
|
||||
def _build_mock_connection(password: str) -> Connection:
|
||||
"""Create mock connection"""
|
||||
def mock_ad_connection(password: str) -> Connection:
|
||||
"""Create mock AD connection"""
|
||||
server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
|
||||
_pass = "foo" # noqa # nosec
|
||||
connection = Connection(
|
||||
|
@ -32,6 +32,7 @@ def _build_mock_connection(password: str) -> Connection:
|
|||
"objectSid": "unique-test-group",
|
||||
"objectCategory": "Group",
|
||||
"distinguishedName": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
|
||||
"member": ["cn=user0,ou=users,DC=AD2012,DC=LAB"],
|
||||
},
|
||||
)
|
||||
# Group without SID
|
||||
|
@ -52,7 +53,6 @@ def _build_mock_connection(password: str) -> Connection:
|
|||
"revision": 0,
|
||||
"objectSid": "user0",
|
||||
"objectCategory": "Person",
|
||||
"memberOf": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
|
||||
"distinguishedName": "cn=user0,ou=users,DC=AD2012,DC=LAB",
|
||||
},
|
||||
)
|
||||
|
|
|
@ -9140,9 +9140,9 @@ definitions:
|
|||
description: Consider Objects matching this filter to be Groups.
|
||||
type: string
|
||||
minLength: 1
|
||||
user_group_membership_field:
|
||||
title: User group membership field
|
||||
description: Field which contains Groups of user.
|
||||
group_membership_field:
|
||||
title: Group membership field
|
||||
description: Field which contains members of a group.
|
||||
type: string
|
||||
minLength: 1
|
||||
object_uniqueness_field:
|
||||
|
|
|
@ -48,7 +48,7 @@ The other settings might need to be adjusted based on the setup of your domain.
|
|||
- Addition Group DN: Additional DN which is _prepended_ to your Base DN for group synchronization.
|
||||
- User object filter: Which objects should be considered users.
|
||||
- Group object filter: Which objects should be considered groups.
|
||||
- User group membership field: Which user field saves the group membership
|
||||
- Group membership field: Which user field saves the group membership
|
||||
- Object uniqueness field: A user field which contains a unique Identifier
|
||||
- Sync parent group: If enabled, all synchronized groups will be given this group as a parent.
|
||||
|
||||
|
|
Reference in a new issue