sources: add SCIM source (#3051)
* initial Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add tests Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * rebuild migration Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * include root URL in API Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * add UI base URL Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * only allow SCIM basic auth for testing and debug Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * start user tests Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * antlr for scim filter parsing, why Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> Signed-off-by: Jens Langhammer <jens@goauthentik.io> * update Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix url mountpoint Signed-off-by: Jens Langhammer <jens@goauthentik.io> * ...turns out we don't need antlr Signed-off-by: Jens Langhammer <jens@goauthentik.io> * start to revive this PR Signed-off-by: Jens Langhammer <jens@goauthentik.io> * Apply suggestions from code review Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com> Signed-off-by: Jens L. <jens@beryju.org> * don't put doc structure changes into this Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix web ui Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make mostly work Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add filter support Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add e2e tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix helper Signed-off-by: Jens Langhammer <jens@goauthentik.io> * re-add codecov oidc Signed-off-by: Jens Langhammer <jens@goauthentik.io> * remove unused fields from API Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix group membership Signed-off-by: Jens Langhammer <jens@goauthentik.io> * unrelated: fix backchannel helper text size Signed-off-by: Jens Langhammer <jens@goauthentik.io> * test against authentik as SCIM server I guess? Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix scim provider task render Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add preview banner Signed-off-by: Jens Langhammer <jens@goauthentik.io> * Revert "re-add codecov oidc" This reverts commit fdeeb391afba710645e77608e0ab2e97485c48d1. * add API for connection objects Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix preview banner Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add UI for users and groups Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> Signed-off-by: Jens Langhammer <jens@goauthentik.io> Signed-off-by: Jens L. <jens@beryju.org> Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -160,6 +160,8 @@ jobs: | |||||||
|             glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap* |             glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap* | ||||||
|           - name: radius |           - name: radius | ||||||
|             glob: tests/e2e/test_provider_radius* |             glob: tests/e2e/test_provider_radius* | ||||||
|  |           - name: scim | ||||||
|  |             glob: tests/e2e/test_source_scim* | ||||||
|           - name: flows |           - name: flows | ||||||
|             glob: tests/e2e/test_flows* |             glob: tests/e2e/test_flows* | ||||||
|     steps: |     steps: | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ from drf_spectacular.settings import spectacular_settings | |||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| from rest_framework.settings import api_settings | from rest_framework.settings import api_settings | ||||||
|  |  | ||||||
|  | from authentik.api.apps import AuthentikAPIConfig | ||||||
| from authentik.api.pagination import PAGINATION_COMPONENT_NAME, PAGINATION_SCHEMA | from authentik.api.pagination import PAGINATION_COMPONENT_NAME, PAGINATION_SCHEMA | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -101,3 +102,12 @@ def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs): | |||||||
|             comp = result["components"]["schemas"][component] |             comp = result["components"]["schemas"][component] | ||||||
|             comp["additionalProperties"] = {} |             comp["additionalProperties"] = {} | ||||||
|     return result |     return result | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def preprocess_schema_exclude_non_api(endpoints, **kwargs): | ||||||
|  |     """Filter out all API Views which are not mounted under /api""" | ||||||
|  |     return [ | ||||||
|  |         (path, path_regex, method, callback) | ||||||
|  |         for path, path_regex, method, callback in endpoints | ||||||
|  |         if path.startswith("/" + AuthentikAPIConfig.mountpoint) | ||||||
|  |     ] | ||||||
|  | |||||||
| @ -51,6 +51,7 @@ from authentik.policies.models import Policy, PolicyBindingModel | |||||||
| from authentik.policies.reputation.models import Reputation | from authentik.policies.reputation.models import Reputation | ||||||
| from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken | from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken | ||||||
| from authentik.providers.scim.models import SCIMGroup, SCIMUser | from authentik.providers.scim.models import SCIMGroup, SCIMUser | ||||||
|  | from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser | ||||||
| from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType | from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType | ||||||
| from authentik.tenants.models import Tenant | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
| @ -97,6 +98,8 @@ def excluded_models() -> list[type[Model]]: | |||||||
|         RefreshToken, |         RefreshToken, | ||||||
|         Reputation, |         Reputation, | ||||||
|         WebAuthnDeviceType, |         WebAuthnDeviceType, | ||||||
|  |         SCIMSourceUser, | ||||||
|  |         SCIMSourceGroup, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -671,7 +671,7 @@ class ExpiringModel(models.Model): | |||||||
|         return self.delete(*args, **kwargs) |         return self.delete(*args, **kwargs) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def filter_not_expired(cls, **kwargs) -> QuerySet: |     def filter_not_expired(cls, **kwargs) -> QuerySet["Token"]: | ||||||
|         """Filer for tokens which are not expired yet or are not expiring, |         """Filer for tokens which are not expired yet or are not expiring, | ||||||
|         and match filters in `kwargs`""" |         and match filters in `kwargs`""" | ||||||
|         for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)): |         for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)): | ||||||
|  | |||||||
| @ -39,6 +39,7 @@ class Migration(migrations.Migration): | |||||||
|                     ("authentik.sources.oauth", "authentik Sources.OAuth"), |                     ("authentik.sources.oauth", "authentik Sources.OAuth"), | ||||||
|                     ("authentik.sources.plex", "authentik Sources.Plex"), |                     ("authentik.sources.plex", "authentik Sources.Plex"), | ||||||
|                     ("authentik.sources.saml", "authentik Sources.SAML"), |                     ("authentik.sources.saml", "authentik Sources.SAML"), | ||||||
|  |                     ("authentik.sources.scim", "authentik Sources.SCIM"), | ||||||
|                     ("authentik.stages.authenticator_duo", "authentik Stages.Authenticator.Duo"), |                     ("authentik.stages.authenticator_duo", "authentik Stages.Authenticator.Duo"), | ||||||
|                     ("authentik.stages.authenticator_sms", "authentik Stages.Authenticator.SMS"), |                     ("authentik.stages.authenticator_sms", "authentik Stages.Authenticator.SMS"), | ||||||
|                     ( |                     ( | ||||||
|  | |||||||
| @ -17,6 +17,7 @@ class User(BaseUser): | |||||||
|         "urn:ietf:params:scim:schemas:core:2.0:User", |         "urn:ietf:params:scim:schemas:core:2.0:User", | ||||||
|     ] |     ] | ||||||
|     externalId: str | None = None |     externalId: str | None = None | ||||||
|  |     meta: dict | None = None | ||||||
|  |  | ||||||
|  |  | ||||||
| class Group(BaseGroup): | class Group(BaseGroup): | ||||||
| @ -26,6 +27,7 @@ class Group(BaseGroup): | |||||||
|         "urn:ietf:params:scim:schemas:core:2.0:Group", |         "urn:ietf:params:scim:schemas:core:2.0:Group", | ||||||
|     ] |     ] | ||||||
|     externalId: str | None = None |     externalId: str | None = None | ||||||
|  |     meta: dict | None = None | ||||||
|  |  | ||||||
|  |  | ||||||
| class ServiceProviderConfiguration(BaseServiceProviderConfiguration): | class ServiceProviderConfiguration(BaseServiceProviderConfiguration): | ||||||
|  | |||||||
| @ -90,6 +90,7 @@ TENANT_APPS = [ | |||||||
|     "authentik.sources.oauth", |     "authentik.sources.oauth", | ||||||
|     "authentik.sources.plex", |     "authentik.sources.plex", | ||||||
|     "authentik.sources.saml", |     "authentik.sources.saml", | ||||||
|  |     "authentik.sources.scim", | ||||||
|     "authentik.stages.authenticator", |     "authentik.stages.authenticator", | ||||||
|     "authentik.stages.authenticator_duo", |     "authentik.stages.authenticator_duo", | ||||||
|     "authentik.stages.authenticator_sms", |     "authentik.stages.authenticator_sms", | ||||||
| @ -157,6 +158,9 @@ SPECTACULAR_SETTINGS = { | |||||||
|     }, |     }, | ||||||
|     "ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False, |     "ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False, | ||||||
|     "ENUM_GENERATE_CHOICE_DESCRIPTION": False, |     "ENUM_GENERATE_CHOICE_DESCRIPTION": False, | ||||||
|  |     "PREPROCESSING_HOOKS": [ | ||||||
|  |         "authentik.api.schema.preprocess_schema_exclude_non_api", | ||||||
|  |     ], | ||||||
|     "POSTPROCESSING_HOOKS": [ |     "POSTPROCESSING_HOOKS": [ | ||||||
|         "authentik.api.schema.postprocess_schema_responses", |         "authentik.api.schema.postprocess_schema_responses", | ||||||
|         "drf_spectacular.hooks.postprocess_schema_enums", |         "drf_spectacular.hooks.postprocess_schema_enums", | ||||||
|  | |||||||
							
								
								
									
										0
									
								
								authentik/sources/scim/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/sources/scim/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								authentik/sources/scim/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/sources/scim/api/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										35
									
								
								authentik/sources/scim/api/groups.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								authentik/sources/scim/api/groups.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | |||||||
|  | """SCIMSourceGroup API Views""" | ||||||
|  |  | ||||||
|  | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
|  | from authentik.core.api.sources import SourceSerializer | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.api.users import UserGroupSerializer | ||||||
|  | from authentik.sources.scim.models import SCIMSourceGroup | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMSourceGroupSerializer(SourceSerializer): | ||||||
|  |     """SCIMSourceGroup Serializer""" | ||||||
|  |  | ||||||
|  |     group_obj = UserGroupSerializer(source="group", read_only=True) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |  | ||||||
|  |         model = SCIMSourceGroup | ||||||
|  |         fields = [ | ||||||
|  |             "id", | ||||||
|  |             "group", | ||||||
|  |             "group_obj", | ||||||
|  |             "source", | ||||||
|  |             "attributes", | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMSourceGroupViewSet(UsedByMixin, ModelViewSet): | ||||||
|  |     """SCIMSourceGroup Viewset""" | ||||||
|  |  | ||||||
|  |     queryset = SCIMSourceGroup.objects.all().select_related("group") | ||||||
|  |     serializer_class = SCIMSourceGroupSerializer | ||||||
|  |     filterset_fields = ["source__slug", "group__name", "group__group_uuid"] | ||||||
|  |     search_fields = ["source__slug", "group__name", "attributes"] | ||||||
|  |     ordering = ["group__name"] | ||||||
							
								
								
									
										77
									
								
								authentik/sources/scim/api/sources.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								authentik/sources/scim/api/sources.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,77 @@ | |||||||
|  | """SCIMSource API Views""" | ||||||
|  |  | ||||||
|  | from django.urls import reverse_lazy | ||||||
|  | from rest_framework.fields import SerializerMethodField | ||||||
|  | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
|  | from authentik.core.api.sources import SourceSerializer | ||||||
|  | from authentik.core.api.tokens import TokenSerializer | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.core.models import Token, TokenIntents, User, UserTypes | ||||||
|  | from authentik.sources.scim.models import SCIMSource | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMSourceSerializer(SourceSerializer): | ||||||
|  |     """SCIMSource Serializer""" | ||||||
|  |  | ||||||
|  |     root_url = SerializerMethodField() | ||||||
|  |     token_obj = TokenSerializer(source="token", required=False, read_only=True) | ||||||
|  |  | ||||||
|  |     def get_root_url(self, instance: SCIMSource) -> str: | ||||||
|  |         """Get Root URL""" | ||||||
|  |         relative_url = reverse_lazy( | ||||||
|  |             "authentik_sources_scim:v2-root", | ||||||
|  |             kwargs={"source_slug": instance.slug}, | ||||||
|  |         ) | ||||||
|  |         if "request" not in self.context: | ||||||
|  |             return relative_url | ||||||
|  |         return self.context["request"].build_absolute_uri(relative_url) | ||||||
|  |  | ||||||
|  |     def create(self, validated_data): | ||||||
|  |         instance: SCIMSource = super().create(validated_data) | ||||||
|  |         identifier = f"ak-source-scim-{instance.pk}" | ||||||
|  |         user = User.objects.create( | ||||||
|  |             username=identifier, | ||||||
|  |             name=f"SCIM Source {instance.name} Service-Account", | ||||||
|  |             type=UserTypes.SERVICE_ACCOUNT, | ||||||
|  |         ) | ||||||
|  |         token = Token.objects.create( | ||||||
|  |             user=user, | ||||||
|  |             identifier=identifier, | ||||||
|  |             intent=TokenIntents.INTENT_API, | ||||||
|  |             expiring=False, | ||||||
|  |             managed=f"goauthentik.io/sources/scim/{instance.pk}", | ||||||
|  |         ) | ||||||
|  |         instance.token = token | ||||||
|  |         instance.save() | ||||||
|  |         return instance | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |  | ||||||
|  |         model = SCIMSource | ||||||
|  |         fields = [ | ||||||
|  |             "pk", | ||||||
|  |             "name", | ||||||
|  |             "slug", | ||||||
|  |             "enabled", | ||||||
|  |             "component", | ||||||
|  |             "verbose_name", | ||||||
|  |             "verbose_name_plural", | ||||||
|  |             "meta_model_name", | ||||||
|  |             "user_matching_mode", | ||||||
|  |             "managed", | ||||||
|  |             "user_path_template", | ||||||
|  |             "root_url", | ||||||
|  |             "token_obj", | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMSourceViewSet(UsedByMixin, ModelViewSet): | ||||||
|  |     """SCIMSource Viewset""" | ||||||
|  |  | ||||||
|  |     queryset = SCIMSource.objects.all() | ||||||
|  |     serializer_class = SCIMSourceSerializer | ||||||
|  |     lookup_field = "slug" | ||||||
|  |     filterset_fields = ["name", "slug"] | ||||||
|  |     search_fields = ["name", "slug", "token__identifier", "token__user__username"] | ||||||
|  |     ordering = ["name"] | ||||||
							
								
								
									
										35
									
								
								authentik/sources/scim/api/users.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								authentik/sources/scim/api/users.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | |||||||
|  | """SCIMSourceUser API Views""" | ||||||
|  |  | ||||||
|  | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
|  | from authentik.core.api.groups import GroupMemberSerializer | ||||||
|  | from authentik.core.api.sources import SourceSerializer | ||||||
|  | from authentik.core.api.used_by import UsedByMixin | ||||||
|  | from authentik.sources.scim.models import SCIMSourceUser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMSourceUserSerializer(SourceSerializer): | ||||||
|  |     """SCIMSourceUser Serializer""" | ||||||
|  |  | ||||||
|  |     user_obj = GroupMemberSerializer(source="user", read_only=True) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |  | ||||||
|  |         model = SCIMSourceUser | ||||||
|  |         fields = [ | ||||||
|  |             "id", | ||||||
|  |             "user", | ||||||
|  |             "user_obj", | ||||||
|  |             "source", | ||||||
|  |             "attributes", | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMSourceUserViewSet(UsedByMixin, ModelViewSet): | ||||||
|  |     """SCIMSourceUser Viewset""" | ||||||
|  |  | ||||||
|  |     queryset = SCIMSourceUser.objects.all().select_related("user") | ||||||
|  |     serializer_class = SCIMSourceUserSerializer | ||||||
|  |     filterset_fields = ["source__slug", "user__username", "user__id"] | ||||||
|  |     search_fields = ["source__slug", "user__username", "attributes"] | ||||||
|  |     ordering = ["user__username"] | ||||||
							
								
								
									
										12
									
								
								authentik/sources/scim/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								authentik/sources/scim/apps.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | """Authentik SCIM app config""" | ||||||
|  |  | ||||||
|  | from django.apps import AppConfig | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AuthentikSourceSCIMConfig(AppConfig): | ||||||
|  |     """authentik SCIM Source app config""" | ||||||
|  |  | ||||||
|  |     name = "authentik.sources.scim" | ||||||
|  |     label = "authentik_sources_scim" | ||||||
|  |     verbose_name = "authentik Sources.SCIM" | ||||||
|  |     mountpoint = "source/scim/" | ||||||
							
								
								
									
										8
									
								
								authentik/sources/scim/errors.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								authentik/sources/scim/errors.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | |||||||
|  | """SCIM Errors""" | ||||||
|  |  | ||||||
|  | from authentik.lib.sentry import SentryIgnoredException | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PatchError(SentryIgnoredException): | ||||||
|  |     """Error raised within an atomic block when an error happened | ||||||
|  |     so nothing is saved""" | ||||||
							
								
								
									
										94
									
								
								authentik/sources/scim/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								authentik/sources/scim/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,94 @@ | |||||||
|  | # Generated by Django 5.0.4 on 2024-04-07 14:34 | ||||||
|  |  | ||||||
|  | import django.db.models.deletion | ||||||
|  | from django.conf import settings | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     initial = True | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_core", "0033_alter_user_options"), | ||||||
|  |         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name="SCIMSource", | ||||||
|  |             fields=[ | ||||||
|  |                 ( | ||||||
|  |                     "source_ptr", | ||||||
|  |                     models.OneToOneField( | ||||||
|  |                         auto_created=True, | ||||||
|  |                         on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                         parent_link=True, | ||||||
|  |                         primary_key=True, | ||||||
|  |                         serialize=False, | ||||||
|  |                         to="authentik_core.source", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ( | ||||||
|  |                     "token", | ||||||
|  |                     models.ForeignKey( | ||||||
|  |                         default=None, | ||||||
|  |                         null=True, | ||||||
|  |                         on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                         to="authentik_core.token", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |             ], | ||||||
|  |             options={ | ||||||
|  |                 "verbose_name": "SCIM Source", | ||||||
|  |                 "verbose_name_plural": "SCIM Sources", | ||||||
|  |             }, | ||||||
|  |             bases=("authentik_core.source",), | ||||||
|  |         ), | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name="SCIMSourceGroup", | ||||||
|  |             fields=[ | ||||||
|  |                 ("id", models.TextField(primary_key=True, serialize=False)), | ||||||
|  |                 ("attributes", models.JSONField(default=dict)), | ||||||
|  |                 ( | ||||||
|  |                     "group", | ||||||
|  |                     models.ForeignKey( | ||||||
|  |                         on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group" | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ( | ||||||
|  |                     "source", | ||||||
|  |                     models.ForeignKey( | ||||||
|  |                         on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                         to="authentik_sources_scim.scimsource", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |             ], | ||||||
|  |             options={ | ||||||
|  |                 "unique_together": {("id", "group", "source")}, | ||||||
|  |             }, | ||||||
|  |         ), | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name="SCIMSourceUser", | ||||||
|  |             fields=[ | ||||||
|  |                 ("id", models.TextField(primary_key=True, serialize=False)), | ||||||
|  |                 ("attributes", models.JSONField(default=dict)), | ||||||
|  |                 ( | ||||||
|  |                     "source", | ||||||
|  |                     models.ForeignKey( | ||||||
|  |                         on_delete=django.db.models.deletion.CASCADE, | ||||||
|  |                         to="authentik_sources_scim.scimsource", | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ( | ||||||
|  |                     "user", | ||||||
|  |                     models.ForeignKey( | ||||||
|  |                         on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |             ], | ||||||
|  |             options={ | ||||||
|  |                 "unique_together": {("id", "user", "source")}, | ||||||
|  |             }, | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
							
								
								
									
										0
									
								
								authentik/sources/scim/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/sources/scim/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										76
									
								
								authentik/sources/scim/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								authentik/sources/scim/models.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,76 @@ | |||||||
|  | """SCIM Source""" | ||||||
|  |  | ||||||
|  | from django.db import models | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  | from rest_framework.serializers import BaseSerializer | ||||||
|  |  | ||||||
|  | from authentik.core.models import Group, Source, Token, User | ||||||
|  | from authentik.lib.models import SerializerModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMSource(Source): | ||||||
|  |     """System for Cross-domain Identity Management Source, allows for | ||||||
|  |     cross-system user provisioning""" | ||||||
|  |  | ||||||
|  |     token = models.ForeignKey(Token, on_delete=models.CASCADE, null=True, default=None) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def component(self) -> str: | ||||||
|  |         """Return component used to edit this object""" | ||||||
|  |         return "ak-source-scim-form" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def serializer(self) -> BaseSerializer: | ||||||
|  |         from authentik.sources.scim.api.sources import SCIMSourceSerializer | ||||||
|  |  | ||||||
|  |         return SCIMSourceSerializer | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"SCIM Source {self.name}" | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |  | ||||||
|  |         verbose_name = _("SCIM Source") | ||||||
|  |         verbose_name_plural = _("SCIM Sources") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMSourceUser(SerializerModel): | ||||||
|  |     """Mapping of a user and source to a SCIM user ID""" | ||||||
|  |  | ||||||
|  |     id = models.TextField(primary_key=True) | ||||||
|  |     user = models.ForeignKey(User, on_delete=models.CASCADE) | ||||||
|  |     source = models.ForeignKey(SCIMSource, on_delete=models.CASCADE) | ||||||
|  |     attributes = models.JSONField(default=dict) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def serializer(self) -> BaseSerializer: | ||||||
|  |         from authentik.sources.scim.api.users import SCIMSourceUserSerializer | ||||||
|  |  | ||||||
|  |         return SCIMSourceUserSerializer | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         unique_together = (("id", "user", "source"),) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"SCIM User {self.user.username} to {self.source.name}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMSourceGroup(SerializerModel): | ||||||
|  |     """Mapping of a group and source to a SCIM user ID""" | ||||||
|  |  | ||||||
|  |     id = models.TextField(primary_key=True) | ||||||
|  |     group = models.ForeignKey(Group, on_delete=models.CASCADE) | ||||||
|  |     source = models.ForeignKey(SCIMSource, on_delete=models.CASCADE) | ||||||
|  |     attributes = models.JSONField(default=dict) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def serializer(self) -> BaseSerializer: | ||||||
|  |         from authentik.sources.scim.api.groups import SCIMSourceGroupSerializer | ||||||
|  |  | ||||||
|  |         return SCIMSourceGroupSerializer | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         unique_together = (("id", "group", "source"),) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"SCIM Group {self.group.name} to {self.source.name}" | ||||||
							
								
								
									
										1796
									
								
								authentik/sources/scim/schemas/schema.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1796
									
								
								authentik/sources/scim/schemas/schema.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										0
									
								
								authentik/sources/scim/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/sources/scim/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										87
									
								
								authentik/sources/scim/tests/test_auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								authentik/sources/scim/tests/test_auth.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,87 @@ | |||||||
|  | """Test SCIM Auth""" | ||||||
|  |  | ||||||
|  | from django.urls import reverse | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import Token, TokenIntents | ||||||
|  | from authentik.core.tests.utils import create_test_admin_user | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
|  | from authentik.sources.scim.models import SCIMSource | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestSCIMAuth(APITestCase): | ||||||
|  |     """Test SCIM Auth view""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         self.user = create_test_admin_user() | ||||||
|  |         self.token = Token.objects.create( | ||||||
|  |             user=self.user, | ||||||
|  |             identifier=generate_id(), | ||||||
|  |             intent=TokenIntents.INTENT_API, | ||||||
|  |         ) | ||||||
|  |         self.token2 = Token.objects.create( | ||||||
|  |             user=self.user, | ||||||
|  |             identifier=generate_id(), | ||||||
|  |             intent=TokenIntents.INTENT_API, | ||||||
|  |         ) | ||||||
|  |         self.token3 = Token.objects.create( | ||||||
|  |             user=self.user, | ||||||
|  |             identifier=generate_id(), | ||||||
|  |             intent=TokenIntents.INTENT_API, | ||||||
|  |         ) | ||||||
|  |         self.source = SCIMSource.objects.create( | ||||||
|  |             name=generate_id(), slug=generate_id(), token=self.token | ||||||
|  |         ) | ||||||
|  |         self.source2 = SCIMSource.objects.create( | ||||||
|  |             name=generate_id(), slug=generate_id(), token=self.token2 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_auth_ok(self): | ||||||
|  |         """Test successful auth""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-schema", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_auth_missing(self): | ||||||
|  |         """Test without header""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-schema", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |     def test_auth_wrong_token(self): | ||||||
|  |         """Test with wrong token""" | ||||||
|  |         # Token for wrong source | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-schema", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token2.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |         # Token for no source | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-schema", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token3.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
							
								
								
									
										65
									
								
								authentik/sources/scim/tests/test_resource_types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								authentik/sources/scim/tests/test_resource_types.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,65 @@ | |||||||
|  | """Test SCIM ResourceTypes""" | ||||||
|  |  | ||||||
|  | from django.urls import reverse | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import Token, TokenIntents | ||||||
|  | from authentik.core.tests.utils import create_test_admin_user | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
|  | from authentik.sources.scim.models import SCIMSource | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestSCIMResourceTypes(APITestCase): | ||||||
|  |     """Test SCIM ResourceTypes view""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         self.user = create_test_admin_user() | ||||||
|  |         self.token = Token.objects.create( | ||||||
|  |             user=self.user, | ||||||
|  |             identifier=generate_id(), | ||||||
|  |             intent=TokenIntents.INTENT_API, | ||||||
|  |         ) | ||||||
|  |         self.source = SCIMSource.objects.create( | ||||||
|  |             name=generate_id(), slug=generate_id(), token=self.token | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_resource_type(self): | ||||||
|  |         """Test full resource type view""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-resource-types", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_resource_type_single(self): | ||||||
|  |         """Test single resource type""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-resource-types", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                     "resource_type": "ServiceProviderConfig", | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_resource_type_single_404(self): | ||||||
|  |         """Test single resource type (404""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-resource-types", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                     "resource_type": "foo", | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 404) | ||||||
							
								
								
									
										65
									
								
								authentik/sources/scim/tests/test_schemas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								authentik/sources/scim/tests/test_schemas.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,65 @@ | |||||||
|  | """Test SCIM Schema""" | ||||||
|  |  | ||||||
|  | from django.urls import reverse | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import Token, TokenIntents | ||||||
|  | from authentik.core.tests.utils import create_test_admin_user | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
|  | from authentik.sources.scim.models import SCIMSource | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestSCIMSchemas(APITestCase): | ||||||
|  |     """Test SCIM Schema view""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         self.user = create_test_admin_user() | ||||||
|  |         self.token = Token.objects.create( | ||||||
|  |             user=self.user, | ||||||
|  |             identifier=generate_id(), | ||||||
|  |             intent=TokenIntents.INTENT_API, | ||||||
|  |         ) | ||||||
|  |         self.source = SCIMSource.objects.create( | ||||||
|  |             name=generate_id(), slug=generate_id(), token=self.token | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_schema(self): | ||||||
|  |         """Test full schema view""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-schema", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_schema_single(self): | ||||||
|  |         """Test single schema""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-schema", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                     "schema_uri": "urn:ietf:params:scim:schemas:core:2.0:Meta", | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_schema_single_404(self): | ||||||
|  |         """Test single schema (404""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-schema", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                     "schema_uri": "foo", | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 404) | ||||||
							
								
								
									
										37
									
								
								authentik/sources/scim/tests/test_service_provider_config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								authentik/sources/scim/tests/test_service_provider_config.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,37 @@ | |||||||
|  | """Test SCIM ServiceProviderConfig""" | ||||||
|  |  | ||||||
|  | from django.urls import reverse | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import Token, TokenIntents | ||||||
|  | from authentik.core.tests.utils import create_test_admin_user | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
|  | from authentik.sources.scim.models import SCIMSource | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestSCIMServiceProviderConfig(APITestCase): | ||||||
|  |     """Test SCIM ServiceProviderConfig view""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         self.user = create_test_admin_user() | ||||||
|  |         self.token = Token.objects.create( | ||||||
|  |             user=self.user, | ||||||
|  |             identifier=generate_id(), | ||||||
|  |             intent=TokenIntents.INTENT_API, | ||||||
|  |         ) | ||||||
|  |         self.source = SCIMSource.objects.create( | ||||||
|  |             name=generate_id(), slug=generate_id(), token=self.token | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_config(self): | ||||||
|  |         """Test full config view""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-service-provider-config", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
							
								
								
									
										90
									
								
								authentik/sources/scim/tests/test_users.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								authentik/sources/scim/tests/test_users.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,90 @@ | |||||||
|  | """Test SCIM User""" | ||||||
|  |  | ||||||
|  | from json import dumps | ||||||
|  | from uuid import uuid4 | ||||||
|  |  | ||||||
|  | from django.urls import reverse | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import Token, TokenIntents | ||||||
|  | from authentik.core.tests.utils import create_test_admin_user | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
|  | from authentik.providers.scim.clients.schema import User as SCIMUserSchema | ||||||
|  | from authentik.sources.scim.models import SCIMSource, SCIMSourceUser | ||||||
|  | from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestSCIMUsers(APITestCase): | ||||||
|  |     """Test SCIM User view""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         self.user = create_test_admin_user() | ||||||
|  |         self.token = Token.objects.create( | ||||||
|  |             user=self.user, | ||||||
|  |             identifier=generate_id(), | ||||||
|  |             intent=TokenIntents.INTENT_API, | ||||||
|  |         ) | ||||||
|  |         self.source = SCIMSource.objects.create( | ||||||
|  |             name=generate_id(), slug=generate_id(), token=self.token | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def test_user_list(self): | ||||||
|  |         """Test full user list""" | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-users", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_user_list_single(self): | ||||||
|  |         """Test full user list (single user)""" | ||||||
|  |         SCIMSourceUser.objects.create( | ||||||
|  |             source=self.source, | ||||||
|  |             user=self.user, | ||||||
|  |             id=str(uuid4()), | ||||||
|  |         ) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-users", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                     "user_id": str(self.user.uuid), | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         SCIMUserSchema.model_validate_json(response.content, strict=True) | ||||||
|  |  | ||||||
|  |     def test_user_create(self): | ||||||
|  |         """Test user create""" | ||||||
|  |         ext_id = generate_id() | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_sources_scim:v2-users", | ||||||
|  |                 kwargs={ | ||||||
|  |                     "source_slug": self.source.slug, | ||||||
|  |                 }, | ||||||
|  |             ), | ||||||
|  |             data=dumps( | ||||||
|  |                 { | ||||||
|  |                     "userName": generate_id(), | ||||||
|  |                     "externalId": ext_id, | ||||||
|  |                     "emails": [ | ||||||
|  |                         { | ||||||
|  |                             "primary": True, | ||||||
|  |                             "value": self.user.email, | ||||||
|  |                         } | ||||||
|  |                     ], | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             content_type=SCIM_CONTENT_TYPE, | ||||||
|  |             HTTP_AUTHORIZATION=f"Bearer {self.token.key}", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 201) | ||||||
|  |         self.assertTrue(SCIMSourceUser.objects.filter(source=self.source, id=ext_id).exists()) | ||||||
							
								
								
									
										74
									
								
								authentik/sources/scim/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								authentik/sources/scim/urls.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,74 @@ | |||||||
|  | """SCIM URLs""" | ||||||
|  |  | ||||||
|  | from django.urls import path | ||||||
|  |  | ||||||
|  | from authentik.sources.scim.api.groups import SCIMSourceGroupViewSet | ||||||
|  | from authentik.sources.scim.api.sources import SCIMSourceViewSet | ||||||
|  | from authentik.sources.scim.api.users import SCIMSourceUserViewSet | ||||||
|  | from authentik.sources.scim.views.v2 import ( | ||||||
|  |     base, | ||||||
|  |     groups, | ||||||
|  |     resource_types, | ||||||
|  |     schemas, | ||||||
|  |     service_provider_config, | ||||||
|  |     users, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | urlpatterns = [ | ||||||
|  |     path( | ||||||
|  |         "<slug:source_slug>/v2", | ||||||
|  |         base.SCIMRootView.as_view(), | ||||||
|  |         name="v2-root", | ||||||
|  |     ), | ||||||
|  |     path( | ||||||
|  |         "<slug:source_slug>/v2/Users", | ||||||
|  |         users.UsersView.as_view(), | ||||||
|  |         name="v2-users", | ||||||
|  |     ), | ||||||
|  |     path( | ||||||
|  |         "<slug:source_slug>/v2/Users/<str:user_id>", | ||||||
|  |         users.UsersView.as_view(), | ||||||
|  |         name="v2-users", | ||||||
|  |     ), | ||||||
|  |     path( | ||||||
|  |         "<slug:source_slug>/v2/Groups", | ||||||
|  |         groups.GroupsView.as_view(), | ||||||
|  |         name="v2-groups", | ||||||
|  |     ), | ||||||
|  |     path( | ||||||
|  |         "<slug:source_slug>/v2/Groups/<str:group_id>", | ||||||
|  |         groups.GroupsView.as_view(), | ||||||
|  |         name="v2-groups", | ||||||
|  |     ), | ||||||
|  |     path( | ||||||
|  |         "<slug:source_slug>/v2/Schemas", | ||||||
|  |         schemas.SchemaView.as_view(), | ||||||
|  |         name="v2-schema", | ||||||
|  |     ), | ||||||
|  |     path( | ||||||
|  |         "<slug:source_slug>/v2/Schemas/<str:schema_uri>", | ||||||
|  |         schemas.SchemaView.as_view(), | ||||||
|  |         name="v2-schema", | ||||||
|  |     ), | ||||||
|  |     path( | ||||||
|  |         "<slug:source_slug>/v2/ServiceProviderConfig", | ||||||
|  |         service_provider_config.ServiceProviderConfigView.as_view(), | ||||||
|  |         name="v2-service-provider-config", | ||||||
|  |     ), | ||||||
|  |     path( | ||||||
|  |         "<slug:source_slug>/v2/ResourceTypes", | ||||||
|  |         resource_types.ResourceTypesView.as_view(), | ||||||
|  |         name="v2-resource-types", | ||||||
|  |     ), | ||||||
|  |     path( | ||||||
|  |         "<slug:source_slug>/v2/ResourceTypes/<str:resource_type>", | ||||||
|  |         resource_types.ResourceTypesView.as_view(), | ||||||
|  |         name="v2-resource-types", | ||||||
|  |     ), | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | api_urlpatterns = [ | ||||||
|  |     ("sources/scim", SCIMSourceViewSet), | ||||||
|  |     ("sources/scim_users", SCIMSourceUserViewSet), | ||||||
|  |     ("sources/scim_groups", SCIMSourceGroupViewSet), | ||||||
|  | ] | ||||||
							
								
								
									
										0
									
								
								authentik/sources/scim/views/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/sources/scim/views/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								authentik/sources/scim/views/v2/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/sources/scim/views/v2/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										55
									
								
								authentik/sources/scim/views/v2/auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								authentik/sources/scim/views/v2/auth.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,55 @@ | |||||||
|  | """SCIM Token auth""" | ||||||
|  |  | ||||||
|  | from base64 import b64decode | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from django.conf import settings | ||||||
|  | from rest_framework.authentication import BaseAuthentication, get_authorization_header | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.views import APIView | ||||||
|  |  | ||||||
|  | from authentik.core.models import Token, TokenIntents, User | ||||||
|  | from authentik.sources.scim.models import SCIMSource | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMTokenAuth(BaseAuthentication): | ||||||
|  |     """SCIM Token auth""" | ||||||
|  |  | ||||||
|  |     def __init__(self, view: APIView) -> None: | ||||||
|  |         super().__init__() | ||||||
|  |         self.view = view | ||||||
|  |  | ||||||
|  |     def legacy(self, key: str, source_slug: str) -> Token | None:  # pragma: no cover | ||||||
|  |         """Legacy HTTP-Basic auth for testing""" | ||||||
|  |         if not settings.TEST and not settings.DEBUG: | ||||||
|  |             return None | ||||||
|  |         _username, _, password = b64decode(key.encode()).decode().partition(":") | ||||||
|  |         token = self.check_token(password, source_slug) | ||||||
|  |         if token: | ||||||
|  |             return (token.user, token) | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     def check_token(self, key: str, source_slug: str) -> Token | None: | ||||||
|  |         """Check that a token exists, is not expired, and is assigned to the correct source""" | ||||||
|  |         token = Token.filter_not_expired(key=key, intent=TokenIntents.INTENT_API).first() | ||||||
|  |         if not token: | ||||||
|  |             return None | ||||||
|  |         source: SCIMSource = token.scimsource_set.first() | ||||||
|  |         if not source: | ||||||
|  |             return None | ||||||
|  |         if source.slug != source_slug: | ||||||
|  |             return None | ||||||
|  |         self.view.source = source | ||||||
|  |         return token | ||||||
|  |  | ||||||
|  |     def authenticate(self, request: Request) -> tuple[User, Any] | None: | ||||||
|  |         kwargs = request._request.resolver_match.kwargs | ||||||
|  |         source_slug = kwargs.get("source_slug", None) | ||||||
|  |         auth = get_authorization_header(request).decode() | ||||||
|  |         auth_type, _, key = auth.partition(" ") | ||||||
|  |         if auth_type != "Bearer": | ||||||
|  |             return self.legacy(key, source_slug) | ||||||
|  |         token = self.check_token(key, source_slug) | ||||||
|  |         if not token: | ||||||
|  |             return None | ||||||
|  |         return (token.user, token) | ||||||
							
								
								
									
										120
									
								
								authentik/sources/scim/views/v2/base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								authentik/sources/scim/views/v2/base.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,120 @@ | |||||||
|  | """SCIM Utils""" | ||||||
|  |  | ||||||
|  | from typing import Any | ||||||
|  | from urllib.parse import urlparse | ||||||
|  |  | ||||||
|  | from django.conf import settings | ||||||
|  | from django.core.paginator import Page, Paginator | ||||||
|  | from django.db.models import Model, Q, QuerySet | ||||||
|  | from django.http import HttpRequest | ||||||
|  | from django.urls import resolve | ||||||
|  | from rest_framework.parsers import JSONParser | ||||||
|  | from rest_framework.permissions import IsAuthenticated | ||||||
|  | from rest_framework.renderers import JSONRenderer | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  | from rest_framework.views import APIView | ||||||
|  | from scim2_filter_parser.transpilers.django_q_object import get_query | ||||||
|  | from structlog import BoundLogger | ||||||
|  | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.core.models import Group, User | ||||||
|  | from authentik.sources.scim.models import SCIMSource | ||||||
|  | from authentik.sources.scim.views.v2.auth import SCIMTokenAuth | ||||||
|  |  | ||||||
|  | SCIM_CONTENT_TYPE = "application/scim+json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMParser(JSONParser): | ||||||
|  |     """SCIM clients use a custom content type""" | ||||||
|  |  | ||||||
|  |     media_type = SCIM_CONTENT_TYPE | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMRenderer(JSONRenderer): | ||||||
|  |     """SCIM clients also expect a custom content type""" | ||||||
|  |  | ||||||
|  |     media_type = SCIM_CONTENT_TYPE | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMView(APIView): | ||||||
|  |     """Base class for SCIM Views""" | ||||||
|  |  | ||||||
|  |     source: SCIMSource | ||||||
|  |     logger: BoundLogger | ||||||
|  |  | ||||||
|  |     permission_classes = [IsAuthenticated] | ||||||
|  |     parser_classes = [SCIMParser] | ||||||
|  |     renderer_classes = [SCIMRenderer] | ||||||
|  |  | ||||||
|  |     model: type[Model] | ||||||
|  |  | ||||||
|  |     def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: | ||||||
|  |         self.logger = get_logger().bind() | ||||||
|  |         return super().setup(request, *args, **kwargs) | ||||||
|  |  | ||||||
|  |     def get_authenticators(self): | ||||||
|  |         return [SCIMTokenAuth(self)] | ||||||
|  |  | ||||||
|  |     def patch_resolve_value(self, raw_value: dict) -> User | Group | None: | ||||||
|  |         """Attempt to resolve a raw `value` attribute of a patch operation into | ||||||
|  |         a database model""" | ||||||
|  |         model = User | ||||||
|  |         query = {} | ||||||
|  |         if "$ref" in raw_value: | ||||||
|  |             url = urlparse(raw_value["$ref"]) | ||||||
|  |             if match := resolve(url.path): | ||||||
|  |                 if match.url_name == "v2-users": | ||||||
|  |                     model = User | ||||||
|  |                     query = {"pk": int(match.kwargs["user_id"])} | ||||||
|  |         elif "type" in raw_value: | ||||||
|  |             match raw_value["type"]: | ||||||
|  |                 case "User": | ||||||
|  |                     model = User | ||||||
|  |                     query = {"pk": int(raw_value["value"])} | ||||||
|  |                 case "Group": | ||||||
|  |                     model = Group | ||||||
|  |         else: | ||||||
|  |             return None | ||||||
|  |         return model.objects.filter(**query).first() | ||||||
|  |  | ||||||
|  |     def filter_parse(self, request: Request): | ||||||
|  |         """Parse the path of a Patch Operation""" | ||||||
|  |         path = request.query_params.get("filter") | ||||||
|  |         if not path: | ||||||
|  |             return Q() | ||||||
|  |         attr_map = {} | ||||||
|  |         if self.model == User: | ||||||
|  |             attr_map = { | ||||||
|  |                 ("userName", None, None): "user__username", | ||||||
|  |                 ("active", None, None): "user__is_active", | ||||||
|  |                 ("name", "familyName", None): "attributes__familyName", | ||||||
|  |             } | ||||||
|  |         elif self.model == Group: | ||||||
|  |             attr_map = { | ||||||
|  |                 ("displayName", None, None): "group__name", | ||||||
|  |                 ("members", None, None): "group__users", | ||||||
|  |             } | ||||||
|  |         return get_query( | ||||||
|  |             path, | ||||||
|  |             attr_map, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def paginate_query(self, query: QuerySet) -> Page: | ||||||
|  |         per_page = 50 | ||||||
|  |         start_index = 1 | ||||||
|  |         try: | ||||||
|  |             per_page = int(settings.REST_FRAMEWORK["PAGE_SIZE"]) | ||||||
|  |             start_index = int(self.request.query_params.get("startIndex", 1)) | ||||||
|  |         except ValueError: | ||||||
|  |             pass | ||||||
|  |         paginator = Paginator(query, per_page=per_page) | ||||||
|  |         page = paginator.page(int(max(start_index / per_page, 1))) | ||||||
|  |         return page | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SCIMRootView(SCIMView): | ||||||
|  |     """Root SCIM View""" | ||||||
|  |  | ||||||
|  |     def dispatch(self, request: Request, *args, **kwargs) -> Response: | ||||||
|  |         return Response({"message": "Use this base-URL with a SCIM-compatible system."}) | ||||||
							
								
								
									
										141
									
								
								authentik/sources/scim/views/v2/groups.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								authentik/sources/scim/views/v2/groups.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,141 @@ | |||||||
|  | """SCIM Group Views""" | ||||||
|  |  | ||||||
|  | from uuid import uuid4 | ||||||
|  |  | ||||||
|  | from django.db.models import Q | ||||||
|  | from django.db.transaction import atomic | ||||||
|  | from django.http import Http404, QueryDict | ||||||
|  | from django.urls import reverse | ||||||
|  | from pydantic import ValidationError as PydanticValidationError | ||||||
|  | from pydanticscim.group import GroupMember | ||||||
|  | from rest_framework.exceptions import ValidationError | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  |  | ||||||
|  | from authentik.core.models import Group, User | ||||||
|  | from authentik.providers.scim.clients.schema import Group as SCIMGroupModel | ||||||
|  | from authentik.sources.scim.models import SCIMSourceGroup | ||||||
|  | from authentik.sources.scim.views.v2.base import SCIMView | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class GroupsView(SCIMView): | ||||||
|  |     """SCIM Group view""" | ||||||
|  |  | ||||||
|  |     model = Group | ||||||
|  |  | ||||||
|  |     def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict: | ||||||
|  |         """Convert Group to SCIM data""" | ||||||
|  |         payload = SCIMGroupModel( | ||||||
|  |             id=str(scim_group.group.pk), | ||||||
|  |             externalId=scim_group.id, | ||||||
|  |             displayName=scim_group.group.name, | ||||||
|  |             meta={ | ||||||
|  |                 "resourceType": "Group", | ||||||
|  |                 "location": self.request.build_absolute_uri( | ||||||
|  |                     reverse( | ||||||
|  |                         "authentik_sources_scim:v2-groups", | ||||||
|  |                         kwargs={ | ||||||
|  |                             "source_slug": self.kwargs["source_slug"], | ||||||
|  |                             "group_id": str(scim_group.group.pk), | ||||||
|  |                         }, | ||||||
|  |                     ) | ||||||
|  |                 ), | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         return payload.model_dump( | ||||||
|  |             mode="json", | ||||||
|  |             exclude_unset=True, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def get(self, request: Request, group_id: str | None = None, **kwargs) -> Response: | ||||||
|  |         """List Group handler""" | ||||||
|  |         if group_id: | ||||||
|  |             connection = ( | ||||||
|  |                 SCIMSourceGroup.objects.filter(source=self.source, group__group_uuid=group_id) | ||||||
|  |                 .select_related("group") | ||||||
|  |                 .first() | ||||||
|  |             ) | ||||||
|  |             if not connection: | ||||||
|  |                 raise Http404 | ||||||
|  |             return Response(self.group_to_scim(connection)) | ||||||
|  |         connections = ( | ||||||
|  |             SCIMSourceGroup.objects.filter(source=self.source) | ||||||
|  |             .select_related("group") | ||||||
|  |             .order_by("pk") | ||||||
|  |         ) | ||||||
|  |         connections = connections.filter(self.filter_parse(request)) | ||||||
|  |         page = self.paginate_query(connections) | ||||||
|  |         return Response( | ||||||
|  |             { | ||||||
|  |                 "totalResults": page.paginator.count, | ||||||
|  |                 "itemsPerPage": page.paginator.per_page, | ||||||
|  |                 "startIndex": page.start_index(), | ||||||
|  |                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], | ||||||
|  |                 "Resources": [self.group_to_scim(connection) for connection in page], | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     @atomic | ||||||
|  |     def update_group(self, connection: SCIMSourceGroup | None, data: QueryDict): | ||||||
|  |         """Partial update a group""" | ||||||
|  |         group = connection.group if connection else Group() | ||||||
|  |         if "displayName" in data: | ||||||
|  |             group.name = data.get("displayName") | ||||||
|  |         if group.name == "": | ||||||
|  |             raise ValidationError("Invalid group") | ||||||
|  |         group.save() | ||||||
|  |         if "members" in data: | ||||||
|  |             query = Q() | ||||||
|  |             for _member in data.get("members", []): | ||||||
|  |                 try: | ||||||
|  |                     member = GroupMember.model_validate(_member) | ||||||
|  |                 except PydanticValidationError as exc: | ||||||
|  |                     self.logger.warning("Invalid group member", exc=exc) | ||||||
|  |                     continue | ||||||
|  |                 query |= Q(uuid=member.value) | ||||||
|  |             group.users.set(User.objects.filter(query)) | ||||||
|  |         if not connection: | ||||||
|  |             connection, _ = SCIMSourceGroup.objects.get_or_create( | ||||||
|  |                 source=self.source, | ||||||
|  |                 group=group, | ||||||
|  |                 attributes=data, | ||||||
|  |                 id=data.get("externalId") or str(uuid4()), | ||||||
|  |             ) | ||||||
|  |         else: | ||||||
|  |             connection.attributes = data | ||||||
|  |             connection.save() | ||||||
|  |         return connection | ||||||
|  |  | ||||||
|  |     def post(self, request: Request, **kwargs) -> Response: | ||||||
|  |         """Create group handler""" | ||||||
|  |         connection = SCIMSourceGroup.objects.filter( | ||||||
|  |             source=self.source, | ||||||
|  |             group__group_uuid=request.data.get("id"), | ||||||
|  |         ).first() | ||||||
|  |         if connection: | ||||||
|  |             self.logger.debug("Found existing group") | ||||||
|  |             return Response(status=409) | ||||||
|  |         connection = self.update_group(None, request.data) | ||||||
|  |         return Response(self.group_to_scim(connection), status=201) | ||||||
|  |  | ||||||
|  |     def put(self, request: Request, group_id: str, **kwargs) -> Response: | ||||||
|  |         """Update group handler""" | ||||||
|  |         connection = SCIMSourceGroup.objects.filter( | ||||||
|  |             source=self.source, group__group_uuid=group_id | ||||||
|  |         ).first() | ||||||
|  |         if not connection: | ||||||
|  |             raise Http404 | ||||||
|  |         connection = self.update_group(connection, request.data) | ||||||
|  |         return Response(self.group_to_scim(connection), status=200) | ||||||
|  |  | ||||||
|  |     @atomic | ||||||
|  |     def delete(self, request: Request, group_id: str, **kwargs) -> Response: | ||||||
|  |         """Delete group handler""" | ||||||
|  |         connection = SCIMSourceGroup.objects.filter( | ||||||
|  |             source=self.source, group__group_uuid=group_id | ||||||
|  |         ).first() | ||||||
|  |         if not connection: | ||||||
|  |             raise Http404 | ||||||
|  |         connection.group.delete() | ||||||
|  |         connection.delete() | ||||||
|  |         return Response(status=204) | ||||||
							
								
								
									
										150
									
								
								authentik/sources/scim/views/v2/resource_types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								authentik/sources/scim/views/v2/resource_types.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,150 @@ | |||||||
|  | """SCIM Meta views""" | ||||||
|  |  | ||||||
|  | from django.http import Http404 | ||||||
|  | from django.urls import reverse | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  |  | ||||||
|  | from authentik.sources.scim.views.v2.base import SCIMView | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ResourceTypesView(SCIMView): | ||||||
|  |     """https://ldapwiki.com/wiki/SCIM%20ResourceTypes%20endpoint""" | ||||||
|  |  | ||||||
|  |     def get_resource_types(self): | ||||||
|  |         """List all resource types""" | ||||||
|  |         return [ | ||||||
|  |             { | ||||||
|  |                 "id": "ServiceProviderConfig", | ||||||
|  |                 "name": "ServiceProviderConfig", | ||||||
|  |                 "description": "the service providers configuration", | ||||||
|  |                 "endpoint": "/ServiceProviderConfig", | ||||||
|  |                 "schema": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", | ||||||
|  |                 "schemas": [ | ||||||
|  |                     "urn:ietf:params:scim:schemas:core:2.0:ResourceType", | ||||||
|  |                 ], | ||||||
|  |                 "meta": { | ||||||
|  |                     "resourceType": "ResourceType", | ||||||
|  |                     "location": self.request.build_absolute_uri( | ||||||
|  |                         reverse( | ||||||
|  |                             "authentik_sources_scim:v2-resource-types", | ||||||
|  |                             kwargs={ | ||||||
|  |                                 "source_slug": self.kwargs["source_slug"], | ||||||
|  |                                 "resource_type": "ServiceProviderConfig", | ||||||
|  |                             }, | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "id": "ResourceType", | ||||||
|  |                 "name": "ResourceType", | ||||||
|  |                 "description": "ResourceType", | ||||||
|  |                 "endpoint": "/ResourceTypes", | ||||||
|  |                 "schema": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", | ||||||
|  |                 "schemas": [ | ||||||
|  |                     "urn:ietf:params:scim:schemas:core:2.0:ResourceType", | ||||||
|  |                 ], | ||||||
|  |                 "meta": { | ||||||
|  |                     "resourceType": "ResourceType", | ||||||
|  |                     "location": self.request.build_absolute_uri( | ||||||
|  |                         reverse( | ||||||
|  |                             "authentik_sources_scim:v2-resource-types", | ||||||
|  |                             kwargs={ | ||||||
|  |                                 "source_slug": self.kwargs["source_slug"], | ||||||
|  |                                 "resource_type": "ResourceType", | ||||||
|  |                             }, | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "id": "Schema", | ||||||
|  |                 "name": "Schema", | ||||||
|  |                 "description": "Schema endpoint description", | ||||||
|  |                 "endpoint": "/Schemas", | ||||||
|  |                 "schema": "urn:ietf:params:scim:schemas:core:2.0:Schema", | ||||||
|  |                 "schemas": [ | ||||||
|  |                     "urn:ietf:params:scim:schemas:core:2.0:ResourceType", | ||||||
|  |                 ], | ||||||
|  |                 "meta": { | ||||||
|  |                     "resourceType": "ResourceType", | ||||||
|  |                     "location": self.request.build_absolute_uri( | ||||||
|  |                         reverse( | ||||||
|  |                             "authentik_sources_scim:v2-resource-types", | ||||||
|  |                             kwargs={ | ||||||
|  |                                 "source_slug": self.kwargs["source_slug"], | ||||||
|  |                                 "resource_type": "Schema", | ||||||
|  |                             }, | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "id": "User", | ||||||
|  |                 "name": "User", | ||||||
|  |                 "endpoint": "/Users", | ||||||
|  |                 "description": "https://tools.ietf.org/html/rfc7643#section-8.7.1", | ||||||
|  |                 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], | ||||||
|  |                 "schema": "urn:ietf:params:scim:schemas:core:2.0:User", | ||||||
|  |                 "schemaExtensions": [ | ||||||
|  |                     { | ||||||
|  |                         "schema": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", | ||||||
|  |                         "required": True, | ||||||
|  |                     } | ||||||
|  |                 ], | ||||||
|  |                 "meta": { | ||||||
|  |                     "location": self.request.build_absolute_uri( | ||||||
|  |                         reverse( | ||||||
|  |                             "authentik_sources_scim:v2-resource-types", | ||||||
|  |                             kwargs={ | ||||||
|  |                                 "source_slug": self.kwargs["source_slug"], | ||||||
|  |                                 "resource_type": "User", | ||||||
|  |                             }, | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                     "resourceType": "ResourceType", | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "id": "Group", | ||||||
|  |                 "name": "Group", | ||||||
|  |                 "description": "Group", | ||||||
|  |                 "endpoint": "/Groups", | ||||||
|  |                 "schema": "urn:ietf:params:scim:schemas:core:2.0:Group", | ||||||
|  |                 "schemas": [ | ||||||
|  |                     "urn:ietf:params:scim:schemas:core:2.0:ResourceType", | ||||||
|  |                 ], | ||||||
|  |                 "meta": { | ||||||
|  |                     "resourceType": "ResourceType", | ||||||
|  |                     "location": self.request.build_absolute_uri( | ||||||
|  |                         reverse( | ||||||
|  |                             "authentik_sources_scim:v2-resource-types", | ||||||
|  |                             kwargs={ | ||||||
|  |                                 "source_slug": self.kwargs["source_slug"], | ||||||
|  |                                 "resource_type": "Group", | ||||||
|  |                             }, | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |     # pylint: disable=unused-argument | ||||||
|  |     def get(self, request: Request, source_slug: str, resource_type: str | None = None) -> Response: | ||||||
|  |         """Get resource types as SCIM response""" | ||||||
|  |         resource_types = self.get_resource_types() | ||||||
|  |         if resource_type: | ||||||
|  |             resource = [x for x in resource_types if x.get("id") == resource_type] | ||||||
|  |             if resource: | ||||||
|  |                 return Response(resource[0]) | ||||||
|  |             raise Http404 | ||||||
|  |         return Response( | ||||||
|  |             { | ||||||
|  |                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], | ||||||
|  |                 "totalResults": len(resource_types), | ||||||
|  |                 "itemsPerPage": len(resource_types), | ||||||
|  |                 "startIndex": 1, | ||||||
|  |                 "Resources": resource_types, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
							
								
								
									
										52
									
								
								authentik/sources/scim/views/v2/schemas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								authentik/sources/scim/views/v2/schemas.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,52 @@ | |||||||
|  | """Schema Views""" | ||||||
|  |  | ||||||
|  | from json import loads | ||||||
|  |  | ||||||
|  | from django.http import Http404 | ||||||
|  | from django.urls import reverse | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  |  | ||||||
|  | from authentik.sources.scim.views.v2.base import SCIMView | ||||||
|  |  | ||||||
|  | with open("authentik/sources/scim/schemas/schema.json", encoding="utf-8") as SCHEMA_FILE: | ||||||
|  |     _raw_schemas = loads(SCHEMA_FILE.read()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SchemaView(SCIMView): | ||||||
|  |     """https://ldapwiki.com/wiki/SCIM%20Schemas%20Attribute""" | ||||||
|  |  | ||||||
|  |     def get_schemas(self): | ||||||
|  |         """List of all schemas""" | ||||||
|  |         schemas = [] | ||||||
|  |         for raw_schema in _raw_schemas: | ||||||
|  |             raw_schema["meta"]["location"] = self.request.build_absolute_uri( | ||||||
|  |                 reverse( | ||||||
|  |                     "authentik_sources_scim:v2-schema", | ||||||
|  |                     kwargs={ | ||||||
|  |                         "source_slug": self.kwargs["source_slug"], | ||||||
|  |                         "schema_uri": raw_schema["id"], | ||||||
|  |                     }, | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |             schemas.append(raw_schema) | ||||||
|  |         return schemas | ||||||
|  |  | ||||||
|  |     # pylint: disable=unused-argument | ||||||
|  |     def get(self, request: Request, source_slug: str, schema_uri: str | None = None) -> Response: | ||||||
|  |         """Get schemas as SCIM response""" | ||||||
|  |         schemas = self.get_schemas() | ||||||
|  |         if schema_uri: | ||||||
|  |             schema = [x for x in schemas if x.get("id") == schema_uri] | ||||||
|  |             if schema: | ||||||
|  |                 return Response(schema[0]) | ||||||
|  |             raise Http404 | ||||||
|  |         return Response( | ||||||
|  |             { | ||||||
|  |                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], | ||||||
|  |                 "totalResults": len(schemas), | ||||||
|  |                 "itemsPerPage": len(schemas), | ||||||
|  |                 "startIndex": 1, | ||||||
|  |                 "Resources": schemas, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
							
								
								
									
										46
									
								
								authentik/sources/scim/views/v2/service_provider_config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								authentik/sources/scim/views/v2/service_provider_config.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | |||||||
|  | """SCIM Meta views""" | ||||||
|  |  | ||||||
|  | from django.conf import settings | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  |  | ||||||
|  | from authentik.sources.scim.views.v2.base import SCIMView | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ServiceProviderConfigView(SCIMView): | ||||||
|  |     """ServiceProviderConfig, https://ldapwiki.com/wiki/SCIM%20ServiceProviderConfig%20endpoint""" | ||||||
|  |  | ||||||
|  |     # pylint: disable=unused-argument | ||||||
|  |     def get(self, request: Request, source_slug: str) -> Response: | ||||||
|  |         """Get ServiceProviderConfig""" | ||||||
|  |         auth_schemas = [ | ||||||
|  |             { | ||||||
|  |                 "type": "oauthbearertoken", | ||||||
|  |                 "name": "OAuth Bearer Token", | ||||||
|  |                 "description": "Authentication scheme using the OAuth Bearer Token Standard", | ||||||
|  |                 "primary": True, | ||||||
|  |             }, | ||||||
|  |         ] | ||||||
|  |         if settings.TEST or settings.DEBUG: | ||||||
|  |             auth_schemas.append( | ||||||
|  |                 { | ||||||
|  |                     "type": "httpbasic", | ||||||
|  |                     "name": "HTTP Basic", | ||||||
|  |                     "description": "Authentication scheme using HTTP Basic authorization", | ||||||
|  |                 }, | ||||||
|  |             ) | ||||||
|  |         return Response( | ||||||
|  |             { | ||||||
|  |                 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], | ||||||
|  |                 "authenticationSchemes": auth_schemas, | ||||||
|  |                 "patch": {"supported": False}, | ||||||
|  |                 "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, | ||||||
|  |                 "filter": { | ||||||
|  |                     "supported": True, | ||||||
|  |                     "maxResults": int(settings.REST_FRAMEWORK["PAGE_SIZE"]), | ||||||
|  |                 }, | ||||||
|  |                 "changePassword": {"supported": False}, | ||||||
|  |                 "sort": {"supported": False}, | ||||||
|  |                 "etag": {"supported": False}, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
							
								
								
									
										154
									
								
								authentik/sources/scim/views/v2/users.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								authentik/sources/scim/views/v2/users.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,154 @@ | |||||||
|  | """SCIM User Views""" | ||||||
|  |  | ||||||
|  | from uuid import uuid4 | ||||||
|  |  | ||||||
|  | from django.db.transaction import atomic | ||||||
|  | from django.http import Http404, QueryDict | ||||||
|  | from django.urls import reverse | ||||||
|  | from pydanticscim.user import Email, EmailKind, Name | ||||||
|  | from rest_framework.exceptions import ValidationError | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  |  | ||||||
|  | from authentik.core.models import User | ||||||
|  | from authentik.providers.scim.clients.schema import User as SCIMUserModel | ||||||
|  | from authentik.sources.scim.models import SCIMSourceUser | ||||||
|  | from authentik.sources.scim.views.v2.base import SCIMView | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class UsersView(SCIMView): | ||||||
|  |     """SCIM User view""" | ||||||
|  |  | ||||||
|  |     model = User | ||||||
|  |  | ||||||
|  |     def get_email(self, data: list[dict]) -> str: | ||||||
|  |         """Wrapper to get primary email or first email""" | ||||||
|  |         for email in data: | ||||||
|  |             if email.get("primary", False): | ||||||
|  |                 return email.get("value") | ||||||
|  |         if len(data) < 1: | ||||||
|  |             return "" | ||||||
|  |         return data[0].get("value") | ||||||
|  |  | ||||||
|  |     def user_to_scim(self, scim_user: SCIMSourceUser) -> dict: | ||||||
|  |         """Convert User to SCIM data""" | ||||||
|  |         payload = SCIMUserModel( | ||||||
|  |             id=str(scim_user.user.uuid), | ||||||
|  |             externalId=scim_user.id, | ||||||
|  |             userName=scim_user.user.username, | ||||||
|  |             name=Name( | ||||||
|  |                 formatted=scim_user.user.name, | ||||||
|  |             ), | ||||||
|  |             displayName=scim_user.user.name, | ||||||
|  |             active=scim_user.user.is_active, | ||||||
|  |             emails=( | ||||||
|  |                 [Email(value=scim_user.user.email, type=EmailKind.work, primary=True)] | ||||||
|  |                 if scim_user.user.email | ||||||
|  |                 else [] | ||||||
|  |             ), | ||||||
|  |             meta={ | ||||||
|  |                 "resourceType": "User", | ||||||
|  |                 "created": scim_user.user.date_joined, | ||||||
|  |                 # TODO: use events to find last edit? | ||||||
|  |                 "lastModified": scim_user.user.date_joined, | ||||||
|  |                 "location": self.request.build_absolute_uri( | ||||||
|  |                     reverse( | ||||||
|  |                         "authentik_sources_scim:v2-users", | ||||||
|  |                         kwargs={ | ||||||
|  |                             "source_slug": self.kwargs["source_slug"], | ||||||
|  |                             "user_id": str(scim_user.user.uuid), | ||||||
|  |                         }, | ||||||
|  |                     ) | ||||||
|  |                 ), | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         final_payload = payload.model_dump( | ||||||
|  |             mode="json", | ||||||
|  |             exclude_unset=True, | ||||||
|  |         ) | ||||||
|  |         final_payload.update(scim_user.attributes) | ||||||
|  |         return final_payload | ||||||
|  |  | ||||||
|  |     def get(self, request: Request, user_id: str | None = None, **kwargs) -> Response: | ||||||
|  |         """List User handler""" | ||||||
|  |         if user_id: | ||||||
|  |             connection = ( | ||||||
|  |                 SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id) | ||||||
|  |                 .select_related("user") | ||||||
|  |                 .first() | ||||||
|  |             ) | ||||||
|  |             if not connection: | ||||||
|  |                 raise Http404 | ||||||
|  |             return Response(self.user_to_scim(connection)) | ||||||
|  |         connections = ( | ||||||
|  |             SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk") | ||||||
|  |         ) | ||||||
|  |         connections = connections.filter(self.filter_parse(request)) | ||||||
|  |         page = self.paginate_query(connections) | ||||||
|  |         return Response( | ||||||
|  |             { | ||||||
|  |                 "totalResults": page.paginator.count, | ||||||
|  |                 "itemsPerPage": page.paginator.per_page, | ||||||
|  |                 "startIndex": page.start_index(), | ||||||
|  |                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], | ||||||
|  |                 "Resources": [self.user_to_scim(connection) for connection in page], | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     @atomic | ||||||
|  |     def update_user(self, connection: SCIMSourceUser | None, data: QueryDict): | ||||||
|  |         """Partial update a user""" | ||||||
|  |         user = connection.user if connection else User() | ||||||
|  |         user.path = self.source.get_user_path() | ||||||
|  |         if "userName" in data: | ||||||
|  |             user.username = data.get("userName") | ||||||
|  |         if "name" in data: | ||||||
|  |             user.name = data.get("name", {}).get("formatted", data.get("displayName")) | ||||||
|  |         if "emails" in data: | ||||||
|  |             user.email = self.get_email(data.get("emails")) | ||||||
|  |         if "active" in data: | ||||||
|  |             user.is_active = data.get("active") | ||||||
|  |         if user.username == "": | ||||||
|  |             raise ValidationError("Invalid user") | ||||||
|  |         user.save() | ||||||
|  |         if not connection: | ||||||
|  |             connection, _ = SCIMSourceUser.objects.get_or_create( | ||||||
|  |                 source=self.source, | ||||||
|  |                 user=user, | ||||||
|  |                 attributes=data, | ||||||
|  |                 id=data.get("externalId") or str(uuid4()), | ||||||
|  |             ) | ||||||
|  |         else: | ||||||
|  |             connection.attributes = data | ||||||
|  |             connection.save() | ||||||
|  |         return connection | ||||||
|  |  | ||||||
|  |     def post(self, request: Request, **kwargs) -> Response: | ||||||
|  |         """Create user handler""" | ||||||
|  |         connection = SCIMSourceUser.objects.filter( | ||||||
|  |             source=self.source, | ||||||
|  |             user__uuid=request.data.get("id"), | ||||||
|  |         ).first() | ||||||
|  |         if connection: | ||||||
|  |             self.logger.debug("Found existing user") | ||||||
|  |             return Response(status=409) | ||||||
|  |         connection = self.update_user(None, request.data) | ||||||
|  |         return Response(self.user_to_scim(connection), status=201) | ||||||
|  |  | ||||||
|  |     def put(self, request: Request, user_id: str, **kwargs) -> Response: | ||||||
|  |         """Update user handler""" | ||||||
|  |         connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() | ||||||
|  |         if not connection: | ||||||
|  |             raise Http404 | ||||||
|  |         self.update_user(connection, request.data) | ||||||
|  |         return Response(self.user_to_scim(connection), status=200) | ||||||
|  |  | ||||||
|  |     @atomic | ||||||
|  |     def delete(self, request: Request, user_id: str, **kwargs) -> Response: | ||||||
|  |         """Delete user handler""" | ||||||
|  |         connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() | ||||||
|  |         if not connection: | ||||||
|  |             raise Http404 | ||||||
|  |         connection.user.delete() | ||||||
|  |         connection.delete() | ||||||
|  |         return Response(status=204) | ||||||
| @ -1225,6 +1225,43 @@ | |||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
|                     }, |                     }, | ||||||
|  |                     { | ||||||
|  |                         "type": "object", | ||||||
|  |                         "required": [ | ||||||
|  |                             "model", | ||||||
|  |                             "identifiers" | ||||||
|  |                         ], | ||||||
|  |                         "properties": { | ||||||
|  |                             "model": { | ||||||
|  |                                 "const": "authentik_sources_scim.scimsource" | ||||||
|  |                             }, | ||||||
|  |                             "id": { | ||||||
|  |                                 "type": "string" | ||||||
|  |                             }, | ||||||
|  |                             "state": { | ||||||
|  |                                 "type": "string", | ||||||
|  |                                 "enum": [ | ||||||
|  |                                     "absent", | ||||||
|  |                                     "present", | ||||||
|  |                                     "created", | ||||||
|  |                                     "must_created" | ||||||
|  |                                 ], | ||||||
|  |                                 "default": "present" | ||||||
|  |                             }, | ||||||
|  |                             "conditions": { | ||||||
|  |                                 "type": "array", | ||||||
|  |                                 "items": { | ||||||
|  |                                     "type": "boolean" | ||||||
|  |                                 } | ||||||
|  |                             }, | ||||||
|  |                             "attrs": { | ||||||
|  |                                 "$ref": "#/$defs/model_authentik_sources_scim.scimsource" | ||||||
|  |                             }, | ||||||
|  |                             "identifiers": { | ||||||
|  |                                 "$ref": "#/$defs/model_authentik_sources_scim.scimsource" | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|                     { |                     { | ||||||
|                         "type": "object", |                         "type": "object", | ||||||
|                         "required": [ |                         "required": [ | ||||||
| @ -3274,6 +3311,7 @@ | |||||||
|                         "authentik.sources.oauth", |                         "authentik.sources.oauth", | ||||||
|                         "authentik.sources.plex", |                         "authentik.sources.plex", | ||||||
|                         "authentik.sources.saml", |                         "authentik.sources.saml", | ||||||
|  |                         "authentik.sources.scim", | ||||||
|                         "authentik.stages.authenticator", |                         "authentik.stages.authenticator", | ||||||
|                         "authentik.stages.authenticator_duo", |                         "authentik.stages.authenticator_duo", | ||||||
|                         "authentik.stages.authenticator_sms", |                         "authentik.stages.authenticator_sms", | ||||||
| @ -3345,6 +3383,7 @@ | |||||||
|                         "authentik_sources_plex.plexsourceconnection", |                         "authentik_sources_plex.plexsourceconnection", | ||||||
|                         "authentik_sources_saml.samlsource", |                         "authentik_sources_saml.samlsource", | ||||||
|                         "authentik_sources_saml.usersamlsourceconnection", |                         "authentik_sources_saml.usersamlsourceconnection", | ||||||
|  |                         "authentik_sources_scim.scimsource", | ||||||
|                         "authentik_stages_authenticator_duo.authenticatorduostage", |                         "authentik_stages_authenticator_duo.authenticatorduostage", | ||||||
|                         "authentik_stages_authenticator_duo.duodevice", |                         "authentik_stages_authenticator_duo.duodevice", | ||||||
|                         "authentik_stages_authenticator_sms.authenticatorsmsstage", |                         "authentik_stages_authenticator_sms.authenticatorsmsstage", | ||||||
| @ -4929,6 +4968,52 @@ | |||||||
|             }, |             }, | ||||||
|             "required": [] |             "required": [] | ||||||
|         }, |         }, | ||||||
|  |         "model_authentik_sources_scim.scimsource": { | ||||||
|  |             "type": "object", | ||||||
|  |             "properties": { | ||||||
|  |                 "name": { | ||||||
|  |                     "type": "string", | ||||||
|  |                     "minLength": 1, | ||||||
|  |                     "title": "Name", | ||||||
|  |                     "description": "Source's display Name." | ||||||
|  |                 }, | ||||||
|  |                 "slug": { | ||||||
|  |                     "type": "string", | ||||||
|  |                     "maxLength": 50, | ||||||
|  |                     "minLength": 1, | ||||||
|  |                     "pattern": "^[-a-zA-Z0-9_]+$", | ||||||
|  |                     "title": "Slug", | ||||||
|  |                     "description": "Internal source name, used in URLs." | ||||||
|  |                 }, | ||||||
|  |                 "enabled": { | ||||||
|  |                     "type": "boolean", | ||||||
|  |                     "title": "Enabled" | ||||||
|  |                 }, | ||||||
|  |                 "user_matching_mode": { | ||||||
|  |                     "type": "string", | ||||||
|  |                     "enum": [ | ||||||
|  |                         "identifier", | ||||||
|  |                         "email_link", | ||||||
|  |                         "email_deny", | ||||||
|  |                         "username_link", | ||||||
|  |                         "username_deny" | ||||||
|  |                     ], | ||||||
|  |                     "title": "User matching mode", | ||||||
|  |                     "description": "How the source determines if an existing user should be authenticated or a new user enrolled." | ||||||
|  |                 }, | ||||||
|  |                 "user_path_template": { | ||||||
|  |                     "type": "string", | ||||||
|  |                     "minLength": 1, | ||||||
|  |                     "title": "User path template" | ||||||
|  |                 }, | ||||||
|  |                 "icon": { | ||||||
|  |                     "type": "string", | ||||||
|  |                     "minLength": 1, | ||||||
|  |                     "title": "Icon" | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             "required": [] | ||||||
|  |         }, | ||||||
|         "model_authentik_stages_authenticator_duo.authenticatorduostage": { |         "model_authentik_stages_authenticator_duo.authenticatorduostage": { | ||||||
|             "type": "object", |             "type": "object", | ||||||
|             "properties": { |             "properties": { | ||||||
|  | |||||||
							
								
								
									
										30
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										30
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @ -3566,6 +3566,23 @@ botocore = ">=1.33.2,<2.0a.0" | |||||||
| [package.extras] | [package.extras] | ||||||
| crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] | crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "scim2-filter-parser" | ||||||
|  | version = "0.5.0" | ||||||
|  | description = "A customizable parser/transpiler for SCIM2.0 filters." | ||||||
|  | optional = false | ||||||
|  | python-versions = ">=3.8" | ||||||
|  | files = [ | ||||||
|  |     {file = "scim2_filter_parser-0.5.0-py3-none-any.whl", hash = "sha256:4aca1b3b64655dc038a973a9659056a103a919fb0218614e36bf19d3b5de5b48"}, | ||||||
|  |     {file = "scim2_filter_parser-0.5.0.tar.gz", hash = "sha256:104c72e6faeb9a6b873950f66b0e3b69134fb19debf67e1d3714e91a6dafd8af"}, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | [package.dependencies] | ||||||
|  | sly = "0.5" | ||||||
|  |  | ||||||
|  | [package.extras] | ||||||
|  | django-query = ["django (>=3.2)"] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "selenium" | name = "selenium" | ||||||
| version = "4.19.0" | version = "4.19.0" | ||||||
| @ -3782,6 +3799,17 @@ files = [ | |||||||
|     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, |     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "sly" | ||||||
|  | version = "0.5" | ||||||
|  | description = "\"SLY - Sly Lex Yacc\"" | ||||||
|  | optional = false | ||||||
|  | python-versions = "*" | ||||||
|  | files = [ | ||||||
|  |     {file = "sly-0.5-py3-none-any.whl", hash = "sha256:20485483259eec7f6ba85ff4d2e96a4e50c6621902667fc2695cc8bc2a3e5133"}, | ||||||
|  |     {file = "sly-0.5.tar.gz", hash = "sha256:251d42015e8507158aec2164f06035df4a82b0314ce6450f457d7125e7649024"}, | ||||||
|  | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "sniffio" | name = "sniffio" | ||||||
| version = "1.3.0" | version = "1.3.0" | ||||||
| @ -4654,4 +4682,4 @@ files = [ | |||||||
| [metadata] | [metadata] | ||||||
| lock-version = "2.0" | lock-version = "2.0" | ||||||
| python-versions = "~3.12" | python-versions = "~3.12" | ||||||
| content-hash = "4544b2a0b0065aa9e13d9a3b5a951fb5212921fe72f0fe259069e2e9205e9830" | content-hash = "a5774b4e09217805c887700b8a0f457a39c7af40ca59823f00c1f6e8678469e1" | ||||||
|  | |||||||
| @ -112,6 +112,7 @@ fido2 = "*" | |||||||
| flower = "*" | flower = "*" | ||||||
| geoip2 = "*" | geoip2 = "*" | ||||||
| gunicorn = "*" | gunicorn = "*" | ||||||
|  | jsonpatch = "*" | ||||||
| kubernetes = "*" | kubernetes = "*" | ||||||
| ldap3 = "*" | ldap3 = "*" | ||||||
| lxml = [ | lxml = [ | ||||||
| @ -120,7 +121,6 @@ lxml = [ | |||||||
|     # 4.9.x works with previous libxml2 versions, which is what we get on linux |     # 4.9.x works with previous libxml2 versions, which is what we get on linux | ||||||
|     { version = "4.9.4", platform = "linux" }, |     { version = "4.9.4", platform = "linux" }, | ||||||
| ] | ] | ||||||
| jsonpatch = "*" |  | ||||||
| opencontainers = { extras = ["reggie"], version = "*" } | opencontainers = { extras = ["reggie"], version = "*" } | ||||||
| packaging = "*" | packaging = "*" | ||||||
| paramiko = "*" | paramiko = "*" | ||||||
| @ -132,6 +132,7 @@ pyjwt = "*" | |||||||
| python = "~3.12" | python = "~3.12" | ||||||
| pyyaml = "*" | pyyaml = "*" | ||||||
| requests-oauthlib = "*" | requests-oauthlib = "*" | ||||||
|  | scim2-filter-parser = "*" | ||||||
| sentry-sdk = "*" | sentry-sdk = "*" | ||||||
| service_identity = "*" | service_identity = "*" | ||||||
| setproctitle = "*" | setproctitle = "*" | ||||||
|  | |||||||
							
								
								
									
										1079
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										1079
									
								
								schema.yml
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										90
									
								
								tests/e2e/test_source_scim.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								tests/e2e/test_source_scim.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,90 @@ | |||||||
|  | """test SCIM Source""" | ||||||
|  |  | ||||||
|  | from pprint import pformat | ||||||
|  | from time import sleep | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from docker.types import Healthcheck | ||||||
|  |  | ||||||
|  | from authentik.core.models import Token, TokenIntents, User | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
|  | from authentik.lib.utils.http import get_http_session | ||||||
|  | from authentik.sources.scim.models import SCIMSource | ||||||
|  | from tests.e2e.utils import SeleniumTestCase, retry | ||||||
|  |  | ||||||
|  | TEST_POLL_MAX = 25 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestSourceSCIM(SeleniumTestCase): | ||||||
|  |     """test SCIM Source flow""" | ||||||
|  |  | ||||||
|  |     def setUp(self): | ||||||
|  |         self.slug = generate_id() | ||||||
|  |         super().setUp() | ||||||
|  |  | ||||||
|  |     def get_container_specs(self) -> dict[str, Any] | None: | ||||||
|  |         return { | ||||||
|  |             "image": ( | ||||||
|  |                 "ghcr.io/suvera/scim2-compliance-test-utility@sha256:eca913bb73" | ||||||
|  |                 "c46892cd1fb2dfd2fef1c5881e6abc5cb0eec7e92fb78c1b933ece" | ||||||
|  |             ), | ||||||
|  |             "detach": True, | ||||||
|  |             "ports": {"8080": "8080"}, | ||||||
|  |             "auto_remove": True, | ||||||
|  |             "healthcheck": Healthcheck( | ||||||
|  |                 test=["CMD", "curl", "http://localhost:8080"], | ||||||
|  |                 interval=5 * 1_000 * 1_000_000, | ||||||
|  |                 start_period=1 * 1_000 * 1_000_000, | ||||||
|  |             ), | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |     @retry() | ||||||
|  |     def test_scim_conformance(self): | ||||||
|  |         user = User.objects.create( | ||||||
|  |             username=generate_id(), | ||||||
|  |         ) | ||||||
|  |         token = Token.objects.create( | ||||||
|  |             user=user, | ||||||
|  |             intent=TokenIntents.INTENT_API, | ||||||
|  |             expiring=False, | ||||||
|  |         ) | ||||||
|  |         source = SCIMSource.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |             slug=generate_id(), | ||||||
|  |             token=token, | ||||||
|  |         ) | ||||||
|  |         session = get_http_session() | ||||||
|  |         test_launch = session.post( | ||||||
|  |             "http://localhost:8080/test/run", | ||||||
|  |             data={ | ||||||
|  |                 "endPoint": self.live_server_url + f"/source/scim/{source.slug}/v2", | ||||||
|  |                 "username": "foo", | ||||||
|  |                 "password": token.key, | ||||||
|  |                 "jwtToken": None, | ||||||
|  |                 "usersCheck": 1, | ||||||
|  |                 "groupsCheck": 1, | ||||||
|  |                 "checkIndResLocation": 1, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(test_launch.status_code, 200) | ||||||
|  |         test_id = test_launch.json()["id"] | ||||||
|  |         attempt = 0 | ||||||
|  |         while attempt <= TEST_POLL_MAX: | ||||||
|  |             test_status = session.get( | ||||||
|  |                 "http://localhost:8080/test/status", | ||||||
|  |                 params={"runId": test_id}, | ||||||
|  |             ) | ||||||
|  |             self.assertEqual(test_status.status_code, 200) | ||||||
|  |             body = test_status.json() | ||||||
|  |             if any([data["title"] == "--DONE--" for data in body["data"]]): | ||||||
|  |                 break | ||||||
|  |             attempt += 1 | ||||||
|  |             sleep(1) | ||||||
|  |         for test in body["data"]: | ||||||
|  |             # Workaround, the test expects DELETE requests to return 204 and have | ||||||
|  |             # the content type set to the JSON SCIM one, which is not what most HTTP servers do | ||||||
|  |             if test["requestMethod"] == "DELETE" and test["responseCode"] == 204:  # noqa: PLR2004 | ||||||
|  |                 continue | ||||||
|  |             if test["title"] == "--DONE--": | ||||||
|  |                 break | ||||||
|  |             self.assertTrue(test["success"], pformat(test)) | ||||||
| @ -119,7 +119,9 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase): | |||||||
|         """Output the container logs to our STDOUT""" |         """Output the container logs to our STDOUT""" | ||||||
|         _container = container or self.container |         _container = container or self.container | ||||||
|         if IS_CI: |         if IS_CI: | ||||||
|             print(f"::group::Container logs - {_container.image.tags[0]}") |             image = _container.image | ||||||
|  |             tags = image.tags[0] if len(image.tags) > 0 else str(image) | ||||||
|  |             print(f"::group::Container logs - {tags}") | ||||||
|         for log in _container.logs().decode().split("\n"): |         for log in _container.logs().decode().split("\n"): | ||||||
|             print(log) |             print(log) | ||||||
|         if IS_CI: |         if IS_CI: | ||||||
|  | |||||||
| @ -74,7 +74,7 @@ export class AkBackchannelProvidersInput extends AKElement { | |||||||
|                         <ak-chip-group> ${map(this.providers, renderOneChip)} </ak-chip-group> |                         <ak-chip-group> ${map(this.providers, renderOneChip)} </ak-chip-group> | ||||||
|                     </div> |                     </div> | ||||||
|                 </div> |                 </div> | ||||||
|                 ${this.help ? html`<p class="pf-c-form__helper-radio">${this.help}</p>` : nothing} |                 ${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing} | ||||||
|             </ak-form-element-horizontal> |             </ak-form-element-horizontal> | ||||||
|         `; |         `; | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ import "@goauthentik/elements/Markdown"; | |||||||
| import "@goauthentik/elements/Tabs"; | import "@goauthentik/elements/Tabs"; | ||||||
| import "@goauthentik/elements/buttons/ActionButton"; | import "@goauthentik/elements/buttons/ActionButton"; | ||||||
| import "@goauthentik/elements/buttons/ModalButton"; | import "@goauthentik/elements/buttons/ModalButton"; | ||||||
|  | import "@goauthentik/elements/events/LogViewer"; | ||||||
| import "@goauthentik/elements/rbac/ObjectPermissionsPage"; | import "@goauthentik/elements/rbac/ObjectPermissionsPage"; | ||||||
|  |  | ||||||
| import { msg, str } from "@lit/localize"; | import { msg, str } from "@lit/localize"; | ||||||
| @ -155,9 +156,7 @@ export class SCIMProviderViewPage extends AKElement { | |||||||
|                         <p>${task.name}</p> |                         <p>${task.name}</p> | ||||||
|                         <ul class="pf-c-list"> |                         <ul class="pf-c-list"> | ||||||
|                             <li>${header}</li> |                             <li>${header}</li> | ||||||
|                             ${task.messages.map((m) => { |                             <ak-log-viewer .logs=${task?.messages}></ak-log-viewer> | ||||||
|                                 return html`<li>${m}</li>`; |  | ||||||
|                             })} |  | ||||||
|                         </ul> |                         </ul> | ||||||
|                     </li> `; |                     </li> `; | ||||||
|                 })} |                 })} | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ import "@goauthentik/admin/sources/ldap/LDAPSourceViewPage"; | |||||||
| import "@goauthentik/admin/sources/oauth/OAuthSourceViewPage"; | import "@goauthentik/admin/sources/oauth/OAuthSourceViewPage"; | ||||||
| import "@goauthentik/admin/sources/plex/PlexSourceViewPage"; | import "@goauthentik/admin/sources/plex/PlexSourceViewPage"; | ||||||
| import "@goauthentik/admin/sources/saml/SAMLSourceViewPage"; | import "@goauthentik/admin/sources/saml/SAMLSourceViewPage"; | ||||||
|  | import "@goauthentik/admin/sources/scim/SCIMSourceViewPage"; | ||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
| import { AKElement } from "@goauthentik/elements/Base"; | import { AKElement } from "@goauthentik/elements/Base"; | ||||||
| import "@goauthentik/elements/EmptyState"; | import "@goauthentik/elements/EmptyState"; | ||||||
| @ -51,6 +52,10 @@ export class SourceViewPage extends AKElement { | |||||||
|                 return html`<ak-source-plex-view |                 return html`<ak-source-plex-view | ||||||
|                     sourceSlug=${this.source.slug} |                     sourceSlug=${this.source.slug} | ||||||
|                 ></ak-source-plex-view>`; |                 ></ak-source-plex-view>`; | ||||||
|  |             case "ak-source-scim-form": | ||||||
|  |                 return html`<ak-source-scim-view | ||||||
|  |                     sourceSlug=${this.source.slug} | ||||||
|  |                 ></ak-source-scim-view>`; | ||||||
|             default: |             default: | ||||||
|                 return html`<p>Invalid source type ${this.source.component}</p>`; |                 return html`<p>Invalid source type ${this.source.component}</p>`; | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ import "@goauthentik/admin/sources/ldap/LDAPSourceForm"; | |||||||
| import "@goauthentik/admin/sources/oauth/OAuthSourceForm"; | import "@goauthentik/admin/sources/oauth/OAuthSourceForm"; | ||||||
| import "@goauthentik/admin/sources/plex/PlexSourceForm"; | import "@goauthentik/admin/sources/plex/PlexSourceForm"; | ||||||
| import "@goauthentik/admin/sources/saml/SAMLSourceForm"; | import "@goauthentik/admin/sources/saml/SAMLSourceForm"; | ||||||
|  | import "@goauthentik/admin/sources/scim/SCIMSourceForm"; | ||||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
| import { AKElement } from "@goauthentik/elements/Base"; | import { AKElement } from "@goauthentik/elements/Base"; | ||||||
| import "@goauthentik/elements/forms/ProxyForm"; | import "@goauthentik/elements/forms/ProxyForm"; | ||||||
|  | |||||||
							
								
								
									
										86
									
								
								web/src/admin/sources/scim/SCIMSourceForm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								web/src/admin/sources/scim/SCIMSourceForm.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,86 @@ | |||||||
|  | import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm"; | ||||||
|  | import { placeholderHelperText } from "@goauthentik/authentik/admin/helperText"; | ||||||
|  | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
|  | import { first } from "@goauthentik/common/utils"; | ||||||
|  | import "@goauthentik/elements/forms/FormGroup"; | ||||||
|  | import "@goauthentik/elements/forms/HorizontalFormElement"; | ||||||
|  |  | ||||||
|  | import { msg } from "@lit/localize"; | ||||||
|  | import { TemplateResult, html } from "lit"; | ||||||
|  | import { customElement } from "lit/decorators.js"; | ||||||
|  | import { ifDefined } from "lit/directives/if-defined.js"; | ||||||
|  |  | ||||||
|  | import { SCIMSource, SCIMSourceRequest, SourcesApi } from "@goauthentik/api"; | ||||||
|  |  | ||||||
|  | @customElement("ak-source-scim-form") | ||||||
|  | export class SCIMSourceForm extends BaseSourceForm<SCIMSource> { | ||||||
|  |     async loadInstance(pk: string): Promise<SCIMSource> { | ||||||
|  |         return new SourcesApi(DEFAULT_CONFIG) | ||||||
|  |             .sourcesScimRetrieve({ | ||||||
|  |                 slug: pk, | ||||||
|  |             }) | ||||||
|  |             .then((source) => { | ||||||
|  |                 return source; | ||||||
|  |             }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async send(data: SCIMSource): Promise<SCIMSource> { | ||||||
|  |         if (this.instance?.slug) { | ||||||
|  |             return new SourcesApi(DEFAULT_CONFIG).sourcesScimPartialUpdate({ | ||||||
|  |                 slug: this.instance.slug, | ||||||
|  |                 patchedSCIMSourceRequest: data, | ||||||
|  |             }); | ||||||
|  |         } else { | ||||||
|  |             return new SourcesApi(DEFAULT_CONFIG).sourcesScimCreate({ | ||||||
|  |                 sCIMSourceRequest: data as unknown as SCIMSourceRequest, | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     renderForm(): TemplateResult { | ||||||
|  |         return html`<form class="pf-c-form pf-m-horizontal"> | ||||||
|  |             <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name"> | ||||||
|  |                 <input | ||||||
|  |                     type="text" | ||||||
|  |                     value="${ifDefined(this.instance?.name)}" | ||||||
|  |                     class="pf-c-form-control" | ||||||
|  |                     required | ||||||
|  |                 /> | ||||||
|  |             </ak-form-element-horizontal> | ||||||
|  |             <ak-form-element-horizontal label=${msg("Slug")} ?required=${true} name="slug"> | ||||||
|  |                 <input | ||||||
|  |                     type="text" | ||||||
|  |                     value="${ifDefined(this.instance?.slug)}" | ||||||
|  |                     class="pf-c-form-control" | ||||||
|  |                     required | ||||||
|  |                 /> | ||||||
|  |             </ak-form-element-horizontal> | ||||||
|  |             <ak-form-element-horizontal name="enabled"> | ||||||
|  |                 <div class="pf-c-check"> | ||||||
|  |                     <input | ||||||
|  |                         type="checkbox" | ||||||
|  |                         class="pf-c-check__input" | ||||||
|  |                         ?checked=${first(this.instance?.enabled, true)} | ||||||
|  |                     /> | ||||||
|  |                     <label class="pf-c-check__label"> ${msg("Enabled")} </label> | ||||||
|  |                 </div> | ||||||
|  |             </ak-form-element-horizontal> | ||||||
|  |             <ak-form-group> | ||||||
|  |                 <span slot="header"> ${msg("Advanced protocol settings")} </span> | ||||||
|  |                 <div slot="body" class="pf-c-form"> | ||||||
|  |                     <ak-form-element-horizontal label=${msg("User path")} name="userPathTemplate"> | ||||||
|  |                         <input | ||||||
|  |                             type="text" | ||||||
|  |                             value="${first( | ||||||
|  |                                 this.instance?.userPathTemplate, | ||||||
|  |                                 "goauthentik.io/sources/%(slug)s", | ||||||
|  |                             )}" | ||||||
|  |                             class="pf-c-form-control" | ||||||
|  |                         /> | ||||||
|  |                         <p class="pf-c-form__helper-text">${placeholderHelperText}</p> | ||||||
|  |                     </ak-form-element-horizontal> | ||||||
|  |                 </div> | ||||||
|  |             </ak-form-group> | ||||||
|  |         </form>`; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										51
									
								
								web/src/admin/sources/scim/SCIMSourceGroups.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								web/src/admin/sources/scim/SCIMSourceGroups.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,51 @@ | |||||||
|  | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
|  | import { uiConfig } from "@goauthentik/common/ui/config"; | ||||||
|  | import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table"; | ||||||
|  |  | ||||||
|  | import { msg } from "@lit/localize"; | ||||||
|  | import { TemplateResult, html } from "lit"; | ||||||
|  | import { customElement, property } from "lit/decorators.js"; | ||||||
|  |  | ||||||
|  | import { SCIMSourceGroup, SourcesApi } from "@goauthentik/api"; | ||||||
|  |  | ||||||
|  | @customElement("ak-source-scim-groups-list") | ||||||
|  | export class SCIMSourceGroupList extends Table<SCIMSourceGroup> { | ||||||
|  |     @property() | ||||||
|  |     sourceSlug?: string; | ||||||
|  |  | ||||||
|  |     expandable = true; | ||||||
|  |     searchEnabled(): boolean { | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async apiEndpoint(page: number): Promise<PaginatedResponse<SCIMSourceGroup>> { | ||||||
|  |         return new SourcesApi(DEFAULT_CONFIG).sourcesScimGroupsList({ | ||||||
|  |             page: page, | ||||||
|  |             pageSize: (await uiConfig()).pagination.perPage, | ||||||
|  |             ordering: this.order, | ||||||
|  |             search: this.search || "", | ||||||
|  |             sourceSlug: this.sourceSlug, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     columns(): TableColumn[] { | ||||||
|  |         return [new TableColumn(msg("Name")), new TableColumn(msg("ID"))]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     renderExpanded(item: SCIMSourceGroup): TemplateResult { | ||||||
|  |         return html`<td role="cell" colspan="4"> | ||||||
|  |             <div class="pf-c-table__expandable-row-content"> | ||||||
|  |                 <pre>${JSON.stringify(item.attributes, null, 4)}</pre> | ||||||
|  |             </div> | ||||||
|  |         </td>`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     row(item: SCIMSourceGroup): TemplateResult[] { | ||||||
|  |         return [ | ||||||
|  |             html`<a href="#/identity/groups/${item.groupObj.pk}"> | ||||||
|  |                 <div>${item.groupObj.name}</div> | ||||||
|  |             </a>`, | ||||||
|  |             html`${item.id}`, | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										52
									
								
								web/src/admin/sources/scim/SCIMSourceUsers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								web/src/admin/sources/scim/SCIMSourceUsers.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,52 @@ | |||||||
|  | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
|  | import { uiConfig } from "@goauthentik/common/ui/config"; | ||||||
|  | import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table"; | ||||||
|  |  | ||||||
|  | import { msg } from "@lit/localize"; | ||||||
|  | import { TemplateResult, html } from "lit"; | ||||||
|  | import { customElement, property } from "lit/decorators.js"; | ||||||
|  |  | ||||||
|  | import { SCIMSourceUser, SourcesApi } from "@goauthentik/api"; | ||||||
|  |  | ||||||
|  | @customElement("ak-source-scim-users-list") | ||||||
|  | export class SCIMSourceUserList extends Table<SCIMSourceUser> { | ||||||
|  |     @property() | ||||||
|  |     sourceSlug?: string; | ||||||
|  |  | ||||||
|  |     expandable = true; | ||||||
|  |     searchEnabled(): boolean { | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async apiEndpoint(page: number): Promise<PaginatedResponse<SCIMSourceUser>> { | ||||||
|  |         return new SourcesApi(DEFAULT_CONFIG).sourcesScimUsersList({ | ||||||
|  |             page: page, | ||||||
|  |             pageSize: (await uiConfig()).pagination.perPage, | ||||||
|  |             ordering: this.order, | ||||||
|  |             search: this.search || "", | ||||||
|  |             sourceSlug: this.sourceSlug, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     columns(): TableColumn[] { | ||||||
|  |         return [new TableColumn(msg("Username")), new TableColumn(msg("ID"))]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     renderExpanded(item: SCIMSourceUser): TemplateResult { | ||||||
|  |         return html`<td role="cell" colspan="4"> | ||||||
|  |             <div class="pf-c-table__expandable-row-content"> | ||||||
|  |                 <pre>${JSON.stringify(item.attributes, null, 4)}</pre> | ||||||
|  |             </div> | ||||||
|  |         </td>`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     row(item: SCIMSourceUser): TemplateResult[] { | ||||||
|  |         return [ | ||||||
|  |             html`<a href="#/identity/users/${item.userObj.pk}"> | ||||||
|  |                 <div>${item.userObj.username}</div> | ||||||
|  |                 <small>${item.userObj.name}</small> | ||||||
|  |             </a>`, | ||||||
|  |             html`${item.id}`, | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										215
									
								
								web/src/admin/sources/scim/SCIMSourceViewPage.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								web/src/admin/sources/scim/SCIMSourceViewPage.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,215 @@ | |||||||
|  | import "@goauthentik/admin/sources/scim/SCIMSourceForm"; | ||||||
|  | import "@goauthentik/admin/sources/scim/SCIMSourceGroups"; | ||||||
|  | import "@goauthentik/admin/sources/scim/SCIMSourceUsers"; | ||||||
|  | import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||||
|  | import { EVENT_REFRESH } from "@goauthentik/common/constants"; | ||||||
|  | import "@goauthentik/components/events/ObjectChangelog"; | ||||||
|  | import { AKElement } from "@goauthentik/elements/Base"; | ||||||
|  | import "@goauthentik/elements/Tabs"; | ||||||
|  | import "@goauthentik/elements/buttons/ActionButton"; | ||||||
|  | import "@goauthentik/elements/buttons/SpinnerButton"; | ||||||
|  | import "@goauthentik/elements/buttons/TokenCopyButton"; | ||||||
|  | import "@goauthentik/elements/forms/ModalForm"; | ||||||
|  | import "@goauthentik/elements/rbac/ObjectPermissionsPage"; | ||||||
|  |  | ||||||
|  | import { msg } from "@lit/localize"; | ||||||
|  | import { CSSResult, TemplateResult, html } from "lit"; | ||||||
|  | import { customElement, property } from "lit/decorators.js"; | ||||||
|  |  | ||||||
|  | import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; | ||||||
|  | import PFButton from "@patternfly/patternfly/components/Button/button.css"; | ||||||
|  | import PFCard from "@patternfly/patternfly/components/Card/card.css"; | ||||||
|  | import PFContent from "@patternfly/patternfly/components/Content/content.css"; | ||||||
|  | import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; | ||||||
|  | import PFForm from "@patternfly/patternfly/components/Form/form.css"; | ||||||
|  | import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; | ||||||
|  | import PFPage from "@patternfly/patternfly/components/Page/page.css"; | ||||||
|  | import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; | ||||||
|  | import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |     RbacPermissionsAssignedByUsersListModelEnum, | ||||||
|  |     SCIMSource, | ||||||
|  |     SourcesApi, | ||||||
|  | } from "@goauthentik/api"; | ||||||
|  |  | ||||||
|  | @customElement("ak-source-scim-view") | ||||||
|  | export class SCIMSourceViewPage extends AKElement { | ||||||
|  |     @property({ type: String }) | ||||||
|  |     set sourceSlug(value: string) { | ||||||
|  |         new SourcesApi(DEFAULT_CONFIG) | ||||||
|  |             .sourcesScimRetrieve({ | ||||||
|  |                 slug: value, | ||||||
|  |             }) | ||||||
|  |             .then((source) => { | ||||||
|  |                 this.source = source; | ||||||
|  |             }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @property({ attribute: false }) | ||||||
|  |     source?: SCIMSource; | ||||||
|  |  | ||||||
|  |     static get styles(): CSSResult[] { | ||||||
|  |         return [ | ||||||
|  |             PFBase, | ||||||
|  |             PFPage, | ||||||
|  |             PFButton, | ||||||
|  |             PFForm, | ||||||
|  |             PFFormControl, | ||||||
|  |             PFGrid, | ||||||
|  |             PFContent, | ||||||
|  |             PFCard, | ||||||
|  |             PFDescriptionList, | ||||||
|  |             PFBanner, | ||||||
|  |         ]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     constructor() { | ||||||
|  |         super(); | ||||||
|  |         this.addEventListener(EVENT_REFRESH, () => { | ||||||
|  |             if (!this.source?.pk) return; | ||||||
|  |             this.sourceSlug = this.source?.slug; | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     render(): TemplateResult { | ||||||
|  |         if (!this.source) { | ||||||
|  |             return html``; | ||||||
|  |         } | ||||||
|  |         return html`<ak-tabs> | ||||||
|  |             <section slot="page-overview" data-tab-title="${msg("Overview")}"> | ||||||
|  |                 <div slot="header" class="pf-c-banner pf-m-info"> | ||||||
|  |                     ${msg("SCIM Source is in preview.")} | ||||||
|  |                     <a href="mailto:hello+feature/scim-source@goauthentik.io" | ||||||
|  |                         >${msg("Send us feedback!")}</a | ||||||
|  |                     > | ||||||
|  |                 </div> | ||||||
|  |                 <div class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter"> | ||||||
|  |                     <div class="pf-c-card pf-l-grid__item pf-m-12-col"> | ||||||
|  |                         <div class="pf-c-card__body"> | ||||||
|  |                             <dl class="pf-c-description-list pf-m-2-col-on-lg"> | ||||||
|  |                                 <div class="pf-c-description-list__group"> | ||||||
|  |                                     <dt class="pf-c-description-list__term"> | ||||||
|  |                                         <span class="pf-c-description-list__text" | ||||||
|  |                                             >${msg("Name")}</span | ||||||
|  |                                         > | ||||||
|  |                                     </dt> | ||||||
|  |                                     <dd class="pf-c-description-list__description"> | ||||||
|  |                                         <div class="pf-c-description-list__text"> | ||||||
|  |                                             ${this.source.name} | ||||||
|  |                                         </div> | ||||||
|  |                                     </dd> | ||||||
|  |                                 </div> | ||||||
|  |                                 <div class="pf-c-description-list__group"> | ||||||
|  |                                     <dt class="pf-c-description-list__term"> | ||||||
|  |                                         <span class="pf-c-description-list__text" | ||||||
|  |                                             >${msg("Slug")}</span | ||||||
|  |                                         > | ||||||
|  |                                     </dt> | ||||||
|  |                                     <dd class="pf-c-description-list__description"> | ||||||
|  |                                         <div class="pf-c-description-list__text"> | ||||||
|  |                                             ${this.source.slug} | ||||||
|  |                                         </div> | ||||||
|  |                                     </dd> | ||||||
|  |                                 </div> | ||||||
|  |                             </dl> | ||||||
|  |                         </div> | ||||||
|  |                         <div class="pf-c-card__footer"> | ||||||
|  |                             <ak-forms-modal> | ||||||
|  |                                 <span slot="submit"> ${msg("Update")} </span> | ||||||
|  |                                 <span slot="header"> ${msg("Update SCIM Source")} </span> | ||||||
|  |                                 <ak-source-scim-form slot="form" .instancePk=${this.source.slug}> | ||||||
|  |                                 </ak-source-scim-form> | ||||||
|  |                                 <button slot="trigger" class="pf-c-button pf-m-primary"> | ||||||
|  |                                     ${msg("Edit")} | ||||||
|  |                                 </button> | ||||||
|  |                             </ak-forms-modal> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                     <div class="pf-c-card pf-l-grid__item pf-m-12-col"> | ||||||
|  |                         <div class="pf-c-card"> | ||||||
|  |                             <div class="pf-c-card__body"> | ||||||
|  |                                 <form class="pf-c-form"> | ||||||
|  |                                     <div class="pf-c-form__group"> | ||||||
|  |                                         <label class="pf-c-form__label"> | ||||||
|  |                                             <span class="pf-c-form__label-text" | ||||||
|  |                                                 >${msg("SCIM Base URL")}</span | ||||||
|  |                                             > | ||||||
|  |                                         </label> | ||||||
|  |                                         <input | ||||||
|  |                                             class="pf-c-form-control" | ||||||
|  |                                             readonly | ||||||
|  |                                             type="text" | ||||||
|  |                                             value="${this.source.rootUrl}" | ||||||
|  |                                         /> | ||||||
|  |                                     </div> | ||||||
|  |                                     <div class="pf-c-form__group"> | ||||||
|  |                                         <label class="pf-c-form__label"> | ||||||
|  |                                             <span class="pf-c-form__label-text" | ||||||
|  |                                                 >${msg("Token")}</span | ||||||
|  |                                             > | ||||||
|  |                                         </label> | ||||||
|  |                                         <div> | ||||||
|  |                                             <ak-token-copy-button | ||||||
|  |                                                 class="pf-m-primary" | ||||||
|  |                                                 identifier="${this.source?.tokenObj.identifier}" | ||||||
|  |                                             > | ||||||
|  |                                                 ${msg("Click to copy token")} | ||||||
|  |                                             </ak-token-copy-button> | ||||||
|  |                                         </div> | ||||||
|  |                                     </div> | ||||||
|  |                                 </form> | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </section> | ||||||
|  |             <section | ||||||
|  |                 slot="page-changelog" | ||||||
|  |                 data-tab-title="${msg("Changelog")}" | ||||||
|  |                 class="pf-c-page__main-section pf-m-no-padding-mobile" | ||||||
|  |             > | ||||||
|  |                 <div class="pf-l-grid pf-m-gutter"> | ||||||
|  |                     <div class="pf-c-card pf-l-grid__item pf-m-12-col"> | ||||||
|  |                         <div class="pf-c-card__body"> | ||||||
|  |                             <ak-object-changelog | ||||||
|  |                                 targetModelPk=${this.source.pk || ""} | ||||||
|  |                                 targetModelApp="authentik_sources_scim" | ||||||
|  |                                 targetModelName="scimsource" | ||||||
|  |                             > | ||||||
|  |                             </ak-object-changelog> | ||||||
|  |                         </div> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </section> | ||||||
|  |             <section | ||||||
|  |                 slot="page-users" | ||||||
|  |                 data-tab-title="${msg("Provisioned Users")}" | ||||||
|  |                 class="pf-c-page__main-section pf-m-no-padding-mobile" | ||||||
|  |             > | ||||||
|  |                 <div class="pf-l-grid pf-m-gutter"> | ||||||
|  |                     <ak-source-scim-users-list | ||||||
|  |                         sourceSlug=${this.source.slug} | ||||||
|  |                     ></ak-source-scim-users-list> | ||||||
|  |                 </div> | ||||||
|  |             </section> | ||||||
|  |             <section | ||||||
|  |                 slot="page-groups" | ||||||
|  |                 data-tab-title="${msg("Provisioned Groups")}" | ||||||
|  |                 class="pf-c-page__main-section pf-m-no-padding-mobile" | ||||||
|  |             > | ||||||
|  |                 <div class="pf-l-grid pf-m-gutter"> | ||||||
|  |                     <ak-source-scim-groups-list | ||||||
|  |                         sourceSlug=${this.source.slug} | ||||||
|  |                     ></ak-source-scim-groups-list> | ||||||
|  |                 </div> | ||||||
|  |             </section> | ||||||
|  |             <ak-rbac-object-permission-page | ||||||
|  |                 slot="page-permissions" | ||||||
|  |                 data-tab-title="${msg("Permissions")}" | ||||||
|  |                 model=${RbacPermissionsAssignedByUsersListModelEnum.SourcesScimScimsource} | ||||||
|  |                 objectPk=${this.source.pk} | ||||||
|  |             ></ak-rbac-object-permission-page> | ||||||
|  |         </ak-tabs>`; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -1,5 +1,5 @@ | |||||||
| --- | --- | ||||||
| title: SAML | title: SAML Source | ||||||
| --- | --- | ||||||
|  |  | ||||||
| This source allows authentik to act as a SAML Service Provider. Just like the SAML Provider, it supports signed requests. Vendor-specific documentation can be found in the Integrations Section. | This source allows authentik to act as a SAML Service Provider. Just like the SAML Provider, it supports signed requests. Vendor-specific documentation can be found in the Integrations Section. | ||||||
|  | |||||||
							
								
								
									
										23
									
								
								website/integrations/sources/scim/index.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								website/integrations/sources/scim/index.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | |||||||
|  | --- | ||||||
|  | title: SCIM source | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | :::info | ||||||
|  | This feature is in technical preview, so please report any bugs on [GitHub](https://github.com/goauthentik/authentik/issues). | ||||||
|  | ::: | ||||||
|  |  | ||||||
|  | The SCIM source allows other applications to directly create users and groups within authentik. SCIM provides predefined schema for users and groups, with a RESTful API, to enable automatic user provisioning and deprovisioning, SCIM is supported by applications such as Microsoft Entra ID, Google Workspace, and Okta. | ||||||
|  |  | ||||||
|  | The base SCIM URL is in the format of `https://authentik.company/source/scim/<source-slug>/v2`. Authentication is done via Bearer tokens that are generated by authentik. When an SCIM source is created, a service account is created and a matching token is provided. | ||||||
|  |  | ||||||
|  | ## Supported Options & Resource types | ||||||
|  |  | ||||||
|  | ### `/v2/Users` | ||||||
|  |  | ||||||
|  | Endpoint to list, create, patch, and delete users. | ||||||
|  |  | ||||||
|  | ### `/v2/Groups` | ||||||
|  |  | ||||||
|  | Endpoint to list, create, patch, and delete groups. | ||||||
|  |  | ||||||
|  | There is also the `/v2/ServiceProviderConfig` and `/v2/ResourceTypes`, which is used by SCIM-enabled applications to find out which features authentik supports. | ||||||
| @ -174,6 +174,7 @@ module.exports = { | |||||||
|                         "sources/ldap/index", |                         "sources/ldap/index", | ||||||
|                         "sources/oauth/index", |                         "sources/oauth/index", | ||||||
|                         "sources/saml/index", |                         "sources/saml/index", | ||||||
|  |                         "sources/scim/index", | ||||||
|                     ], |                     ], | ||||||
|                 }, |                 }, | ||||||
|                 { |                 { | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user
	 Jens L
					Jens L