From 50fffa72cc2711fb5208726525c5740a3ab26aca Mon Sep 17 00:00:00 2001 From: Jens L Date: Thu, 30 May 2024 10:40:10 +0900 Subject: [PATCH] lib/providers/sync: improve outgoing sync (#9835) * make connection objects not updatable but allow creating with provider Signed-off-by: Jens Langhammer * save data returned from google/entra and show it in UI Signed-off-by: Jens Langhammer * pass connection object Signed-off-by: Jens Langhammer * set immutable id on user automatically Signed-off-by: Jens Langhammer * better define transient error codes Signed-off-by: Jens Langhammer * format Signed-off-by: Jens Langhammer * fix entra Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- .../providers/google_workspace/api/groups.py | 19 +- .../providers/google_workspace/api/users.py | 19 +- .../google_workspace/clients/groups.py | 21 +- .../google_workspace/clients/users.py | 21 +- ...kspaceprovidergroup_attributes_and_more.py | 26 ++ .../providers/google_workspace/models.py | 2 + .../providers/microsoft_entra/api/groups.py | 19 +- .../providers/microsoft_entra/api/users.py | 19 +- .../providers/microsoft_entra/clients/base.py | 9 + .../microsoft_entra/clients/groups.py | 24 +- .../microsoft_entra/clients/users.py | 25 +- ...tentraprovidergroup_attributes_and_more.py | 23 ++ .../providers/microsoft_entra/models.py | 2 + authentik/lib/sync/outgoing/__init__.py | 3 + authentik/lib/sync/outgoing/base.py | 4 +- authentik/outposts/controllers/k8s/base.py | 1 - authentik/providers/scim/clients/base.py | 17 +- authentik/providers/scim/clients/groups.py | 10 +- authentik/providers/scim/clients/users.py | 10 +- .../system/providers-microsoft-entra.yaml | 8 +- schema.yml | 378 ++---------------- .../GoogleWorkspaceProviderGroupList.ts | 10 + .../GoogleWorkspaceProviderUserList.ts | 10 + .../MicrosoftEntraProviderGroupList.ts | 10 + .../MicrosoftEntraProviderUserList.ts | 10 + 25 files changed, 292 insertions(+), 408 deletions(-) create mode 100644 authentik/enterprise/providers/google_workspace/migrations/0003_googleworkspaceprovidergroup_attributes_and_more.py create mode 100644 authentik/enterprise/providers/microsoft_entra/migrations/0002_microsoftentraprovidergroup_attributes_and_more.py diff --git a/authentik/enterprise/providers/google_workspace/api/groups.py b/authentik/enterprise/providers/google_workspace/api/groups.py index 7317a5a733..06a2449dd8 100644 --- a/authentik/enterprise/providers/google_workspace/api/groups.py +++ b/authentik/enterprise/providers/google_workspace/api/groups.py @@ -1,14 +1,15 @@ """GoogleWorkspaceProviderGroup API Views""" -from rest_framework.viewsets import ModelViewSet +from rest_framework import mixins +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import GenericViewSet -from authentik.core.api.sources import SourceSerializer from authentik.core.api.used_by import UsedByMixin from authentik.core.api.users import UserGroupSerializer from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderGroup -class GoogleWorkspaceProviderGroupSerializer(SourceSerializer): +class GoogleWorkspaceProviderGroupSerializer(ModelSerializer): """GoogleWorkspaceProviderGroup Serializer""" group_obj = UserGroupSerializer(source="group", read_only=True) @@ -20,10 +21,20 @@ class GoogleWorkspaceProviderGroupSerializer(SourceSerializer): "id", "group", "group_obj", + "provider", + "attributes", ] + extra_kwargs = {"attributes": {"read_only": True}} -class GoogleWorkspaceProviderGroupViewSet(UsedByMixin, ModelViewSet): +class GoogleWorkspaceProviderGroupViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + UsedByMixin, + mixins.ListModelMixin, + GenericViewSet, +): """GoogleWorkspaceProviderGroup Viewset""" queryset = GoogleWorkspaceProviderGroup.objects.all().select_related("group") diff --git a/authentik/enterprise/providers/google_workspace/api/users.py b/authentik/enterprise/providers/google_workspace/api/users.py index a0fa658e3d..3826598eea 100644 --- a/authentik/enterprise/providers/google_workspace/api/users.py +++ b/authentik/enterprise/providers/google_workspace/api/users.py @@ -1,14 +1,15 @@ """GoogleWorkspaceProviderUser API Views""" -from rest_framework.viewsets import ModelViewSet +from rest_framework import mixins +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import GenericViewSet from authentik.core.api.groups import GroupMemberSerializer -from authentik.core.api.sources import SourceSerializer from authentik.core.api.used_by import UsedByMixin from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderUser -class GoogleWorkspaceProviderUserSerializer(SourceSerializer): +class GoogleWorkspaceProviderUserSerializer(ModelSerializer): """GoogleWorkspaceProviderUser Serializer""" user_obj = GroupMemberSerializer(source="user", read_only=True) @@ -20,10 +21,20 @@ class GoogleWorkspaceProviderUserSerializer(SourceSerializer): "id", "user", "user_obj", + "provider", + "attributes", ] + extra_kwargs = {"attributes": {"read_only": True}} -class GoogleWorkspaceProviderUserViewSet(UsedByMixin, ModelViewSet): +class GoogleWorkspaceProviderUserViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + UsedByMixin, + mixins.ListModelMixin, + GenericViewSet, +): """GoogleWorkspaceProviderUser Viewset""" queryset = GoogleWorkspaceProviderUser.objects.all().select_related("user") diff --git a/authentik/enterprise/providers/google_workspace/clients/groups.py b/authentik/enterprise/providers/google_workspace/clients/groups.py index aeb0ac63e8..158fe07247 100644 --- a/authentik/enterprise/providers/google_workspace/clients/groups.py +++ b/authentik/enterprise/providers/google_workspace/clients/groups.py @@ -33,14 +33,14 @@ class GoogleWorkspaceGroupClient( self.mapper = PropertyMappingManager( self.provider.property_mappings_group.all().order_by("name").select_subclasses(), GoogleWorkspaceProviderMapping, - ["group", "provider", "creating"], + ["group", "provider", "connection"], ) - def to_schema(self, obj: Group, creating: bool) -> dict: + def to_schema(self, obj: Group, connection: GoogleWorkspaceProviderGroup) -> dict: """Convert authentik group""" return super().to_schema( obj, - creating, + connection=connection, email=f"{slugify(obj.name)}@{self.provider.default_group_email_domain}", ) @@ -61,7 +61,7 @@ class GoogleWorkspaceGroupClient( def create(self, group: Group): """Create group from scratch and create a connection object""" - google_group = self.to_schema(group, True) + google_group = self.to_schema(group, None) self.check_email_valid(google_group["email"]) with transaction.atomic(): try: @@ -74,16 +74,22 @@ class GoogleWorkspaceGroupClient( self.directory_service.groups().get(groupKey=google_group["email"]) ) return GoogleWorkspaceProviderGroup.objects.create( - provider=self.provider, group=group, google_id=group_data["id"] + provider=self.provider, + group=group, + google_id=group_data["id"], + attributes=group_data, ) else: return GoogleWorkspaceProviderGroup.objects.create( - provider=self.provider, group=group, google_id=response["id"] + provider=self.provider, + group=group, + google_id=response["id"], + attributes=response, ) def update(self, group: Group, connection: GoogleWorkspaceProviderGroup): """Update existing group""" - google_group = self.to_schema(group, False) + google_group = self.to_schema(group, connection) self.check_email_valid(google_group["email"]) try: return self._request( @@ -204,4 +210,5 @@ class GoogleWorkspaceGroupClient( provider=self.provider, group=matching_authentik_group, google_id=google_id, + attributes=group, ) diff --git a/authentik/enterprise/providers/google_workspace/clients/users.py b/authentik/enterprise/providers/google_workspace/clients/users.py index 52d60046bd..859efa25e8 100644 --- a/authentik/enterprise/providers/google_workspace/clients/users.py +++ b/authentik/enterprise/providers/google_workspace/clients/users.py @@ -28,15 +28,12 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP self.mapper = PropertyMappingManager( self.provider.property_mappings.all().order_by("name").select_subclasses(), GoogleWorkspaceProviderMapping, - ["provider", "creating"], + ["provider", "connection"], ) - def to_schema(self, obj: User, creating: bool) -> dict: + def to_schema(self, obj: User, connection: GoogleWorkspaceProviderUser) -> dict: """Convert authentik user""" - 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) + return delete_none_values(super().to_schema(obj, connection, primaryEmail=obj.email)) def delete(self, obj: User): """Delete user""" @@ -63,7 +60,7 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP def create(self, user: User): """Create user from scratch and create a connection object""" - google_user = self.to_schema(user, True) + google_user = self.to_schema(user, None) self.check_email_valid( google_user["primaryEmail"], *[x["address"] for x in google_user.get("emails", [])] ) @@ -73,18 +70,21 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP except ObjectExistsSyncException: # user already exists in google workspace, so we can connect them manually return GoogleWorkspaceProviderUser.objects.create( - provider=self.provider, user=user, google_id=user.email + provider=self.provider, user=user, google_id=user.email, attributes={} ) except TransientSyncException as exc: raise exc else: return GoogleWorkspaceProviderUser.objects.create( - provider=self.provider, user=user, google_id=response["primaryEmail"] + provider=self.provider, + user=user, + google_id=response["primaryEmail"], + attributes=response, ) def update(self, user: User, connection: GoogleWorkspaceProviderUser): """Update existing user""" - google_user = self.to_schema(user, False) + google_user = self.to_schema(user, connection) self.check_email_valid( google_user["primaryEmail"], *[x["address"] for x in google_user.get("emails", [])] ) @@ -115,4 +115,5 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP provider=self.provider, user=matching_authentik_user, google_id=email, + attributes=user, ) diff --git a/authentik/enterprise/providers/google_workspace/migrations/0003_googleworkspaceprovidergroup_attributes_and_more.py b/authentik/enterprise/providers/google_workspace/migrations/0003_googleworkspaceprovidergroup_attributes_and_more.py new file mode 100644 index 0000000000..e92ca69da6 --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/migrations/0003_googleworkspaceprovidergroup_attributes_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.6 on 2024-05-23 20:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "authentik_providers_google_workspace", + "0001_squashed_0002_alter_googleworkspaceprovidergroup_options_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="googleworkspaceprovidergroup", + name="attributes", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="googleworkspaceprovideruser", + name="attributes", + field=models.JSONField(default=dict), + ), + ] diff --git a/authentik/enterprise/providers/google_workspace/models.py b/authentik/enterprise/providers/google_workspace/models.py index 12ace4cb39..9628da2fb2 100644 --- a/authentik/enterprise/providers/google_workspace/models.py +++ b/authentik/enterprise/providers/google_workspace/models.py @@ -153,6 +153,7 @@ class GoogleWorkspaceProviderUser(SerializerModel): google_id = models.TextField() user = models.ForeignKey(User, on_delete=models.CASCADE) provider = models.ForeignKey(GoogleWorkspaceProvider, on_delete=models.CASCADE) + attributes = models.JSONField(default=dict) @property def serializer(self) -> type[Serializer]: @@ -178,6 +179,7 @@ class GoogleWorkspaceProviderGroup(SerializerModel): google_id = models.TextField() group = models.ForeignKey(Group, on_delete=models.CASCADE) provider = models.ForeignKey(GoogleWorkspaceProvider, on_delete=models.CASCADE) + attributes = models.JSONField(default=dict) @property def serializer(self) -> type[Serializer]: diff --git a/authentik/enterprise/providers/microsoft_entra/api/groups.py b/authentik/enterprise/providers/microsoft_entra/api/groups.py index 6934a841ce..afb7606b1c 100644 --- a/authentik/enterprise/providers/microsoft_entra/api/groups.py +++ b/authentik/enterprise/providers/microsoft_entra/api/groups.py @@ -1,14 +1,15 @@ """MicrosoftEntraProviderGroup API Views""" -from rest_framework.viewsets import ModelViewSet +from rest_framework import mixins +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import GenericViewSet -from authentik.core.api.sources import SourceSerializer from authentik.core.api.used_by import UsedByMixin from authentik.core.api.users import UserGroupSerializer from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderGroup -class MicrosoftEntraProviderGroupSerializer(SourceSerializer): +class MicrosoftEntraProviderGroupSerializer(ModelSerializer): """MicrosoftEntraProviderGroup Serializer""" group_obj = UserGroupSerializer(source="group", read_only=True) @@ -20,10 +21,20 @@ class MicrosoftEntraProviderGroupSerializer(SourceSerializer): "id", "group", "group_obj", + "provider", + "attributes", ] + extra_kwargs = {"attributes": {"read_only": True}} -class MicrosoftEntraProviderGroupViewSet(UsedByMixin, ModelViewSet): +class MicrosoftEntraProviderGroupViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + UsedByMixin, + mixins.ListModelMixin, + GenericViewSet, +): """MicrosoftEntraProviderGroup Viewset""" queryset = MicrosoftEntraProviderGroup.objects.all().select_related("group") diff --git a/authentik/enterprise/providers/microsoft_entra/api/users.py b/authentik/enterprise/providers/microsoft_entra/api/users.py index 5b5efbafb8..65251a11fb 100644 --- a/authentik/enterprise/providers/microsoft_entra/api/users.py +++ b/authentik/enterprise/providers/microsoft_entra/api/users.py @@ -1,14 +1,15 @@ """MicrosoftEntraProviderUser API Views""" -from rest_framework.viewsets import ModelViewSet +from rest_framework import mixins +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import GenericViewSet from authentik.core.api.groups import GroupMemberSerializer -from authentik.core.api.sources import SourceSerializer from authentik.core.api.used_by import UsedByMixin from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderUser -class MicrosoftEntraProviderUserSerializer(SourceSerializer): +class MicrosoftEntraProviderUserSerializer(ModelSerializer): """MicrosoftEntraProviderUser Serializer""" user_obj = GroupMemberSerializer(source="user", read_only=True) @@ -20,10 +21,20 @@ class MicrosoftEntraProviderUserSerializer(SourceSerializer): "id", "user", "user_obj", + "provider", + "attributes", ] + extra_kwargs = {"attributes": {"read_only": True}} -class MicrosoftEntraProviderUserViewSet(UsedByMixin, ModelViewSet): +class MicrosoftEntraProviderUserViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + UsedByMixin, + mixins.ListModelMixin, + GenericViewSet, +): """MicrosoftEntraProviderUser Viewset""" queryset = MicrosoftEntraProviderUser.objects.all().select_related("user") diff --git a/authentik/enterprise/providers/microsoft_entra/clients/base.py b/authentik/enterprise/providers/microsoft_entra/clients/base.py index f2a3747363..860c52f814 100644 --- a/authentik/enterprise/providers/microsoft_entra/clients/base.py +++ b/authentik/enterprise/providers/microsoft_entra/clients/base.py @@ -1,5 +1,6 @@ from asyncio import run from collections.abc import Coroutine +from dataclasses import asdict from typing import Any from azure.core.exceptions import ( @@ -15,6 +16,7 @@ from kiota_authentication_azure.azure_identity_authentication_provider import ( AzureIdentityAuthenticationProvider, ) from kiota_http.kiota_client_factory import KiotaClientFactory +from msgraph.generated.models.entity import Entity from msgraph.generated.models.o_data_errors.o_data_error import ODataError from msgraph.graph_request_adapter import GraphRequestAdapter, options from msgraph.graph_service_client import GraphServiceClient @@ -98,3 +100,10 @@ class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict] for email in emails: if not any(email.endswith(f"@{domain_name}") for domain_name in self.domains): raise BadRequestSyncException(f"Invalid email domain: {email}") + + def entity_as_dict(self, entity: Entity) -> dict: + """Create a dictionary of a model instance, making sure to remove (known) things + we can't JSON serialize""" + raw_data = asdict(entity) + raw_data.pop("backing_store", None) + return raw_data diff --git a/authentik/enterprise/providers/microsoft_entra/clients/groups.py b/authentik/enterprise/providers/microsoft_entra/clients/groups.py index 0121c132be..54545594c3 100644 --- a/authentik/enterprise/providers/microsoft_entra/clients/groups.py +++ b/authentik/enterprise/providers/microsoft_entra/clients/groups.py @@ -36,12 +36,12 @@ class MicrosoftEntraGroupClient( self.mapper = PropertyMappingManager( self.provider.property_mappings_group.all().order_by("name").select_subclasses(), MicrosoftEntraProviderMapping, - ["group", "provider", "creating"], + ["group", "provider", "connection"], ) - def to_schema(self, obj: Group, creating: bool) -> MSGroup: + def to_schema(self, obj: Group, connection: MicrosoftEntraProviderGroup) -> MSGroup: """Convert authentik group""" - raw_microsoft_group = super().to_schema(obj, creating) + raw_microsoft_group = super().to_schema(obj, connection) try: return MSGroup(**raw_microsoft_group) except TypeError as exc: @@ -62,7 +62,7 @@ class MicrosoftEntraGroupClient( def create(self, group: Group): """Create group from scratch and create a connection object""" - microsoft_group = self.to_schema(group, True) + microsoft_group = self.to_schema(group, None) with transaction.atomic(): try: response = self._request(self.client.groups.post(microsoft_group)) @@ -79,22 +79,29 @@ class MicrosoftEntraGroupClient( ) ) group_data = self._request(self.client.groups.get(request_configuration)) - if group_data.odata_count < 1: + if group_data.odata_count < 1 or len(group_data.value) < 1: self.logger.warning( "Group which could not be created also does not exist", group=group ) return + ms_group = group_data.value[0] return MicrosoftEntraProviderGroup.objects.create( - provider=self.provider, group=group, microsoft_id=group_data.value[0].id + provider=self.provider, + group=group, + microsoft_id=ms_group.id, + attributes=self.entity_as_dict(ms_group), ) else: return MicrosoftEntraProviderGroup.objects.create( - provider=self.provider, group=group, microsoft_id=response.id + provider=self.provider, + group=group, + microsoft_id=response.id, + attributes=self.entity_as_dict(response), ) def update(self, group: Group, connection: MicrosoftEntraProviderGroup): """Update existing group""" - microsoft_group = self.to_schema(group, False) + microsoft_group = self.to_schema(group, connection) microsoft_group.id = connection.microsoft_id try: return self._request( @@ -213,4 +220,5 @@ class MicrosoftEntraGroupClient( provider=self.provider, group=matching_authentik_group, microsoft_id=group.id, + attributes=self.entity_as_dict(group), ) diff --git a/authentik/enterprise/providers/microsoft_entra/clients/users.py b/authentik/enterprise/providers/microsoft_entra/clients/users.py index a9539ba465..56e4ac10d0 100644 --- a/authentik/enterprise/providers/microsoft_entra/clients/users.py +++ b/authentik/enterprise/providers/microsoft_entra/clients/users.py @@ -31,12 +31,12 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv self.mapper = PropertyMappingManager( self.provider.property_mappings.all().order_by("name").select_subclasses(), MicrosoftEntraProviderMapping, - ["provider", "creating"], + ["provider", "connection"], ) - def to_schema(self, obj: User, creating: bool) -> MSUser: + def to_schema(self, obj: User, connection: MicrosoftEntraProviderUser) -> MSUser: """Convert authentik user""" - raw_microsoft_user = super().to_schema(obj, creating) + raw_microsoft_user = super().to_schema(obj, connection) try: return MSUser(**delete_none_values(raw_microsoft_user)) except TypeError as exc: @@ -67,7 +67,7 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv def create(self, user: User): """Create user from scratch and create a connection object""" - microsoft_user = self.to_schema(user, True) + microsoft_user = self.to_schema(user, None) self.check_email_valid(microsoft_user.user_principal_name) with transaction.atomic(): try: @@ -83,24 +83,32 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv ) ) user_data = self._request(self.client.users.get(request_configuration)) - if user_data.odata_count < 1: + if user_data.odata_count < 1 or len(user_data.value) < 1: self.logger.warning( "User which could not be created also does not exist", user=user ) return + ms_user = user_data.value[0] return MicrosoftEntraProviderUser.objects.create( - provider=self.provider, user=user, microsoft_id=user_data.value[0].id + provider=self.provider, + user=user, + microsoft_id=ms_user.id, + attributes=self.entity_as_dict(ms_user), ) except TransientSyncException as exc: raise exc else: + print(self.entity_as_dict(response)) return MicrosoftEntraProviderUser.objects.create( - provider=self.provider, user=user, microsoft_id=response.id + provider=self.provider, + user=user, + microsoft_id=response.id, + attributes=self.entity_as_dict(response), ) def update(self, user: User, connection: MicrosoftEntraProviderUser): """Update existing user""" - microsoft_user = self.to_schema(user, False) + microsoft_user = self.to_schema(user, connection) self.check_email_valid(microsoft_user.user_principal_name) self._request(self.client.users.by_user_id(connection.microsoft_id).patch(microsoft_user)) @@ -125,4 +133,5 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv provider=self.provider, user=matching_authentik_user, microsoft_id=user.id, + attributes=self.entity_as_dict(user), ) diff --git a/authentik/enterprise/providers/microsoft_entra/migrations/0002_microsoftentraprovidergroup_attributes_and_more.py b/authentik/enterprise/providers/microsoft_entra/migrations/0002_microsoftentraprovidergroup_attributes_and_more.py new file mode 100644 index 0000000000..914e396b5a --- /dev/null +++ b/authentik/enterprise/providers/microsoft_entra/migrations/0002_microsoftentraprovidergroup_attributes_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2024-05-23 20:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_microsoft_entra", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="microsoftentraprovidergroup", + name="attributes", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="microsoftentraprovideruser", + name="attributes", + field=models.JSONField(default=dict), + ), + ] diff --git a/authentik/enterprise/providers/microsoft_entra/models.py b/authentik/enterprise/providers/microsoft_entra/models.py index 92d1725107..079519f615 100644 --- a/authentik/enterprise/providers/microsoft_entra/models.py +++ b/authentik/enterprise/providers/microsoft_entra/models.py @@ -142,6 +142,7 @@ class MicrosoftEntraProviderUser(SerializerModel): microsoft_id = models.TextField() user = models.ForeignKey(User, on_delete=models.CASCADE) provider = models.ForeignKey(MicrosoftEntraProvider, on_delete=models.CASCADE) + attributes = models.JSONField(default=dict) @property def serializer(self) -> type[Serializer]: @@ -167,6 +168,7 @@ class MicrosoftEntraProviderGroup(SerializerModel): microsoft_id = models.TextField() group = models.ForeignKey(Group, on_delete=models.CASCADE) provider = models.ForeignKey(MicrosoftEntraProvider, on_delete=models.CASCADE) + attributes = models.JSONField(default=dict) @property def serializer(self) -> type[Serializer]: diff --git a/authentik/lib/sync/outgoing/__init__.py b/authentik/lib/sync/outgoing/__init__.py index 1005a6b242..39d28cfc22 100644 --- a/authentik/lib/sync/outgoing/__init__.py +++ b/authentik/lib/sync/outgoing/__init__.py @@ -3,3 +3,6 @@ PAGE_SIZE = 100 PAGE_TIMEOUT = 60 * 60 * 0.5 # Half an hour HTTP_CONFLICT = 409 +HTTP_NO_CONTENT = 204 +HTTP_SERVICE_UNAVAILABLE = 503 +HTTP_TOO_MANY_REQUESTS = 429 diff --git a/authentik/lib/sync/outgoing/base.py b/authentik/lib/sync/outgoing/base.py index 45b60cd2ce..f6a1ca3738 100644 --- a/authentik/lib/sync/outgoing/base.py +++ b/authentik/lib/sync/outgoing/base.py @@ -79,14 +79,14 @@ class BaseOutgoingSyncClient[ """Delete object from destination""" raise NotImplementedError() - def to_schema(self, obj: TModel, creating: bool, **defaults) -> TSchema: + def to_schema(self, obj: TModel, connection: TConnection | None, **defaults) -> TSchema: """Convert object to destination schema""" raw_final_object = {} try: eval_kwargs = { "request": None, "provider": self.provider, - "creating": creating, + "connection": connection, obj._meta.model_name: obj, } eval_kwargs.setdefault("user", None) diff --git a/authentik/outposts/controllers/k8s/base.py b/authentik/outposts/controllers/k8s/base.py index 2a254ade09..f306369c98 100644 --- a/authentik/outposts/controllers/k8s/base.py +++ b/authentik/outposts/controllers/k8s/base.py @@ -124,7 +124,6 @@ class KubernetesObjectReconciler(Generic[T]): self.update(current, reference) self.logger.debug("Updating") except (OpenApiException, HTTPError) as exc: - if isinstance(exc, ApiException) and exc.status == 422: # noqa: PLR2004 self.logger.debug("Failed to update current, triggering re-create") self._recreate(current=current, reference=reference) diff --git a/authentik/providers/scim/clients/base.py b/authentik/providers/scim/clients/base.py index 27ba4eeb06..19d55a4dfe 100644 --- a/authentik/providers/scim/clients/base.py +++ b/authentik/providers/scim/clients/base.py @@ -6,9 +6,18 @@ from django.http import HttpResponseBadRequest, HttpResponseNotFound from pydantic import ValidationError from requests import RequestException, Session -from authentik.lib.sync.outgoing import HTTP_CONFLICT +from authentik.lib.sync.outgoing import ( + HTTP_CONFLICT, + HTTP_NO_CONTENT, + HTTP_SERVICE_UNAVAILABLE, + HTTP_TOO_MANY_REQUESTS, +) from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient -from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException, ObjectExistsSyncException +from authentik.lib.sync.outgoing.exceptions import ( + NotFoundSyncException, + ObjectExistsSyncException, + TransientSyncException, +) from authentik.lib.utils.http import get_http_session from authentik.providers.scim.clients.exceptions import SCIMRequestException from authentik.providers.scim.clients.schema import ServiceProviderConfiguration @@ -61,13 +70,15 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"]( if response.status_code >= HttpResponseBadRequest.status_code: if response.status_code == HttpResponseNotFound.status_code: raise NotFoundSyncException(response) + if response.status_code in [HTTP_TOO_MANY_REQUESTS, HTTP_SERVICE_UNAVAILABLE]: + raise TransientSyncException() if response.status_code == HTTP_CONFLICT: raise ObjectExistsSyncException(response) self.logger.warning( "Failed to send SCIM request", path=path, method=method, response=response.text ) raise SCIMRequestException(response) - if response.status_code == 204: # noqa: PLR2004 + if response.status_code == HTTP_NO_CONTENT: return {} return response.json() diff --git a/authentik/providers/scim/clients/groups.py b/authentik/providers/scim/clients/groups.py index dc4fadb74e..363945f5cc 100644 --- a/authentik/providers/scim/clients/groups.py +++ b/authentik/providers/scim/clients/groups.py @@ -34,14 +34,14 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]): self.mapper = PropertyMappingManager( self.provider.property_mappings_group.all().order_by("name").select_subclasses(), SCIMMapping, - ["group", "provider", "creating"], + ["group", "provider", "connection"], ) - def to_schema(self, obj: Group, creating: bool) -> SCIMGroupSchema: + def to_schema(self, obj: Group, connection: SCIMGroup) -> SCIMGroupSchema: """Convert authentik user into SCIM""" raw_scim_group = super().to_schema( obj, - creating, + connection, schemas=(SCIM_GROUP_SCHEMA,), ) try: @@ -76,7 +76,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]): def create(self, group: Group): """Create group from scratch and create a connection object""" - scim_group = self.to_schema(group, True) + scim_group = self.to_schema(group, None) response = self._request( "POST", "/Groups", @@ -92,7 +92,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroup, SCIMGroupSchema]): def update(self, group: Group, connection: SCIMGroup): """Update existing group""" - scim_group = self.to_schema(group, False) + scim_group = self.to_schema(group, connection) scim_group.id = connection.scim_id try: return self._request( diff --git a/authentik/providers/scim/clients/users.py b/authentik/providers/scim/clients/users.py index 350020d34f..f85c19bcd6 100644 --- a/authentik/providers/scim/clients/users.py +++ b/authentik/providers/scim/clients/users.py @@ -24,14 +24,14 @@ class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]): self.mapper = PropertyMappingManager( self.provider.property_mappings.all().order_by("name").select_subclasses(), SCIMMapping, - ["provider", "creating"], + ["provider", "connection"], ) - def to_schema(self, obj: User, creating: bool) -> SCIMUserSchema: + def to_schema(self, obj: User, connection: SCIMUser) -> SCIMUserSchema: """Convert authentik user into SCIM""" raw_scim_user = super().to_schema( obj, - creating, + connection, schemas=(SCIM_USER_SCHEMA,), ) try: @@ -54,7 +54,7 @@ class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]): def create(self, user: User): """Create user from scratch and create a connection object""" - scim_user = self.to_schema(user, True) + scim_user = self.to_schema(user, None) response = self._request( "POST", "/Users", @@ -70,7 +70,7 @@ class SCIMUserClient(SCIMClient[User, SCIMUser, SCIMUserSchema]): def update(self, user: User, connection: SCIMUser): """Update existing user""" - scim_user = self.to_schema(user, False) + scim_user = self.to_schema(user, connection) scim_user.id = connection.scim_id self._request( "PUT", diff --git a/blueprints/system/providers-microsoft-entra.yaml b/blueprints/system/providers-microsoft-entra.yaml index 9a69f95940..de3051f659 100644 --- a/blueprints/system/providers-microsoft-entra.yaml +++ b/blueprints/system/providers-microsoft-entra.yaml @@ -19,10 +19,16 @@ entries: "mail_nickname": request.user.username, "user_principal_name": request.user.email, } - if creating: + if connection: + # If there is a connection already made (discover or update), we can use + # that connection's immutable_id... + user["on_premises_immutable_id"] = connection.attributes.get("on_premises_immutable_id") + else: user["password_profile"] = PasswordProfile( password=request.user.password ) + # ...otherwise we set an immutable ID based on the user's UID + user["on_premises_immutable_id"] = request.user.uid, return user - identifiers: managed: goauthentik.io/providers/microsoft_entra/group diff --git a/schema.yml b/schema.yml index 7f43698aba..0fc89940d8 100644 --- a/schema.yml +++ b/schema.yml @@ -16369,85 +16369,6 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' - put: - operationId: providers_google_workspace_groups_update - description: GoogleWorkspaceProviderGroup Viewset - parameters: - - in: path - name: id - schema: - type: string - format: uuid - description: A UUID string identifying this Google Workspace Provider Group. - required: true - tags: - - providers - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/GoogleWorkspaceProviderGroupRequest' - required: true - security: - - authentik: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GoogleWorkspaceProviderGroup' - description: '' - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/ValidationError' - description: '' - '403': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericError' - description: '' - patch: - operationId: providers_google_workspace_groups_partial_update - description: GoogleWorkspaceProviderGroup Viewset - parameters: - - in: path - name: id - schema: - type: string - format: uuid - description: A UUID string identifying this Google Workspace Provider Group. - required: true - tags: - - providers - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedGoogleWorkspaceProviderGroupRequest' - security: - - authentik: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GoogleWorkspaceProviderGroup' - description: '' - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/ValidationError' - description: '' - '403': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericError' - description: '' delete: operationId: providers_google_workspace_groups_destroy description: GoogleWorkspaceProviderGroup Viewset @@ -16646,85 +16567,6 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' - put: - operationId: providers_google_workspace_users_update - description: GoogleWorkspaceProviderUser Viewset - parameters: - - in: path - name: id - schema: - type: string - format: uuid - description: A UUID string identifying this Google Workspace Provider User. - required: true - tags: - - providers - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/GoogleWorkspaceProviderUserRequest' - required: true - security: - - authentik: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GoogleWorkspaceProviderUser' - description: '' - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/ValidationError' - description: '' - '403': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericError' - description: '' - patch: - operationId: providers_google_workspace_users_partial_update - description: GoogleWorkspaceProviderUser Viewset - parameters: - - in: path - name: id - schema: - type: string - format: uuid - description: A UUID string identifying this Google Workspace Provider User. - required: true - tags: - - providers - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedGoogleWorkspaceProviderUserRequest' - security: - - authentik: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GoogleWorkspaceProviderUser' - description: '' - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/ValidationError' - description: '' - '403': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericError' - description: '' delete: operationId: providers_google_workspace_users_destroy description: GoogleWorkspaceProviderUser Viewset @@ -17539,85 +17381,6 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' - put: - operationId: providers_microsoft_entra_groups_update - description: MicrosoftEntraProviderGroup Viewset - parameters: - - in: path - name: id - schema: - type: string - format: uuid - description: A UUID string identifying this Microsoft Entra Provider Group. - required: true - tags: - - providers - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/MicrosoftEntraProviderGroupRequest' - required: true - security: - - authentik: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/MicrosoftEntraProviderGroup' - description: '' - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/ValidationError' - description: '' - '403': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericError' - description: '' - patch: - operationId: providers_microsoft_entra_groups_partial_update - description: MicrosoftEntraProviderGroup Viewset - parameters: - - in: path - name: id - schema: - type: string - format: uuid - description: A UUID string identifying this Microsoft Entra Provider Group. - required: true - tags: - - providers - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedMicrosoftEntraProviderGroupRequest' - security: - - authentik: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/MicrosoftEntraProviderGroup' - description: '' - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/ValidationError' - description: '' - '403': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericError' - description: '' delete: operationId: providers_microsoft_entra_groups_destroy description: MicrosoftEntraProviderGroup Viewset @@ -17816,85 +17579,6 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' - put: - operationId: providers_microsoft_entra_users_update - description: MicrosoftEntraProviderUser Viewset - parameters: - - in: path - name: id - schema: - type: string - format: uuid - description: A UUID string identifying this Microsoft Entra Provider User. - required: true - tags: - - providers - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/MicrosoftEntraProviderUserRequest' - required: true - security: - - authentik: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/MicrosoftEntraProviderUser' - description: '' - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/ValidationError' - description: '' - '403': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericError' - description: '' - patch: - operationId: providers_microsoft_entra_users_partial_update - description: MicrosoftEntraProviderUser Viewset - parameters: - - in: path - name: id - schema: - type: string - format: uuid - description: A UUID string identifying this Microsoft Entra Provider User. - required: true - tags: - - providers - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedMicrosoftEntraProviderUserRequest' - security: - - authentik: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/MicrosoftEntraProviderUser' - description: '' - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/ValidationError' - description: '' - '403': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericError' - description: '' delete: operationId: providers_microsoft_entra_users_destroy description: MicrosoftEntraProviderUser Viewset @@ -36634,10 +36318,16 @@ components: allOf: - $ref: '#/components/schemas/UserGroup' readOnly: true + provider: + type: integer + attributes: + readOnly: true required: + - attributes - group - group_obj - id + - provider GoogleWorkspaceProviderGroupRequest: type: object description: GoogleWorkspaceProviderGroup Serializer @@ -36645,8 +36335,11 @@ components: group: type: string format: uuid + provider: + type: integer required: - group + - provider GoogleWorkspaceProviderMapping: type: object description: GoogleWorkspaceProviderMapping Serializer @@ -36773,8 +36466,14 @@ components: allOf: - $ref: '#/components/schemas/GroupMember' readOnly: true + provider: + type: integer + attributes: + readOnly: true required: + - attributes - id + - provider - user - user_obj GoogleWorkspaceProviderUserRequest: @@ -36783,7 +36482,10 @@ components: properties: user: type: integer + provider: + type: integer required: + - provider - user Group: type: object @@ -38284,10 +37986,16 @@ components: allOf: - $ref: '#/components/schemas/UserGroup' readOnly: true + provider: + type: integer + attributes: + readOnly: true required: + - attributes - group - group_obj - id + - provider MicrosoftEntraProviderGroupRequest: type: object description: MicrosoftEntraProviderGroup Serializer @@ -38295,8 +38003,11 @@ components: group: type: string format: uuid + provider: + type: integer required: - group + - provider MicrosoftEntraProviderMapping: type: object description: MicrosoftEntraProviderMapping Serializer @@ -38420,8 +38131,14 @@ components: allOf: - $ref: '#/components/schemas/GroupMember' readOnly: true + provider: + type: integer + attributes: + readOnly: true required: + - attributes - id + - provider - user - user_obj MicrosoftEntraProviderUserRequest: @@ -38430,7 +38147,10 @@ components: properties: user: type: integer + provider: + type: integer required: + - provider - user ModelEnum: enum: @@ -41826,13 +41546,6 @@ components: to a challenge. RETRY returns the error message and a similar challenge to the executor. RESTART restarts the flow from the beginning, and RESTART_WITH_CONTEXT restarts the flow while keeping the current context. - PatchedGoogleWorkspaceProviderGroupRequest: - type: object - description: GoogleWorkspaceProviderGroup Serializer - properties: - group: - type: string - format: uuid PatchedGoogleWorkspaceProviderMappingRequest: type: object description: GoogleWorkspaceProviderMapping Serializer @@ -41892,12 +41605,6 @@ components: default_group_email_domain: type: string minLength: 1 - PatchedGoogleWorkspaceProviderUserRequest: - type: object - description: GoogleWorkspaceProviderUser Serializer - properties: - user: - type: integer PatchedGroupRequest: type: object description: Group Serializer @@ -42253,13 +41960,6 @@ components: key: type: string minLength: 1 - PatchedMicrosoftEntraProviderGroupRequest: - type: object - description: MicrosoftEntraProviderGroup Serializer - properties: - group: - type: string - format: uuid PatchedMicrosoftEntraProviderMappingRequest: type: object description: MicrosoftEntraProviderMapping Serializer @@ -42316,12 +42016,6 @@ components: $ref: '#/components/schemas/OutgoingSyncDeleteAction' group_delete_action: $ref: '#/components/schemas/OutgoingSyncDeleteAction' - PatchedMicrosoftEntraProviderUserRequest: - type: object - description: MicrosoftEntraProviderUser Serializer - properties: - user: - type: integer PatchedNotificationRequest: type: object description: Notification Serializer diff --git a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderGroupList.ts b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderGroupList.ts index 474922c8bd..cd3509ec3b 100644 --- a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderGroupList.ts +++ b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderGroupList.ts @@ -13,6 +13,8 @@ export class GoogleWorkspaceProviderGroupList extends Table +
+
${JSON.stringify(item.attributes, null, 4)}
+
+ `; + } } diff --git a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderUserList.ts b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderUserList.ts index d8bf35b249..99ad822784 100644 --- a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderUserList.ts +++ b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderUserList.ts @@ -17,6 +17,8 @@ export class GoogleWorkspaceProviderUserList extends Table> { return new ProvidersApi(DEFAULT_CONFIG).providersGoogleWorkspaceUsersList({ page: page, @@ -40,4 +42,12 @@ export class GoogleWorkspaceProviderUserList extends Table +
+
${JSON.stringify(item.attributes, null, 4)}
+
+ `; + } } diff --git a/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderGroupList.ts b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderGroupList.ts index 6760ead66e..809d69914c 100644 --- a/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderGroupList.ts +++ b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderGroupList.ts @@ -13,6 +13,8 @@ export class MicrosoftEntraProviderGroupList extends Table +
+
${JSON.stringify(item.attributes, null, 4)}
+
+ `; + } } diff --git a/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderUserList.ts b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderUserList.ts index aadb8abc9c..7c7aa70b41 100644 --- a/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderUserList.ts +++ b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderUserList.ts @@ -13,6 +13,8 @@ export class MicrosoftEntraProviderUserList extends Table +
+
${JSON.stringify(item.attributes, null, 4)}
+
+ `; + } }