initial
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
51d3511f8b
commit
2cab4b7cda
|
@ -17,7 +17,7 @@
|
||||||
"kubernetes",
|
"kubernetes",
|
||||||
"sso",
|
"sso",
|
||||||
"slo",
|
"slo",
|
||||||
"scim",
|
"scim"
|
||||||
],
|
],
|
||||||
"python.linting.pylintEnabled": true,
|
"python.linting.pylintEnabled": true,
|
||||||
"todo-tree.tree.showCountsInTree": true,
|
"todo-tree.tree.showCountsInTree": true,
|
||||||
|
|
|
@ -13,6 +13,8 @@ from rest_framework.settings import api_settings
|
||||||
|
|
||||||
from authentik.api.pagination import PAGINATION_COMPONENT_NAME, PAGINATION_SCHEMA
|
from authentik.api.pagination import PAGINATION_COMPONENT_NAME, PAGINATION_SCHEMA
|
||||||
|
|
||||||
|
from authentik.api.apps import AuthentikAPIConfig
|
||||||
|
|
||||||
|
|
||||||
def build_standard_type(obj, **kwargs):
|
def build_standard_type(obj, **kwargs):
|
||||||
"""Build a basic type with optional add owns."""
|
"""Build a basic type with optional add owns."""
|
||||||
|
@ -100,3 +102,12 @@ def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs):
|
||||||
comp = result["components"]["schemas"][component]
|
comp = result["components"]["schemas"][component]
|
||||||
comp["additionalProperties"] = {}
|
comp["additionalProperties"] = {}
|
||||||
return result
|
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)
|
||||||
|
]
|
||||||
|
|
|
@ -82,8 +82,9 @@ INSTALLED_APPS = [
|
||||||
"authentik.sources.oauth",
|
"authentik.sources.oauth",
|
||||||
"authentik.sources.plex",
|
"authentik.sources.plex",
|
||||||
"authentik.sources.saml",
|
"authentik.sources.saml",
|
||||||
"authentik.stages.authenticator",
|
"authentik.sources.scim",
|
||||||
"authentik.stages.authenticator_duo",
|
"authentik.stages.authenticator_duo",
|
||||||
|
"authentik.stages.authenticator",
|
||||||
"authentik.stages.authenticator_sms",
|
"authentik.stages.authenticator_sms",
|
||||||
"authentik.stages.authenticator_static",
|
"authentik.stages.authenticator_static",
|
||||||
"authentik.stages.authenticator_totp",
|
"authentik.stages.authenticator_totp",
|
||||||
|
@ -146,6 +147,9 @@ SPECTACULAR_SETTINGS = {
|
||||||
"UserTypeEnum": "authentik.core.models.UserTypes",
|
"UserTypeEnum": "authentik.core.models.UserTypes",
|
||||||
},
|
},
|
||||||
"ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
|
"ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
|
||||||
|
"PREPROCESSING_HOOKS": [
|
||||||
|
"authentik.api.schema.preprocess_schema_exclude_non_api",
|
||||||
|
],
|
||||||
"POSTPROCESSING_HOOKS": [
|
"POSTPROCESSING_HOOKS": [
|
||||||
"authentik.api.schema.postprocess_schema_responses",
|
"authentik.api.schema.postprocess_schema_responses",
|
||||||
"drf_spectacular.hooks.postprocess_schema_enums",
|
"drf_spectacular.hooks.postprocess_schema_enums",
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""SCIMSource 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.sources.scim.models import SCIMSource
|
||||||
|
|
||||||
|
|
||||||
|
class SCIMSourceSerializer(SourceSerializer):
|
||||||
|
"""SCIMSource Serializer"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
|
||||||
|
model = SCIMSource
|
||||||
|
fields = SourceSerializer.Meta.fields + ["token"]
|
||||||
|
|
||||||
|
|
||||||
|
class SCIMSourceViewSet(UsedByMixin, ModelViewSet):
|
||||||
|
"""SCIMSource Viewset"""
|
||||||
|
|
||||||
|
queryset = SCIMSource.objects.all()
|
||||||
|
serializer_class = SCIMSourceSerializer
|
||||||
|
lookup_field = "slug"
|
||||||
|
filterset_fields = "__all__"
|
||||||
|
search_fields = ["name", "slug"]
|
||||||
|
ordering = ["name"]
|
|
@ -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/"
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Generated by Django 3.2.9 on 2021-11-15 12:51
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_core", "0018_auto_20210330_1345_squashed_0028_alter_token_intent"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.token"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
bases=("authentik_core.source",),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""SCIM Source"""
|
||||||
|
from django.db import models
|
||||||
|
from rest_framework.serializers import BaseSerializer
|
||||||
|
|
||||||
|
from authentik.core.models import Source, Token
|
||||||
|
|
||||||
|
USER_ATTRIBUTE_SCIM_ID = "goauthentik.io/sources/scim/id"
|
||||||
|
USER_ATTRIBUTE_SCIM_ADDRESS = "goauthentik.io/sources/scim/address"
|
||||||
|
USER_ATTRIBUTE_SCIM_ENTERPRISE = "goauthentik.io/sources/scim/enterprise"
|
||||||
|
|
||||||
|
|
||||||
|
class SCIMSource(Source):
|
||||||
|
"""SCIM Source"""
|
||||||
|
|
||||||
|
token = models.ForeignKey(Token, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
@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 import SCIMSourceSerializer
|
||||||
|
|
||||||
|
return SCIMSourceSerializer
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,58 @@
|
||||||
|
"""SCIM URLs"""
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from authentik.sources.scim.views.v2 import (
|
||||||
|
groups,
|
||||||
|
resource_types,
|
||||||
|
schemas,
|
||||||
|
service_provider_config,
|
||||||
|
users,
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""SCIM Token auth"""
|
||||||
|
from base64 import b64decode
|
||||||
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
from rest_framework.authentication import BaseAuthentication, get_authorization_header
|
||||||
|
from rest_framework.request import Request
|
||||||
|
|
||||||
|
from authentik.core.models import Token, TokenIntents, User
|
||||||
|
|
||||||
|
|
||||||
|
class SCIMTokenAuth(BaseAuthentication):
|
||||||
|
"""SCIM Token auth"""
|
||||||
|
|
||||||
|
def legacy(self, key: str, source_slug: str) -> Optional[Token]:
|
||||||
|
"""Legacy HTTP-Basic auth for testing"""
|
||||||
|
_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) -> Optional[Token]:
|
||||||
|
"""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
|
||||||
|
if not token.scimsource_set.exists():
|
||||||
|
return None
|
||||||
|
if token.scimsource_set.first().slug != source_slug:
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
def authenticate(self, request: Request) -> Union[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)
|
||||||
|
return (token.user, token)
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""SCIM Utils"""
|
||||||
|
from rest_framework.parsers import JSONParser
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.renderers import JSONRenderer
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
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"""
|
||||||
|
|
||||||
|
authentication_classes = [SCIMTokenAuth]
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [SCIMParser]
|
||||||
|
renderer_classes = [SCIMRenderer]
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""SCIM Group Views"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.http import Http404, QueryDict
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.core.models import Group
|
||||||
|
from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE, SCIMView
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class GroupsView(SCIMView):
|
||||||
|
"""SCIM Group View"""
|
||||||
|
|
||||||
|
def group_to_scim(self, group: Group) -> dict:
|
||||||
|
"""Convert group to SCIM"""
|
||||||
|
return {
|
||||||
|
"id": str(group.pk),
|
||||||
|
"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(group.pk),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"displayName": group.name,
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(self, request: Request, group_id: Optional[str] = None, **kwargs) -> Response:
|
||||||
|
"""List Group handler"""
|
||||||
|
if group_id:
|
||||||
|
group = Group.objects.filter(pk=group_id).first()
|
||||||
|
if not group:
|
||||||
|
raise Http404
|
||||||
|
return Response(self.group_to_scim(group))
|
||||||
|
groups = Group.objects.all().order_by("pk")
|
||||||
|
per_page = 50
|
||||||
|
paginator = Paginator(groups, per_page=per_page)
|
||||||
|
start_index = int(request.query_params.get("startIndex", 1))
|
||||||
|
page = paginator.page(int(max(start_index / per_page, 1)))
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"totalResults": paginator.count,
|
||||||
|
"itemsPerPage": per_page,
|
||||||
|
"startIndex": page.start_index(),
|
||||||
|
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
"Resources": [self.group_to_scim(group) for group in page],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_group(self, group: Group, data: QueryDict) -> Group:
|
||||||
|
"""Partial update a group"""
|
||||||
|
if "displayName" in data:
|
||||||
|
group.name = data.get("displayName")
|
||||||
|
return group
|
||||||
|
|
||||||
|
def post(self, request: Request, **kwargs) -> Response:
|
||||||
|
"""Create group handler"""
|
||||||
|
group = Group.objects.filter(name=request.data.get("displayName")).first()
|
||||||
|
if group:
|
||||||
|
LOGGER.debug("Found existing group")
|
||||||
|
return Response(status=409)
|
||||||
|
group = self.update_group(Group(), request.data)
|
||||||
|
group.save()
|
||||||
|
return Response(self.group_to_scim(group), status=201)
|
||||||
|
|
||||||
|
def patch(self, request: Request, group_id: str, **kwargs) -> Response:
|
||||||
|
"""Update group handler"""
|
||||||
|
return self.put(request, group_id, **kwargs)
|
||||||
|
|
||||||
|
def put(self, request: Request, group_id: str, **kwargs) -> Response:
|
||||||
|
"""Update group handler"""
|
||||||
|
group: Optional[Group] = Group.objects.filter(pk=group_id).first()
|
||||||
|
if not group:
|
||||||
|
raise Http404
|
||||||
|
self.update_group(group, request.data)
|
||||||
|
group.save()
|
||||||
|
return Response(self.group_to_scim(group), status=200)
|
||||||
|
|
||||||
|
def delete(self, request: Request, group_id: str, **kwargs) -> Response:
|
||||||
|
"""Delete group handler"""
|
||||||
|
group: Optional[Group] = Group.objects.filter(pk=group_id).first()
|
||||||
|
if not group:
|
||||||
|
raise Http404
|
||||||
|
group.delete()
|
||||||
|
return Response(
|
||||||
|
{},
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"Content-Type": SCIM_CONTENT_TYPE,
|
||||||
|
},
|
||||||
|
)
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""SCIM Meta views"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
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: Optional[str] = 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,
|
||||||
|
}
|
||||||
|
)
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""Schema Views"""
|
||||||
|
from json import loads
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
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", "r", 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: Optional[str] = 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,
|
||||||
|
}
|
||||||
|
)
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""SCIM Meta views"""
|
||||||
|
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"""
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
||||||
|
"authenticationSchemes": [
|
||||||
|
{
|
||||||
|
"type": "oauthbearertoken",
|
||||||
|
"name": "OAuth Bearer Token",
|
||||||
|
"description": (
|
||||||
|
"Authentication scheme using the OAuth Bearer Token Standard"
|
||||||
|
),
|
||||||
|
"specUri": "https://www.rfc-editor.org/info/rfc6750",
|
||||||
|
"primary": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"patch": {"supported": True},
|
||||||
|
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
|
||||||
|
"filter": {"supported": False, "maxResults": 200},
|
||||||
|
"changePassword": {"supported": True},
|
||||||
|
"sort": {"supported": False},
|
||||||
|
"etag": {"supported": False},
|
||||||
|
}
|
||||||
|
)
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""SCIM User Views"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.http import Http404, QueryDict
|
||||||
|
from django.urls import reverse
|
||||||
|
from guardian.shortcuts import get_anonymous_user
|
||||||
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.core.models import User
|
||||||
|
from authentik.sources.scim.models import (
|
||||||
|
USER_ATTRIBUTE_SCIM_ADDRESS,
|
||||||
|
USER_ATTRIBUTE_SCIM_ENTERPRISE,
|
||||||
|
USER_ATTRIBUTE_SCIM_ID,
|
||||||
|
)
|
||||||
|
from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE, SCIMView
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class UsersView(SCIMView):
|
||||||
|
"""SCIM User view"""
|
||||||
|
|
||||||
|
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")
|
||||||
|
return data[0].get("value")
|
||||||
|
|
||||||
|
def user_to_scim(self, user: User) -> dict:
|
||||||
|
"""Convert User to SCIM data"""
|
||||||
|
payload = {
|
||||||
|
"id": str(user.pk),
|
||||||
|
"meta": {
|
||||||
|
"resourceType": "User",
|
||||||
|
"created": user.date_joined,
|
||||||
|
# TODO: use events to find last edit?
|
||||||
|
"lastModified": 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(user.pk),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": user.attributes.get(
|
||||||
|
USER_ATTRIBUTE_SCIM_ENTERPRISE, {}
|
||||||
|
),
|
||||||
|
"schemas": [
|
||||||
|
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||||
|
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
|
||||||
|
],
|
||||||
|
"userName": user.username,
|
||||||
|
"name": {},
|
||||||
|
"displayName": user.name,
|
||||||
|
"active": user.is_active,
|
||||||
|
"emails": [{"value": user.email, "type": "work", "primary": True}],
|
||||||
|
}
|
||||||
|
if USER_ATTRIBUTE_SCIM_ID in user.attributes:
|
||||||
|
payload["externalId"] = user.attributes[USER_ATTRIBUTE_SCIM_ID]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def get(self, request: Request, user_id: Optional[str] = None, **kwargs) -> Response:
|
||||||
|
"""List User handler"""
|
||||||
|
if user_id:
|
||||||
|
user = User.objects.filter(pk=user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise Http404
|
||||||
|
return Response(self.user_to_scim(user))
|
||||||
|
users = User.objects.all().exclude(pk=get_anonymous_user().pk).order_by("pk")
|
||||||
|
per_page = 50
|
||||||
|
paginator = Paginator(users, per_page=per_page)
|
||||||
|
start_index = int(request.query_params.get("startIndex", 1))
|
||||||
|
page = paginator.page(int(max(start_index / per_page, 1)))
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"totalResults": paginator.count,
|
||||||
|
"itemsPerPage": per_page,
|
||||||
|
"startIndex": page.start_index(),
|
||||||
|
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
"Resources": [self.user_to_scim(user) for user in page],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_user(self, user: User, data: QueryDict) -> User:
|
||||||
|
"""Partial update a user"""
|
||||||
|
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 "externalId" in data:
|
||||||
|
user.attributes[USER_ATTRIBUTE_SCIM_ID] = data.get("externalId")
|
||||||
|
if "addresses" in data:
|
||||||
|
user.attributes[USER_ATTRIBUTE_SCIM_ADDRESS] = data.get("addresses")
|
||||||
|
if "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" in data:
|
||||||
|
user.attributes[USER_ATTRIBUTE_SCIM_ENTERPRISE] = data.get(
|
||||||
|
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def post(self, request: Request, **kwargs) -> Response:
|
||||||
|
"""Create user handler"""
|
||||||
|
user = User.objects.filter(
|
||||||
|
**{
|
||||||
|
f"attributes__{USER_ATTRIBUTE_SCIM_ID}": request.data.get("externalId"),
|
||||||
|
"username": request.data.get("userName"),
|
||||||
|
}
|
||||||
|
).first()
|
||||||
|
if user:
|
||||||
|
LOGGER.debug("Found existing user")
|
||||||
|
return Response(status=409)
|
||||||
|
user = self.update_user(User(), request.data)
|
||||||
|
user.save()
|
||||||
|
return Response(self.user_to_scim(user), status=201)
|
||||||
|
|
||||||
|
def patch(self, request: Request, user_id: str, **kwargs) -> Response:
|
||||||
|
"""Update user handler"""
|
||||||
|
return self.put(request, user_id, **kwargs)
|
||||||
|
|
||||||
|
def put(self, request: Request, user_id: str, **kwargs) -> Response:
|
||||||
|
"""Update user handler"""
|
||||||
|
user: Optional[User] = User.objects.filter(pk=user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise Http404
|
||||||
|
self.update_user(user, request.data)
|
||||||
|
user.save()
|
||||||
|
return Response(self.user_to_scim(user), status=200)
|
||||||
|
|
||||||
|
def delete(self, request: Request, user_id: str, **kwargs) -> Response:
|
||||||
|
"""Delete user handler"""
|
||||||
|
user: Optional[User] = User.objects.filter(pk=user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise Http404
|
||||||
|
user.delete()
|
||||||
|
return Response(
|
||||||
|
{},
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"Content-Type": SCIM_CONTENT_TYPE,
|
||||||
|
},
|
||||||
|
)
|
|
@ -51,6 +51,10 @@ export class SourceViewPage extends AKElement {
|
||||||
return html`<ak-source-plex-view
|
return html`<ak-source-plex-view
|
||||||
sourceSlug=${this.source.slug}
|
sourceSlug=${this.source.slug}
|
||||||
></ak-source-plex-view>`;
|
></ak-source-plex-view>`;
|
||||||
|
case "ak-source-scim-form":
|
||||||
|
return html`<ak-source-scim-view
|
||||||
|
sourceSlug=${this.source.slug}
|
||||||
|
></ak-source-scim-view>`;
|
||||||
default:
|
default:
|
||||||
return html`<p>Invalid source type ${this.source.component}</p>`;
|
return html`<p>Invalid source type ${this.source.component}</p>`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { t } from "@lingui/macro";
|
||||||
|
|
||||||
|
import { TemplateResult, html } from "lit";
|
||||||
|
import { customElement } from "lit/decorators.js";
|
||||||
|
import { ifDefined } from "lit/directives/if-defined.js";
|
||||||
|
|
||||||
|
import { SCIMSource, SCIMSourceRequest, SourcesApi } from "@goauthentik/api";
|
||||||
|
|
||||||
|
import { DEFAULT_CONFIG } from "../../../api/Config";
|
||||||
|
import "../../../elements/CodeMirror";
|
||||||
|
import "../../../elements/forms/FormGroup";
|
||||||
|
import "../../../elements/forms/HorizontalFormElement";
|
||||||
|
import { ModelForm } from "../../../elements/forms/ModelForm";
|
||||||
|
import { first } from "../../../utils";
|
||||||
|
|
||||||
|
@customElement("ak-source-scim-form")
|
||||||
|
export class SCIMSourceForm extends ModelForm<SCIMSource, string> {
|
||||||
|
loadInstance(pk: string): Promise<SCIMSource> {
|
||||||
|
return new SourcesApi(DEFAULT_CONFIG)
|
||||||
|
.sourcesScimRetrieve({
|
||||||
|
slug: pk,
|
||||||
|
})
|
||||||
|
.then((source) => {
|
||||||
|
return source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getSuccessMessage(): string {
|
||||||
|
if (this.instance) {
|
||||||
|
return t`Successfully updated source.`;
|
||||||
|
} else {
|
||||||
|
return t`Successfully created source.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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=${t`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=${t`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"> ${t`Enabled`} </label>
|
||||||
|
</div>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
</form>`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { t } from "@lingui/macro";
|
||||||
|
|
||||||
|
import { CSSResult, LitElement, TemplateResult, html } from "lit";
|
||||||
|
import { customElement, property } from "lit/decorators.js";
|
||||||
|
|
||||||
|
import AKGlobal from "../../../authentik.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 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 { SCIMSource, SourcesApi } from "@goauthentik/api";
|
||||||
|
|
||||||
|
import { DEFAULT_CONFIG } from "../../../api/Config";
|
||||||
|
import { EVENT_REFRESH } from "../../../constants";
|
||||||
|
import "../../../elements/CodeMirror";
|
||||||
|
import "../../../elements/Tabs";
|
||||||
|
import "../../../elements/buttons/SpinnerButton";
|
||||||
|
import "../../../elements/events/ObjectChangelog";
|
||||||
|
import "../../../elements/forms/ModalForm";
|
||||||
|
import "../../policies/BoundPoliciesList";
|
||||||
|
import "./SCIMSourceForm";
|
||||||
|
|
||||||
|
@customElement("ak-source-scim-view")
|
||||||
|
export class SCIMSourceViewPage extends LitElement {
|
||||||
|
@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, PFGrid, PFContent, PFCard, PFDescriptionList, AKGlobal];
|
||||||
|
}
|
||||||
|
|
||||||
|
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="${t`Overview`}"
|
||||||
|
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">
|
||||||
|
<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">${t`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"
|
||||||
|
>${t`Callback URL`}</span
|
||||||
|
>
|
||||||
|
</dt>
|
||||||
|
<dd class="pf-c-description-list__description">
|
||||||
|
<code class="pf-c-description-list__text"></code>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="pf-c-card__footer">
|
||||||
|
<ak-forms-modal>
|
||||||
|
<span slot="submit"> ${t`Update`} </span>
|
||||||
|
<span slot="header"> ${t`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">
|
||||||
|
${t`Edit`}
|
||||||
|
</button>
|
||||||
|
</ak-forms-modal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
slot="page-changelog"
|
||||||
|
data-tab-title="${t`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>
|
||||||
|
</ak-tabs>`;
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue