sources: introduce new property mappings per user and group (#8750)

* sources: introduce new property mappings per-user and group

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* sources/ldap: migrate to new property mappings

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* lint-fix and make gen

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* web changes

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* fix tests

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* update tests

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* remove flatten for generic implem

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* rework migration

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* lint-fix

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* wip

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* fix migrations

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* re-add field migration to property mappings

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* fix migrations

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* more migrations fixes

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* easy fixes

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* migrate to propertymappingmanager

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* ruff and small fixes

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* move mapping things into a separate class

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* migrations: use using(db_alias)

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* migrations: use built-in variable

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* add docs

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

* add release notes

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>

---------

Signed-off-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Marc 'risson' Schmitt
2024-07-22 15:26:22 +02:00
committed by GitHub
parent 919d5fce39
commit 1a6ac4740d
31 changed files with 814 additions and 298 deletions

View File

@ -60,6 +60,8 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
"enabled", "enabled",
"authentication_flow", "authentication_flow",
"enrollment_flow", "enrollment_flow",
"user_property_mappings",
"group_property_mappings",
"component", "component",
"verbose_name", "verbose_name",
"verbose_name_plural", "verbose_name_plural",

View File

@ -0,0 +1,43 @@
# Generated by Django 5.0.2 on 2024-02-29 11:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0035_alter_group_options_and_more"),
]
operations = [
migrations.AddField(
model_name="source",
name="group_property_mappings",
field=models.ManyToManyField(
blank=True,
default=None,
related_name="source_grouppropertymappings_set",
to="authentik_core.propertymapping",
),
),
migrations.AddField(
model_name="source",
name="user_property_mappings",
field=models.ManyToManyField(
blank=True,
default=None,
related_name="source_userpropertymappings_set",
to="authentik_core.propertymapping",
),
),
migrations.AlterField(
model_name="source",
name="property_mappings",
field=models.ManyToManyField(
blank=True,
default=None,
related_name="source_set",
to="authentik_core.propertymapping",
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-02-29 11:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_ldap", "0005_remove_ldappropertymapping_object_field_and_more"),
("authentik_core", "0036_source_group_property_mappings_and_more"),
]
operations = [
migrations.RemoveField(
model_name="source",
name="property_mappings",
),
]

View File

@ -543,7 +543,12 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
user_path_template = models.TextField(default="goauthentik.io/sources/%(slug)s") user_path_template = models.TextField(default="goauthentik.io/sources/%(slug)s")
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) user_property_mappings = models.ManyToManyField(
"PropertyMapping", default=None, blank=True, related_name="source_userpropertymappings_set"
)
group_property_mappings = models.ManyToManyField(
"PropertyMapping", default=None, blank=True, related_name="source_grouppropertymappings_set"
)
icon = models.FileField( icon = models.FileField(
upload_to="source-icons/", upload_to="source-icons/",
default=None, default=None,
@ -607,6 +612,11 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
"""Return component used to edit this object""" """Return component used to edit this object"""
raise NotImplementedError raise NotImplementedError
@property
def property_mapping_type(self) -> "type[PropertyMapping]":
"""Return property mapping type used by this object"""
raise NotImplementedError
def ui_login_button(self, request: HttpRequest) -> UILoginButton | None: def ui_login_button(self, request: HttpRequest) -> UILoginButton | None:
"""If source uses a http-based flow, return UI Information about the login """If source uses a http-based flow, return UI Information about the login
button. If source doesn't use http-based flow, return None.""" button. If source doesn't use http-based flow, return None."""
@ -617,6 +627,14 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
user settings are available, or UserSettingSerializer.""" user settings are available, or UserSettingSerializer."""
return None return None
def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a user to build final properties upon."""
raise NotImplementedError
def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a group to build final properties upon."""
raise NotImplementedError
def __str__(self): def __str__(self):
return str(self.name) return str(self.name)

View File

@ -0,0 +1,102 @@
from typing import Any
from django.http import HttpRequest
from structlog.stdlib import get_logger
from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.models import Group, PropertyMapping, Source, User
from authentik.events.models import Event, EventAction
from authentik.lib.merge import MERGE_LIST_UNIQUE
from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.policies.utils import delete_none_values
LOGGER = get_logger()
class SourceMapper:
def __init__(self, source: Source):
self.source = source
def get_manager(
self, object_type: type[User | Group], context_keys: list[str]
) -> PropertyMappingManager:
"""Get property mapping manager for this source."""
qs = PropertyMapping.objects.none()
if object_type == User:
qs = self.source.user_property_mappings.all().select_subclasses()
elif object_type == Group:
qs = self.source.group_property_mappings.all().select_subclasses()
return PropertyMappingManager(
qs,
self.source.property_mapping_type,
["source", "properties"] + context_keys,
)
def get_base_properties(
self, object_type: type[User | Group], **kwargs
) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a user or a group to build final properties upon."""
if object_type == User:
properties = self.source.get_base_user_properties(**kwargs)
properties.setdefault("path", self.source.get_user_path())
return properties
if object_type == Group:
return self.source.get_base_group_properties(**kwargs)
return {}
def build_object_properties(
self,
object_type: type[User | Group],
manager: "PropertyMappingManager | None" = None,
user: User | None = None,
request: HttpRequest | None = None,
**kwargs,
) -> dict[str, Any | dict[str, Any]]:
"""Build a user or group properties from the source configured property mappings."""
properties = self.get_base_properties(object_type, **kwargs)
if "attributes" not in properties:
properties["attributes"] = {}
if not manager:
manager = self.get_manager(object_type, list(kwargs.keys()))
evaluations = manager.iter_eval(
user=user,
request=request,
return_mapping=True,
source=self.source,
properties=properties,
**kwargs,
)
while True:
try:
value, mapping = next(evaluations)
except StopIteration:
break
except PropertyMappingExpressionException as exc:
Event.new(
EventAction.CONFIGURATION_ERROR,
message=f"Failed to evaluate property mapping: '{exc.mapping.name}'",
source=self,
mapping=exc.mapping,
).save()
LOGGER.warning(
"Mapping failed to evaluate",
exc=exc,
source=self,
mapping=exc.mapping,
)
raise exc
if not value or not isinstance(value, dict):
LOGGER.debug(
"Mapping evaluated to None or is not a dict. Skipping",
source=self,
mapping=mapping,
)
continue
MERGE_LIST_UNIQUE.merge(properties, value)
return delete_none_values(properties)

View File

@ -0,0 +1,72 @@
"""Test Source Property mappings"""
from django.test import TestCase
from authentik.core.models import Group, PropertyMapping, Source, User
from authentik.core.sources.mapper import SourceMapper
from authentik.lib.generators import generate_id
class ProxySource(Source):
@property
def property_mapping_type(self):
return PropertyMapping
def get_base_user_properties(self, **kwargs):
return {
"username": kwargs.get("username", None),
"email": kwargs.get("email", "default@authentik"),
}
def get_base_group_properties(self, **kwargs):
return {"name": kwargs.get("name", None)}
class Meta:
proxy = True
class TestSourcePropertyMappings(TestCase):
"""Test Source PropertyMappings"""
def test_base_properties(self):
source = ProxySource.objects.create(name=generate_id(), slug=generate_id(), enabled=True)
mapper = SourceMapper(source)
user_base_properties = mapper.get_base_properties(User, username="test1")
self.assertEqual(
user_base_properties,
{
"username": "test1",
"email": "default@authentik",
"path": f"goauthentik.io/sources/{source.slug}",
},
)
group_base_properties = mapper.get_base_properties(Group)
self.assertEqual(group_base_properties, {"name": None})
def test_build_properties(self):
source = ProxySource.objects.create(name=generate_id(), slug=generate_id(), enabled=True)
mapper = SourceMapper(source)
source.user_property_mappings.add(
PropertyMapping.objects.create(
name=generate_id(),
expression="""
return {"username": data.get("username", None), "email": None}
""",
)
)
properties = mapper.build_object_properties(
object_type=User, user=None, request=None, username="test1", data={"username": "test2"}
)
self.assertEqual(
properties,
{
"username": "test2",
"path": f"goauthentik.io/sources/{source.slug}",
"attributes": {},
},
)

View File

@ -13,7 +13,7 @@ from lxml import etree # nosec
from lxml.etree import Element, SubElement # nosec from lxml.etree import Element, SubElement # nosec
from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout
from authentik.lib.config import get_path_from_dict from authentik.lib.utils.dict import get_path_from_dict
from authentik.lib.utils.http import get_http_session from authentik.lib.utils.http import get_http_session
from authentik.tenants.utils import get_current_tenant from authentik.tenants.utils import get_current_tenant

View File

@ -19,6 +19,8 @@ from urllib.parse import quote_plus, urlparse
import yaml import yaml
from django.conf import ImproperlyConfigured from django.conf import ImproperlyConfigured
from authentik.lib.utils.dict import get_path_from_dict, set_path_in_dict
SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob( SEARCH_PATHS = ["authentik/lib/default.yml", "/etc/authentik/config.yml", ""] + glob(
"/etc/authentik/config.d/*.yml", recursive=True "/etc/authentik/config.d/*.yml", recursive=True
) )
@ -47,29 +49,6 @@ DEPRECATIONS = {
} }
def get_path_from_dict(root: dict, path: str, sep=".", default=None) -> Any:
"""Recursively walk through `root`, checking each part of `path` separated by `sep`.
If at any point a dict does not exist, return default"""
for comp in path.split(sep):
if root and comp in root:
root = root.get(comp)
else:
return default
return root
def set_path_in_dict(root: dict, path: str, value: Any, sep="."):
"""Recursively walk through `root`, checking each part of `path` separated by `sep`
and setting the last value to `value`"""
# Walk each component of the path
path_parts = path.split(sep)
for comp in path_parts[:-1]:
if comp not in root:
root[comp] = {}
root = root.get(comp, {})
root[path_parts[-1]] = value
@dataclass(slots=True) @dataclass(slots=True)
class Attr: class Attr:
"""Single configuration attribute""" """Single configuration attribute"""

View File

@ -0,0 +1,24 @@
from typing import Any
def get_path_from_dict(root: dict, path: str, sep=".", default=None) -> Any:
"""Recursively walk through `root`, checking each part of `path` separated by `sep`.
If at any point a dict does not exist, return default"""
for comp in path.split(sep):
if root and comp in root:
root = root.get(comp)
else:
return default
return root
def set_path_in_dict(root: dict, path: str, value: Any, sep="."):
"""Recursively walk through `root`, checking each part of `path` separated by `sep`
and setting the last value to `value`"""
# Walk each component of the path
path_parts = path.split(sep)
for comp in path_parts[:-1]:
if comp not in root:
root[comp] = {}
root = root.get(comp, {})
root[path_parts[-1]] = value

View File

@ -81,8 +81,6 @@ class LDAPSourceSerializer(SourceSerializer):
"sync_users_password", "sync_users_password",
"sync_groups", "sync_groups",
"sync_parent_group", "sync_parent_group",
"property_mappings",
"property_mappings_group",
"connectivity", "connectivity",
] ]
extra_kwargs = {"bind_password": {"write_only": True}} extra_kwargs = {"bind_password": {"write_only": True}}
@ -116,8 +114,8 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
"sync_users_password", "sync_users_password",
"sync_groups", "sync_groups",
"sync_parent_group", "sync_parent_group",
"property_mappings", "user_property_mappings",
"property_mappings_group", "group_property_mappings",
] ]
search_fields = ["name", "slug"] search_fields = ["name", "slug"]
ordering = ["name"] ordering = ["name"]
@ -184,9 +182,7 @@ class LDAPPropertyMappingSerializer(PropertyMappingSerializer):
class Meta: class Meta:
model = LDAPPropertyMapping model = LDAPPropertyMapping
fields = PropertyMappingSerializer.Meta.fields + [ fields = PropertyMappingSerializer.Meta.fields
"object_field",
]
class LDAPPropertyMappingFilter(FilterSet): class LDAPPropertyMappingFilter(FilterSet):

View File

@ -6,10 +6,9 @@ from structlog.stdlib import get_logger
from authentik.core.auth import InbuiltBackend from authentik.core.auth import InbuiltBackend
from authentik.core.models import User from authentik.core.models import User
from authentik.sources.ldap.models import LDAPSource from authentik.sources.ldap.models import LDAP_DISTINGUISHED_NAME, LDAPSource
LOGGER = get_logger() LOGGER = get_logger()
LDAP_DISTINGUISHED_NAME = "distinguishedName"
class LDAPBackend(InbuiltBackend): class LDAPBackend(InbuiltBackend):

View File

@ -0,0 +1,64 @@
# Generated by Django 5.0.2 on 2024-02-29 11:21
import textwrap
from django.db import migrations
def migrate_ldap_property_mappings_object_field(apps, schema_editor):
db_alias = schema_editor.connection.alias
LDAPPropertyMapping = apps.get_model("authentik_sources_ldap", "LDAPPropertyMapping")
for mapping in LDAPPropertyMapping.objects.using(db_alias).all():
mapping.expression = f"""
# This property mapping has been automatically changed to
# match the new semantics of source property mappings.
# You can simplify it if you want.
# You should return a dictionary of fields to set on the user or the group.
# For instance:
# return {{
# "{mapping.object_field}": ldap.get("{mapping.object_field}")
# }}
# Note that this example has been generated and should not be used as-is.
def get_field():
{textwrap.indent(mapping.expression, prefix=' ')}
from authentik.lib.utils.dict import set_path_in_dict
field = "{mapping.object_field}"
result = {{"attributes": {{}}}}
if field.startswith("attributes."):
set_path_in_dict(result, field, get_field(), sep=".")
else:
result[field] = get_field()
return result
"""
mapping.save()
def migrate_ldap_property_mappings_to_new_fields(apps, schema_editor):
db_alias = schema_editor.connection.alias
LDAPSource = apps.get_model("authentik_sources_ldap", "LDAPSource")
for source in LDAPSource.objects.using(db_alias).all():
source.user_property_mappings.using(db_alias).set(source.property_mappings)
source.group_property_mappings.using(db_alias).set(source.property_mappings_group)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_ldap", "0004_ldapsource_password_login_update_internal_password"),
("authentik_core", "0036_source_group_property_mappings_and_more"),
]
operations = [
migrations.RunPython(migrate_ldap_property_mappings_object_field),
migrations.RunPython(migrate_ldap_property_mappings_to_new_fields),
migrations.RemoveField(
model_name="ldappropertymapping",
name="object_field",
),
migrations.RemoveField(
model_name="ldapsource",
name="property_mappings_group",
),
]

View File

@ -5,6 +5,7 @@ from os.path import dirname, exists
from shutil import rmtree from shutil import rmtree
from ssl import CERT_REQUIRED from ssl import CERT_REQUIRED
from tempfile import NamedTemporaryFile, mkdtemp from tempfile import NamedTemporaryFile, mkdtemp
from typing import Any
import pglock import pglock
from django.db import connection, models from django.db import connection, models
@ -20,6 +21,19 @@ from authentik.lib.config import CONFIG
from authentik.lib.models import DomainlessURLValidator from authentik.lib.models import DomainlessURLValidator
LDAP_TIMEOUT = 15 LDAP_TIMEOUT = 15
LDAP_UNIQUENESS = "ldap_uniq"
LDAP_DISTINGUISHED_NAME = "distinguishedName"
def flatten(value: Any) -> Any:
"""Flatten `value` if its a list, set or tuple"""
if isinstance(value, list | set | tuple):
if len(value) < 1:
return None
if isinstance(value, set):
return value.pop()
return value[0]
return value
class MultiURLValidator(DomainlessURLValidator): class MultiURLValidator(DomainlessURLValidator):
@ -91,13 +105,6 @@ 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."),
)
password_login_update_internal_password = models.BooleanField( password_login_update_internal_password = models.BooleanField(
default=False, default=False,
help_text=_("Update internal authentik password when login succeeds with LDAP"), help_text=_("Update internal authentik password when login succeeds with LDAP"),
@ -126,6 +133,31 @@ class LDAPSource(Source):
return LDAPSourceSerializer return LDAPSourceSerializer
@property
def property_mapping_type(self) -> "type[PropertyMapping]":
from authentik.sources.ldap.models import LDAPPropertyMapping
return LDAPPropertyMapping
def update_properties_with_uniqueness_field(self, properties, dn, ldap, **kwargs):
properties.setdefault("attributes", {})[LDAP_DISTINGUISHED_NAME] = dn
if self.object_uniqueness_field in ldap:
properties["attributes"][LDAP_UNIQUENESS] = flatten(
ldap.get(self.object_uniqueness_field)
)
return properties
def get_base_user_properties(self, **kwargs):
return self.update_properties_with_uniqueness_field({}, **kwargs)
def get_base_group_properties(self, **kwargs):
return self.update_properties_with_uniqueness_field(
{
"parent": self.sync_parent_group,
},
**kwargs,
)
@property @property
def icon_url(self) -> str: def icon_url(self) -> str:
return static("authentik/sources/ldap.png") return static("authentik/sources/ldap.png")
@ -218,8 +250,6 @@ class LDAPSource(Source):
def check_connection(self) -> dict[str, dict[str, str]]: def check_connection(self) -> dict[str, dict[str, str]]:
"""Check LDAP Connection""" """Check LDAP Connection"""
from authentik.sources.ldap.sync.base import flatten
servers = self.server() servers = self.server()
server_info = {} server_info = {}
# Check each individual server # Check each individual server
@ -258,8 +288,6 @@ class LDAPSource(Source):
class LDAPPropertyMapping(PropertyMapping): class LDAPPropertyMapping(PropertyMapping):
"""Map LDAP Property to User or Group object attribute""" """Map LDAP Property to User or Group object attribute"""
object_field = models.TextField()
@property @property
def component(self) -> str: def component(self) -> str:
return "ak-property-mapping-ldap-form" return "ak-property-mapping-ldap-form"

View File

@ -30,7 +30,10 @@ def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
# - the user forgets to set them or # - the user forgets to set them or
# - the source is newly created, this is the first save event # - the source is newly created, this is the first save event
# and the mappings are created with an m2m event # and the mappings are created with an m2m event
if not instance.property_mappings.exists() or not instance.property_mappings_group.exists(): if (
not instance.user_property_mappings.exists()
or not instance.group_property_mappings.exists()
):
return return
ldap_sync_single.delay(instance.pk) ldap_sync_single.delay(instance.pk)
ldap_connectivity_check.delay(instance.pk) ldap_connectivity_check.delay(instance.pk)

View File

@ -8,30 +8,12 @@ from django.db.models.base import Model
from ldap3 import DEREF_ALWAYS, SUBTREE, Connection from ldap3 import DEREF_ALWAYS, SUBTREE, Connection
from structlog.stdlib import BoundLogger, get_logger from structlog.stdlib import BoundLogger, get_logger
from authentik.core.expression.exceptions import ( from authentik.core.sources.mapper import SourceMapper
PropertyMappingExpressionException, from authentik.lib.config import CONFIG
SkipObjectException,
)
from authentik.events.models import Event, EventAction
from authentik.lib.config import CONFIG, set_path_in_dict
from authentik.lib.merge import MERGE_LIST_UNIQUE from authentik.lib.merge import MERGE_LIST_UNIQUE
from authentik.lib.sync.mapper import PropertyMappingManager from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.lib.utils.errors import exception_to_string
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
from authentik.sources.ldap.models import LDAPSource from authentik.sources.ldap.models import LDAPSource
LDAP_UNIQUENESS = "ldap_uniq"
def flatten(value: Any) -> Any:
"""Flatten `value` if its a list"""
if isinstance(value, list):
if len(value) < 1:
return None
return value[0]
return value
class BaseLDAPSynchronizer: class BaseLDAPSynchronizer:
"""Sync LDAP Users and groups into authentik""" """Sync LDAP Users and groups into authentik"""
@ -40,7 +22,8 @@ class BaseLDAPSynchronizer:
_logger: BoundLogger _logger: BoundLogger
_connection: Connection _connection: Connection
_messages: list[str] _messages: list[str]
mapper: PropertyMappingManager mapper: SourceMapper
manager: PropertyMappingManager
def __init__(self, source: LDAPSource): def __init__(self, source: LDAPSource):
self._source = source self._source = source
@ -140,55 +123,6 @@ class BaseLDAPSynchronizer:
cookie = None cookie = None
yield self._connection.response yield self._connection.response
def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]:
"""Build attributes for User object based on property mappings."""
props = self._build_object_properties(user_dn, **kwargs)
props.setdefault("path", self._source.get_user_path())
return props
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, **kwargs)
def _build_object_properties(self, object_dn: str, **kwargs) -> dict[str, dict[Any, Any]]:
properties = {"attributes": {}}
try:
for value, mapping in self.mapper.iter_eval(
user=None,
request=None,
return_mapping=True,
ldap=kwargs,
dn=object_dn,
source=self._source,
):
if isinstance(value, (bytes)):
self._logger.warning("property mapping returned bytes", mapping=mapping)
continue
object_field = mapping.object_field
if object_field.startswith("attributes."):
# Because returning a list might desired, we can't
# rely on flatten here. Instead, just save the result as-is
set_path_in_dict(properties, object_field, value)
else:
properties[object_field] = flatten(value)
except SkipObjectException as exc:
raise exc from exc
except PropertyMappingExpressionException as exc:
# Value error can be raised when assigning invalid data to an attribute
Event.new(
EventAction.CONFIGURATION_ERROR,
message=f"Failed to evaluate property-mapping {exception_to_string(exc)}",
mapping=exc.mapping,
).save()
self._logger.warning("Mapping failed to evaluate", exc=exc, mapping=exc.mapping)
raise StopSync(exc, None, exc.mapping) from exc
if self._source.object_uniqueness_field in kwargs:
properties["attributes"][LDAP_UNIQUENESS] = flatten(
kwargs.get(self._source.object_uniqueness_field)
)
properties["attributes"][LDAP_DISTINGUISHED_NAME] = object_dn
return properties
def update_or_create_attributes( def update_or_create_attributes(
self, self,
obj: type[Model], obj: type[Model],

View File

@ -6,12 +6,16 @@ from django.core.exceptions import FieldError
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE
from authentik.core.expression.exceptions import SkipObjectException from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import Group from authentik.core.models import Group
from authentik.core.sources.mapper import SourceMapper
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.lib.sync.mapper import PropertyMappingManager from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.models import LDAP_UNIQUENESS, LDAPSource, flatten
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
class GroupLDAPSynchronizer(BaseLDAPSynchronizer): class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
@ -19,11 +23,8 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
def __init__(self, source: LDAPSource): def __init__(self, source: LDAPSource):
super().__init__(source) super().__init__(source)
self.mapper = PropertyMappingManager( self.mapper = SourceMapper(source)
self._source.property_mappings_group.all().order_by("name").select_subclasses(), self.manager = self.mapper.get_manager(Group, ["ldap", "dn"])
LDAPPropertyMapping,
["ldap", "dn", "source"],
)
@staticmethod @staticmethod
def name() -> str: def name() -> str:
@ -61,8 +62,17 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
continue continue
uniq = flatten(attributes[self._source.object_uniqueness_field]) uniq = flatten(attributes[self._source.object_uniqueness_field])
try: try:
defaults = self.build_group_properties(group_dn, **attributes) defaults = {
defaults["parent"] = self._source.sync_parent_group k: flatten(v)
for k, v in self.mapper.build_object_properties(
object_type=Group,
manager=self.manager,
user=None,
request=None,
dn=group_dn,
ldap=attributes,
).items()
}
if "name" not in defaults: if "name" not in defaults:
raise IntegrityError("Name was not set by propertymappings") raise IntegrityError("Name was not set by propertymappings")
# Special check for `users` field, as this is an M2M relation, and cannot be sync'd # Special check for `users` field, as this is an M2M relation, and cannot be sync'd
@ -78,6 +88,8 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
self._logger.debug("Created group with attributes", **defaults) self._logger.debug("Created group with attributes", **defaults)
except SkipObjectException: except SkipObjectException:
continue continue
except PropertyMappingExpressionException as exc:
raise StopSync(exc, None, exc.mapping) from exc
except (IntegrityError, FieldError, TypeError, AttributeError) as exc: except (IntegrityError, FieldError, TypeError, AttributeError) as exc:
Event.new( Event.new(
EventAction.CONFIGURATION_ERROR, EventAction.CONFIGURATION_ERROR,

View File

@ -7,9 +7,8 @@ from django.db.models import Q
from ldap3 import SUBTREE from ldap3 import SUBTREE
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME from authentik.sources.ldap.models import LDAP_DISTINGUISHED_NAME, LDAP_UNIQUENESS, LDAPSource
from authentik.sources.ldap.models import LDAPSource from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer
class MembershipLDAPSynchronizer(BaseLDAPSynchronizer): class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):

View File

@ -6,12 +6,16 @@ from django.core.exceptions import FieldError
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE
from authentik.core.expression.exceptions import SkipObjectException from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import User from authentik.core.models import User
from authentik.core.sources.mapper import SourceMapper
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.lib.sync.mapper import PropertyMappingManager from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.models import LDAP_UNIQUENESS, LDAPSource, flatten
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
from authentik.sources.ldap.sync.vendor.freeipa import FreeIPA from authentik.sources.ldap.sync.vendor.freeipa import FreeIPA
from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory
@ -21,11 +25,8 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
def __init__(self, source: LDAPSource): def __init__(self, source: LDAPSource):
super().__init__(source) super().__init__(source)
self.mapper = PropertyMappingManager( self.mapper = SourceMapper(source)
self._source.property_mappings.all().order_by("name").select_subclasses(), self.manager = self.mapper.get_manager(User, ["ldap", "dn"])
LDAPPropertyMapping,
["ldap", "dn", "source"],
)
@staticmethod @staticmethod
def name() -> str: def name() -> str:
@ -63,13 +64,25 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
continue continue
uniq = flatten(attributes[self._source.object_uniqueness_field]) uniq = flatten(attributes[self._source.object_uniqueness_field])
try: try:
defaults = self.build_user_properties(user_dn, **attributes) defaults = {
k: flatten(v)
for k, v in self.mapper.build_object_properties(
object_type=User,
manager=self.manager,
user=None,
request=None,
dn=user_dn,
ldap=attributes,
).items()
}
self._logger.debug("Writing user with attributes", **defaults) self._logger.debug("Writing 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")
ak_user, created = self.update_or_create_attributes( ak_user, created = self.update_or_create_attributes(
User, {f"attributes__{LDAP_UNIQUENESS}": uniq}, defaults User, {f"attributes__{LDAP_UNIQUENESS}": uniq}, defaults
) )
except PropertyMappingExpressionException as exc:
raise StopSync(exc, None, exc.mapping) from exc
except SkipObjectException: except SkipObjectException:
continue continue
except (IntegrityError, FieldError, TypeError, AttributeError) as exc: except (IntegrityError, FieldError, TypeError, AttributeError) as exc:

View File

@ -5,7 +5,8 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from authentik.core.models import User from authentik.core.models import User
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer, flatten from authentik.sources.ldap.models import flatten
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
class FreeIPA(BaseLDAPSynchronizer): class FreeIPA(BaseLDAPSynchronizer):

View File

@ -32,7 +32,7 @@ class LDAPSyncTests(TestCase):
def test_auth_direct_user_ad(self): def test_auth_direct_user_ad(self):
"""Test direct auth""" """Test direct auth"""
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default-") Q(managed__startswith="goauthentik.io/sources/ldap/default-")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms-") | Q(managed__startswith="goauthentik.io/sources/ldap/ms-")
@ -63,7 +63,7 @@ class LDAPSyncTests(TestCase):
def test_auth_synced_user_ad(self): def test_auth_synced_user_ad(self):
"""Test Cached auth""" """Test Cached auth"""
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default-") Q(managed__startswith="goauthentik.io/sources/ldap/default-")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms-") | Q(managed__startswith="goauthentik.io/sources/ldap/ms-")
@ -89,7 +89,7 @@ class LDAPSyncTests(TestCase):
def test_auth_synced_user_openldap(self): def test_auth_synced_user_openldap(self):
"""Test Cached auth""" """Test Cached auth"""
self.source.object_uniqueness_field = "uid" self.source.object_uniqueness_field = "uid"
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(name__startswith="authentik default LDAP Mapping") Q(name__startswith="authentik default LDAP Mapping")
| Q(name__startswith="authentik default OpenLDAP Mapping") | Q(name__startswith="authentik default OpenLDAP Mapping")

View File

@ -25,7 +25,7 @@ class LDAPPasswordTests(TestCase):
additional_user_dn="ou=users", additional_user_dn="ou=users",
additional_group_dn="ou=groups", additional_group_dn="ou=groups",
) )
self.source.property_mappings.set(LDAPPropertyMapping.objects.all()) self.source.user_property_mappings.set(LDAPPropertyMapping.objects.all())
self.source.save() self.source.save()
@patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH) @patch("authentik.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)

View File

@ -48,7 +48,7 @@ class LDAPSyncTests(TestCase):
def test_sync_error(self): def test_sync_error(self):
"""Test user sync""" """Test user sync"""
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default") Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms") | Q(managed__startswith="goauthentik.io/sources/ldap/ms")
@ -56,10 +56,9 @@ class LDAPSyncTests(TestCase):
) )
mapping = LDAPPropertyMapping.objects.create( mapping = LDAPPropertyMapping.objects.create(
name="name", name="name",
object_field="name",
expression="q", expression="q",
) )
self.source.property_mappings.set([mapping]) self.source.user_property_mappings.set([mapping])
self.source.save() self.source.save()
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD)) connection = MagicMock(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):
@ -70,25 +69,24 @@ class LDAPSyncTests(TestCase):
self.assertFalse(User.objects.filter(username="user1_sn").exists()) self.assertFalse(User.objects.filter(username="user1_sn").exists())
events = Event.objects.filter( events = Event.objects.filter(
action=EventAction.CONFIGURATION_ERROR, action=EventAction.CONFIGURATION_ERROR,
context__message="Failed to evaluate property mapping: 'name'",
context__mapping__pk=mapping.pk.hex, context__mapping__pk=mapping.pk.hex,
) )
self.assertTrue(events.exists()) self.assertTrue(events.exists())
def test_sync_mapping(self): def test_sync_mapping(self):
"""Test property mappings""" """Test property mappings"""
none = LDAPPropertyMapping.objects.create( none = LDAPPropertyMapping.objects.create(name=generate_id(), expression="return None")
name=generate_id(), object_field="none", expression="return None"
)
byte_mapping = LDAPPropertyMapping.objects.create( byte_mapping = LDAPPropertyMapping.objects.create(
name=generate_id(), object_field="bytes", expression="return b''" name=generate_id(), expression="return b''"
) )
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default") Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms") | Q(managed__startswith="goauthentik.io/sources/ldap/ms")
) )
) )
self.source.property_mappings.add(none, byte_mapping) self.source.user_property_mappings.add(none, byte_mapping)
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD)) connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
# we basically just test that the mappings don't throw errors # we basically just test that the mappings don't throw errors
@ -98,7 +96,7 @@ class LDAPSyncTests(TestCase):
def test_sync_users_ad(self): def test_sync_users_ad(self):
"""Test user sync""" """Test user sync"""
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default") Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms") | Q(managed__startswith="goauthentik.io/sources/ldap/ms")
@ -132,7 +130,7 @@ class LDAPSyncTests(TestCase):
def test_sync_users_openldap(self): def test_sync_users_openldap(self):
"""Test user sync""" """Test user sync"""
self.source.object_uniqueness_field = "uid" self.source.object_uniqueness_field = "uid"
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default") Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap") | Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
@ -148,7 +146,7 @@ class LDAPSyncTests(TestCase):
def test_sync_users_freeipa_ish(self): def test_sync_users_freeipa_ish(self):
"""Test user sync (FreeIPA-ish), mainly testing vendor quirks""" """Test user sync (FreeIPA-ish), mainly testing vendor quirks"""
self.source.object_uniqueness_field = "uid" self.source.object_uniqueness_field = "uid"
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default") Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap") | Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
@ -164,13 +162,13 @@ class LDAPSyncTests(TestCase):
def test_sync_groups_ad(self): def test_sync_groups_ad(self):
"""Test group sync""" """Test group sync"""
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default") Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms") | Q(managed__startswith="goauthentik.io/sources/ldap/ms")
) )
) )
self.source.property_mappings_group.set( self.source.group_property_mappings.set(
LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name") LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name")
) )
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD)) connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
@ -191,13 +189,13 @@ class LDAPSyncTests(TestCase):
"""Test group sync""" """Test group sync"""
self.source.object_uniqueness_field = "uid" self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)" self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default") Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap") | Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
) )
) )
self.source.property_mappings_group.set( self.source.group_property_mappings.set(
LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/openldap-cn") LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/openldap-cn")
) )
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
@ -216,13 +214,13 @@ class LDAPSyncTests(TestCase):
self.source.group_membership_field = "memberUid" self.source.group_membership_field = "memberUid"
self.source.user_object_filter = "(objectClass=posixAccount)" self.source.user_object_filter = "(objectClass=posixAccount)"
self.source.group_object_filter = "(objectClass=posixGroup)" self.source.group_object_filter = "(objectClass=posixGroup)"
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default") Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap") | Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
) )
) )
self.source.property_mappings_group.set( self.source.group_property_mappings.set(
LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/openldap-cn") LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/openldap-cn")
) )
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
@ -240,7 +238,7 @@ class LDAPSyncTests(TestCase):
def test_tasks_ad(self): def test_tasks_ad(self):
"""Test Scheduled tasks""" """Test Scheduled tasks"""
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default") Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms") | Q(managed__startswith="goauthentik.io/sources/ldap/ms")
@ -255,7 +253,7 @@ class LDAPSyncTests(TestCase):
"""Test Scheduled tasks""" """Test Scheduled tasks"""
self.source.object_uniqueness_field = "uid" self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)" self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.property_mappings.set( self.source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default") Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap") | Q(managed__startswith="goauthentik.io/sources/ldap/openldap")

View File

@ -17,7 +17,7 @@ from authentik.events.utils import sanitize_item
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
from authentik.flows.views.executor import FlowExecutorView from authentik.flows.views.executor import FlowExecutorView
from authentik.lib.config import set_path_in_dict from authentik.lib.utils.dict import set_path_in_dict
from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT

View File

@ -4479,6 +4479,22 @@
"title": "Enrollment flow", "title": "Enrollment flow",
"description": "Flow to use when enrolling new users." "description": "Flow to use when enrolling new users."
}, },
"user_property_mappings": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "User property mappings"
},
"group_property_mappings": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "Group property mappings"
},
"policy_engine_mode": { "policy_engine_mode": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -4603,24 +4619,6 @@
"type": "string", "type": "string",
"format": "uuid", "format": "uuid",
"title": "Sync parent group" "title": "Sync parent group"
},
"property_mappings": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "Property mappings"
},
"property_mappings_group": {
"type": "array",
"items": {
"type": "string",
"format": "uuid",
"description": "Property mappings used for group creation/updating."
},
"title": "Property mappings group",
"description": "Property mappings used for group creation/updating."
} }
}, },
"required": [] "required": []
@ -4646,11 +4644,6 @@
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"title": "Expression" "title": "Expression"
},
"object_field": {
"type": "string",
"minLength": 1,
"title": "Object field"
} }
}, },
"required": [] "required": []
@ -4688,6 +4681,22 @@
"title": "Enrollment flow", "title": "Enrollment flow",
"description": "Flow to use when enrolling new users." "description": "Flow to use when enrolling new users."
}, },
"user_property_mappings": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "User property mappings"
},
"group_property_mappings": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "Group property mappings"
},
"policy_engine_mode": { "policy_engine_mode": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -4865,6 +4874,22 @@
"title": "Enrollment flow", "title": "Enrollment flow",
"description": "Flow to use when enrolling new users." "description": "Flow to use when enrolling new users."
}, },
"user_property_mappings": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "User property mappings"
},
"group_property_mappings": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "Group property mappings"
},
"policy_engine_mode": { "policy_engine_mode": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -4979,6 +5004,22 @@
"title": "Enrollment flow", "title": "Enrollment flow",
"description": "Flow to use when enrolling new users." "description": "Flow to use when enrolling new users."
}, },
"user_property_mappings": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "User property mappings"
},
"group_property_mappings": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"title": "Group property mappings"
},
"policy_engine_mode": { "policy_engine_mode": {
"type": "string", "type": "string",
"enum": [ "enum": [

View File

@ -9,7 +9,6 @@ entries:
model: authentik_sources_ldap.ldappropertymapping model: authentik_sources_ldap.ldappropertymapping
attrs: attrs:
name: "authentik default LDAP Mapping: DN to User Path" name: "authentik default LDAP Mapping: DN to User Path"
object_field: "path"
expression: | expression: |
path_elements = [] path_elements = []
for pair in dn.split(","): for pair in dn.split(","):
@ -23,32 +22,37 @@ entries:
path = source.get_user_path() path = source.get_user_path()
if len(path_elements) > 0: if len(path_elements) > 0:
path = f"{path}/{'/'.join(path_elements)}" path = f"{path}/{'/'.join(path_elements)}"
return path return {
"path": path
}
- identifiers: - identifiers:
managed: goauthentik.io/sources/ldap/default-name managed: goauthentik.io/sources/ldap/default-name
model: authentik_sources_ldap.ldappropertymapping model: authentik_sources_ldap.ldappropertymapping
attrs: attrs:
name: "authentik default LDAP Mapping: Name" name: "authentik default LDAP Mapping: Name"
object_field: "name"
expression: | expression: |
return ldap.get('name') return {
"name": ldap.get('name'),
}
- identifiers: - identifiers:
managed: goauthentik.io/sources/ldap/default-mail managed: goauthentik.io/sources/ldap/default-mail
model: authentik_sources_ldap.ldappropertymapping model: authentik_sources_ldap.ldappropertymapping
attrs: attrs:
name: "authentik default LDAP Mapping: mail" name: "authentik default LDAP Mapping: mail"
object_field: "email"
expression: | expression: |
return ldap.get('mail') return {
"email": ldap.get('mail'),
}
# ActiveDirectory-specific mappings # ActiveDirectory-specific mappings
- identifiers: - identifiers:
managed: goauthentik.io/sources/ldap/ms-samaccountname managed: goauthentik.io/sources/ldap/ms-samaccountname
model: authentik_sources_ldap.ldappropertymapping model: authentik_sources_ldap.ldappropertymapping
attrs: attrs:
name: "authentik default Active Directory Mapping: sAMAccountName" name: "authentik default Active Directory Mapping: sAMAccountName"
object_field: "username"
expression: | expression: |
return ldap.get('sAMAccountName') return {
"username": ldap.get('sAMAccountName'),
}
- identifiers: - identifiers:
managed: goauthentik.io/sources/ldap/ms-userprincipalname managed: goauthentik.io/sources/ldap/ms-userprincipalname
model: authentik_sources_ldap.ldappropertymapping model: authentik_sources_ldap.ldappropertymapping
@ -56,37 +60,49 @@ entries:
name: "authentik default Active Directory Mapping: userPrincipalName" name: "authentik default Active Directory Mapping: userPrincipalName"
object_field: "attributes.upn" object_field: "attributes.upn"
expression: | expression: |
return list_flatten(ldap.get('userPrincipalName')) return {
"attributes": {
"upn": list_flatten(ldap.get('userPrincipalName')),
},
}
- identifiers: - identifiers:
managed: goauthentik.io/sources/ldap/ms-givenName managed: goauthentik.io/sources/ldap/ms-givenName
model: authentik_sources_ldap.ldappropertymapping model: authentik_sources_ldap.ldappropertymapping
attrs: attrs:
name: "authentik default Active Directory Mapping: givenName" name: "authentik default Active Directory Mapping: givenName"
object_field: "attributes.givenName"
expression: | expression: |
return list_flatten(ldap.get('givenName')) return {
"attributes": {
"givenName": list_flatten(ldap.get('givenName')),
},
}
- identifiers: - identifiers:
managed: goauthentik.io/sources/ldap/ms-sn managed: goauthentik.io/sources/ldap/ms-sn
model: authentik_sources_ldap.ldappropertymapping model: authentik_sources_ldap.ldappropertymapping
attrs: attrs:
name: "authentik default Active Directory Mapping: sn" name: "authentik default Active Directory Mapping: sn"
object_field: "attributes.sn"
expression: | expression: |
return list_flatten(ldap.get('sn')) return {
"attributes": {
"sn": list_flatten(ldap.get('sn')),
},
}
# OpenLDAP specific mappings # OpenLDAP specific mappings
- identifiers: - identifiers:
managed: goauthentik.io/sources/ldap/openldap-uid managed: goauthentik.io/sources/ldap/openldap-uid
model: authentik_sources_ldap.ldappropertymapping model: authentik_sources_ldap.ldappropertymapping
attrs: attrs:
name: "authentik default OpenLDAP Mapping: uid" name: "authentik default OpenLDAP Mapping: uid"
object_field: "username"
expression: | expression: |
return ldap.get('uid') return {
"username": ldap.get('uid'),
}
- identifiers: - identifiers:
managed: goauthentik.io/sources/ldap/openldap-cn managed: goauthentik.io/sources/ldap/openldap-cn
model: authentik_sources_ldap.ldappropertymapping model: authentik_sources_ldap.ldappropertymapping
attrs: attrs:
name: "authentik default OpenLDAP Mapping: cn" name: "authentik default OpenLDAP Mapping: cn"
object_field: "name"
expression: | expression: |
return ldap.get('cn') return {
"name": ldap.get('cn'),
}

View File

@ -13494,10 +13494,6 @@ paths:
name: name name: name
schema: schema:
type: string type: string
- in: query
name: object_field
schema:
type: string
- name: ordering - name: ordering
required: false required: false
in: query in: query
@ -22080,6 +22076,15 @@ paths:
name: group_object_filter name: group_object_filter
schema: schema:
type: string type: string
- in: query
name: group_property_mappings
schema:
type: array
items:
type: string
format: uuid
explode: true
style: form
- in: query - in: query
name: name name: name
schema: schema:
@ -22115,24 +22120,6 @@ paths:
schema: schema:
type: string type: string
format: uuid format: uuid
- in: query
name: property_mappings
schema:
type: array
items:
type: string
format: uuid
explode: true
style: form
- in: query
name: property_mappings_group
schema:
type: array
items:
type: string
format: uuid
explode: true
style: form
- name: search - name: search
required: false required: false
in: query in: query
@ -22176,6 +22163,15 @@ paths:
name: user_object_filter name: user_object_filter
schema: schema:
type: string type: string
- in: query
name: user_property_mappings
schema:
type: array
items:
type: string
format: uuid
explode: true
style: form
tags: tags:
- sources - sources
security: security:
@ -37609,14 +37605,11 @@ components:
type: string type: string
description: Return internal model name description: Return internal model name
readOnly: true readOnly: true
object_field:
type: string
required: required:
- component - component
- expression - expression
- meta_model_name - meta_model_name
- name - name
- object_field
- pk - pk
- verbose_name - verbose_name
- verbose_name_plural - verbose_name_plural
@ -37639,13 +37632,9 @@ components:
expression: expression:
type: string type: string
minLength: 1 minLength: 1
object_field:
type: string
minLength: 1
required: required:
- expression - expression
- name - name
- object_field
LDAPProvider: LDAPProvider:
type: object type: object
description: LDAPProvider Serializer description: LDAPProvider Serializer
@ -37858,6 +37847,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
component: component:
type: string type: string
description: Get object component so that we know how to edit the object description: Get object component so that we know how to edit the object
@ -37956,17 +37955,6 @@ components:
type: string type: string
format: uuid format: uuid
nullable: true nullable: true
property_mappings:
type: array
items:
type: string
format: uuid
property_mappings_group:
type: array
items:
type: string
format: uuid
description: Property mappings used for group creation/updating.
connectivity: connectivity:
type: object type: object
additionalProperties: additionalProperties:
@ -38015,6 +38003,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
policy_engine_mode: policy_engine_mode:
$ref: '#/components/schemas/PolicyEngineMode' $ref: '#/components/schemas/PolicyEngineMode'
user_matching_mode: user_matching_mode:
@ -38095,17 +38093,6 @@ components:
type: string type: string
format: uuid format: uuid
nullable: true nullable: true
property_mappings:
type: array
items:
type: string
format: uuid
property_mappings_group:
type: array
items:
type: string
format: uuid
description: Property mappings used for group creation/updating.
required: required:
- base_dn - base_dn
- name - name
@ -39190,6 +39177,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
component: component:
type: string type: string
description: Get object component so that we know how to edit the object description: Get object component so that we know how to edit the object
@ -39308,6 +39305,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
policy_engine_mode: policy_engine_mode:
$ref: '#/components/schemas/PolicyEngineMode' $ref: '#/components/schemas/PolicyEngineMode'
user_matching_mode: user_matching_mode:
@ -42225,9 +42232,6 @@ components:
expression: expression:
type: string type: string
minLength: 1 minLength: 1
object_field:
type: string
minLength: 1
PatchedLDAPProviderRequest: PatchedLDAPProviderRequest:
type: object type: object
description: LDAPProvider Serializer description: LDAPProvider Serializer
@ -42318,6 +42322,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
policy_engine_mode: policy_engine_mode:
$ref: '#/components/schemas/PolicyEngineMode' $ref: '#/components/schemas/PolicyEngineMode'
user_matching_mode: user_matching_mode:
@ -42398,17 +42412,6 @@ components:
type: string type: string
format: uuid format: uuid
nullable: true nullable: true
property_mappings:
type: array
items:
type: string
format: uuid
property_mappings_group:
type: array
items:
type: string
format: uuid
description: Property mappings used for group creation/updating.
PatchedLicenseRequest: PatchedLicenseRequest:
type: object type: object
description: License Serializer description: License Serializer
@ -42641,6 +42644,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
policy_engine_mode: policy_engine_mode:
$ref: '#/components/schemas/PolicyEngineMode' $ref: '#/components/schemas/PolicyEngineMode'
user_matching_mode: user_matching_mode:
@ -42876,6 +42889,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
policy_engine_mode: policy_engine_mode:
$ref: '#/components/schemas/PolicyEngineMode' $ref: '#/components/schemas/PolicyEngineMode'
user_matching_mode: user_matching_mode:
@ -43339,6 +43362,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
policy_engine_mode: policy_engine_mode:
$ref: '#/components/schemas/PolicyEngineMode' $ref: '#/components/schemas/PolicyEngineMode'
user_matching_mode: user_matching_mode:
@ -43923,6 +43956,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
component: component:
type: string type: string
description: Get object component so that we know how to edit the object description: Get object component so that we know how to edit the object
@ -44050,6 +44093,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
policy_engine_mode: policy_engine_mode:
$ref: '#/components/schemas/PolicyEngineMode' $ref: '#/components/schemas/PolicyEngineMode'
user_matching_mode: user_matching_mode:
@ -45990,6 +46043,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
component: component:
type: string type: string
description: Get object component so that we know how to edit the object description: Get object component so that we know how to edit the object
@ -46118,6 +46181,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
policy_engine_mode: policy_engine_mode:
$ref: '#/components/schemas/PolicyEngineMode' $ref: '#/components/schemas/PolicyEngineMode'
user_matching_mode: user_matching_mode:
@ -46965,6 +47038,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
component: component:
type: string type: string
description: Get object component so that we know how to edit the object description: Get object component so that we know how to edit the object
@ -47042,6 +47125,16 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Flow to use when enrolling new users. description: Flow to use when enrolling new users.
user_property_mappings:
type: array
items:
type: string
format: uuid
group_property_mappings:
type: array
items:
type: string
format: uuid
policy_engine_mode: policy_engine_mode:
$ref: '#/components/schemas/PolicyEngineMode' $ref: '#/components/schemas/PolicyEngineMode'
user_matching_mode: user_matching_mode:

View File

@ -55,13 +55,13 @@ class TestSourceLDAPSamba(SeleniumTestCase):
additional_user_dn="ou=users", additional_user_dn="ou=users",
additional_group_dn="ou=groups", additional_group_dn="ou=groups",
) )
source.property_mappings.set( source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default-") Q(managed__startswith="goauthentik.io/sources/ldap/default-")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms-") | Q(managed__startswith="goauthentik.io/sources/ldap/ms-")
) )
) )
source.property_mappings_group.set( source.group_property_mappings.set(
LDAPPropertyMapping.objects.filter(name="goauthentik.io/sources/ldap/default-name") LDAPPropertyMapping.objects.filter(name="goauthentik.io/sources/ldap/default-name")
) )
UserLDAPSynchronizer(source).sync_full() UserLDAPSynchronizer(source).sync_full()
@ -86,13 +86,13 @@ class TestSourceLDAPSamba(SeleniumTestCase):
additional_user_dn="ou=users", additional_user_dn="ou=users",
additional_group_dn="ou=groups", additional_group_dn="ou=groups",
) )
source.property_mappings.set( source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default-") Q(managed__startswith="goauthentik.io/sources/ldap/default-")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms-") | Q(managed__startswith="goauthentik.io/sources/ldap/ms-")
) )
) )
source.property_mappings_group.set( source.group_property_mappings.set(
LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name") LDAPPropertyMapping.objects.filter(managed="goauthentik.io/sources/ldap/default-name")
) )
GroupLDAPSynchronizer(source).sync_full() GroupLDAPSynchronizer(source).sync_full()
@ -130,13 +130,13 @@ class TestSourceLDAPSamba(SeleniumTestCase):
additional_group_dn="ou=groups", additional_group_dn="ou=groups",
password_login_update_internal_password=True, password_login_update_internal_password=True,
) )
source.property_mappings.set( source.user_property_mappings.set(
LDAPPropertyMapping.objects.filter( LDAPPropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default-") Q(managed__startswith="goauthentik.io/sources/ldap/default-")
| Q(managed__startswith="goauthentik.io/sources/ldap/ms-") | Q(managed__startswith="goauthentik.io/sources/ldap/ms-")
) )
) )
source.property_mappings_group.set( source.group_property_mappings.set(
LDAPPropertyMapping.objects.filter(name="goauthentik.io/sources/ldap/default-name") LDAPPropertyMapping.objects.filter(name="goauthentik.io/sources/ldap/default-name")
) )
UserLDAPSynchronizer(source).sync_full() UserLDAPSynchronizer(source).sync_full()

View File

@ -42,21 +42,6 @@ export class PropertyMappingLDAPForm extends BasePropertyMappingForm<LDAPPropert
required required
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Object field")}
?required=${true}
name="objectField"
>
<input
type="text"
value="${ifDefined(this.instance?.objectField)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Field of the user object this value is written to.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("Expression")} label=${msg("Expression")}
?required=${true} ?required=${true}

View File

@ -28,7 +28,7 @@ import {
async function propertyMappingsProvider(page = 1, search = "") { async function propertyMappingsProvider(page = 1, search = "") {
const propertyMappings = await new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsLdapList( const propertyMappings = await new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsLdapList(
{ {
ordering: "managed,object_field", ordering: "managed",
pageSize: 20, pageSize: 20,
search: search.trim(), search: search.trim(),
page, page,
@ -291,12 +291,12 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
<div slot="body" class="pf-c-form"> <div slot="body" class="pf-c-form">
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("User Property Mappings")} label=${msg("User Property Mappings")}
name="propertyMappings" name="userPropertyMappings"
> >
<ak-dual-select-dynamic-selected <ak-dual-select-dynamic-selected
.provider=${propertyMappingsProvider} .provider=${propertyMappingsProvider}
.selector=${makePropertyMappingsSelector( .selector=${makePropertyMappingsSelector(
this.instance?.propertyMappings, this.instance?.userPropertyMappings,
)} )}
available-label="${msg("Available User Property Mappings")}" available-label="${msg("Available User Property Mappings")}"
selected-label="${msg("Selected User Property Mappings")}" selected-label="${msg("Selected User Property Mappings")}"
@ -307,12 +307,12 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("Group Property Mappings")} label=${msg("Group Property Mappings")}
name="propertyMappingsGroup" name="groupPropertyMappings"
> >
<ak-dual-select-dynamic-selected <ak-dual-select-dynamic-selected
.provider=${propertyMappingsProvider} .provider=${propertyMappingsProvider}
.selector=${makePropertyMappingsSelector( .selector=${makePropertyMappingsSelector(
this.instance?.propertyMappingsGroup, this.instance?.groupPropertyMappings,
)} )}
available-label="${msg("Available Group Property Mappings")}" available-label="${msg("Available Group Property Mappings")}"
selected-label="${msg("Selected Group Property Mappings")}" selected-label="${msg("Selected Group Property Mappings")}"

View File

@ -0,0 +1,67 @@
---
title: Release 2024.next
slug: "/releases/2024.next"
---
:::::note
2024.next has not been released yet! We're publishing these release notes as a preview of what's to come, and for our awesome beta testers trying out release candidates.
To try out the release candidate, replace your Docker image tag with the latest release candidate number, such as 2024.next.0-rc1. You can find the latest one in [the latest releases on GitHub](https://github.com/goauthentik/authentik/releases). If you don't find any, it means we haven't released one yet.
:::::
## Breaking changes
### Action is required
- **LDAP property mappings simplification**
LDAP property mappings have been reworked to remove **Object field**. With this release, instead of returning a single user or group attribute for each property mapping, you can now return several of them. Here is an example of what new property mappings look like:
```python
return {
"username": ldap.get("uid"), # list_flatten is automatically applied to top-level attributes
"attributes": {
"phone": list_flatten(ldap.get("phoneNumber")), # but not for attributes!
},
}
```
This property mapping populates the `username` and `attributes.phone` attributes of a user at the same time, reducing the number of mappings that are run and thus improving performance. Additionally, they are more straightforward to read, and this change allowed us to implement property mappings for OAuth and SAML sources as well.
authentik will automatically migrate existing property mappings to this new format, by generating some Python code for each of the existing property mappings expressions. authentik-manager property mappings will automatically get updated to the new format.
**If you have any custom property mappings, we recommend migrating them to this new format.**
## New features
## Upgrading
This release does not introduce any new requirements.
### docker-compose
To upgrade, download the new docker-compose file and update the Docker stack with the new version, using these commands:
```shell
wget -O docker-compose.yml https://goauthentik.io/version/xxxx.x/docker-compose.yml
docker compose up -d
```
The `-O` flag retains the downloaded file's name, overwriting any existing local file with the same name.
### Kubernetes
Upgrade the Helm Chart to the new version, using the following commands:
```shell
helm repo update
helm upgrade authentik authentik/authentik -f values.yaml --version ^xxxx.x
```
## Minor changes/fixes
<!-- _Insert the output of `make gen-changelog` here_ -->
## API Changes
<!-- _Insert output of `make gen-diff` here_ -->

View File

@ -71,7 +71,16 @@ LDAP property mappings can be used to convert the raw LDAP response into an auth
By default, authentik ships with [pre-configured mappings](../../property-mappings/index.md#ldap-property-mapping) for the most common LDAP setups. These mappings can be found on the LDAP Source Configuration page in the Admin interface. By default, authentik ships with [pre-configured mappings](../../property-mappings/index.md#ldap-property-mapping) for the most common LDAP setups. These mappings can be found on the LDAP Source Configuration page in the Admin interface.
You can assign the value of a mapping to any user attribute, or save it as a custom attribute by prefixing the object field with `attribute.` Keep in mind though, data types from the LDAP server will be carried over. This means that with some implementations, where fields are stored as array in LDAP, they will be saved as array in authentik. To prevent this, use the built-in `list_flatten` function. You can assign the value of a mapping to any user attribute. Keep in mind though, data types from the LDAP server will be carried over. This means that with some implementations, where fields are stored as array in LDAP, they will be saved as array in authentik. To prevent this, use the built-in `list_flatten` function. Here is an example mapping for the user's username and a custom attribute for a phone number:
```python
return {
"username": ldap.get("uid"), # list_flatten is automatically applied to top-level attributes
"attributes": {
"phone": list_flatten(ldap.get("phoneNumber")), # but not for attributes!
},
}
```
### Custom LDAP Property Mapping ### Custom LDAP Property Mapping