providers/scim: optimize PropertyMapping fetching and execution (#9689)

* add helper to mass-compile and re-use mappings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* implement for scim

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* actually make it even simpler

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* migrate google

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* migrate microsoft too

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove sleeps in tests with freezegun

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* migrate ldap to propertymapping helper

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* move mapper to generic sync

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* apparently that doesn't work

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* forgot a sleep

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* backport fixes from #9783

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L
2024-05-20 15:37:22 +02:00
committed by GitHub
parent 2e91b9d035
commit 44d7e81a93
20 changed files with 301 additions and 288 deletions

View File

@ -1,5 +1,6 @@
"""Property Mapping Evaluator""" """Property Mapping Evaluator"""
from types import CodeType
from typing import Any from typing import Any
from django.db.models import Model from django.db.models import Model
@ -24,6 +25,8 @@ class PropertyMappingEvaluator(BaseEvaluator):
"""Custom Evaluator that adds some different context variables.""" """Custom Evaluator that adds some different context variables."""
dry_run: bool dry_run: bool
model: Model
_compiled: CodeType | None = None
def __init__( def __init__(
self, self,
@ -33,23 +36,32 @@ class PropertyMappingEvaluator(BaseEvaluator):
dry_run: bool | None = False, dry_run: bool | None = False,
**kwargs, **kwargs,
): ):
self.model = model
if hasattr(model, "name"): if hasattr(model, "name"):
_filename = model.name _filename = model.name
else: else:
_filename = str(model) _filename = str(model)
super().__init__(filename=_filename) super().__init__(filename=_filename)
self.dry_run = dry_run
self.set_context(user, request, **kwargs)
def set_context(
self,
user: User | None = None,
request: HttpRequest | None = None,
**kwargs,
):
req = PolicyRequest(user=User()) req = PolicyRequest(user=User())
req.obj = model req.obj = self.model
if user: if user:
req.user = user req.user = user
self._context["user"] = user self._context["user"] = user
if request: if request:
req.http_request = request req.http_request = request
self._context["request"] = req
req.context.update(**kwargs) req.context.update(**kwargs)
self._context["request"] = req
self._context.update(**kwargs) self._context.update(**kwargs)
self._globals["SkipObject"] = SkipObjectException self._globals["SkipObject"] = SkipObjectException
self.dry_run = dry_run
def handle_error(self, exc: Exception, expression_source: str): def handle_error(self, exc: Exception, expression_source: str):
"""Exception Handler""" """Exception Handler"""
@ -71,3 +83,9 @@ class PropertyMappingEvaluator(BaseEvaluator):
def evaluate(self, *args, **kwargs) -> Any: def evaluate(self, *args, **kwargs) -> Any:
with PROPERTY_MAPPING_TIME.labels(mapping_name=self._filename).time(): with PROPERTY_MAPPING_TIME.labels(mapping_name=self._filename).time():
return super().evaluate(*args, **kwargs) return super().evaluate(*args, **kwargs)
def compile(self, expression: str | None = None) -> Any:
if not self._compiled:
compiled = super().compile(expression or self.model.expression)
self._compiled = compiled
return self._compiled

View File

@ -6,6 +6,11 @@ from authentik.lib.sentry import SentryIgnoredException
class PropertyMappingExpressionException(SentryIgnoredException): class PropertyMappingExpressionException(SentryIgnoredException):
"""Error when a PropertyMapping Exception expression could not be parsed or evaluated.""" """Error when a PropertyMapping Exception expression could not be parsed or evaluated."""
def __init__(self, exc: Exception, mapping) -> None:
super().__init__()
self.exc = exc
self.mapping = mapping
class SkipObjectException(PropertyMappingExpressionException): class SkipObjectException(PropertyMappingExpressionException):
"""Exception which can be raised in a property mapping to skip syncing an object. """Exception which can be raised in a property mapping to skip syncing an object.

View File

@ -768,7 +768,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
try: try:
return evaluator.evaluate(self.expression) return evaluator.evaluate(self.expression)
except Exception as exc: except Exception as exc:
raise PropertyMappingExpressionException(exc) from exc raise PropertyMappingExpressionException(self, exc) from exc
def __str__(self): def __str__(self):
return f"Property Mapping {self.name}" return f"Property Mapping {self.name}"

View File

@ -1,10 +1,11 @@
"""authentik core models tests""" """authentik core models tests"""
from collections.abc import Callable from collections.abc import Callable
from time import sleep from datetime import timedelta
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from django.utils.timezone import now from django.utils.timezone import now
from freezegun import freeze_time
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from authentik.core.models import Provider, Source, Token from authentik.core.models import Provider, Source, Token
@ -17,15 +18,17 @@ class TestModels(TestCase):
def test_token_expire(self): def test_token_expire(self):
"""Test token expiring""" """Test token expiring"""
token = Token.objects.create(expires=now(), user=get_anonymous_user()) with freeze_time() as freeze:
sleep(0.5) token = Token.objects.create(expires=now(), user=get_anonymous_user())
self.assertTrue(token.is_expired) freeze.tick(timedelta(seconds=1))
self.assertTrue(token.is_expired)
def test_token_expire_no_expire(self): def test_token_expire_no_expire(self):
"""Test token expiring with "expiring" set""" """Test token expiring with "expiring" set"""
token = Token.objects.create(expires=now(), user=get_anonymous_user(), expiring=False) with freeze_time() as freeze:
sleep(0.5) token = Token.objects.create(expires=now(), user=get_anonymous_user(), expiring=False)
self.assertFalse(token.is_expired) freeze.tick(timedelta(seconds=1))
self.assertFalse(token.is_expired)
def source_tester_factory(test_model: type[Stage]) -> Callable: def source_tester_factory(test_model: type[Stage]) -> Callable:

View File

@ -1,28 +1,22 @@
from deepmerge import always_merger
from django.db import transaction from django.db import transaction
from django.utils.text import slugify from django.utils.text import slugify
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import Group from authentik.core.models import Group
from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient
from authentik.enterprise.providers.google_workspace.models import ( from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProvider,
GoogleWorkspaceProviderGroup, GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderMapping, GoogleWorkspaceProviderMapping,
GoogleWorkspaceProviderUser, GoogleWorkspaceProviderUser,
) )
from authentik.events.models import Event, EventAction from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.base import Direction from authentik.lib.sync.outgoing.base import Direction
from authentik.lib.sync.outgoing.exceptions import ( from authentik.lib.sync.outgoing.exceptions import (
NotFoundSyncException, NotFoundSyncException,
ObjectExistsSyncException, ObjectExistsSyncException,
StopSync,
TransientSyncException, TransientSyncException,
) )
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.utils.errors import exception_to_string
class GoogleWorkspaceGroupClient( class GoogleWorkspaceGroupClient(
@ -34,41 +28,21 @@ class GoogleWorkspaceGroupClient(
connection_type_query = "group" connection_type_query = "group"
can_discover = True can_discover = True
def __init__(self, provider: GoogleWorkspaceProvider) -> None:
super().__init__(provider)
self.mapper = PropertyMappingManager(
self.provider.property_mappings_group.all().order_by("name").select_subclasses(),
GoogleWorkspaceProviderMapping,
["group", "provider", "creating"],
)
def to_schema(self, obj: Group, creating: bool) -> dict: def to_schema(self, obj: Group, creating: bool) -> dict:
"""Convert authentik group""" """Convert authentik group"""
raw_google_group = {} return super().to_schema(
for mapping in ( obj,
self.provider.property_mappings_group.all().order_by("name").select_subclasses() creating,
): email=f"{slugify(obj.name)}@{self.provider.default_group_email_domain}",
if not isinstance(mapping, GoogleWorkspaceProviderMapping):
continue
try:
value = mapping.evaluate(
user=None,
request=None,
group=obj,
provider=self.provider,
creating=creating,
)
if value is None:
continue
always_merger.merge(raw_google_group, value)
except SkipObjectException as exc:
raise exc from exc
except (PropertyMappingExpressionException, ValueError) 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=mapping,
).save()
raise StopSync(exc, obj, mapping) from exc
if not raw_google_group:
raise StopSync(ValueError("No group mappings configured"), obj)
raw_google_group.setdefault(
"email", f"{slugify(obj.name)}@{self.provider.default_group_email_domain}"
) )
return raw_google_group
def delete(self, obj: Group): def delete(self, obj: Group):
"""Delete group""" """Delete group"""

View File

@ -1,24 +1,18 @@
from deepmerge import always_merger
from django.db import transaction from django.db import transaction
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import User from authentik.core.models import User
from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient
from authentik.enterprise.providers.google_workspace.models import ( from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProvider,
GoogleWorkspaceProviderMapping, GoogleWorkspaceProviderMapping,
GoogleWorkspaceProviderUser, GoogleWorkspaceProviderUser,
) )
from authentik.events.models import Event, EventAction from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.exceptions import ( from authentik.lib.sync.outgoing.exceptions import (
ObjectExistsSyncException, ObjectExistsSyncException,
StopSync,
TransientSyncException, TransientSyncException,
) )
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.utils.errors import exception_to_string
from authentik.policies.utils import delete_none_values from authentik.policies.utils import delete_none_values
@ -29,35 +23,19 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP
connection_type_query = "user" connection_type_query = "user"
can_discover = True can_discover = True
def __init__(self, provider: GoogleWorkspaceProvider) -> None:
super().__init__(provider)
self.mapper = PropertyMappingManager(
self.provider.property_mappings.all().order_by("name").select_subclasses(),
GoogleWorkspaceProviderMapping,
["provider", "creating"],
)
def to_schema(self, obj: User, creating: bool) -> dict: def to_schema(self, obj: User, creating: bool) -> dict:
"""Convert authentik user""" """Convert authentik user"""
raw_google_user = {} raw_google_user = super().to_schema(obj, creating)
for mapping in self.provider.property_mappings.all().order_by("name").select_subclasses(): if "primaryEmail" not in raw_google_user:
if not isinstance(mapping, GoogleWorkspaceProviderMapping): raw_google_user["primaryEmail"] = str(obj.email)
continue
try:
value = mapping.evaluate(
user=obj,
request=None,
provider=self.provider,
creating=creating,
)
if value is None:
continue
always_merger.merge(raw_google_user, value)
except SkipObjectException as exc:
raise exc from exc
except (PropertyMappingExpressionException, ValueError) 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=mapping,
).save()
raise StopSync(exc, obj, mapping) from exc
if not raw_google_user:
raise StopSync(ValueError("No user mappings configured"), obj)
raw_google_user.setdefault("primaryEmail", str(obj.email))
return delete_none_values(raw_google_user) return delete_none_values(raw_google_user)
def delete(self, obj: User): def delete(self, obj: User):

View File

@ -1,21 +1,17 @@
from deepmerge import always_merger
from django.db import transaction from django.db import transaction
from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder
from msgraph.generated.models.group import Group as MSGroup from msgraph.generated.models.group import Group as MSGroup
from msgraph.generated.models.reference_create import ReferenceCreate from msgraph.generated.models.reference_create import ReferenceCreate
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import Group from authentik.core.models import Group
from authentik.enterprise.providers.microsoft_entra.clients.base import MicrosoftEntraSyncClient from authentik.enterprise.providers.microsoft_entra.clients.base import MicrosoftEntraSyncClient
from authentik.enterprise.providers.microsoft_entra.models import ( from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProvider,
MicrosoftEntraProviderGroup, MicrosoftEntraProviderGroup,
MicrosoftEntraProviderMapping, MicrosoftEntraProviderMapping,
MicrosoftEntraProviderUser, MicrosoftEntraProviderUser,
) )
from authentik.events.models import Event, EventAction from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.base import Direction from authentik.lib.sync.outgoing.base import Direction
from authentik.lib.sync.outgoing.exceptions import ( from authentik.lib.sync.outgoing.exceptions import (
NotFoundSyncException, NotFoundSyncException,
@ -24,7 +20,6 @@ from authentik.lib.sync.outgoing.exceptions import (
TransientSyncException, TransientSyncException,
) )
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.utils.errors import exception_to_string
class MicrosoftEntraGroupClient( class MicrosoftEntraGroupClient(
@ -36,37 +31,17 @@ class MicrosoftEntraGroupClient(
connection_type_query = "group" connection_type_query = "group"
can_discover = True can_discover = True
def __init__(self, provider: MicrosoftEntraProvider) -> None:
super().__init__(provider)
self.mapper = PropertyMappingManager(
self.provider.property_mappings_group.all().order_by("name").select_subclasses(),
MicrosoftEntraProviderMapping,
["group", "provider", "creating"],
)
def to_schema(self, obj: Group, creating: bool) -> MSGroup: def to_schema(self, obj: Group, creating: bool) -> MSGroup:
"""Convert authentik group""" """Convert authentik group"""
raw_microsoft_group = {} raw_microsoft_group = super().to_schema(obj, creating)
for mapping in (
self.provider.property_mappings_group.all().order_by("name").select_subclasses()
):
if not isinstance(mapping, MicrosoftEntraProviderMapping):
continue
try:
value = mapping.evaluate(
user=None,
request=None,
group=obj,
provider=self.provider,
creating=creating,
)
if value is None:
continue
always_merger.merge(raw_microsoft_group, value)
except SkipObjectException as exc:
raise exc from exc
except (PropertyMappingExpressionException, ValueError) 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=mapping,
).save()
raise StopSync(exc, obj, mapping) from exc
if not raw_microsoft_group:
raise StopSync(ValueError("No group mappings configured"), obj)
try: try:
return MSGroup(**raw_microsoft_group) return MSGroup(**raw_microsoft_group)
except TypeError as exc: except TypeError as exc:

View File

@ -1,26 +1,21 @@
from deepmerge import always_merger
from django.db import transaction from django.db import transaction
from msgraph.generated.models.user import User as MSUser from msgraph.generated.models.user import User as MSUser
from msgraph.generated.users.users_request_builder import UsersRequestBuilder from msgraph.generated.users.users_request_builder import UsersRequestBuilder
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import User from authentik.core.models import User
from authentik.enterprise.providers.microsoft_entra.clients.base import MicrosoftEntraSyncClient from authentik.enterprise.providers.microsoft_entra.clients.base import MicrosoftEntraSyncClient
from authentik.enterprise.providers.microsoft_entra.models import ( from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProvider,
MicrosoftEntraProviderMapping, MicrosoftEntraProviderMapping,
MicrosoftEntraProviderUser, MicrosoftEntraProviderUser,
) )
from authentik.events.models import Event, EventAction from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.exceptions import ( from authentik.lib.sync.outgoing.exceptions import (
ObjectExistsSyncException, ObjectExistsSyncException,
StopSync, StopSync,
TransientSyncException, TransientSyncException,
) )
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.utils.errors import exception_to_string
from authentik.policies.utils import delete_none_values from authentik.policies.utils import delete_none_values
@ -31,34 +26,17 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv
connection_type_query = "user" connection_type_query = "user"
can_discover = True can_discover = True
def __init__(self, provider: MicrosoftEntraProvider) -> None:
super().__init__(provider)
self.mapper = PropertyMappingManager(
self.provider.property_mappings.all().order_by("name").select_subclasses(),
MicrosoftEntraProviderMapping,
["provider", "creating"],
)
def to_schema(self, obj: User, creating: bool) -> MSUser: def to_schema(self, obj: User, creating: bool) -> MSUser:
"""Convert authentik user""" """Convert authentik user"""
raw_microsoft_user = {} raw_microsoft_user = super().to_schema(obj, creating)
for mapping in self.provider.property_mappings.all().order_by("name").select_subclasses():
if not isinstance(mapping, MicrosoftEntraProviderMapping):
continue
try:
value = mapping.evaluate(
user=obj,
request=None,
provider=self.provider,
creating=creating,
)
if value is None:
continue
always_merger.merge(raw_microsoft_user, value)
except SkipObjectException as exc:
raise exc from exc
except (PropertyMappingExpressionException, ValueError) 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=mapping,
).save()
raise StopSync(exc, obj, mapping) from exc
if not raw_microsoft_user:
raise StopSync(ValueError("No user mappings configured"), obj)
try: try:
return MSUser(**delete_none_values(raw_microsoft_user)) return MSUser(**delete_none_values(raw_microsoft_user))
except TypeError as exc: except TypeError as exc:

View File

@ -5,6 +5,7 @@ import socket
from collections.abc import Iterable from collections.abc import Iterable
from ipaddress import ip_address, ip_network from ipaddress import ip_address, ip_network
from textwrap import indent from textwrap import indent
from types import CodeType
from typing import Any from typing import Any
from cachetools import TLRUCache, cached from cachetools import TLRUCache, cached
@ -184,7 +185,7 @@ class BaseEvaluator:
full_expression += f"\nresult = handler({handler_signature})" full_expression += f"\nresult = handler({handler_signature})"
return full_expression return full_expression
def compile(self, expression: str) -> Any: def compile(self, expression: str) -> CodeType:
"""Parse expression. Raises SyntaxError or ValueError if the syntax is incorrect.""" """Parse expression. Raises SyntaxError or ValueError if the syntax is incorrect."""
param_keys = self._context.keys() param_keys = self._context.keys()
return compile(self.wrap_expression(expression, param_keys), self._filename, "exec") return compile(self.wrap_expression(expression, param_keys), self._filename, "exec")

View File

@ -0,0 +1,67 @@
from collections.abc import Generator
from django.db.models import QuerySet
from django.http import HttpRequest
from authentik.core.expression.evaluator import PropertyMappingEvaluator
from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.models import PropertyMapping, User
class PropertyMappingManager:
"""Pre-compile and cache property mappings when an identical
set is used multiple times"""
query_set: QuerySet[PropertyMapping]
mapping_subclass: type[PropertyMapping]
_evaluators: list[PropertyMappingEvaluator]
def __init__(
self,
qs: QuerySet[PropertyMapping],
# Expected subclass of PropertyMappings, any objects in the queryset
# that are not an instance of this class will be discarded
mapping_subclass: type[PropertyMapping],
# As they keys of parameters are part of the compilation,
# we need a list of all parameter names that will be used during evaluation
context_keys: list[str],
) -> None:
self.query_set = qs
self.mapping_subclass = mapping_subclass
self.context_keys = context_keys
self.compile()
def compile(self):
self._evaluators = []
for mapping in self.query_set:
if not isinstance(mapping, self.mapping_subclass):
continue
evaluator = PropertyMappingEvaluator(
mapping, **{key: None for key in self.context_keys}
)
# Compile and cache expression
evaluator.compile()
self._evaluators.append(evaluator)
def iter_eval(
self,
user: User | None,
request: HttpRequest | None,
return_mapping: bool = False,
**kwargs,
) -> Generator[tuple[dict, PropertyMapping], None]:
"""Iterate over all mappings that were pre-compiled and
execute all of them with the given context"""
for mapping in self._evaluators:
mapping.set_context(user, request, **kwargs)
try:
value = mapping.evaluate(mapping.model.expression)
except Exception as exc:
raise PropertyMappingExpressionException(exc, mapping.model) from exc
if value is None:
continue
if return_mapping:
yield value, mapping.model
else:
yield value

View File

@ -3,10 +3,18 @@
from enum import StrEnum from enum import StrEnum
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from deepmerge import always_merger
from django.db import DatabaseError from django.db import DatabaseError
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.events.models import Event, EventAction
from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException, StopSync
from authentik.lib.utils.errors import exception_to_string
if TYPE_CHECKING: if TYPE_CHECKING:
from django.db.models import Model from django.db.models import Model
@ -28,6 +36,7 @@ class BaseOutgoingSyncClient[
provider: TProvider provider: TProvider
connection_type: type[TConnection] connection_type: type[TConnection]
connection_type_query: str connection_type_query: str
mapper: PropertyMappingManager
can_discover = False can_discover = False
@ -70,9 +79,35 @@ class BaseOutgoingSyncClient[
"""Delete object from destination""" """Delete object from destination"""
raise NotImplementedError() raise NotImplementedError()
def to_schema(self, obj: TModel, creating: bool) -> TSchema: def to_schema(self, obj: TModel, creating: bool, **defaults) -> TSchema:
"""Convert object to destination schema""" """Convert object to destination schema"""
raise NotImplementedError() raw_final_object = {}
try:
eval_kwargs = {
"request": None,
"provider": self.provider,
"creating": creating,
obj._meta.model_name: obj,
}
eval_kwargs.setdefault("user", None)
for value in self.mapper.iter_eval(**eval_kwargs):
try:
always_merger.merge(raw_final_object, 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()
raise StopSync(exc, obj, exc.mapping) from exc
if not raw_final_object:
raise StopSync(ValueError("No user mappings configured"), obj)
for key, value in defaults.items():
raw_final_object.setdefault(key, value)
return raw_final_object
def discover(self): def discover(self):
"""Optional method. Can be used to implement a "discovery" where """Optional method. Can be used to implement a "discovery" where

View File

@ -1,31 +1,25 @@
"""Group client""" """Group client"""
from deepmerge import always_merger
from pydantic import ValidationError from pydantic import ValidationError
from pydanticscim.group import GroupMember from pydanticscim.group import GroupMember
from pydanticscim.responses import PatchOp, PatchOperation from pydanticscim.responses import PatchOp, PatchOperation
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import Group from authentik.core.models import Group
from authentik.events.models import Event, EventAction from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.base import Direction from authentik.lib.sync.outgoing.base import Direction
from authentik.lib.sync.outgoing.exceptions import ( from authentik.lib.sync.outgoing.exceptions import (
NotFoundSyncException, NotFoundSyncException,
ObjectExistsSyncException, ObjectExistsSyncException,
StopSync, StopSync,
) )
from authentik.lib.utils.errors import exception_to_string
from authentik.policies.utils import delete_none_values from authentik.policies.utils import delete_none_values
from authentik.providers.scim.clients.base import SCIMClient from authentik.providers.scim.clients.base import SCIMClient
from authentik.providers.scim.clients.exceptions import ( from authentik.providers.scim.clients.exceptions import (
SCIMRequestException, SCIMRequestException,
) )
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchRequest
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
from authentik.providers.scim.clients.schema import PatchRequest from authentik.providers.scim.models import SCIMGroup, SCIMMapping, SCIMProvider, SCIMUser
from authentik.providers.scim.models import SCIMGroup, SCIMMapping, SCIMUser
class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]): class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]):
@ -33,41 +27,23 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]):
connection_type = SCIMGroup connection_type = SCIMGroup
connection_type_query = "group" connection_type_query = "group"
mapper: PropertyMappingManager
def __init__(self, provider: SCIMProvider):
super().__init__(provider)
self.mapper = PropertyMappingManager(
self.provider.property_mappings_group.all().order_by("name").select_subclasses(),
SCIMMapping,
["group", "provider", "creating"],
)
def to_schema(self, obj: Group, creating: bool) -> SCIMGroupSchema: def to_schema(self, obj: Group, creating: bool) -> SCIMGroupSchema:
"""Convert authentik user into SCIM""" """Convert authentik user into SCIM"""
raw_scim_group = { raw_scim_group = super().to_schema(
"schemas": ("urn:ietf:params:scim:schemas:core:2.0:Group",), obj,
} creating,
for mapping in ( schemas=(SCIM_GROUP_SCHEMA,),
self.provider.property_mappings_group.all().order_by("name").select_subclasses() )
):
if not isinstance(mapping, SCIMMapping):
continue
try:
mapping: SCIMMapping
value = mapping.evaluate(
user=None,
request=None,
group=obj,
provider=self.provider,
creating=creating,
)
if value is None:
continue
always_merger.merge(raw_scim_group, value)
except SkipObjectException as exc:
raise exc from exc
except (PropertyMappingExpressionException, ValueError) 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=mapping,
).save()
raise StopSync(exc, obj, mapping) from exc
if not raw_scim_group:
raise StopSync(ValueError("No group mappings configured"), obj)
try: try:
scim_group = SCIMGroupSchema.model_validate(delete_none_values(raw_scim_group)) scim_group = SCIMGroupSchema.model_validate(delete_none_values(raw_scim_group))
except ValidationError as exc: except ValidationError as exc:

View File

@ -1,20 +1,15 @@
"""User client""" """User client"""
from deepmerge import always_merger
from pydantic import ValidationError from pydantic import ValidationError
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import User from authentik.core.models import User
from authentik.events.models import Event, EventAction from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.exceptions import StopSync from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.lib.utils.errors import exception_to_string
from authentik.policies.utils import delete_none_values from authentik.policies.utils import delete_none_values
from authentik.providers.scim.clients.base import SCIMClient from authentik.providers.scim.clients.base import SCIMClient
from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
from authentik.providers.scim.clients.schema import User as SCIMUserSchema from authentik.providers.scim.clients.schema import User as SCIMUserSchema
from authentik.providers.scim.models import SCIMMapping, SCIMUser from authentik.providers.scim.models import SCIMMapping, SCIMProvider, SCIMUser
class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]): class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]):
@ -22,38 +17,23 @@ class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]):
connection_type = SCIMUser connection_type = SCIMUser
connection_type_query = "user" connection_type_query = "user"
mapper: PropertyMappingManager
def __init__(self, provider: SCIMProvider):
super().__init__(provider)
self.mapper = PropertyMappingManager(
self.provider.property_mappings.all().order_by("name").select_subclasses(),
SCIMMapping,
["provider", "creating"],
)
def to_schema(self, obj: User, creating: bool) -> SCIMUserSchema: def to_schema(self, obj: User, creating: bool) -> SCIMUserSchema:
"""Convert authentik user into SCIM""" """Convert authentik user into SCIM"""
raw_scim_user = { raw_scim_user = super().to_schema(
"schemas": ("urn:ietf:params:scim:schemas:core:2.0:User",), obj,
} creating,
for mapping in self.provider.property_mappings.all().order_by("name").select_subclasses(): schemas=(SCIM_USER_SCHEMA,),
if not isinstance(mapping, SCIMMapping): )
continue
try:
mapping: SCIMMapping
value = mapping.evaluate(
user=obj,
request=None,
provider=self.provider,
creating=creating,
)
if value is None:
continue
always_merger.merge(raw_scim_user, value)
except SkipObjectException as exc:
raise exc from exc
except (PropertyMappingExpressionException, ValueError) 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=mapping,
).save()
raise StopSync(exc, obj, mapping) from exc
if not raw_scim_user:
raise StopSync(ValueError("No user mappings configured"), obj)
try: try:
scim_user = SCIMUserSchema.model_validate(delete_none_values(raw_scim_user)) scim_user = SCIMUserSchema.model_validate(delete_none_values(raw_scim_user))
except ValidationError as exc: except ValidationError as exc:

View File

@ -5,7 +5,6 @@ from typing import Any
from django.conf import settings from django.conf import settings
from django.db.models.base import Model from django.db.models.base import Model
from django.db.models.query import QuerySet
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
@ -16,8 +15,11 @@ from authentik.core.expression.exceptions import (
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.lib.config import CONFIG, set_path_in_dict 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.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.auth import LDAP_DISTINGUISHED_NAME
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.models import LDAPSource
LDAP_UNIQUENESS = "ldap_uniq" LDAP_UNIQUENESS = "ldap_uniq"
@ -38,6 +40,7 @@ class BaseLDAPSynchronizer:
_logger: BoundLogger _logger: BoundLogger
_connection: Connection _connection: Connection
_messages: list[str] _messages: list[str]
mapper: PropertyMappingManager
def __init__(self, source: LDAPSource): def __init__(self, source: LDAPSource):
self._source = source self._source = source
@ -139,52 +142,47 @@ class BaseLDAPSynchronizer:
def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]: def build_user_properties(self, user_dn: str, **kwargs) -> dict[str, Any]:
"""Build attributes for User object based on property mappings.""" """Build attributes for User object based on property mappings."""
props = self._build_object_properties(user_dn, self._source.property_mappings, **kwargs) props = self._build_object_properties(user_dn, **kwargs)
props.setdefault("path", self._source.get_user_path()) props.setdefault("path", self._source.get_user_path())
return props return props
def build_group_properties(self, group_dn: str, **kwargs) -> dict[str, Any]: def build_group_properties(self, group_dn: str, **kwargs) -> dict[str, Any]:
"""Build attributes for Group object based on property mappings.""" """Build attributes for Group object based on property mappings."""
return self._build_object_properties( return self._build_object_properties(group_dn, **kwargs)
group_dn, self._source.property_mappings_group, **kwargs
)
def _build_object_properties( def _build_object_properties(self, object_dn: str, **kwargs) -> dict[str, dict[Any, Any]]:
self, object_dn: str, mappings: QuerySet, **kwargs
) -> dict[str, dict[Any, Any]]:
properties = {"attributes": {}} properties = {"attributes": {}}
for mapping in mappings.all().select_subclasses(): try:
if not isinstance(mapping, LDAPPropertyMapping): for value, mapping in self.mapper.iter_eval(
continue user=None,
mapping: LDAPPropertyMapping request=None,
try: return_mapping=True,
value = mapping.evaluate( ldap=kwargs,
user=None, request=None, ldap=kwargs, dn=object_dn, source=self._source dn=object_dn,
) source=self._source,
if value is None: ):
self._logger.warning("property mapping returned None", mapping=mapping) try:
continue if isinstance(value, (bytes)):
if isinstance(value, (bytes)): self._logger.warning("property mapping returned bytes", mapping=mapping)
self._logger.warning("property mapping returned bytes", mapping=mapping) continue
continue object_field = mapping.object_field
object_field = mapping.object_field if object_field.startswith("attributes."):
if object_field.startswith("attributes."): # Because returning a list might desired, we can't
# Because returning a list might desired, we can't # rely on flatten here. Instead, just save the result as-is
# rely on flatten here. Instead, just save the result as-is set_path_in_dict(properties, object_field, value)
set_path_in_dict(properties, object_field, value) else:
else: properties[object_field] = flatten(value)
properties[object_field] = flatten(value) except SkipObjectException as exc:
except SkipObjectException as exc: raise exc from exc
raise exc from exc except PropertyMappingExpressionException as exc:
except PropertyMappingExpressionException as exc: # Value error can be raised when assigning invalid data to an attribute
Event.new( Event.new(
EventAction.CONFIGURATION_ERROR, EventAction.CONFIGURATION_ERROR,
message=f"Failed to evaluate property-mapping: '{mapping.name}'", message=f"Failed to evaluate property-mapping {exception_to_string(exc)}",
source=self._source, mapping=exc.mapping,
mapping=mapping, ).save()
).save() self._logger.warning("Mapping failed to evaluate", exc=exc, mapping=exc.mapping)
self._logger.warning("Mapping failed to evaluate", exc=exc, mapping=mapping) raise StopSync(exc, None, exc.mapping) from exc
continue
if self._source.object_uniqueness_field in kwargs: if self._source.object_uniqueness_field in kwargs:
properties["attributes"][LDAP_UNIQUENESS] = flatten( properties["attributes"][LDAP_UNIQUENESS] = flatten(
kwargs.get(self._source.object_uniqueness_field) kwargs.get(self._source.object_uniqueness_field)

View File

@ -9,12 +9,22 @@ from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE
from authentik.core.expression.exceptions import SkipObjectException from authentik.core.expression.exceptions import SkipObjectException
from authentik.core.models import Group from authentik.core.models import Group
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten
class GroupLDAPSynchronizer(BaseLDAPSynchronizer): class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
"""Sync LDAP Users and groups into authentik""" """Sync LDAP Users and groups into authentik"""
def __init__(self, source: LDAPSource):
super().__init__(source)
self.mapper = PropertyMappingManager(
self._source.property_mappings_group.all().order_by("name").select_subclasses(),
LDAPPropertyMapping,
["ldap", "dn", "source"],
)
@staticmethod @staticmethod
def name() -> str: def name() -> str:
return "groups" return "groups"

View File

@ -9,6 +9,8 @@ from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE
from authentik.core.expression.exceptions import SkipObjectException from authentik.core.expression.exceptions import SkipObjectException
from authentik.core.models import User from authentik.core.models import User
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten from authentik.sources.ldap.sync.base import LDAP_UNIQUENESS, BaseLDAPSynchronizer, flatten
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
@ -17,6 +19,14 @@ from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory
class UserLDAPSynchronizer(BaseLDAPSynchronizer): class UserLDAPSynchronizer(BaseLDAPSynchronizer):
"""Sync LDAP Users into authentik""" """Sync LDAP Users into authentik"""
def __init__(self, source: LDAPSource):
super().__init__(source)
self.mapper = PropertyMappingManager(
self._source.property_mappings.all().order_by("name").select_subclasses(),
LDAPPropertyMapping,
["ldap", "dn", "source"],
)
@staticmethod @staticmethod
def name() -> str: def name() -> str:
return "users" return "users"

View File

@ -12,6 +12,7 @@ from authentik.events.models import SystemTask as DBSystemTask
from authentik.events.models import TaskStatus from authentik.events.models import TaskStatus
from authentik.events.system_tasks import SystemTask from authentik.events.system_tasks import SystemTask
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import class_to_path, path_to_class from authentik.lib.utils.reflection import class_to_path, path_to_class
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
@ -138,7 +139,7 @@ def ldap_sync(self: SystemTask, source_pk: str, sync_class: str, page_cache_key:
*messages, *messages,
) )
cache.delete(page_cache_key) cache.delete(page_cache_key)
except LDAPException as exc: except (LDAPException, StopSync) as exc:
# No explicit event is created here as .set_status with an error will do that # No explicit event is created here as .set_status with an error will do that
LOGGER.warning(exception_to_string(exc)) LOGGER.warning(exception_to_string(exc))
self.set_error(exc) self.set_error(exc)

View File

@ -11,6 +11,7 @@ from authentik.core.tests.utils import create_test_admin_user
from authentik.events.models import Event, EventAction, SystemTask from authentik.events.models import Event, EventAction, SystemTask
from authentik.events.system_tasks import TaskStatus from authentik.events.system_tasks import TaskStatus
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key
from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.lib.utils.reflection import class_to_path from authentik.lib.utils.reflection import class_to_path
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
@ -63,12 +64,13 @@ class LDAPSyncTests(TestCase):
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):
user_sync = UserLDAPSynchronizer(self.source) user_sync = UserLDAPSynchronizer(self.source)
user_sync.sync_full() with self.assertRaises(StopSync):
user_sync.sync_full()
self.assertFalse(User.objects.filter(username="user0_sn").exists()) self.assertFalse(User.objects.filter(username="user0_sn").exists())
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,
) )
self.assertTrue(events.exists()) self.assertTrue(events.exists())

View File

@ -1,8 +1,9 @@
"""consent tests""" """consent tests"""
from time import sleep from datetime import timedelta
from django.urls import reverse from django.urls import reverse
from freezegun import freeze_time
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tasks import clean_expired_models from authentik.core.tasks import clean_expired_models
@ -136,11 +137,12 @@ class TestConsentStage(FlowTestCase):
self.assertTrue( self.assertTrue(
UserConsent.objects.filter(user=self.user, application=self.application).exists() UserConsent.objects.filter(user=self.user, application=self.application).exists()
) )
sleep(1) with freeze_time() as frozen_time:
clean_expired_models.delay().get() frozen_time.tick(timedelta(seconds=3))
self.assertFalse( clean_expired_models.delay().get()
UserConsent.objects.filter(user=self.user, application=self.application).exists() self.assertFalse(
) UserConsent.objects.filter(user=self.user, application=self.application).exists()
)
def test_permanent_more_perms(self): def test_permanent_more_perms(self):
"""Test permanent consent from user""" """Test permanent consent from user"""

View File

@ -208,7 +208,7 @@ class Prompt(SerializerModel):
try: try:
return evaluator.evaluate(self.placeholder) return evaluator.evaluate(self.placeholder)
except Exception as exc: # pylint:disable=broad-except except Exception as exc: # pylint:disable=broad-except
wrapped = PropertyMappingExpressionException(str(exc)) wrapped = PropertyMappingExpressionException(str(exc), None)
LOGGER.warning( LOGGER.warning(
"failed to evaluate prompt placeholder", "failed to evaluate prompt placeholder",
exc=wrapped, exc=wrapped,