diff --git a/authentik/enterprise/providers/google_workspace/api/groups.py b/authentik/enterprise/providers/google_workspace/api/groups.py index db54b4820f..cd8799df8e 100644 --- a/authentik/enterprise/providers/google_workspace/api/groups.py +++ b/authentik/enterprise/providers/google_workspace/api/groups.py @@ -7,6 +7,7 @@ from rest_framework.viewsets import GenericViewSet from authentik.core.api.used_by import UsedByMixin from authentik.core.api.users import UserGroupSerializer from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderGroup +from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin class GoogleWorkspaceProviderGroupSerializer(ModelSerializer): @@ -30,6 +31,7 @@ class GoogleWorkspaceProviderGroupSerializer(ModelSerializer): class GoogleWorkspaceProviderGroupViewSet( mixins.CreateModelMixin, + OutgoingSyncConnectionCreateMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, UsedByMixin, diff --git a/authentik/enterprise/providers/google_workspace/api/users.py b/authentik/enterprise/providers/google_workspace/api/users.py index de794e135b..9b8926209e 100644 --- a/authentik/enterprise/providers/google_workspace/api/users.py +++ b/authentik/enterprise/providers/google_workspace/api/users.py @@ -7,6 +7,7 @@ from rest_framework.viewsets import GenericViewSet from authentik.core.api.groups import GroupMemberSerializer from authentik.core.api.used_by import UsedByMixin from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProviderUser +from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin class GoogleWorkspaceProviderUserSerializer(ModelSerializer): @@ -30,6 +31,7 @@ class GoogleWorkspaceProviderUserSerializer(ModelSerializer): class GoogleWorkspaceProviderUserViewSet( mixins.CreateModelMixin, + OutgoingSyncConnectionCreateMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, UsedByMixin, diff --git a/authentik/enterprise/providers/google_workspace/clients/groups.py b/authentik/enterprise/providers/google_workspace/clients/groups.py index 7cb841dcc8..de85d96c59 100644 --- a/authentik/enterprise/providers/google_workspace/clients/groups.py +++ b/authentik/enterprise/providers/google_workspace/clients/groups.py @@ -214,3 +214,7 @@ class GoogleWorkspaceGroupClient( google_id=google_id, attributes=group, ) + + def update_single_attribute(self, connection: GoogleWorkspaceProviderUser): + group = self.directory_service.groups().get(connection.google_id) + connection.attributes = group diff --git a/authentik/enterprise/providers/google_workspace/clients/users.py b/authentik/enterprise/providers/google_workspace/clients/users.py index b8316f476b..3c6206a4ec 100644 --- a/authentik/enterprise/providers/google_workspace/clients/users.py +++ b/authentik/enterprise/providers/google_workspace/clients/users.py @@ -119,3 +119,7 @@ class GoogleWorkspaceUserClient(GoogleWorkspaceSyncClient[User, GoogleWorkspaceP google_id=email, attributes=user, ) + + def update_single_attribute(self, connection: GoogleWorkspaceProviderUser): + user = self.directory_service.users().get(connection.google_id) + connection.attributes = user diff --git a/authentik/enterprise/providers/google_workspace/models.py b/authentik/enterprise/providers/google_workspace/models.py index 9628da2fb2..e3396a2e71 100644 --- a/authentik/enterprise/providers/google_workspace/models.py +++ b/authentik/enterprise/providers/google_workspace/models.py @@ -31,6 +31,58 @@ def default_scopes() -> list[str]: ] +class GoogleWorkspaceProviderUser(SerializerModel): + """Mapping of a user and provider to a Google user ID""" + + id = models.UUIDField(primary_key=True, editable=False, default=uuid4) + 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]: + from authentik.enterprise.providers.google_workspace.api.users import ( + GoogleWorkspaceProviderUserSerializer, + ) + + return GoogleWorkspaceProviderUserSerializer + + class Meta: + verbose_name = _("Google Workspace Provider User") + verbose_name_plural = _("Google Workspace Provider Users") + unique_together = (("google_id", "user", "provider"),) + + def __str__(self) -> str: + return f"Google Workspace Provider User {self.user_id} to {self.provider_id}" + + +class GoogleWorkspaceProviderGroup(SerializerModel): + """Mapping of a group and provider to a Google group ID""" + + id = models.UUIDField(primary_key=True, editable=False, default=uuid4) + 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]: + from authentik.enterprise.providers.google_workspace.api.groups import ( + GoogleWorkspaceProviderGroupSerializer, + ) + + return GoogleWorkspaceProviderGroupSerializer + + class Meta: + verbose_name = _("Google Workspace Provider Group") + verbose_name_plural = _("Google Workspace Provider Groups") + unique_together = (("google_id", "group", "provider"),) + + def __str__(self) -> str: + return f"Google Workspace Provider Group {self.group_id} to {self.provider_id}" + + class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): """Sync users from authentik into Google Workspace.""" @@ -59,15 +111,16 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): ) def client_for_model( - self, model: type[User | Group] + self, + model: type[User | Group | GoogleWorkspaceProviderUser | GoogleWorkspaceProviderGroup], ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]: - if issubclass(model, User): + if issubclass(model, User | GoogleWorkspaceProviderUser): from authentik.enterprise.providers.google_workspace.clients.users import ( GoogleWorkspaceUserClient, ) return GoogleWorkspaceUserClient(self) - if issubclass(model, Group): + if issubclass(model, Group | GoogleWorkspaceProviderGroup): from authentik.enterprise.providers.google_workspace.clients.groups import ( GoogleWorkspaceGroupClient, ) @@ -144,55 +197,3 @@ class GoogleWorkspaceProviderMapping(PropertyMapping): class Meta: verbose_name = _("Google Workspace Provider Mapping") verbose_name_plural = _("Google Workspace Provider Mappings") - - -class GoogleWorkspaceProviderUser(SerializerModel): - """Mapping of a user and provider to a Google user ID""" - - id = models.UUIDField(primary_key=True, editable=False, default=uuid4) - 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]: - from authentik.enterprise.providers.google_workspace.api.users import ( - GoogleWorkspaceProviderUserSerializer, - ) - - return GoogleWorkspaceProviderUserSerializer - - class Meta: - verbose_name = _("Google Workspace Provider User") - verbose_name_plural = _("Google Workspace Provider Users") - unique_together = (("google_id", "user", "provider"),) - - def __str__(self) -> str: - return f"Google Workspace Provider User {self.user_id} to {self.provider_id}" - - -class GoogleWorkspaceProviderGroup(SerializerModel): - """Mapping of a group and provider to a Google group ID""" - - id = models.UUIDField(primary_key=True, editable=False, default=uuid4) - 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]: - from authentik.enterprise.providers.google_workspace.api.groups import ( - GoogleWorkspaceProviderGroupSerializer, - ) - - return GoogleWorkspaceProviderGroupSerializer - - class Meta: - verbose_name = _("Google Workspace Provider Group") - verbose_name_plural = _("Google Workspace Provider Groups") - unique_together = (("google_id", "group", "provider"),) - - def __str__(self) -> str: - return f"Google Workspace Provider Group {self.group_id} to {self.provider_id}" diff --git a/authentik/enterprise/providers/microsoft_entra/api/groups.py b/authentik/enterprise/providers/microsoft_entra/api/groups.py index 6be035e4c2..ac38764831 100644 --- a/authentik/enterprise/providers/microsoft_entra/api/groups.py +++ b/authentik/enterprise/providers/microsoft_entra/api/groups.py @@ -7,6 +7,7 @@ from rest_framework.viewsets import GenericViewSet from authentik.core.api.used_by import UsedByMixin from authentik.core.api.users import UserGroupSerializer from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderGroup +from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin class MicrosoftEntraProviderGroupSerializer(ModelSerializer): @@ -30,6 +31,7 @@ class MicrosoftEntraProviderGroupSerializer(ModelSerializer): class MicrosoftEntraProviderGroupViewSet( mixins.CreateModelMixin, + OutgoingSyncConnectionCreateMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, UsedByMixin, diff --git a/authentik/enterprise/providers/microsoft_entra/api/users.py b/authentik/enterprise/providers/microsoft_entra/api/users.py index da0203ed94..c30dc647f2 100644 --- a/authentik/enterprise/providers/microsoft_entra/api/users.py +++ b/authentik/enterprise/providers/microsoft_entra/api/users.py @@ -7,6 +7,7 @@ from rest_framework.viewsets import GenericViewSet from authentik.core.api.groups import GroupMemberSerializer from authentik.core.api.used_by import UsedByMixin from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProviderUser +from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin class MicrosoftEntraProviderUserSerializer(ModelSerializer): @@ -29,6 +30,7 @@ class MicrosoftEntraProviderUserSerializer(ModelSerializer): class MicrosoftEntraProviderUserViewSet( + OutgoingSyncConnectionCreateMixin, mixins.CreateModelMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, diff --git a/authentik/enterprise/providers/microsoft_entra/clients/groups.py b/authentik/enterprise/providers/microsoft_entra/clients/groups.py index f826ae67fd..2cb135822f 100644 --- a/authentik/enterprise/providers/microsoft_entra/clients/groups.py +++ b/authentik/enterprise/providers/microsoft_entra/clients/groups.py @@ -226,3 +226,7 @@ class MicrosoftEntraGroupClient( microsoft_id=group.id, attributes=self.entity_as_dict(group), ) + + def update_single_attribute(self, connection: MicrosoftEntraProviderGroup): + data = self._request(self.client.groups.by_group_id(connection.microsoft_id).get()) + connection.attributes = self.entity_as_dict(data) diff --git a/authentik/enterprise/providers/microsoft_entra/clients/users.py b/authentik/enterprise/providers/microsoft_entra/clients/users.py index 7371cb3f4d..a650b42a52 100644 --- a/authentik/enterprise/providers/microsoft_entra/clients/users.py +++ b/authentik/enterprise/providers/microsoft_entra/clients/users.py @@ -66,6 +66,26 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv microsoft_user.delete() return response + def get_select_fields(self) -> list[str]: + """All fields that should be selected when we fetch user data.""" + # TODO: Make this customizable in the future + return [ + # Default fields + "businessPhones", + "displayName", + "givenName", + "jobTitle", + "mail", + "mobilePhone", + "officeLocation", + "preferredLanguage", + "surname", + "userPrincipalName", + "id", + # Required for logging into M365 using authentik + "onPremisesImmutableId", + ] + def create(self, user: User): """Create user from scratch and create a connection object""" microsoft_user = self.to_schema(user, None) @@ -75,12 +95,12 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv response = self._request(self.client.users.post(microsoft_user)) except ObjectExistsSyncException: # user already exists in microsoft entra, so we can connect them manually - query_params = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters()( - filter=f"mail eq '{microsoft_user.mail}'", - ) request_configuration = ( UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration( - query_parameters=query_params, + query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( + filter=f"mail eq '{microsoft_user.mail}'", + select=self.get_select_fields(), + ), ) ) user_data = self._request(self.client.users.get(request_configuration)) @@ -99,7 +119,6 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv except TransientSyncException as exc: raise exc else: - print(self.entity_as_dict(response)) return MicrosoftEntraProviderUser.objects.create( provider=self.provider, user=user, @@ -120,7 +139,12 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv def discover(self): """Iterate through all users and connect them with authentik users if possible""" - users = self._request(self.client.users.get()) + request_configuration = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration( + query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( + select=self.get_select_fields(), + ), + ) + users = self._request(self.client.users.get(request_configuration)) next_link = True while next_link: for user in users.value: @@ -141,3 +165,14 @@ class MicrosoftEntraUserClient(MicrosoftEntraSyncClient[User, MicrosoftEntraProv microsoft_id=user.id, attributes=self.entity_as_dict(user), ) + + def update_single_attribute(self, connection: MicrosoftEntraProviderUser): + request_configuration = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration( + query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( + select=self.get_select_fields(), + ), + ) + data = self._request( + self.client.users.by_user_id(connection.microsoft_id).get(request_configuration) + ) + connection.attributes = self.entity_as_dict(data) diff --git a/authentik/enterprise/providers/microsoft_entra/models.py b/authentik/enterprise/providers/microsoft_entra/models.py index 079519f615..a9092a76d2 100644 --- a/authentik/enterprise/providers/microsoft_entra/models.py +++ b/authentik/enterprise/providers/microsoft_entra/models.py @@ -22,6 +22,58 @@ from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider +class MicrosoftEntraProviderUser(SerializerModel): + """Mapping of a user and provider to a Microsoft user ID""" + + id = models.UUIDField(primary_key=True, editable=False, default=uuid4) + 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]: + from authentik.enterprise.providers.microsoft_entra.api.users import ( + MicrosoftEntraProviderUserSerializer, + ) + + return MicrosoftEntraProviderUserSerializer + + class Meta: + verbose_name = _("Microsoft Entra Provider User") + verbose_name_plural = _("Microsoft Entra Provider User") + unique_together = (("microsoft_id", "user", "provider"),) + + def __str__(self) -> str: + return f"Microsoft Entra Provider User {self.user_id} to {self.provider_id}" + + +class MicrosoftEntraProviderGroup(SerializerModel): + """Mapping of a group and provider to a Microsoft group ID""" + + id = models.UUIDField(primary_key=True, editable=False, default=uuid4) + 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]: + from authentik.enterprise.providers.microsoft_entra.api.groups import ( + MicrosoftEntraProviderGroupSerializer, + ) + + return MicrosoftEntraProviderGroupSerializer + + class Meta: + verbose_name = _("Microsoft Entra Provider Group") + verbose_name_plural = _("Microsoft Entra Provider Groups") + unique_together = (("microsoft_id", "group", "provider"),) + + def __str__(self) -> str: + return f"Microsoft Entra Provider Group {self.group_id} to {self.provider_id}" + + class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): """Sync users from authentik into Microsoft Entra.""" @@ -48,15 +100,16 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): ) def client_for_model( - self, model: type[User | Group] + self, + model: type[User | Group | MicrosoftEntraProviderUser | MicrosoftEntraProviderGroup], ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]: - if issubclass(model, User): + if issubclass(model, User | MicrosoftEntraProviderUser): from authentik.enterprise.providers.microsoft_entra.clients.users import ( MicrosoftEntraUserClient, ) return MicrosoftEntraUserClient(self) - if issubclass(model, Group): + if issubclass(model, Group | MicrosoftEntraProviderGroup): from authentik.enterprise.providers.microsoft_entra.clients.groups import ( MicrosoftEntraGroupClient, ) @@ -133,55 +186,3 @@ class MicrosoftEntraProviderMapping(PropertyMapping): class Meta: verbose_name = _("Microsoft Entra Provider Mapping") verbose_name_plural = _("Microsoft Entra Provider Mappings") - - -class MicrosoftEntraProviderUser(SerializerModel): - """Mapping of a user and provider to a Microsoft user ID""" - - id = models.UUIDField(primary_key=True, editable=False, default=uuid4) - 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]: - from authentik.enterprise.providers.microsoft_entra.api.users import ( - MicrosoftEntraProviderUserSerializer, - ) - - return MicrosoftEntraProviderUserSerializer - - class Meta: - verbose_name = _("Microsoft Entra Provider User") - verbose_name_plural = _("Microsoft Entra Provider User") - unique_together = (("microsoft_id", "user", "provider"),) - - def __str__(self) -> str: - return f"Microsoft Entra Provider User {self.user_id} to {self.provider_id}" - - -class MicrosoftEntraProviderGroup(SerializerModel): - """Mapping of a group and provider to a Microsoft group ID""" - - id = models.UUIDField(primary_key=True, editable=False, default=uuid4) - 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]: - from authentik.enterprise.providers.microsoft_entra.api.groups import ( - MicrosoftEntraProviderGroupSerializer, - ) - - return MicrosoftEntraProviderGroupSerializer - - class Meta: - verbose_name = _("Microsoft Entra Provider Group") - verbose_name_plural = _("Microsoft Entra Provider Groups") - unique_together = (("microsoft_id", "group", "provider"),) - - def __str__(self) -> str: - return f"Microsoft Entra Provider Group {self.group_id} to {self.provider_id}" diff --git a/authentik/enterprise/providers/microsoft_entra/tests/test_users.py b/authentik/enterprise/providers/microsoft_entra/tests/test_users.py index 7a00213efc..aa491f703c 100644 --- a/authentik/enterprise/providers/microsoft_entra/tests/test_users.py +++ b/authentik/enterprise/providers/microsoft_entra/tests/test_users.py @@ -3,16 +3,18 @@ from unittest.mock import AsyncMock, MagicMock, patch from azure.identity.aio import ClientSecretCredential -from django.test import TestCase +from django.urls import reverse from msgraph.generated.models.group_collection_response import GroupCollectionResponse from msgraph.generated.models.organization import Organization from msgraph.generated.models.organization_collection_response import OrganizationCollectionResponse from msgraph.generated.models.user import User as MSUser from msgraph.generated.models.user_collection_response import UserCollectionResponse from msgraph.generated.models.verified_domain import VerifiedDomain +from rest_framework.test import APITestCase from authentik.blueprints.tests import apply_blueprint from authentik.core.models import Application, Group, User +from authentik.core.tests.utils import create_test_admin_user from authentik.enterprise.providers.microsoft_entra.models import ( MicrosoftEntraProvider, MicrosoftEntraProviderMapping, @@ -25,11 +27,12 @@ from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction from authentik.tenants.models import Tenant -class MicrosoftEntraUserTests(TestCase): +class MicrosoftEntraUserTests(APITestCase): """Microsoft Entra User tests""" @apply_blueprint("system/providers-microsoft-entra.yaml") def setUp(self) -> None: + # Delete all users and groups as the mocked HTTP responses only return one ID # which will cause errors with multiple users Tenant.objects.update(avatars="none") @@ -371,3 +374,45 @@ class MicrosoftEntraUserTests(TestCase): ) self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) user_list.assert_called_once() + + def test_connect_manual(self): + """test manual user connection""" + uid = generate_id() + self.app.backchannel_providers.remove(self.provider) + admin = create_test_admin_user() + different_user = User.objects.create( + username=uid, + email=f"{uid}@goauthentik.io", + ) + self.app.backchannel_providers.add(self.provider) + with ( + patch( + "authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials", + MagicMock(return_value={"credentials": self.creds}), + ), + patch( + "msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get", + AsyncMock( + return_value=OrganizationCollectionResponse( + value=[ + Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")]) + ] + ) + ), + ), + patch( + "authentik.enterprise.providers.microsoft_entra.clients.users.MicrosoftEntraUserClient.update_single_attribute", + MagicMock(), + ) as user_get, + ): + self.client.force_login(admin) + response = self.client.post( + reverse("authentik_api:microsoftentraprovideruser-list"), + data={ + "microsoft_id": generate_id(), + "user": different_user.pk, + "provider": self.provider.pk, + }, + ) + self.assertEqual(response.status_code, 201) + user_get.assert_called_once() diff --git a/authentik/lib/sync/outgoing/api.py b/authentik/lib/sync/outgoing/api.py index 03f31ecc5b..6b496efc61 100644 --- a/authentik/lib/sync/outgoing/api.py +++ b/authentik/lib/sync/outgoing/api.py @@ -7,6 +7,7 @@ from rest_framework.decorators import action from rest_framework.fields import BooleanField from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer from authentik.core.api.utils import PassiveSerializer from authentik.events.api.tasks import SystemTaskSerializer @@ -54,3 +55,17 @@ class OutgoingSyncProviderStatusMixin: "is_running": not lock_acquired, } return Response(SyncStatusSerializer(status).data) + + +class OutgoingSyncConnectionCreateMixin: + """Mixin for connection objects that fetches remote data upon creation""" + + def perform_create(self, serializer: ModelSerializer): + super().perform_create(serializer) + try: + instance = serializer.instance + client = instance.provider.client_for_model(instance.__class__) + client.update_single_attribute(instance) + instance.save() + except NotImplementedError: + pass diff --git a/authentik/lib/sync/outgoing/base.py b/authentik/lib/sync/outgoing/base.py index 52e7da145b..47c2accf9f 100644 --- a/authentik/lib/sync/outgoing/base.py +++ b/authentik/lib/sync/outgoing/base.py @@ -114,3 +114,8 @@ class BaseOutgoingSyncClient[ pre-link any users/groups in the remote system with the respective object in authentik based on a common identifier""" raise NotImplementedError() + + def update_single_attribute(self, connection: TConnection): + """Update connection attributes on a connection object, when the connection + is manually created""" + raise NotImplementedError diff --git a/authentik/providers/scim/api/groups.py b/authentik/providers/scim/api/groups.py index f9fd697e47..9bb36c8337 100644 --- a/authentik/providers/scim/api/groups.py +++ b/authentik/providers/scim/api/groups.py @@ -6,6 +6,7 @@ from rest_framework.viewsets import GenericViewSet from authentik.core.api.used_by import UsedByMixin from authentik.core.api.users import UserGroupSerializer +from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin from authentik.providers.scim.models import SCIMProviderGroup @@ -28,6 +29,7 @@ class SCIMProviderGroupSerializer(ModelSerializer): class SCIMProviderGroupViewSet( mixins.CreateModelMixin, + OutgoingSyncConnectionCreateMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, UsedByMixin, diff --git a/authentik/providers/scim/api/users.py b/authentik/providers/scim/api/users.py index 632421f024..5d58d3fdac 100644 --- a/authentik/providers/scim/api/users.py +++ b/authentik/providers/scim/api/users.py @@ -6,6 +6,7 @@ from rest_framework.viewsets import GenericViewSet from authentik.core.api.groups import GroupMemberSerializer from authentik.core.api.used_by import UsedByMixin +from authentik.lib.sync.outgoing.api import OutgoingSyncConnectionCreateMixin from authentik.providers.scim.models import SCIMProviderUser @@ -28,6 +29,7 @@ class SCIMProviderUserSerializer(ModelSerializer): class SCIMProviderUserViewSet( mixins.CreateModelMixin, + OutgoingSyncConnectionCreateMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, UsedByMixin, diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py index 735d3a1ca3..e00543207b 100644 --- a/authentik/providers/scim/models.py +++ b/authentik/providers/scim/models.py @@ -15,6 +15,48 @@ from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient from authentik.lib.sync.outgoing.models import OutgoingSyncProvider +class SCIMProviderUser(SerializerModel): + """Mapping of a user and provider to a SCIM user ID""" + + id = models.UUIDField(primary_key=True, editable=False, default=uuid4) + scim_id = models.TextField() + user = models.ForeignKey(User, on_delete=models.CASCADE) + provider = models.ForeignKey("SCIMProvider", on_delete=models.CASCADE) + + @property + def serializer(self) -> type[Serializer]: + from authentik.providers.scim.api.users import SCIMProviderUserSerializer + + return SCIMProviderUserSerializer + + class Meta: + unique_together = (("scim_id", "user", "provider"),) + + def __str__(self) -> str: + return f"SCIM Provider User {self.user_id} to {self.provider_id}" + + +class SCIMProviderGroup(SerializerModel): + """Mapping of a group and provider to a SCIM user ID""" + + id = models.UUIDField(primary_key=True, editable=False, default=uuid4) + scim_id = models.TextField() + group = models.ForeignKey(Group, on_delete=models.CASCADE) + provider = models.ForeignKey("SCIMProvider", on_delete=models.CASCADE) + + @property + def serializer(self) -> type[Serializer]: + from authentik.providers.scim.api.groups import SCIMProviderGroupSerializer + + return SCIMProviderGroupSerializer + + class Meta: + unique_together = (("scim_id", "group", "provider"),) + + def __str__(self) -> str: + return f"SCIM Provider Group {self.group_id} to {self.provider_id}" + + class SCIMProvider(OutgoingSyncProvider, BackchannelProvider): """SCIM 2.0 provider to create users and groups in external applications""" @@ -39,13 +81,13 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider): return static("authentik/sources/scim.png") def client_for_model( - self, model: type[User | Group] + self, model: type[User | Group | SCIMProviderUser | SCIMProviderGroup] ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]: - if issubclass(model, User): + if issubclass(model, User | SCIMProviderUser): from authentik.providers.scim.clients.users import SCIMUserClient return SCIMUserClient(self) - if issubclass(model, Group): + if issubclass(model, Group | SCIMProviderGroup): from authentik.providers.scim.clients.groups import SCIMGroupClient return SCIMGroupClient(self) @@ -105,45 +147,3 @@ class SCIMMapping(PropertyMapping): class Meta: verbose_name = _("SCIM Mapping") verbose_name_plural = _("SCIM Mappings") - - -class SCIMProviderUser(SerializerModel): - """Mapping of a user and provider to a SCIM user ID""" - - id = models.UUIDField(primary_key=True, editable=False, default=uuid4) - scim_id = models.TextField() - user = models.ForeignKey(User, on_delete=models.CASCADE) - provider = models.ForeignKey(SCIMProvider, on_delete=models.CASCADE) - - @property - def serializer(self) -> type[Serializer]: - from authentik.providers.scim.api.users import SCIMProviderUserSerializer - - return SCIMProviderUserSerializer - - class Meta: - unique_together = (("scim_id", "user", "provider"),) - - def __str__(self) -> str: - return f"SCIM Provider User {self.user_id} to {self.provider_id}" - - -class SCIMProviderGroup(SerializerModel): - """Mapping of a group and provider to a SCIM user ID""" - - id = models.UUIDField(primary_key=True, editable=False, default=uuid4) - scim_id = models.TextField() - group = models.ForeignKey(Group, on_delete=models.CASCADE) - provider = models.ForeignKey(SCIMProvider, on_delete=models.CASCADE) - - @property - def serializer(self) -> type[Serializer]: - from authentik.providers.scim.api.groups import SCIMProviderGroupSerializer - - return SCIMProviderGroupSerializer - - class Meta: - unique_together = (("scim_id", "group", "provider"),) - - def __str__(self) -> str: - return f"SCIM Provider Group {self.group_id} to {self.provider_id}"