sources/ldap: add property_mappings_group to make group mapping more customisable
This commit is contained in:
parent
83bf639926
commit
32cf960053
|
@ -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}}
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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')",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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:
|
||||||
|
|
Reference in New Issue