From 3b1c42776bf9bd1ed63f71789562f572f34ba809 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Mon, 29 Jul 2024 22:32:51 +0200 Subject: [PATCH] sources/scim: add property mappings (#10650) * sources/scim: add property mappings Signed-off-by: Marc 'risson' Schmitt * fix filterset Signed-off-by: Marc 'risson' Schmitt * fix doc link Signed-off-by: Marc 'risson' Schmitt * lint Signed-off-by: Marc 'risson' Schmitt --------- Signed-off-by: Marc 'risson' Schmitt --- authentik/core/models.py | 2 - .../sources/scim/api/property_mappings.py | 32 ++ authentik/sources/scim/api/sources.py | 3 +- .../0002_scimsourcepropertymapping.py | 36 ++ authentik/sources/scim/models.py | 60 ++- authentik/sources/scim/tests/test_users.py | 43 +- authentik/sources/scim/urls.py | 2 + authentik/sources/scim/views/v2/base.py | 33 +- authentik/sources/scim/views/v2/groups.py | 21 +- authentik/sources/scim/views/v2/users.py | 36 +- blueprints/schema.json | 89 +++- schema.yml | 432 +++++++++++++++++- .../PropertyMappingListPage.ts | 1 + .../PropertyMappingSCIMSourceForm.ts | 75 +++ .../PropertyMappingWizard.ts | 1 + web/src/admin/sources/scim/SCIMSourceForm.ts | 69 ++- 16 files changed, 864 insertions(+), 71 deletions(-) create mode 100644 authentik/sources/scim/api/property_mappings.py create mode 100644 authentik/sources/scim/migrations/0002_scimsourcepropertymapping.py create mode 100644 web/src/admin/property-mappings/PropertyMappingSCIMSourceForm.ts diff --git a/authentik/core/models.py b/authentik/core/models.py index 42824b1f56..d0a70d6bbf 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -269,8 +269,6 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser): ak_groups = models.ManyToManyField("Group", related_name="users") password_change_date = models.DateTimeField(auto_now_add=True) - attributes = models.JSONField(default=dict, blank=True) - objects = UserManager() class Meta: diff --git a/authentik/sources/scim/api/property_mappings.py b/authentik/sources/scim/api/property_mappings.py new file mode 100644 index 0000000000..880fed7e79 --- /dev/null +++ b/authentik/sources/scim/api/property_mappings.py @@ -0,0 +1,32 @@ +"""SCIM source property mappings API""" + +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer +from authentik.core.api.used_by import UsedByMixin +from authentik.sources.scim.models import SCIMSourcePropertyMapping + + +class SCIMSourcePropertyMappingSerializer(PropertyMappingSerializer): + """SCIMSourcePropertyMapping Serializer""" + + class Meta: + model = SCIMSourcePropertyMapping + fields = PropertyMappingSerializer.Meta.fields + + +class SCIMSourcePropertyMappingFilter(PropertyMappingFilterSet): + """Filter for SCIMSourcePropertyMapping""" + + class Meta(PropertyMappingFilterSet.Meta): + model = SCIMSourcePropertyMapping + + +class SCIMSourcePropertyMappingViewSet(UsedByMixin, ModelViewSet): + """SCIMSourcePropertyMapping Viewset""" + + queryset = SCIMSourcePropertyMapping.objects.all() + serializer_class = SCIMSourcePropertyMappingSerializer + filterset_class = SCIMSourcePropertyMappingFilter + search_fields = ["name"] + ordering = ["name"] diff --git a/authentik/sources/scim/api/sources.py b/authentik/sources/scim/api/sources.py index f89830f8fe..729e89c7a2 100644 --- a/authentik/sources/scim/api/sources.py +++ b/authentik/sources/scim/api/sources.py @@ -34,11 +34,12 @@ class SCIMSourceSerializer(SourceSerializer): "name", "slug", "enabled", + "user_property_mappings", + "group_property_mappings", "component", "verbose_name", "verbose_name_plural", "meta_model_name", - "user_matching_mode", "managed", "user_path_template", "root_url", diff --git a/authentik/sources/scim/migrations/0002_scimsourcepropertymapping.py b/authentik/sources/scim/migrations/0002_scimsourcepropertymapping.py new file mode 100644 index 0000000000..582110aa6c --- /dev/null +++ b/authentik/sources/scim/migrations/0002_scimsourcepropertymapping.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.7 on 2024-07-26 13:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0037_remove_source_property_mappings"), + ("authentik_sources_scim", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="SCIMSourcePropertyMapping", + fields=[ + ( + "propertymapping_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_core.propertymapping", + ), + ), + ], + options={ + "verbose_name": "SCIM Source Property Mapping", + "verbose_name_plural": "SCIM Source Property Mappings", + }, + bases=("authentik_core.propertymapping",), + ), + ] diff --git a/authentik/sources/scim/models.py b/authentik/sources/scim/models.py index de2149ee67..8bc753077c 100644 --- a/authentik/sources/scim/models.py +++ b/authentik/sources/scim/models.py @@ -1,13 +1,14 @@ """SCIM Source""" +from typing import Any from uuid import uuid4 from django.db import models from django.templatetags.static import static from django.utils.translation import gettext_lazy as _ -from rest_framework.serializers import BaseSerializer +from rest_framework.serializers import BaseSerializer, Serializer -from authentik.core.models import Group, Source, Token, User +from authentik.core.models import Group, PropertyMapping, Source, Token, User from authentik.lib.models import SerializerModel @@ -38,6 +39,41 @@ class SCIMSource(Source): return SCIMSourceSerializer + @property + def property_mapping_type(self) -> type[PropertyMapping]: + return SCIMSourcePropertyMapping + + def get_base_user_properties(self, data: dict[str, Any]) -> dict[str, Any | dict[str, Any]]: + properties = {} + + def get_email(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") + + if "userName" in data: + properties["username"] = data.get("userName") + if "name" in data: + properties["name"] = data.get("name", {}).get("formatted", data.get("displayName")) + if "emails" in data: + properties["email"] = get_email(data.get("emails")) + if "active" in data: + properties["is_active"] = data.get("active") + + return properties + + def get_base_group_properties(self, data: dict[str, Any]) -> dict[str, Any | dict[str, Any]]: + properties = {} + + if "displayName" in data: + properties["name"] = data.get("displayName") + + return properties + def __str__(self) -> str: return f"SCIM Source {self.name}" @@ -47,6 +83,26 @@ class SCIMSource(Source): verbose_name_plural = _("SCIM Sources") +class SCIMSourcePropertyMapping(PropertyMapping): + """Map SCIM properties to User or Group object attributes""" + + @property + def component(self) -> str: + return "ak-property-mapping-scim-source-form" + + @property + def serializer(self) -> type[Serializer]: + from authentik.sources.scim.api.property_mappings import ( + SCIMSourcePropertyMappingSerializer, + ) + + return SCIMSourcePropertyMappingSerializer + + class Meta: + verbose_name = _("SCIM Source Property Mapping") + verbose_name_plural = _("SCIM Source Property Mappings") + + class SCIMSourceUser(SerializerModel): """Mapping of a user and source to a SCIM user ID""" diff --git a/authentik/sources/scim/tests/test_users.py b/authentik/sources/scim/tests/test_users.py index 18ad33fc46..45730ce5c5 100644 --- a/authentik/sources/scim/tests/test_users.py +++ b/authentik/sources/scim/tests/test_users.py @@ -10,7 +10,7 @@ from authentik.core.tests.utils import create_test_user from authentik.events.models import Event, EventAction 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.models import SCIMSource, SCIMSourcePropertyMapping, SCIMSourceUser from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE @@ -87,3 +87,44 @@ class TestSCIMUsers(APITestCase): action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username ).exists() ) + + def test_user_property_mappings(self): + """Test user property_mappings""" + self.source.user_property_mappings.set( + [ + SCIMSourcePropertyMapping.objects.create( + name=generate_id(), + expression='return {"attributes": {"phone": data.get("phoneNumber")}}', + ) + ] + ) + user = create_test_user() + 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": user.email, + } + ], + "phoneNumber": "0123456789", + } + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, 201) + self.assertEqual( + SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"], + "0123456789", + ) diff --git a/authentik/sources/scim/urls.py b/authentik/sources/scim/urls.py index 84776bcbcb..122a10a6a8 100644 --- a/authentik/sources/scim/urls.py +++ b/authentik/sources/scim/urls.py @@ -3,6 +3,7 @@ from django.urls import path from authentik.sources.scim.api.groups import SCIMSourceGroupViewSet +from authentik.sources.scim.api.property_mappings import SCIMSourcePropertyMappingViewSet from authentik.sources.scim.api.sources import SCIMSourceViewSet from authentik.sources.scim.api.users import SCIMSourceUserViewSet from authentik.sources.scim.views.v2 import ( @@ -68,6 +69,7 @@ urlpatterns = [ ] api_urlpatterns = [ + ("propertymappings/source/scim", SCIMSourcePropertyMappingViewSet), ("sources/scim", SCIMSourceViewSet), ("sources/scim_users", SCIMSourceUserViewSet), ("sources/scim_groups", SCIMSourceGroupViewSet), diff --git a/authentik/sources/scim/views/v2/base.py b/authentik/sources/scim/views/v2/base.py index c32c8077fd..b541f6ca7c 100644 --- a/authentik/sources/scim/views/v2/base.py +++ b/authentik/sources/scim/views/v2/base.py @@ -5,7 +5,7 @@ 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.db.models import Q, QuerySet from django.http import HttpRequest from django.urls import resolve from rest_framework.parsers import JSONParser @@ -19,6 +19,8 @@ from structlog import BoundLogger from structlog.stdlib import get_logger from authentik.core.models import Group, User +from authentik.core.sources.mapper import SourceMapper +from authentik.lib.sync.mapper import PropertyMappingManager from authentik.sources.scim.models import SCIMSource from authentik.sources.scim.views.v2.auth import SCIMTokenAuth @@ -47,11 +49,9 @@ class SCIMView(APIView): 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) + super().setup(request, *args, **kwargs) def get_authenticators(self): return [SCIMTokenAuth(self)] @@ -113,6 +113,31 @@ class SCIMView(APIView): return page +class SCIMObjectView(SCIMView): + """Base SCIM View for object management""" + + mapper: SourceMapper + manager: PropertyMappingManager + + model: type[User | Group] + + def initial(self, request: Request, *args, **kwargs) -> None: + super().initial(request, *args, **kwargs) + # This needs to happen after authentication has happened, because we don't have + # a source attribute before + self.mapper = SourceMapper(self.source) + self.manager = self.mapper.get_manager(self.model, ["data"]) + + def build_object_properties(self, data: dict[str, Any]) -> dict[str, Any | dict[str, Any]]: + return self.mapper.build_object_properties( + object_type=self.model, + manager=self.manager, + user=None, + request=self.request, + data=data, + ) + + class SCIMRootView(SCIMView): """Root SCIM View""" diff --git a/authentik/sources/scim/views/v2/groups.py b/authentik/sources/scim/views/v2/groups.py index ff27efc162..dabc6c07c8 100644 --- a/authentik/sources/scim/views/v2/groups.py +++ b/authentik/sources/scim/views/v2/groups.py @@ -16,10 +16,10 @@ from authentik.core.models import Group, User from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA 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 +from authentik.sources.scim.views.v2.base import SCIMObjectView -class GroupsView(SCIMView): +class GroupsView(SCIMObjectView): """SCIM Group view""" model = Group @@ -77,14 +77,17 @@ class GroupsView(SCIMView): @atomic def update_group(self, connection: SCIMSourceGroup | None, data: QueryDict): """Partial update a group""" - group = connection.group if connection else Group() - if _group := Group.objects.filter(name=data.get("displayName")).first(): - group = _group - if "displayName" in data: - group.name = data.get("displayName") - if group.name == "": + properties = self.build_object_properties(data) + + if not properties.get("name"): raise ValidationError("Invalid group") - group.save() + + group = connection.group if connection else Group() + if _group := Group.objects.filter(name=properties.get("name")).first(): + group = _group + + group.update_attributes(properties) + if "members" in data: query = Q() for _member in data.get("members", []): diff --git a/authentik/sources/scim/views/v2/users.py b/authentik/sources/scim/views/v2/users.py index 84dd555f29..d2c6cf73b8 100644 --- a/authentik/sources/scim/views/v2/users.py +++ b/authentik/sources/scim/views/v2/users.py @@ -14,23 +14,14 @@ from authentik.core.models import User from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA 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 +from authentik.sources.scim.views.v2.base import SCIMObjectView -class UsersView(SCIMView): +class UsersView(SCIMObjectView): """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( @@ -97,21 +88,16 @@ class UsersView(SCIMView): @atomic def update_user(self, connection: SCIMSourceUser | None, data: QueryDict): """Partial update a user""" - user = connection.user if connection else User() - if _user := User.objects.filter(username=data.get("userName")).first(): - user = _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 == "": + properties = self.build_object_properties(data) + + if not properties.get("username"): raise ValidationError("Invalid user") - user.save() + + user = connection.user if connection else User() + if _user := User.objects.filter(username=properties.get("username")).first(): + user = _user + user.update_attributes(properties) + if not connection: connection, _ = SCIMSourceUser.objects.get_or_create( source=self.source, diff --git a/blueprints/schema.json b/blueprints/schema.json index 67e04ab206..52596fa263 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -1299,6 +1299,43 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_sources_scim.scimsourcepropertymapping" + }, + "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.scimsourcepropertymapping" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_sources_scim.scimsourcepropertymapping" + } + } + }, { "type": "object", "required": [ @@ -3572,6 +3609,7 @@ "authentik_sources_saml.samlsource", "authentik_sources_saml.usersamlsourceconnection", "authentik_sources_scim.scimsource", + "authentik_sources_scim.scimsourcepropertymapping", "authentik_stages_authenticator_duo.authenticatorduostage", "authentik_stages_authenticator_duo.duodevice", "authentik_stages_authenticator_sms.authenticatorsmsstage", @@ -5247,17 +5285,21 @@ "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_property_mappings": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "title": "User property mappings" + }, + "group_property_mappings": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "title": "Group property mappings" }, "user_path_template": { "type": "string", @@ -5272,6 +5314,31 @@ }, "required": [] }, + "model_authentik_sources_scim.scimsourcepropertymapping": { + "type": "object", + "properties": { + "managed": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "title": "Managed by authentik", + "description": "Objects that are managed by authentik. These objects are created and updated automatically. This flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update." + }, + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "expression": { + "type": "string", + "minLength": 1, + "title": "Expression" + } + }, + "required": [] + }, "model_authentik_stages_authenticator_duo.authenticatorduostage": { "type": "object", "properties": { diff --git a/schema.yml b/schema.yml index ff936bb1b0..26452e064f 100644 --- a/schema.yml +++ b/schema.yml @@ -15948,6 +15948,292 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /propertymappings/source/scim/: + get: + operationId: propertymappings_source_scim_list + description: SCIMSourcePropertyMapping Viewset + parameters: + - in: query + name: expression + schema: + type: string + - in: query + name: managed + schema: + type: array + items: + type: string + explode: true + style: form + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: pm_uuid + schema: + type: string + format: uuid + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedSCIMSourcePropertyMappingList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: propertymappings_source_scim_create + description: SCIMSourcePropertyMapping Viewset + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SCIMSourcePropertyMappingRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/SCIMSourcePropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /propertymappings/source/scim/{pm_uuid}/: + get: + operationId: propertymappings_source_scim_retrieve + description: SCIMSourcePropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this SCIM Source Property Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SCIMSourcePropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: propertymappings_source_scim_update + description: SCIMSourcePropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this SCIM Source Property Mapping. + required: true + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SCIMSourcePropertyMappingRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SCIMSourcePropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: propertymappings_source_scim_partial_update + description: SCIMSourcePropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this SCIM Source Property Mapping. + required: true + tags: + - propertymappings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedSCIMSourcePropertyMappingRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SCIMSourcePropertyMapping' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: propertymappings_source_scim_destroy + description: SCIMSourcePropertyMapping Viewset + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this SCIM Source Property Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /propertymappings/source/scim/{pm_uuid}/used_by/: + get: + operationId: propertymappings_source_scim_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: pm_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this SCIM Source Property Mapping. + required: true + tags: + - propertymappings + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /providers/all/: get: operationId: providers_all_list @@ -21023,6 +21309,7 @@ paths: - authentik_sources_saml.samlsource - authentik_sources_saml.usersamlsourceconnection - authentik_sources_scim.scimsource + - authentik_sources_scim.scimsourcepropertymapping - authentik_stages_authenticator_duo.authenticatorduostage - authentik_stages_authenticator_duo.duodevice - authentik_stages_authenticator_sms.authenticatorsmsstage @@ -21243,6 +21530,7 @@ paths: - authentik_sources_saml.samlsource - authentik_sources_saml.usersamlsourceconnection - authentik_sources_scim.scimsource + - authentik_sources_scim.scimsourcepropertymapping - authentik_stages_authenticator_duo.authenticatorduostage - authentik_stages_authenticator_duo.duodevice - authentik_stages_authenticator_sms.authenticatorsmsstage @@ -38829,6 +39117,7 @@ components: - authentik_sources_saml.samlsource - authentik_sources_saml.usersamlsourceconnection - authentik_sources_scim.scimsource + - authentik_sources_scim.scimsourcepropertymapping - authentik_stages_authenticator_duo.authenticatorduostage - authentik_stages_authenticator_duo.duodevice - authentik_stages_authenticator_sms.authenticatorsmsstage @@ -40874,6 +41163,18 @@ components: required: - pagination - results + PaginatedSCIMSourcePropertyMappingList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/SCIMSourcePropertyMapping' + required: + - pagination + - results PaginatedSCIMSourceUserList: type: object properties: @@ -43781,6 +44082,25 @@ components: type: string format: uuid attributes: {} + PatchedSCIMSourcePropertyMappingRequest: + type: object + description: SCIMSourcePropertyMapping Serializer + properties: + managed: + type: string + nullable: true + minLength: 1 + title: Managed by authentik + description: Objects that are managed by authentik. These objects are created + and updated automatically. This flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the + API, but expect changes to be overwritten in a later update. + name: + type: string + minLength: 1 + expression: + type: string + minLength: 1 PatchedSCIMSourceRequest: type: object description: SCIMSource Serializer @@ -43797,11 +44117,16 @@ components: pattern: ^[-a-zA-Z0-9_]+$ enabled: type: boolean - user_matching_mode: - allOf: - - $ref: '#/components/schemas/UserMatchingModeEnum' - description: How the source determines if an existing user should be authenticated - or a new user enrolled. + user_property_mappings: + type: array + items: + type: string + format: uuid + group_property_mappings: + type: array + items: + type: string + format: uuid user_path_template: type: string minLength: 1 @@ -46886,6 +47211,16 @@ components: pattern: ^[-a-zA-Z0-9_]+$ enabled: type: boolean + user_property_mappings: + type: array + items: + type: string + format: uuid + group_property_mappings: + type: array + items: + type: string + format: uuid component: type: string description: Get object component so that we know how to edit the object @@ -46902,11 +47237,6 @@ components: type: string description: Return internal model name readOnly: true - user_matching_mode: - allOf: - - $ref: '#/components/schemas/UserMatchingModeEnum' - description: How the source determines if an existing user should be authenticated - or a new user enrolled. managed: type: string nullable: true @@ -46977,6 +47307,73 @@ components: - group - id - source + SCIMSourcePropertyMapping: + type: object + description: SCIMSourcePropertyMapping Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Pm uuid + managed: + type: string + nullable: true + title: Managed by authentik + description: Objects that are managed by authentik. These objects are created + and updated automatically. This flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the + API, but expect changes to be overwritten in a later update. + name: + type: string + expression: + type: string + component: + type: string + description: Get object's component so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + required: + - component + - expression + - meta_model_name + - name + - pk + - verbose_name + - verbose_name_plural + SCIMSourcePropertyMappingRequest: + type: object + description: SCIMSourcePropertyMapping Serializer + properties: + managed: + type: string + nullable: true + minLength: 1 + title: Managed by authentik + description: Objects that are managed by authentik. These objects are created + and updated automatically. This flag only indicates that an object can + be overwritten by migrations. You can still modify the objects via the + API, but expect changes to be overwritten in a later update. + name: + type: string + minLength: 1 + expression: + type: string + minLength: 1 + required: + - expression + - name SCIMSourceRequest: type: object description: SCIMSource Serializer @@ -46993,11 +47390,16 @@ components: pattern: ^[-a-zA-Z0-9_]+$ enabled: type: boolean - user_matching_mode: - allOf: - - $ref: '#/components/schemas/UserMatchingModeEnum' - description: How the source determines if an existing user should be authenticated - or a new user enrolled. + user_property_mappings: + type: array + items: + type: string + format: uuid + group_property_mappings: + type: array + items: + type: string + format: uuid user_path_template: type: string minLength: 1 diff --git a/web/src/admin/property-mappings/PropertyMappingListPage.ts b/web/src/admin/property-mappings/PropertyMappingListPage.ts index ca7bf87feb..8db861924b 100644 --- a/web/src/admin/property-mappings/PropertyMappingListPage.ts +++ b/web/src/admin/property-mappings/PropertyMappingListPage.ts @@ -6,6 +6,7 @@ import "@goauthentik/admin/property-mappings/PropertyMappingRACForm"; import "@goauthentik/admin/property-mappings/PropertyMappingRadiusForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSCIMForm"; +import "@goauthentik/admin/property-mappings/PropertyMappingSCIMSourceForm"; import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm"; import "@goauthentik/admin/property-mappings/PropertyMappingTestForm"; import "@goauthentik/admin/property-mappings/PropertyMappingWizard"; diff --git a/web/src/admin/property-mappings/PropertyMappingSCIMSourceForm.ts b/web/src/admin/property-mappings/PropertyMappingSCIMSourceForm.ts new file mode 100644 index 0000000000..7af0e13b2a --- /dev/null +++ b/web/src/admin/property-mappings/PropertyMappingSCIMSourceForm.ts @@ -0,0 +1,75 @@ +import { BasePropertyMappingForm } from "@goauthentik/admin/property-mappings/BasePropertyMappingForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { docLink } from "@goauthentik/common/global"; +import "@goauthentik/elements/CodeMirror"; +import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; +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 { PropertymappingsApi, SCIMSourcePropertyMapping } from "@goauthentik/api"; + +@customElement("ak-property-mapping-scim-source-form") +export class PropertyMappingSCIMSourceForm extends BasePropertyMappingForm { + loadInstance(pk: string): Promise { + return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSourceScimRetrieve({ + pmUuid: pk, + }); + } + + async send(data: SCIMSourcePropertyMapping): Promise { + if (this.instance) { + return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSourceScimUpdate({ + pmUuid: this.instance.pk, + sCIMSourcePropertyMappingRequest: data, + }); + } else { + return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSourceScimCreate({ + sCIMSourcePropertyMappingRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + + + + + +

+ ${msg("Expression using Python.")} + + ${msg("See documentation for a list of all variables.")} + +

+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-property-mapping-scim-source-form": PropertyMappingSCIMSourceForm; + } +} diff --git a/web/src/admin/property-mappings/PropertyMappingWizard.ts b/web/src/admin/property-mappings/PropertyMappingWizard.ts index 2b8ea4cda1..9e06c8d363 100644 --- a/web/src/admin/property-mappings/PropertyMappingWizard.ts +++ b/web/src/admin/property-mappings/PropertyMappingWizard.ts @@ -2,6 +2,7 @@ import "@goauthentik/admin/property-mappings/PropertyMappingLDAPSourceForm"; import "@goauthentik/admin/property-mappings/PropertyMappingNotification"; import "@goauthentik/admin/property-mappings/PropertyMappingRACForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm"; +import "@goauthentik/admin/property-mappings/PropertyMappingSCIMSourceForm"; import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm"; import "@goauthentik/admin/property-mappings/PropertyMappingTestForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; diff --git a/web/src/admin/sources/scim/SCIMSourceForm.ts b/web/src/admin/sources/scim/SCIMSourceForm.ts index 68eec954a4..c156d21663 100644 --- a/web/src/admin/sources/scim/SCIMSourceForm.ts +++ b/web/src/admin/sources/scim/SCIMSourceForm.ts @@ -2,6 +2,8 @@ import { placeholderHelperText } from "@goauthentik/admin/helperText"; import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; @@ -10,7 +12,35 @@ 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"; +import { + PropertymappingsApi, + SCIMSource, + SCIMSourcePropertyMapping, + SCIMSourceRequest, + SourcesApi, +} from "@goauthentik/api"; + +async function propertyMappingsProvider(page = 1, search = "") { + const propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsSourceScimList({ + ordering: "managed", + pageSize: 20, + search: search.trim(), + page, + }); + return { + pagination: propertyMappings.pagination, + options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]), + }; +} + +function makePropertyMappingsSelector(instanceMappings?: string[]) { + const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, _]: DualSelectPair) => false; +} @customElement("ak-source-scim-form") export class SCIMSourceForm extends BaseSourceForm { @@ -65,6 +95,43 @@ export class SCIMSourceForm extends BaseSourceForm { + + ${msg("SCIM Attribute mapping")} +
+ + +

+ ${msg("Property mappings for user creation.")} +

+
+ + +

+ ${msg("Property mappings for group creation.")} +

+
+
+
${msg("Advanced protocol settings")}