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