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:
Jens L
2024-04-15 14:23:43 +02:00
committed by GitHub
parent 4a9c95b44e
commit 3c28cf1909
51 changed files with 5018 additions and 9 deletions

View File

@ -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:

View File

@ -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)
]

View File

@ -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,
)

View File

@ -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)):

View File

@ -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"),
(

View File

@ -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):

View File

@ -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",

View File

View File

View 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"]

View 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"]

View 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"]

View 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/"

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

View 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")},
},
),
]

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

File diff suppressed because it is too large Load Diff

View File

View 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)

View 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)

View 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)

View 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)

View 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())

View 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),
]

View File

View 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)

View 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."})

View 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)

View 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,
}
)

View 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,
}
)

View 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},
}
)

View 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)

View File

@ -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
View File

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

View File

@ -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

File diff suppressed because it is too large Load Diff

View 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))

View File

@ -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:

View File

@ -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>
`;
}

View File

@ -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> `;
})}

View File

@ -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>`;
}

View File

@ -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";

View 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>`;
}
}

View 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}`,
];
}
}

View 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}`,
];
}
}

View 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>`;
}
}

View File

@ -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.

View 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.

View File

@ -174,6 +174,7 @@ module.exports = {
"sources/ldap/index",
"sources/oauth/index",
"sources/saml/index",
"sources/scim/index",
],
},
{