Skip to content

Commit

Permalink
sources/scim: add property mappings (#10650)
Browse files Browse the repository at this point in the history
* sources/scim: add property mappings

Signed-off-by: Marc 'risson' Schmitt <[email protected]>

* fix filterset

Signed-off-by: Marc 'risson' Schmitt <[email protected]>

* fix doc link

Signed-off-by: Marc 'risson' Schmitt <[email protected]>

* lint

Signed-off-by: Marc 'risson' Schmitt <[email protected]>

---------

Signed-off-by: Marc 'risson' Schmitt <[email protected]>
  • Loading branch information
rissson authored Jul 29, 2024
1 parent 1b285f8 commit 3b1c427
Show file tree
Hide file tree
Showing 16 changed files with 862 additions and 69 deletions.
2 changes: 0 additions & 2 deletions authentik/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions authentik/sources/scim/api/property_mappings.py
Original file line number Diff line number Diff line change
@@ -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"]
3 changes: 2 additions & 1 deletion authentik/sources/scim/api/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ class Meta:
"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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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",),
),
]
60 changes: 58 additions & 2 deletions authentik/sources/scim/models.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -38,6 +39,41 @@ def serializer(self) -> BaseSerializer:

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}"

Expand All @@ -47,6 +83,26 @@ class Meta:
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"""

Expand Down
43 changes: 42 additions & 1 deletion authentik/sources/scim/tests/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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


Expand Down Expand Up @@ -87,3 +87,44 @@ def test_user_create(self):
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",
)
2 changes: 2 additions & 0 deletions authentik/sources/scim/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -68,6 +69,7 @@
]

api_urlpatterns = [
("propertymappings/source/scim", SCIMSourcePropertyMappingViewSet),
("sources/scim", SCIMSourceViewSet),
("sources/scim_users", SCIMSourceUserViewSet),
("sources/scim_groups", SCIMSourceGroupViewSet),
Expand Down
33 changes: 29 additions & 4 deletions authentik/sources/scim/views/v2/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

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
Expand All @@ -19,6 +19,8 @@
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

Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -113,6 +113,31 @@ def paginate_query(self, query: QuerySet) -> Page:
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"""

Expand Down
19 changes: 11 additions & 8 deletions authentik/sources/scim/views/v2/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
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
Expand Down Expand Up @@ -77,14 +77,17 @@ def get(self, request: Request, group_id: str | None = None, **kwargs) -> Respon
@atomic
def update_group(self, connection: SCIMSourceGroup | None, data: QueryDict):
"""Partial update a group"""
properties = self.build_object_properties(data)

if not properties.get("name"):
raise ValidationError("Invalid group")

group = connection.group if connection else Group()
if _group := Group.objects.filter(name=data.get("displayName")).first():
if _group := Group.objects.filter(name=properties.get("name")).first():
group = _group
if "displayName" in data:
group.name = data.get("displayName")
if group.name == "":
raise ValidationError("Invalid group")
group.save()

group.update_attributes(properties)

if "members" in data:
query = Q()
for _member in data.get("members", []):
Expand Down
Loading

0 comments on commit 3b1c427

Please sign in to comment.