Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sources: add SCIM source #3051

Merged
merged 30 commits into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3bd9fca
initial
BeryJu Nov 15, 2021
db668ce
add tests
BeryJu Jun 6, 2022
f70b992
rebuild migration
BeryJu Jun 6, 2022
2965075
include root URL in API
BeryJu Jun 6, 2022
18e7350
add UI base URL
BeryJu Jun 6, 2022
38b9005
only allow SCIM basic auth for testing and debug
BeryJu Jun 6, 2022
9405879
start user tests
BeryJu Jun 6, 2022
cbc0efb
antlr for scim filter parsing, why
BeryJu Jun 11, 2022
7f2c161
update
BeryJu Sep 26, 2023
6b1e05e
fix url mountpoint
BeryJu Nov 16, 2023
3414990
...turns out we don't need antlr
BeryJu Apr 7, 2024
a5db1ca
start to revive this PR
BeryJu Apr 7, 2024
935b72b
Apply suggestions from code review
BeryJu Apr 7, 2024
96f4821
don't put doc structure changes into this
BeryJu Apr 7, 2024
3858f10
fix web ui
BeryJu Apr 7, 2024
df071d5
make mostly work
BeryJu Apr 7, 2024
bf1b597
add filter support
BeryJu Apr 7, 2024
0f48f51
add e2e tests
BeryJu Apr 8, 2024
98db407
fix helper
BeryJu Apr 8, 2024
5f266be
re-add codecov oidc
BeryJu Apr 8, 2024
844ce6c
remove unused fields from API
BeryJu Apr 8, 2024
4276b4b
fix group membership
BeryJu Apr 9, 2024
30e1d7b
unrelated: fix backchannel helper text size
BeryJu Apr 9, 2024
486a5a6
test against authentik as SCIM server I guess?
BeryJu Apr 9, 2024
f470cfc
fix scim provider task render
BeryJu Apr 9, 2024
1d58676
add preview banner
BeryJu Apr 9, 2024
e516586
Revert "re-add codecov oidc"
BeryJu Apr 9, 2024
5460346
add API for connection objects
BeryJu Apr 9, 2024
322c545
fix preview banner
BeryJu Apr 15, 2024
1d0ad2a
add UI for users and groups
BeryJu Apr 15, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions authentik/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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


Expand Down Expand Up @@ -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)
]
3 changes: 3 additions & 0 deletions authentik/blueprints/v1/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
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

Expand Down Expand Up @@ -97,6 +98,8 @@ def excluded_models() -> list[type[Model]]:
RefreshToken,
Reputation,
WebAuthnDeviceType,
SCIMSourceUser,
SCIMSourceGroup,
)


Expand Down
2 changes: 1 addition & 1 deletion authentik/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@ def expire_action(self, *args, **kwargs):
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)):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
(
Expand Down
2 changes: 2 additions & 0 deletions authentik/providers/scim/clients/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions authentik/root/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"authentik.sources.oauth",
"authentik.sources.plex",
"authentik.sources.saml",
"authentik.sources.scim",
"authentik.stages.authenticator",
"authentik.stages.authenticator_duo",
"authentik.stages.authenticator_sms",
Expand Down Expand Up @@ -157,6 +158,9 @@
},
"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",
Expand Down
Empty file.
Empty file.
35 changes: 35 additions & 0 deletions authentik/sources/scim/api/groups.py
Original file line number Diff line number Diff line change
@@ -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 changes: 77 additions & 0 deletions authentik/sources/scim/api/sources.py
Original file line number Diff line number Diff line change
@@ -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(

Check warning on line 22 in authentik/sources/scim/api/sources.py

View check run for this annotation

Codecov / codecov/patch

authentik/sources/scim/api/sources.py#L22

Added line #L22 was not covered by tests
"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)

Check warning on line 28 in authentik/sources/scim/api/sources.py

View check run for this annotation

Codecov / codecov/patch

authentik/sources/scim/api/sources.py#L26-L28

Added lines #L26 - L28 were not covered by tests

def create(self, validated_data):
instance: SCIMSource = super().create(validated_data)
identifier = f"ak-source-scim-{instance.pk}"
user = User.objects.create(

Check warning on line 33 in authentik/sources/scim/api/sources.py

View check run for this annotation

Codecov / codecov/patch

authentik/sources/scim/api/sources.py#L31-L33

Added lines #L31 - L33 were not covered by tests
username=identifier,
name=f"SCIM Source {instance.name} Service-Account",
type=UserTypes.SERVICE_ACCOUNT,
)
token = Token.objects.create(

Check warning on line 38 in authentik/sources/scim/api/sources.py

View check run for this annotation

Codecov / codecov/patch

authentik/sources/scim/api/sources.py#L38

Added line #L38 was not covered by tests
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

Check warning on line 47 in authentik/sources/scim/api/sources.py

View check run for this annotation

Codecov / codecov/patch

authentik/sources/scim/api/sources.py#L45-L47

Added lines #L45 - L47 were not covered by tests

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 changes: 35 additions & 0 deletions authentik/sources/scim/api/users.py
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions authentik/sources/scim/apps.py
Original file line number Diff line number Diff line change
@@ -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 changes: 8 additions & 0 deletions authentik/sources/scim/errors.py
Original file line number Diff line number Diff line change
@@ -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 changes: 94 additions & 0 deletions authentik/sources/scim/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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")},
},
),
]
Empty file.
Loading
Loading