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"""
from types import CodeType
from typing import Any
from django.db.models import Model
@ -24,6 +25,8 @@ class PropertyMappingEvaluator(BaseEvaluator):
"""Custom Evaluator that adds some different context variables."""
dry_run: bool
model: Model
_compiled: CodeType | None = None
def __init__(
self,
@ -33,23 +36,32 @@ class PropertyMappingEvaluator(BaseEvaluator):
dry_run: bool | None = False,
**kwargs,
):
self.model = model
if hasattr(model, "name"):
_filename = model.name
else:
_filename = str(model)
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.obj = model
req.obj = self.model
if user:
req.user = user
self._context["user"] = user
if request:
req.http_request = request
self._context["request"] = req
req.context.update(**kwargs)
self._context["request"] = req
self._context.update(**kwargs)
self._globals["SkipObject"] = SkipObjectException
self.dry_run = dry_run
def handle_error(self, exc: Exception, expression_source: str):
"""Exception Handler"""
@ -71,3 +83,9 @@ class PropertyMappingEvaluator(BaseEvaluator):
def evaluate(self, *args, **kwargs) -> Any:
with PROPERTY_MAPPING_TIME.labels(mapping_name=self._filename).time():
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):
"""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):
"""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:
return evaluator.evaluate(self.expression)
except Exception as exc:
raise PropertyMappingExpressionException(exc) from exc
raise PropertyMappingExpressionException(self, exc) from exc
def __str__(self):
return f"Property Mapping {self.name}"

View File

@ -1,10 +1,11 @@
"""authentik core models tests"""
from collections.abc import Callable
from time import sleep
from datetime import timedelta
from django.test import RequestFactory, TestCase
from django.utils.timezone import now
from freezegun import freeze_time
from guardian.shortcuts import get_anonymous_user
from authentik.core.models import Provider, Source, Token
@ -17,15 +18,17 @@ class TestModels(TestCase):
def test_token_expire(self):
"""Test token expiring"""
token = Token.objects.create(expires=now(), user=get_anonymous_user())
sleep(0.5)
self.assertTrue(token.is_expired)
with freeze_time() as freeze:
token = Token.objects.create(expires=now(), user=get_anonymous_user())
freeze.tick(timedelta(seconds=1))
self.assertTrue(token.is_expired)
def test_token_expire_no_expire(self):
"""Test token expiring with "expiring" set"""
token = Token.objects.create(expires=now(), user=get_anonymous_user(), expiring=False)
sleep(0.5)
self.assertFalse(token.is_expired)
with freeze_time() as freeze:
token = Token.objects.create(expires=now(), user=get_anonymous_user(), expiring=False)
freeze.tick(timedelta(seconds=1))
self.assertFalse(token.is_expired)
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.utils.text import slugify
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import Group
from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProvider,
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderMapping,
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.exceptions import (
NotFoundSyncException,
ObjectExistsSyncException,
StopSync,
TransientSyncException,
)
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.utils.errors import exception_to_string
class GoogleWorkspaceGroupClient(
@ -34,41 +28,21 @@ class GoogleWorkspaceGroupClient(
connection_type_query = "group"
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:
"""Convert authentik group"""
raw_google_group = {}
for mapping in (
self.provider.property_mappings_group.all().order_by("name").select_subclasses()
):
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 super().to_schema(
obj,
creating,
email=f"{slugify(obj.name)}@{self.provider.default_group_email_domain}",
)
return raw_google_group
def delete(self, obj: Group):
"""Delete group"""

View File

@ -1,24 +1,18 @@
from deepmerge import always_merger
from django.db import transaction
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import User
from authentik.enterprise.providers.google_workspace.clients.base import GoogleWorkspaceSyncClient
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProvider,
GoogleWorkspaceProviderMapping,
GoogleWorkspaceProviderUser,
)
from authentik.events.models import Event, EventAction
from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.exceptions import (
ObjectExistsSyncException,
StopSync,
TransientSyncException,
)
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
@ -29,35 +23,19 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP
connection_type_query = "user"
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:
"""Convert authentik user"""
raw_google_user = {}
for mapping in self.provider.property_mappings.all().order_by("name").select_subclasses():
if not isinstance(mapping, GoogleWorkspaceProviderMapping):
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))
raw_google_user = super().to_schema(obj, creating)
if "primaryEmail" not in raw_google_user:
raw_google_user["primaryEmail"] = str(obj.email)
return delete_none_values(raw_google_user)
def delete(self, obj: User):

View File

@ -1,21 +1,17 @@
from deepmerge import always_merger
from django.db import transaction
from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder
from msgraph.generated.models.group import Group as MSGroup
from msgraph.generated.models.reference_create import ReferenceCreate
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
from authentik.core.models import Group
from authentik.enterprise.providers.microsoft_entra.clients.base import MicrosoftEntraSyncClient
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProvider,
MicrosoftEntraProviderGroup,
MicrosoftEntraProviderMapping,
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.exceptions import (
NotFoundSyncException,
@ -24,7 +20,6 @@ from authentik.lib.sync.outgoing.exceptions import (
TransientSyncException,
)
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction
from authentik.lib.utils.errors import exception_to_string
class MicrosoftEntraGroupClient(
@ -36,37 +31,17 @@ class MicrosoftEntraGroupClient(
connection_type_query = "group"
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:
"""Convert authentik group"""
raw_microsoft_group = {}
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)
raw_microsoft_group = super().to_schema(obj, creating)
try:
return MSGroup(**raw_microsoft_group)
except TypeError as exc:

View File

@ -1,26 +1,21 @@
from deepmerge import always_merger
from django.db import transaction
from msgraph.generated.models.user import User as MSUser
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.enterprise.providers.microsoft_entra.clients.base import MicrosoftEntraSyncClient
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProvider,
MicrosoftEntraProviderMapping,
MicrosoftEntraProviderUser,
)
from authentik.events.models import Event, EventAction
from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.exceptions import (
ObjectExistsSyncException,
StopSync,
TransientSyncException,
)
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
@ -31,34 +26,17 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv
connection_type_query = "user"
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:
"""Convert authentik user"""
raw_microsoft_user = {}
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)
raw_microsoft_user = super().to_schema(obj, creating)
try:
return MSUser(**delete_none_values(raw_microsoft_user))
except TypeError as exc:

View File

@ -5,6 +5,7 @@ import socket
from collections.abc import Iterable
from ipaddress import ip_address, ip_network
from textwrap import indent
from types import CodeType
from typing import Any
from cachetools import TLRUCache, cached
@ -184,7 +185,7 @@ class BaseEvaluator:
full_expression += f"\nresult = handler({handler_signature})"
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."""
param_keys = self._context.keys()
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 typing import TYPE_CHECKING
from deepmerge import always_merger
from django.db import DatabaseError
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:
from django.db.models import Model
@ -28,6 +36,7 @@ class BaseOutgoingSyncClient[
provider: TProvider
connection_type: type[TConnection]
connection_type_query: str
mapper: PropertyMappingManager
can_discover = False
@ -70,9 +79,35 @@ class BaseOutgoingSyncClient[
"""Delete object from destination"""
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"""
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):
"""Optional method. Can be used to implement a "discovery" where

View File

@ -1,31 +1,25 @@
"""Group client"""
from deepmerge import always_merger
from pydantic import ValidationError
from pydanticscim.group import GroupMember
from pydanticscim.responses import PatchOp, PatchOperation
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
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.exceptions import (
NotFoundSyncException,
ObjectExistsSyncException,
StopSync,
)
from authentik.lib.utils.errors import exception_to_string
from authentik.policies.utils import delete_none_values
from authentik.providers.scim.clients.base import SCIMClient
from authentik.providers.scim.clients.exceptions import (
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 PatchRequest
from authentik.providers.scim.models import SCIMGroup, SCIMMapping, SCIMUser
from authentik.providers.scim.models import SCIMGroup, SCIMMapping, SCIMProvider, SCIMUser
class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]):
@ -33,41 +27,23 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]):
connection_type = SCIMGroup
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:
"""Convert authentik user into SCIM"""
raw_scim_group = {
"schemas": ("urn:ietf:params:scim:schemas:core:2.0:Group",),
}
for mapping in (
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)
raw_scim_group = super().to_schema(
obj,
creating,
schemas=(SCIM_GROUP_SCHEMA,),
)
try:
scim_group = SCIMGroupSchema.model_validate(delete_none_values(raw_scim_group))
except ValidationError as exc:

View File

@ -1,20 +1,15 @@
"""User client"""
from deepmerge import always_merger
from pydantic import ValidationError
from authentik.core.expression.exceptions import (
PropertyMappingExpressionException,
SkipObjectException,
)
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.utils.errors import exception_to_string
from authentik.policies.utils import delete_none_values
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.models import SCIMMapping, SCIMUser
from authentik.providers.scim.models import SCIMMapping, SCIMProvider, SCIMUser
class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]):
@ -22,38 +17,23 @@ class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]):
connection_type = SCIMUser
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:
"""Convert authentik user into SCIM"""
raw_scim_user = {
"schemas": ("urn:ietf:params:scim:schemas:core:2.0:User",),
}
for mapping in self.provider.property_mappings.all().order_by("name").select_subclasses():
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)
raw_scim_user = super().to_schema(
obj,
creating,
schemas=(SCIM_USER_SCHEMA,),
)
try:
scim_user = SCIMUserSchema.model_validate(delete_none_values(raw_scim_user))
except ValidationError as exc:

View File

@ -5,7 +5,6 @@ from typing import Any
from django.conf import settings
from django.db.models.base import Model
from django.db.models.query import QuerySet
from ldap3 import DEREF_ALWAYS, SUBTREE, Connection
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.lib.config import CONFIG, set_path_in_dict
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.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.models import LDAPSource
LDAP_UNIQUENESS = "ldap_uniq"
@ -38,6 +40,7 @@ class BaseLDAPSynchronizer:
_logger: BoundLogger
_connection: Connection
_messages: list[str]
mapper: PropertyMappingManager
def __init__(self, source: LDAPSource):
self._source = source
@ -139,52 +142,47 @@ class BaseLDAPSynchronizer:
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, self._source.property_mappings, **kwargs)
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, self._source.property_mappings_group, **kwargs
)
return self._build_object_properties(group_dn, **kwargs)
def _build_object_properties(
self, object_dn: str, mappings: QuerySet, **kwargs
) -> dict[str, dict[Any, Any]]:
def _build_object_properties(self, object_dn: str, **kwargs) -> dict[str, dict[Any, Any]]:
properties = {"attributes": {}}
for mapping in mappings.all().select_subclasses():
if not isinstance(mapping, LDAPPropertyMapping):
continue
mapping: LDAPPropertyMapping
try:
value = mapping.evaluate(
user=None, request=None, ldap=kwargs, dn=object_dn, source=self._source
)
if value is None:
self._logger.warning("property mapping returned None", mapping=mapping)
continue
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:
Event.new(
EventAction.CONFIGURATION_ERROR,
message=f"Failed to evaluate property-mapping: '{mapping.name}'",
source=self._source,
mapping=mapping,
).save()
self._logger.warning("Mapping failed to evaluate", exc=exc, mapping=mapping)
continue
try:
for value, mapping in self.mapper.iter_eval(
user=None,
request=None,
return_mapping=True,
ldap=kwargs,
dn=object_dn,
source=self._source,
):
try:
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)

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.models import Group
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
class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
"""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
def name() -> str:
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.models import User
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.vendor.freeipa import FreeIPA
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):
"""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
def name() -> str:
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.system_tasks import SystemTask
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.reflection import class_to_path, path_to_class
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,
)
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
LOGGER.warning(exception_to_string(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.system_tasks import TaskStatus
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.sources.ldap.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
@ -63,12 +64,13 @@ class LDAPSyncTests(TestCase):
connection = MagicMock(return_value=mock_ad_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
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="user1_sn").exists())
events = Event.objects.filter(
action=EventAction.CONFIGURATION_ERROR,
context__message="Failed to evaluate property-mapping: 'name'",
context__mapping__pk=mapping.pk.hex,
)
self.assertTrue(events.exists())

View File

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

View File

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