sources/ldap: add property_mappings_group to make group mapping more customisable

This commit is contained in:
Jens Langhammer 2021-02-06 15:24:11 +01:00
parent 83bf639926
commit 32cf960053
11 changed files with 150 additions and 69 deletions

View File

@ -29,6 +29,7 @@ class LDAPSourceSerializer(ModelSerializer, MetaNameSerializer):
"sync_groups", "sync_groups",
"sync_parent_group", "sync_parent_group",
"property_mappings", "property_mappings",
"property_mappings_group",
] ]
extra_kwargs = {"bind_password": {"write_only": True}} extra_kwargs = {"bind_password": {"write_only": True}}

View File

@ -14,6 +14,9 @@ class LDAPSourceForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["property_mappings"].queryset = LDAPPropertyMapping.objects.all() self.fields["property_mappings"].queryset = LDAPPropertyMapping.objects.all()
self.fields[
"property_mappings_group"
].queryset = LDAPPropertyMapping.objects.all()
class Meta: class Meta:
@ -33,6 +36,7 @@ class LDAPSourceForm(forms.ModelForm):
"sync_users_password", "sync_users_password",
"sync_groups", "sync_groups",
"property_mappings", "property_mappings",
"property_mappings_group",
"additional_user_dn", "additional_user_dn",
"additional_group_dn", "additional_group_dn",
"user_object_filter", "user_object_filter",

View File

@ -11,6 +11,7 @@ class LDAPProviderManager(ObjectManager):
EnsureExists( EnsureExists(
LDAPPropertyMapping, LDAPPropertyMapping,
"object_field", "object_field",
"expression",
name="authentik default LDAP Mapping: name", name="authentik default LDAP Mapping: name",
object_field="name", object_field="name",
expression="return ldap.get('name')", expression="return ldap.get('name')",
@ -47,4 +48,12 @@ class LDAPProviderManager(ObjectManager):
object_field="username", object_field="username",
expression="return ldap.get('uid')", expression="return ldap.get('uid')",
), ),
EnsureExists(
LDAPPropertyMapping,
"object_field",
"expression",
name="authentik default OpenLDAP Mapping: cn",
object_field="name",
expression="return ldap.get('cn')",
),
] ]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.1.6 on 2021-02-06 14:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0017_managed"),
("authentik_sources_ldap", "0010_auto_20210205_1027"),
]
operations = [
migrations.AddField(
model_name="ldapsource",
name="property_mappings_group",
field=models.ManyToManyField(
blank=True,
default=None,
help_text="Property mappings used for group creation/updating.",
to="authentik_core.PropertyMapping",
),
),
]

View File

@ -52,6 +52,13 @@ class LDAPSource(Source):
default="objectSid", help_text=_("Field which contains a unique Identifier.") default="objectSid", help_text=_("Field which contains a unique Identifier.")
) )
property_mappings_group = models.ManyToManyField(
PropertyMapping,
default=None,
blank=True,
help_text=_("Property mappings used for group creation/updating."),
)
sync_users = models.BooleanField(default=True) sync_users = models.BooleanField(default=True)
sync_users_password = models.BooleanField( sync_users_password = models.BooleanField(
default=True, default=True,

View File

@ -1,9 +1,12 @@
"""Sync LDAP Users and groups into authentik""" """Sync LDAP Users and groups into authentik"""
from typing import Any from typing import Any
from django.db.models.query import QuerySet
from structlog.stdlib import BoundLogger, get_logger from structlog.stdlib import BoundLogger, get_logger
from authentik.sources.ldap.models import LDAPSource from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
LDAP_UNIQUENESS = "ldap_uniq" LDAP_UNIQUENESS = "ldap_uniq"
@ -43,3 +46,50 @@ class BaseLDAPSynchronizer:
return None return None
return value[0] return value[0]
return value return value
def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]:
"""Build attributes for User object based on property mappings."""
return self._build_object_properties(
user_dn, self._source.property_mappings, **kwargs
)
def build_group_properties(self, group_dn: str, **kwargs) -> dict[str, Any]:
"""Build attributes for Group object based on property mappings."""
return self._build_object_properties(
group_dn, self._source.property_mappings_group, **kwargs
)
def _build_object_properties(
self, object_dn: str, mappings: QuerySet, **kwargs
) -> dict[str, dict[Any, Any]]:
properties = {"attributes": {}}
for mapping in mappings.all().select_subclasses():
if not isinstance(mapping, LDAPPropertyMapping):
continue
mapping: LDAPPropertyMapping
try:
value = mapping.evaluate(
user=None, request=None, ldap=kwargs, dn=object_dn
)
if value is None:
continue
object_field = mapping.object_field
if object_field.startswith("attributes."):
# Because returning a list might desired, we can't
# rely on self._flatten here. Instead, just save the result as-is
properties["attributes"][
object_field.replace("attributes.", "")
] = value
else:
properties[object_field] = self._flatten(value)
except PropertyMappingExpressionException as exc:
self._logger.warning(
"Mapping failed to evaluate", exc=exc, mapping=mapping
)
continue
if self._source.object_uniqueness_field in kwargs:
properties["attributes"][LDAP_UNIQUENESS] = self._flatten(
kwargs.get(self._source.object_uniqueness_field)
)
properties["attributes"][LDAP_DISTINGUISHED_NAME] = object_dn
return properties

View File

@ -1,9 +1,9 @@
"""Sync LDAP Users and groups into authentik""" """Sync LDAP Users and groups into authentik"""
import ldap3 import ldap3
import ldap3.core.exceptions import ldap3.core.exceptions
from django.db.utils import IntegrityError
from authentik.core.models import Group 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 from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
@ -34,22 +34,28 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
dn=group_dn, dn=group_dn,
) )
continue continue
uniq = attributes[self._source.object_uniqueness_field] uniq = self._flatten(attributes[self._source.object_uniqueness_field])
# TODO: Use Property Mappings try:
name = self._flatten(attributes.get("name", "")) defaults = self.build_group_properties(group_dn, **attributes)
_, created = Group.objects.update_or_create( self._logger.debug("Creating group with attributes", **defaults)
if "name" not in defaults:
raise IntegrityError("Name was not set by propertymappings")
ak_group, created = Group.objects.update_or_create(
**{ **{
f"attributes__{LDAP_UNIQUENESS}": uniq, f"attributes__{LDAP_UNIQUENESS}": uniq,
"parent": self._source.sync_parent_group, "parent": self._source.sync_parent_group,
"defaults": { "defaults": defaults,
"name": name,
"attributes": {
LDAP_UNIQUENESS: uniq,
LDAP_DISTINGUISHED_NAME: group_dn,
},
},
} }
) )
self._logger.debug("Synced group", group=name, created=created) except IntegrityError as exc:
self._logger.warning("Failed to create group", exc=exc)
self._logger.warning(
(
"To merge new group with existing group, set the group's "
f"Attribute '{LDAP_UNIQUENESS}' to '{uniq}'"
)
)
else:
self._logger.debug("Synced group", group=ak_group.name, created=created)
group_count += 1 group_count += 1
return group_count return group_count

View File

@ -1,14 +1,9 @@
"""Sync LDAP Users into authentik""" """Sync LDAP Users into authentik"""
from typing import Any
import ldap3 import ldap3
import ldap3.core.exceptions import ldap3.core.exceptions
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.models import User 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 from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
@ -39,11 +34,11 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
continue continue
uniq = self._flatten(attributes[self._source.object_uniqueness_field]) uniq = self._flatten(attributes[self._source.object_uniqueness_field])
try: try:
defaults = self._build_object_properties(user_dn, **attributes) defaults = self.build_user_properties(user_dn, **attributes)
self._logger.debug("Creating user with attributes", **defaults) self._logger.debug("Creating user with attributes", **defaults)
if "username" not in defaults: if "username" not in defaults:
raise IntegrityError("Username was not set by propertymappings") raise IntegrityError("Username was not set by propertymappings")
user, created = User.objects.update_or_create( ak_user, created = User.objects.update_or_create(
**{ **{
f"attributes__{LDAP_UNIQUENESS}": uniq, f"attributes__{LDAP_UNIQUENESS}": uniq,
"defaults": defaults, "defaults": defaults,
@ -53,49 +48,16 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
self._logger.warning("Failed to create user", exc=exc) self._logger.warning("Failed to create user", exc=exc)
self._logger.warning( self._logger.warning(
( (
"To merge new User with existing user, set the User's " "To merge new user with existing user, set the user's "
f"Attribute '{LDAP_UNIQUENESS}' to '{uniq}'" f"Attribute '{LDAP_UNIQUENESS}' to '{uniq}'"
) )
) )
else: else:
if created: if created:
user.set_unusable_password() ak_user.set_unusable_password()
user.save() ak_user.save()
self._logger.debug("Synced User", user=user.username, created=created) self._logger.debug(
"Synced User", user=ak_user.username, created=created
)
user_count += 1 user_count += 1
return user_count return user_count
def _build_object_properties(
self, user_dn: str, **kwargs
) -> 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=kwargs, dn=user_dn
)
if value is None:
continue
object_field = mapping.object_field
if object_field.startswith("attributes."):
# Because returning a list might desired, we can't
# rely on self._flatten here. Instead, just save the result as-is
properties["attributes"][
object_field.replace("attributes.", "")
] = value
else:
properties[object_field] = self._flatten(value)
except PropertyMappingExpressionException as exc:
self._logger.warning(
"Mapping failed to evaluate", exc=exc, mapping=mapping
)
continue
if self._source.object_uniqueness_field in kwargs:
properties["attributes"][LDAP_UNIQUENESS] = self._flatten(
kwargs.get(self._source.object_uniqueness_field)
)
properties["attributes"][LDAP_DISTINGUISHED_NAME] = user_dn
return properties

View File

@ -26,7 +26,7 @@ def mock_slapd_connection(password: str) -> Connection:
connection.strategy.add_entry( connection.strategy.add_entry(
"cn=group1,ou=groups,dc=goauthentik,dc=io", "cn=group1,ou=groups,dc=goauthentik,dc=io",
{ {
"name": "test-group", "cn": "group1",
"uid": "unique-test-group", "uid": "unique-test-group",
"objectClass": "groupOfNames", "objectClass": "groupOfNames",
"member": ["cn=user0,ou=users,dc=goauthentik,dc=io"], "member": ["cn=user0,ou=users,dc=goauthentik,dc=io"],
@ -36,7 +36,7 @@ def mock_slapd_connection(password: str) -> Connection:
connection.strategy.add_entry( connection.strategy.add_entry(
"cn=group2,ou=groups,dc=goauthentik,dc=io", "cn=group2,ou=groups,dc=goauthentik,dc=io",
{ {
"name": "test-group", "cn": "group2",
"objectClass": "groupOfNames", "objectClass": "groupOfNames",
}, },
) )

View File

@ -72,6 +72,11 @@ class LDAPSyncTests(TestCase):
| Q(name__startswith="authentik default Active Directory Mapping") | Q(name__startswith="authentik default Active Directory Mapping")
) )
) )
self.source.property_mappings_group.set(
LDAPPropertyMapping.objects.filter(
name="authentik default LDAP Mapping: name"
)
)
self.source.save() self.source.save()
connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD)) connection = PropertyMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
@ -92,6 +97,11 @@ class LDAPSyncTests(TestCase):
| Q(name__startswith="authentik default OpenLDAP Mapping") | Q(name__startswith="authentik default OpenLDAP Mapping")
) )
) )
self.source.property_mappings_group.set(
LDAPPropertyMapping.objects.filter(
name="authentik default OpenLDAP Mapping: cn"
)
)
self.source.save() self.source.save()
connection = PropertyMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) connection = PropertyMock(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):
@ -99,7 +109,7 @@ class LDAPSyncTests(TestCase):
group_sync.sync() group_sync.sync()
membership_sync = MembershipLDAPSynchronizer(self.source) membership_sync = MembershipLDAPSynchronizer(self.source)
membership_sync.sync() membership_sync.sync()
group = Group.objects.filter(name="test-group") group = Group.objects.filter(name="group1")
self.assertTrue(group.exists()) self.assertTrue(group.exists())
def test_tasks_ad(self): def test_tasks_ad(self):

View File

@ -9172,6 +9172,14 @@ definitions:
type: string type: string
format: uuid format: uuid
uniqueItems: true uniqueItems: true
property_mappings_group:
description: Property mappings used for group creation/updating.
type: array
items:
description: Property mappings used for group creation/updating.
type: string
format: uuid
uniqueItems: true
OAuthSource: OAuthSource:
description: OAuth Source Serializer description: OAuth Source Serializer
required: required: