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:
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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}"
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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"""
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
67
authentik/lib/sync/mapper.py
Normal file
67
authentik/lib/sync/mapper.py
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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())
|
||||||
|
|
||||||
|
|||||||
@ -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"""
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user