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*
 | 
			
		||||
          - name: radius
 | 
			
		||||
            glob: tests/e2e/test_provider_radius*
 | 
			
		||||
          - name: scim
 | 
			
		||||
            glob: tests/e2e/test_source_scim*
 | 
			
		||||
          - name: flows
 | 
			
		||||
            glob: tests/e2e/test_flows*
 | 
			
		||||
    steps:
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ from drf_spectacular.settings import spectacular_settings
 | 
			
		||||
from drf_spectacular.types import OpenApiTypes
 | 
			
		||||
from rest_framework.settings import api_settings
 | 
			
		||||
 | 
			
		||||
from authentik.api.apps import AuthentikAPIConfig
 | 
			
		||||
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["additionalProperties"] = {}
 | 
			
		||||
    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.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
 | 
			
		||||
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.tenants.models import Tenant
 | 
			
		||||
 | 
			
		||||
@ -97,6 +98,8 @@ def excluded_models() -> list[type[Model]]:
 | 
			
		||||
        RefreshToken,
 | 
			
		||||
        Reputation,
 | 
			
		||||
        WebAuthnDeviceType,
 | 
			
		||||
        SCIMSourceUser,
 | 
			
		||||
        SCIMSourceGroup,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -671,7 +671,7 @@ class ExpiringModel(models.Model):
 | 
			
		||||
        return self.delete(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    @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,
 | 
			
		||||
        and match filters in `kwargs`"""
 | 
			
		||||
        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.plex", "authentik Sources.Plex"),
 | 
			
		||||
                    ("authentik.sources.saml", "authentik Sources.SAML"),
 | 
			
		||||
                    ("authentik.sources.scim", "authentik Sources.SCIM"),
 | 
			
		||||
                    ("authentik.stages.authenticator_duo", "authentik Stages.Authenticator.Duo"),
 | 
			
		||||
                    ("authentik.stages.authenticator_sms", "authentik Stages.Authenticator.SMS"),
 | 
			
		||||
                    (
 | 
			
		||||
 | 
			
		||||
@ -17,6 +17,7 @@ class User(BaseUser):
 | 
			
		||||
        "urn:ietf:params:scim:schemas:core:2.0:User",
 | 
			
		||||
    ]
 | 
			
		||||
    externalId: str | None = None
 | 
			
		||||
    meta: dict | None = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Group(BaseGroup):
 | 
			
		||||
@ -26,6 +27,7 @@ class Group(BaseGroup):
 | 
			
		||||
        "urn:ietf:params:scim:schemas:core:2.0:Group",
 | 
			
		||||
    ]
 | 
			
		||||
    externalId: str | None = None
 | 
			
		||||
    meta: dict | None = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ServiceProviderConfiguration(BaseServiceProviderConfiguration):
 | 
			
		||||
 | 
			
		||||
@ -90,6 +90,7 @@ TENANT_APPS = [
 | 
			
		||||
    "authentik.sources.oauth",
 | 
			
		||||
    "authentik.sources.plex",
 | 
			
		||||
    "authentik.sources.saml",
 | 
			
		||||
    "authentik.sources.scim",
 | 
			
		||||
    "authentik.stages.authenticator",
 | 
			
		||||
    "authentik.stages.authenticator_duo",
 | 
			
		||||
    "authentik.stages.authenticator_sms",
 | 
			
		||||
@ -157,6 +158,9 @@ SPECTACULAR_SETTINGS = {
 | 
			
		||||
    },
 | 
			
		||||
    "ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
 | 
			
		||||
    "ENUM_GENERATE_CHOICE_DESCRIPTION": False,
 | 
			
		||||
    "PREPROCESSING_HOOKS": [
 | 
			
		||||
        "authentik.api.schema.preprocess_schema_exclude_non_api",
 | 
			
		||||
    ],
 | 
			
		||||
    "POSTPROCESSING_HOOKS": [
 | 
			
		||||
        "authentik.api.schema.postprocess_schema_responses",
 | 
			
		||||
        "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",
 | 
			
		||||
                        "required": [
 | 
			
		||||
@ -3274,6 +3311,7 @@
 | 
			
		||||
                        "authentik.sources.oauth",
 | 
			
		||||
                        "authentik.sources.plex",
 | 
			
		||||
                        "authentik.sources.saml",
 | 
			
		||||
                        "authentik.sources.scim",
 | 
			
		||||
                        "authentik.stages.authenticator",
 | 
			
		||||
                        "authentik.stages.authenticator_duo",
 | 
			
		||||
                        "authentik.stages.authenticator_sms",
 | 
			
		||||
@ -3345,6 +3383,7 @@
 | 
			
		||||
                        "authentik_sources_plex.plexsourceconnection",
 | 
			
		||||
                        "authentik_sources_saml.samlsource",
 | 
			
		||||
                        "authentik_sources_saml.usersamlsourceconnection",
 | 
			
		||||
                        "authentik_sources_scim.scimsource",
 | 
			
		||||
                        "authentik_stages_authenticator_duo.authenticatorduostage",
 | 
			
		||||
                        "authentik_stages_authenticator_duo.duodevice",
 | 
			
		||||
                        "authentik_stages_authenticator_sms.authenticatorsmsstage",
 | 
			
		||||
@ -4929,6 +4968,52 @@
 | 
			
		||||
            },
 | 
			
		||||
            "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": {
 | 
			
		||||
            "type": "object",
 | 
			
		||||
            "properties": {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										30
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										30
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							@ -3566,6 +3566,23 @@ botocore = ">=1.33.2,<2.0a.0"
 | 
			
		||||
[package.extras]
 | 
			
		||||
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]]
 | 
			
		||||
name = "selenium"
 | 
			
		||||
version = "4.19.0"
 | 
			
		||||
@ -3782,6 +3799,17 @@ files = [
 | 
			
		||||
    {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]]
 | 
			
		||||
name = "sniffio"
 | 
			
		||||
version = "1.3.0"
 | 
			
		||||
@ -4654,4 +4682,4 @@ files = [
 | 
			
		||||
[metadata]
 | 
			
		||||
lock-version = "2.0"
 | 
			
		||||
python-versions = "~3.12"
 | 
			
		||||
content-hash = "4544b2a0b0065aa9e13d9a3b5a951fb5212921fe72f0fe259069e2e9205e9830"
 | 
			
		||||
content-hash = "a5774b4e09217805c887700b8a0f457a39c7af40ca59823f00c1f6e8678469e1"
 | 
			
		||||
 | 
			
		||||
@ -112,6 +112,7 @@ fido2 = "*"
 | 
			
		||||
flower = "*"
 | 
			
		||||
geoip2 = "*"
 | 
			
		||||
gunicorn = "*"
 | 
			
		||||
jsonpatch = "*"
 | 
			
		||||
kubernetes = "*"
 | 
			
		||||
ldap3 = "*"
 | 
			
		||||
lxml = [
 | 
			
		||||
@ -120,7 +121,6 @@ lxml = [
 | 
			
		||||
    # 4.9.x works with previous libxml2 versions, which is what we get on linux
 | 
			
		||||
    { version = "4.9.4", platform = "linux" },
 | 
			
		||||
]
 | 
			
		||||
jsonpatch = "*"
 | 
			
		||||
opencontainers = { extras = ["reggie"], version = "*" }
 | 
			
		||||
packaging = "*"
 | 
			
		||||
paramiko = "*"
 | 
			
		||||
@ -132,6 +132,7 @@ pyjwt = "*"
 | 
			
		||||
python = "~3.12"
 | 
			
		||||
pyyaml = "*"
 | 
			
		||||
requests-oauthlib = "*"
 | 
			
		||||
scim2-filter-parser = "*"
 | 
			
		||||
sentry-sdk = "*"
 | 
			
		||||
service_identity = "*"
 | 
			
		||||
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"""
 | 
			
		||||
        _container = container or self.container
 | 
			
		||||
        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"):
 | 
			
		||||
            print(log)
 | 
			
		||||
        if IS_CI:
 | 
			
		||||
 | 
			
		||||
@ -74,7 +74,7 @@ export class AkBackchannelProvidersInput extends AKElement {
 | 
			
		||||
                        <ak-chip-group> ${map(this.providers, renderOneChip)} </ak-chip-group>
 | 
			
		||||
                    </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>
 | 
			
		||||
        `;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,7 @@ import "@goauthentik/elements/Markdown";
 | 
			
		||||
import "@goauthentik/elements/Tabs";
 | 
			
		||||
import "@goauthentik/elements/buttons/ActionButton";
 | 
			
		||||
import "@goauthentik/elements/buttons/ModalButton";
 | 
			
		||||
import "@goauthentik/elements/events/LogViewer";
 | 
			
		||||
import "@goauthentik/elements/rbac/ObjectPermissionsPage";
 | 
			
		||||
 | 
			
		||||
import { msg, str } from "@lit/localize";
 | 
			
		||||
@ -155,9 +156,7 @@ export class SCIMProviderViewPage extends AKElement {
 | 
			
		||||
                        <p>${task.name}</p>
 | 
			
		||||
                        <ul class="pf-c-list">
 | 
			
		||||
                            <li>${header}</li>
 | 
			
		||||
                            ${task.messages.map((m) => {
 | 
			
		||||
                                return html`<li>${m}</li>`;
 | 
			
		||||
                            })}
 | 
			
		||||
                            <ak-log-viewer .logs=${task?.messages}></ak-log-viewer>
 | 
			
		||||
                        </ul>
 | 
			
		||||
                    </li> `;
 | 
			
		||||
                })}
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ import "@goauthentik/admin/sources/ldap/LDAPSourceViewPage";
 | 
			
		||||
import "@goauthentik/admin/sources/oauth/OAuthSourceViewPage";
 | 
			
		||||
import "@goauthentik/admin/sources/plex/PlexSourceViewPage";
 | 
			
		||||
import "@goauthentik/admin/sources/saml/SAMLSourceViewPage";
 | 
			
		||||
import "@goauthentik/admin/sources/scim/SCIMSourceViewPage";
 | 
			
		||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
			
		||||
import { AKElement } from "@goauthentik/elements/Base";
 | 
			
		||||
import "@goauthentik/elements/EmptyState";
 | 
			
		||||
@ -51,6 +52,10 @@ export class SourceViewPage extends AKElement {
 | 
			
		||||
                return html`<ak-source-plex-view
 | 
			
		||||
                    sourceSlug=${this.source.slug}
 | 
			
		||||
                ></ak-source-plex-view>`;
 | 
			
		||||
            case "ak-source-scim-form":
 | 
			
		||||
                return html`<ak-source-scim-view
 | 
			
		||||
                    sourceSlug=${this.source.slug}
 | 
			
		||||
                ></ak-source-scim-view>`;
 | 
			
		||||
            default:
 | 
			
		||||
                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/plex/PlexSourceForm";
 | 
			
		||||
import "@goauthentik/admin/sources/saml/SAMLSourceForm";
 | 
			
		||||
import "@goauthentik/admin/sources/scim/SCIMSourceForm";
 | 
			
		||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
			
		||||
import { AKElement } from "@goauthentik/elements/Base";
 | 
			
		||||
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.
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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/oauth/index",
 | 
			
		||||
                        "sources/saml/index",
 | 
			
		||||
                        "sources/scim/index",
 | 
			
		||||
                    ],
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user