providers: SCIM (#4835)
* basic user sync Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add group sync and some refactor Signed-off-by: Jens Langhammer <jens@goauthentik.io> * start API Signed-off-by: Jens Langhammer <jens@goauthentik.io> * allow null authorization flow Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add UI Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make task monitored Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add missing dependency Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make authorization_flow required for most providers via API Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more UI Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make task result better readable, exclude anonymous user Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add task UI Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add scheduled task for all sync Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make scim errors more readable Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add mappings, migrate to mappings Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add mapping UI and more Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add scim docs to web Signed-off-by: Jens Langhammer <jens@goauthentik.io> * start implementing membership Signed-off-by: Jens Langhammer <jens@goauthentik.io> * migrate signals to tasks Signed-off-by: Jens Langhammer <jens@goauthentik.io> * migrate fully to tasks Signed-off-by: Jens Langhammer <jens@goauthentik.io> * strip none keys, fix lint errors Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix things Signed-off-by: Jens Langhammer <jens@goauthentik.io> * start adding tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix saml Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add scim schemas and validate against it Signed-off-by: Jens Langhammer <jens@goauthentik.io> * improve error handling Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add group put support, add group tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * send correct application/scim+json headers Signed-off-by: Jens Langhammer <jens@goauthentik.io> * stop sync if no mappings are confiugred Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add test for task sync Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add membership tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * use decorator for tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make tests better Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
dbc07f55f4
commit
28ddeb124f
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -16,7 +16,8 @@
|
|||
"passwordless",
|
||||
"kubernetes",
|
||||
"sso",
|
||||
"slo"
|
||||
"slo",
|
||||
"scim",
|
||||
],
|
||||
"python.linting.pylintEnabled": true,
|
||||
"todo-tree.tree.showCountsInTree": true,
|
||||
|
|
|
@ -96,7 +96,7 @@ RUN apt-get update && \
|
|||
|
||||
COPY ./authentik/ /authentik
|
||||
COPY ./pyproject.toml /
|
||||
COPY ./xml /xml
|
||||
COPY ./schemas /schemas
|
||||
COPY ./locale /locale
|
||||
COPY ./tests /tests
|
||||
COPY ./manage.py /
|
||||
|
|
|
@ -58,6 +58,8 @@ from authentik.providers.oauth2.api.tokens import (
|
|||
from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet
|
||||
from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet
|
||||
from authentik.providers.saml.api.providers import SAMLProviderViewSet
|
||||
from authentik.providers.scim.api.property_mapping import SCIMMappingViewSet
|
||||
from authentik.providers.scim.api.providers import SCIMProviderViewSet
|
||||
from authentik.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
|
||||
from authentik.sources.oauth.api.source import OAuthSourceViewSet
|
||||
from authentik.sources.oauth.api.source_connection import UserOAuthSourceConnectionViewSet
|
||||
|
@ -163,6 +165,7 @@ router.register("providers/ldap", LDAPProviderViewSet)
|
|||
router.register("providers/proxy", ProxyProviderViewSet)
|
||||
router.register("providers/oauth2", OAuth2ProviderViewSet)
|
||||
router.register("providers/saml", SAMLProviderViewSet)
|
||||
router.register("providers/scim", SCIMProviderViewSet)
|
||||
|
||||
router.register("oauth2/authorization_codes", AuthorizationCodeViewSet)
|
||||
router.register("oauth2/refresh_tokens", RefreshTokenViewSet)
|
||||
|
@ -173,6 +176,7 @@ router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
|
|||
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
|
||||
router.register("propertymappings/scope", ScopeMappingViewSet)
|
||||
router.register("propertymappings/notification", NotificationWebhookMappingViewSet)
|
||||
router.register("propertymappings/scim", SCIMMappingViewSet)
|
||||
|
||||
router.register("authenticators/all", DeviceViewSet, basename="device")
|
||||
router.register("authenticators/duo", DuoDeviceViewSet)
|
||||
|
|
|
@ -44,6 +44,9 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
|||
"verbose_name_plural",
|
||||
"meta_model_name",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"authorization_flow": {"required": True, "allow_null": False},
|
||||
}
|
||||
|
||||
|
||||
class ProviderViewSet(
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 4.1.7 on 2023-03-02 21:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_flows", "0025_alter_flowstagebinding_evaluate_on_plan_and_more"),
|
||||
("authentik_core", "0024_source_icon"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="provider",
|
||||
name="authorization_flow",
|
||||
field=models.ForeignKey(
|
||||
help_text="Flow used when authorizing this provider.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="provider_authorization",
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -248,6 +248,7 @@ class Provider(SerializerModel):
|
|||
authorization_flow = models.ForeignKey(
|
||||
"authentik_flows.Flow",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
help_text=_("Flow used when authorizing this provider."),
|
||||
related_name="provider_authorization",
|
||||
)
|
||||
|
@ -630,7 +631,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
|
|||
try:
|
||||
return evaluator.evaluate(self.expression)
|
||||
except Exception as exc:
|
||||
raise PropertyMappingExpressionException(str(exc)) from exc
|
||||
raise PropertyMappingExpressionException(exc) from exc
|
||||
|
||||
def __str__(self):
|
||||
return f"Property Mapping {self.name}"
|
||||
|
|
|
@ -29,6 +29,7 @@ from authentik.lib.utils.errors import exception_to_string
|
|||
from authentik.outposts.models import OutpostServiceConnection
|
||||
from authentik.policies.models import Policy, PolicyBindingModel
|
||||
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
||||
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
||||
|
||||
IGNORED_MODELS = (
|
||||
Event,
|
||||
|
@ -49,6 +50,8 @@ IGNORED_MODELS = (
|
|||
AuthorizationCode,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
SCIMUser,
|
||||
SCIMGroup,
|
||||
)
|
||||
|
||||
|
||||
|
@ -188,7 +191,7 @@ class AuditMiddleware:
|
|||
user: User, request: HttpRequest, sender, instance: Model, action: str, **_
|
||||
):
|
||||
"""Signal handler for all object's m2m_changed"""
|
||||
if action not in ["pre_add", "pre_remove"]:
|
||||
if action not in ["pre_add", "pre_remove", "post_clear"]:
|
||||
return
|
||||
if not should_log_m2m(instance):
|
||||
return
|
||||
|
|
|
@ -16,7 +16,6 @@ from authentik.outposts.controllers.k8s.triggers import NeedsRecreate, NeedsUpda
|
|||
if TYPE_CHECKING:
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
T = TypeVar("T", V1Pod, V1Deployment)
|
||||
|
||||
|
||||
|
@ -56,6 +55,7 @@ class KubernetesObjectReconciler(Generic[T]):
|
|||
}
|
||||
).lower()
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def up(self):
|
||||
"""Create object if it doesn't exist, update if needed or recreate if needed."""
|
||||
current = None
|
||||
|
|
|
@ -73,9 +73,9 @@ class AssertionProcessor:
|
|||
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
|
||||
attribute_statement = Element(f"{{{NS_SAML_ASSERTION}}}AttributeStatement")
|
||||
user = self.http_request.user
|
||||
for mapping in self.provider.property_mappings.all().select_subclasses():
|
||||
if not isinstance(mapping, SAMLPropertyMapping):
|
||||
continue
|
||||
for mapping in SAMLPropertyMapping.objects.filter(provider=self.provider).order_by(
|
||||
"saml_name"
|
||||
):
|
||||
try:
|
||||
mapping: SAMLPropertyMapping
|
||||
value = mapping.evaluate(
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
"""saml provider settings"""
|
||||
|
||||
AUTHENTIK_PROVIDERS_SAML_PROCESSORS = [
|
||||
"authentik.providers.saml.processors.generic",
|
||||
"authentik.providers.saml.processors.salesforce",
|
||||
]
|
|
@ -59,7 +59,7 @@ class TestServiceProviderMetadataParser(TestCase):
|
|||
request = self.factory.get("/")
|
||||
metadata = lxml_from_string(MetadataProcessor(provider, request).build_entity_descriptor())
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("xml/saml-schema-metadata-2.0.xsd")) # nosec
|
||||
schema = etree.XMLSchema(etree.parse("schemas/saml-schema-metadata-2.0.xsd")) # nosec
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
||||
def test_simple(self):
|
||||
|
|
|
@ -46,7 +46,7 @@ class TestSchema(TestCase):
|
|||
|
||||
metadata = lxml_from_string(request)
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("xml/saml-schema-protocol-2.0.xsd")) # nosec
|
||||
schema = etree.XMLSchema(etree.parse("schemas/saml-schema-protocol-2.0.xsd")) # nosec
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
||||
def test_response_schema(self):
|
||||
|
@ -67,5 +67,5 @@ class TestSchema(TestCase):
|
|||
|
||||
metadata = lxml_from_string(response)
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("xml/saml-schema-protocol-2.0.xsd"))
|
||||
schema = etree.XMLSchema(etree.parse("schemas/saml-schema-protocol-2.0.xsd"))
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
|
0
authentik/providers/scim/__init__.py
Normal file
0
authentik/providers/scim/__init__.py
Normal file
0
authentik/providers/scim/api/__init__.py
Normal file
0
authentik/providers/scim/api/__init__.py
Normal file
38
authentik/providers/scim/api/property_mapping.py
Normal file
38
authentik/providers/scim/api/property_mapping.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
"""scim Property mappings API Views"""
|
||||
from django_filters.filters import AllValuesMultipleFilter
|
||||
from django_filters.filterset import FilterSet
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.propertymappings import PropertyMappingSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.providers.scim.models import SCIMMapping
|
||||
|
||||
|
||||
class SCIMMappingSerializer(PropertyMappingSerializer):
|
||||
"""SCIMMapping Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = SCIMMapping
|
||||
fields = PropertyMappingSerializer.Meta.fields
|
||||
|
||||
|
||||
class SCIMMappingFilter(FilterSet):
|
||||
"""Filter for SCIMMapping"""
|
||||
|
||||
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
|
||||
|
||||
class Meta:
|
||||
model = SCIMMapping
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class SCIMMappingViewSet(UsedByMixin, ModelViewSet):
|
||||
"""SCIMMapping Viewset"""
|
||||
|
||||
queryset = SCIMMapping.objects.all()
|
||||
serializer_class = SCIMMappingSerializer
|
||||
filterset_class = SCIMMappingFilter
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
60
authentik/providers/scim/api/providers.py
Normal file
60
authentik/providers/scim/api/providers.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
"""SCIM Provider API Views"""
|
||||
from django.utils.text import slugify
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.admin.api.tasks import TaskSerializer
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.events.monitored_tasks import TaskInfo
|
||||
from authentik.providers.scim.models import SCIMProvider
|
||||
|
||||
|
||||
class SCIMProviderSerializer(ProviderSerializer):
|
||||
"""SCIMProvider Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = SCIMProvider
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"property_mappings",
|
||||
"property_mappings_group",
|
||||
"component",
|
||||
"assigned_application_slug",
|
||||
"assigned_application_name",
|
||||
"verbose_name",
|
||||
"verbose_name_plural",
|
||||
"meta_model_name",
|
||||
"url",
|
||||
"token",
|
||||
]
|
||||
extra_kwargs = {}
|
||||
|
||||
|
||||
class SCIMProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"""SCIMProvider Viewset"""
|
||||
|
||||
queryset = SCIMProvider.objects.all()
|
||||
serializer_class = SCIMProviderSerializer
|
||||
filterset_fields = ["name", "authorization_flow", "url", "token"]
|
||||
search_fields = ["name", "url"]
|
||||
ordering = ["name", "url"]
|
||||
|
||||
@extend_schema(
|
||||
responses={
|
||||
200: TaskSerializer(),
|
||||
404: OpenApiResponse(description="Task not found"),
|
||||
}
|
||||
)
|
||||
@action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[])
|
||||
def sync_status(self, request: Request, pk: int) -> Response:
|
||||
"""Get provider's sync status"""
|
||||
provider = self.get_object()
|
||||
task = TaskInfo.by_name(f"scim_sync:{slugify(provider.name)}")
|
||||
if not task:
|
||||
return Response(status=404)
|
||||
return Response(TaskSerializer(task).data)
|
15
authentik/providers/scim/apps.py
Normal file
15
authentik/providers/scim/apps.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
"""authentik SCIM Provider app config"""
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
|
||||
|
||||
class AuthentikProviderSCIMConfig(ManagedAppConfig):
|
||||
"""authentik SCIM Provider app config"""
|
||||
|
||||
name = "authentik.providers.scim"
|
||||
label = "authentik_providers_scim"
|
||||
verbose_name = "authentik Providers.SCIM"
|
||||
default = True
|
||||
|
||||
def reconcile_load_signals(self):
|
||||
"""Load signals"""
|
||||
self.import_module("authentik.providers.scim.signals")
|
2
authentik/providers/scim/clients/__init__.py
Normal file
2
authentik/providers/scim/clients/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
"""SCIM constants"""
|
||||
PAGE_SIZE = 100
|
105
authentik/providers/scim/clients/base.py
Normal file
105
authentik/providers/scim/clients/base.py
Normal file
|
@ -0,0 +1,105 @@
|
|||
"""SCIM Client"""
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.service_provider import (
|
||||
Bulk,
|
||||
ChangePassword,
|
||||
Filter,
|
||||
Patch,
|
||||
ServiceProviderConfiguration,
|
||||
Sort,
|
||||
)
|
||||
from requests import RequestException, Session
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.providers.scim.clients.exceptions import SCIMRequestException
|
||||
from authentik.providers.scim.models import SCIMProvider
|
||||
|
||||
T = TypeVar("T")
|
||||
# pylint: disable=invalid-name
|
||||
SchemaType = TypeVar("SchemaType")
|
||||
|
||||
|
||||
def default_service_provider_config() -> ServiceProviderConfiguration:
|
||||
"""Fallback service provider configuration"""
|
||||
return ServiceProviderConfiguration(
|
||||
patch=Patch(supported=False),
|
||||
bulk=Bulk(supported=False),
|
||||
filter=Filter(supported=False),
|
||||
changePassword=ChangePassword(supported=False),
|
||||
sort=Sort(supported=False),
|
||||
authenticationSchemes=[],
|
||||
)
|
||||
|
||||
|
||||
class SCIMClient(Generic[T, SchemaType]):
|
||||
"""SCIM Client"""
|
||||
|
||||
base_url: str
|
||||
token: str
|
||||
provider: SCIMProvider
|
||||
|
||||
_session: Session
|
||||
_config: ServiceProviderConfiguration
|
||||
|
||||
def __init__(self, provider: SCIMProvider):
|
||||
self._session = get_http_session()
|
||||
self.provider = provider
|
||||
# Remove trailing slashes as we assume the URL doesn't have any
|
||||
base_url = provider.url
|
||||
if base_url.endswith("/"):
|
||||
base_url = base_url[:-1]
|
||||
self.base_url = base_url
|
||||
self.token = provider.token
|
||||
self.logger = get_logger().bind(provider=provider.name)
|
||||
self._config = self.get_service_provider_config()
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs) -> dict:
|
||||
"""Wrapper to send a request to the full URL"""
|
||||
try:
|
||||
response = self._session.request(
|
||||
method,
|
||||
f"{self.base_url}{path}",
|
||||
**kwargs,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/scim+json",
|
||||
"Content-Type": "application/scim+json",
|
||||
},
|
||||
)
|
||||
except RequestException as exc:
|
||||
raise SCIMRequestException(None) from exc
|
||||
self.logger.debug("scim request", path=path, method=method, **kwargs)
|
||||
if response.status_code >= 400:
|
||||
self.logger.warning(
|
||||
"Failed to send SCIM request", path=path, method=method, response=response.text
|
||||
)
|
||||
raise SCIMRequestException(response)
|
||||
if response.status_code == 204:
|
||||
return {}
|
||||
return response.json()
|
||||
|
||||
def get_service_provider_config(self):
|
||||
"""Get Service provider config"""
|
||||
default_config = default_service_provider_config()
|
||||
try:
|
||||
return ServiceProviderConfiguration.parse_obj(
|
||||
self._request("GET", "/ServiceProviderConfig")
|
||||
)
|
||||
except ValidationError as exc:
|
||||
self.logger.warning("ServiceProviderConfig invalid", exc=exc)
|
||||
return default_config
|
||||
|
||||
def write(self, obj: T):
|
||||
"""Write object to SCIM"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def delete(self, obj: T):
|
||||
"""Delete object from SCIM"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def to_scim(self, obj: T) -> SchemaType:
|
||||
"""Convert object to scim"""
|
||||
raise NotImplementedError()
|
43
authentik/providers/scim/clients/exceptions.py
Normal file
43
authentik/providers/scim/clients/exceptions.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
"""SCIM Client exceptions"""
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.responses import SCIMError
|
||||
from requests import Response
|
||||
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
|
||||
|
||||
class StopSync(SentryIgnoredException):
|
||||
"""Exception raised when a configuration error should stop the sync process"""
|
||||
|
||||
def __init__(self, exc: Exception, obj: object, mapping: Optional[object] = None) -> None:
|
||||
self.exc = exc
|
||||
self.obj = obj
|
||||
self.mapping = mapping
|
||||
|
||||
def __str__(self) -> str:
|
||||
msg = f"Error {str(self.exc)}, caused by {self.obj}"
|
||||
|
||||
if self.mapping:
|
||||
msg += f" (mapping {self.mapping})"
|
||||
return msg
|
||||
|
||||
|
||||
class SCIMRequestException(SentryIgnoredException):
|
||||
"""Exception raised when an SCIM request fails"""
|
||||
|
||||
_response: Optional[Response]
|
||||
|
||||
def __init__(self, response: Optional[Response] = None) -> None:
|
||||
self._response = response
|
||||
|
||||
def __str__(self) -> str:
|
||||
if not self._response:
|
||||
return super().__str__()
|
||||
try:
|
||||
error = SCIMError.parse_raw(self._response.text)
|
||||
return error.detail
|
||||
except ValidationError:
|
||||
pass
|
||||
return super().__str__()
|
166
authentik/providers/scim/clients/group.py
Normal file
166
authentik/providers/scim/clients/group.py
Normal file
|
@ -0,0 +1,166 @@
|
|||
"""Group client"""
|
||||
from deepmerge import always_merger
|
||||
from pydantic import ValidationError
|
||||
from pydanticscim.group import GroupMember
|
||||
from pydanticscim.responses import PatchOp, PatchOperation, PatchRequest
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import Group
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.policies.utils import delete_none_keys
|
||||
from authentik.providers.scim.clients.base import SCIMClient
|
||||
from authentik.providers.scim.clients.exceptions import StopSync
|
||||
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
|
||||
from authentik.providers.scim.models import SCIMGroup, SCIMMapping, SCIMUser
|
||||
|
||||
|
||||
class SCIMGroupClient(SCIMClient[Group, SCIMGroupSchema]):
|
||||
"""SCIM client for groups"""
|
||||
|
||||
def write(self, obj: Group):
|
||||
"""Write a group"""
|
||||
scim_group = SCIMGroup.objects.filter(provider=self.provider, group=obj).first()
|
||||
if not scim_group:
|
||||
return self._create(obj)
|
||||
scim_group = self.to_scim(obj)
|
||||
scim_group.id = scim_group.id
|
||||
return self._request(
|
||||
"PUT",
|
||||
f"/Groups/{scim_group.id}",
|
||||
data=scim_group.json(
|
||||
exclude_unset=True,
|
||||
),
|
||||
)
|
||||
|
||||
def delete(self, obj: Group):
|
||||
"""Delete group"""
|
||||
scim_group = SCIMGroup.objects.filter(provider=self.provider, group=obj).first()
|
||||
if not scim_group:
|
||||
self.logger.debug("Group does not exist in SCIM, skipping")
|
||||
return None
|
||||
response = self._request("DELETE", f"/Groups/{scim_group.id}")
|
||||
scim_group.delete()
|
||||
return response
|
||||
|
||||
def to_scim(self, obj: Group) -> SCIMGroupSchema:
|
||||
"""Convert authentik user into SCIM"""
|
||||
raw_scim_group = {}
|
||||
for mapping in (
|
||||
self.provider.property_mappings_group.all().order_by("name").select_subclasses()
|
||||
):
|
||||
if not isinstance(mapping, SCIMMapping):
|
||||
continue
|
||||
try:
|
||||
mapping: SCIMMapping
|
||||
value = mapping.evaluate(
|
||||
user=None,
|
||||
request=None,
|
||||
group=obj,
|
||||
provider=self.provider,
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
always_merger.merge(raw_scim_group, value)
|
||||
except (PropertyMappingExpressionException, ValueError) as exc:
|
||||
# Value error can be raised when assigning invalid data to an attribute
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=f"Failed to evaluate property-mapping {exception_to_string(exc)}",
|
||||
mapping=mapping,
|
||||
).save()
|
||||
raise StopSync(exc, obj, mapping) from exc
|
||||
if not raw_scim_group:
|
||||
raise StopSync(ValueError("No group mappings configured"), obj)
|
||||
try:
|
||||
scim_group = SCIMGroupSchema.parse_obj(delete_none_keys(raw_scim_group))
|
||||
except ValidationError as exc:
|
||||
raise StopSync(exc, obj) from exc
|
||||
scim_group.externalId = str(obj.pk)
|
||||
|
||||
users = list(obj.users.order_by("id").values_list("id", flat=True))
|
||||
connections = SCIMUser.objects.filter(provider=self.provider, user__pk__in=users)
|
||||
for user in connections:
|
||||
scim_group.members.append(
|
||||
GroupMember(
|
||||
value=user.id,
|
||||
)
|
||||
)
|
||||
return scim_group
|
||||
|
||||
def _create(self, group: Group):
|
||||
"""Create group from scratch and create a connection object"""
|
||||
scim_group = self.to_scim(group)
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/Groups",
|
||||
data=scim_group.json(
|
||||
exclude_unset=True,
|
||||
),
|
||||
)
|
||||
SCIMGroup.objects.create(provider=self.provider, group=group, id=response["id"])
|
||||
|
||||
def _patch(
|
||||
self,
|
||||
group_id: str,
|
||||
*ops: PatchOperation,
|
||||
):
|
||||
req = PatchRequest(Operations=ops)
|
||||
self._request("PATCH", f"/Groups/{group_id}", data=req.json(exclude_unset=True))
|
||||
|
||||
def update_group(self, group: Group, action: PatchOp, users_set: set[int]):
|
||||
"""Update a group, either using PUT to replace it or PATCH if supported"""
|
||||
if self._config.patch.supported:
|
||||
if action == PatchOp.add:
|
||||
return self._patch_add_users(group, users_set)
|
||||
if action == PatchOp.remove:
|
||||
return self._patch_remove_users(group, users_set)
|
||||
return self.write(group)
|
||||
|
||||
def _patch_add_users(self, group: Group, users_set: set[int]):
|
||||
"""Add users in users_set to group"""
|
||||
if len(users_set) < 1:
|
||||
return
|
||||
scim_group = SCIMGroup.objects.filter(provider=self.provider, group=group).first()
|
||||
if not scim_group:
|
||||
self.logger.warning(
|
||||
"could not sync group membership, group does not exist", group=group
|
||||
)
|
||||
return
|
||||
user_ids = list(
|
||||
SCIMUser.objects.filter(user__pk__in=users_set, provider=self.provider).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
self._patch(
|
||||
scim_group.id,
|
||||
PatchOperation(
|
||||
op=PatchOp.add,
|
||||
path="members",
|
||||
value=[{"value": x} for x in user_ids],
|
||||
),
|
||||
)
|
||||
|
||||
def _patch_remove_users(self, group: Group, users_set: set[int]):
|
||||
"""Remove users in users_set from group"""
|
||||
if len(users_set) < 1:
|
||||
return
|
||||
scim_group = SCIMGroup.objects.filter(provider=self.provider, group=group).first()
|
||||
if not scim_group:
|
||||
self.logger.warning(
|
||||
"could not sync group membership, group does not exist", group=group
|
||||
)
|
||||
return
|
||||
user_ids = list(
|
||||
SCIMUser.objects.filter(user__pk__in=users_set, provider=self.provider).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
self._patch(
|
||||
scim_group.id,
|
||||
PatchOperation(
|
||||
op=PatchOp.remove,
|
||||
path="members",
|
||||
value=[{"value": x} for x in user_ids],
|
||||
),
|
||||
)
|
17
authentik/providers/scim/clients/schema.py
Normal file
17
authentik/providers/scim/clients/schema.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
"""Custom SCIM schemas"""
|
||||
from typing import Optional
|
||||
|
||||
from pydanticscim.group import Group as SCIMGroupSchema
|
||||
from pydanticscim.user import User as SCIMUserSchema
|
||||
|
||||
|
||||
class User(SCIMUserSchema):
|
||||
"""Modified User schema with added externalId field"""
|
||||
|
||||
externalId: Optional[str] = None
|
||||
|
||||
|
||||
class Group(SCIMGroupSchema):
|
||||
"""Modified Group schema with added externalId field"""
|
||||
|
||||
externalId: Optional[str] = None
|
91
authentik/providers/scim/clients/user.py
Normal file
91
authentik/providers/scim/clients/user.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
"""User client"""
|
||||
from deepmerge import always_merger
|
||||
from pydantic import ValidationError
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.policies.utils import delete_none_keys
|
||||
from authentik.providers.scim.clients.base import SCIMClient
|
||||
from authentik.providers.scim.clients.exceptions import StopSync
|
||||
from authentik.providers.scim.clients.schema import User as SCIMUserSchema
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMUser
|
||||
|
||||
|
||||
class SCIMUserClient(SCIMClient[User, SCIMUserSchema]):
|
||||
"""SCIM client for users"""
|
||||
|
||||
def write(self, obj: User):
|
||||
"""Write a user"""
|
||||
scim_user = SCIMUser.objects.filter(provider=self.provider, user=obj).first()
|
||||
if not scim_user:
|
||||
return self._create(obj)
|
||||
return self._update(obj, scim_user)
|
||||
|
||||
def delete(self, obj: User):
|
||||
"""Delete user"""
|
||||
scim_user = SCIMUser.objects.filter(provider=self.provider, user=obj).first()
|
||||
if not scim_user:
|
||||
self.logger.debug("User does not exist in SCIM, skipping")
|
||||
return None
|
||||
response = self._request("DELETE", f"/Users/{scim_user.id}")
|
||||
scim_user.delete()
|
||||
return response
|
||||
|
||||
def to_scim(self, obj: User) -> SCIMUserSchema:
|
||||
"""Convert authentik user into SCIM"""
|
||||
raw_scim_user = {}
|
||||
for mapping in self.provider.property_mappings.all().order_by("name").select_subclasses():
|
||||
if not isinstance(mapping, SCIMMapping):
|
||||
continue
|
||||
try:
|
||||
mapping: SCIMMapping
|
||||
value = mapping.evaluate(
|
||||
user=obj,
|
||||
request=None,
|
||||
provider=self.provider,
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
always_merger.merge(raw_scim_user, value)
|
||||
except (PropertyMappingExpressionException, ValueError) as exc:
|
||||
# Value error can be raised when assigning invalid data to an attribute
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=f"Failed to evaluate property-mapping {exception_to_string(exc)}",
|
||||
mapping=mapping,
|
||||
).save()
|
||||
raise StopSync(exc, obj, mapping) from exc
|
||||
if not raw_scim_user:
|
||||
raise StopSync(ValueError("No user mappings configured"), obj)
|
||||
try:
|
||||
scim_user = SCIMUserSchema.parse_obj(delete_none_keys(raw_scim_user))
|
||||
except ValidationError as exc:
|
||||
raise StopSync(exc, obj) from exc
|
||||
scim_user.externalId = str(obj.uid)
|
||||
return scim_user
|
||||
|
||||
def _create(self, user: User):
|
||||
"""Create user from scratch and create a connection object"""
|
||||
scim_user = self.to_scim(user)
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/Users",
|
||||
data=scim_user.json(
|
||||
exclude_unset=True,
|
||||
),
|
||||
)
|
||||
SCIMUser.objects.create(provider=self.provider, user=user, id=response["id"])
|
||||
|
||||
def _update(self, user: User, connection: SCIMUser):
|
||||
"""Update existing user"""
|
||||
scim_user = self.to_scim(user)
|
||||
scim_user.id = connection.id
|
||||
self._request(
|
||||
"PUT",
|
||||
f"/Users/{connection.id}",
|
||||
data=scim_user.json(
|
||||
exclude_unset=True,
|
||||
),
|
||||
)
|
62
authentik/providers/scim/migrations/0001_initial.py
Normal file
62
authentik/providers/scim/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
# Generated by Django 4.1.7 on 2023-03-02 13:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0024_source_icon"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SCIMMapping",
|
||||
fields=[
|
||||
(
|
||||
"propertymapping_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SCIM Mapping",
|
||||
"verbose_name_plural": "SCIM Mappings",
|
||||
},
|
||||
bases=("authentik_core.propertymapping",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SCIMProvider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_core.provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"url",
|
||||
models.TextField(help_text="Base URL to SCIM requests, usually ends in /v2"),
|
||||
),
|
||||
("token", models.TextField(help_text="Authentication token")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SCIM Provider",
|
||||
"verbose_name_plural": "SCIM Providers",
|
||||
},
|
||||
bases=("authentik_core.provider",),
|
||||
),
|
||||
]
|
37
authentik/providers/scim/migrations/0002_scimuser.py
Normal file
37
authentik/providers/scim/migrations/0002_scimuser.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 4.1.7 on 2023-03-02 15:03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("authentik_providers_scim", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SCIMUser",
|
||||
fields=[
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_providers_scim.scimprovider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("id", "user", "provider")},
|
||||
},
|
||||
),
|
||||
]
|
36
authentik/providers/scim/migrations/0003_scimgroup.py
Normal file
36
authentik/providers/scim/migrations/0003_scimgroup.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 4.1.7 on 2023-03-02 15:55
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_core", "0024_source_icon"),
|
||||
("authentik_providers_scim", "0002_scimuser"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SCIMGroup",
|
||||
fields=[
|
||||
("id", models.TextField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.group"
|
||||
),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_providers_scim.scimprovider",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("id", "group", "provider")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 4.1.7 on 2023-03-03 14:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_core", "0025_alter_provider_authorization_flow"),
|
||||
("authentik_providers_scim", "0003_scimgroup"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scimprovider",
|
||||
name="property_mappings_group",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Property mappings used for group creation/updating.",
|
||||
to="authentik_core.propertymapping",
|
||||
),
|
||||
),
|
||||
]
|
0
authentik/providers/scim/migrations/__init__.py
Normal file
0
authentik/providers/scim/migrations/__init__.py
Normal file
80
authentik/providers/scim/models.py
Normal file
80
authentik/providers/scim/models.py
Normal file
|
@ -0,0 +1,80 @@
|
|||
"""SCIM Provider models"""
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
from authentik.core.models import Group, PropertyMapping, Provider, User
|
||||
|
||||
|
||||
class SCIMProvider(Provider):
|
||||
"""SCIM 2.0 provider to create users and groups in external applications"""
|
||||
|
||||
url = models.TextField(help_text=_("Base URL to SCIM requests, usually ends in /v2"))
|
||||
token = models.TextField(help_text=_("Authentication token"))
|
||||
|
||||
property_mappings_group = models.ManyToManyField(
|
||||
PropertyMapping,
|
||||
default=None,
|
||||
blank=True,
|
||||
help_text=_("Property mappings used for group creation/updating."),
|
||||
)
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
return "ak-provider-scim-form"
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.providers.scim.api.providers import SCIMProviderSerializer
|
||||
|
||||
return SCIMProviderSerializer
|
||||
|
||||
def __str__(self):
|
||||
return f"SCIM Provider {self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("SCIM Provider")
|
||||
verbose_name_plural = _("SCIM Providers")
|
||||
|
||||
|
||||
class SCIMMapping(PropertyMapping):
|
||||
"""Map authentik data to outgoing SCIM requests"""
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
return "ak-property-mapping-scim-form"
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.providers.scim.api.property_mapping import SCIMMappingSerializer
|
||||
|
||||
return SCIMMappingSerializer
|
||||
|
||||
def __str__(self):
|
||||
return f"SCIM Mapping {self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("SCIM Mapping")
|
||||
verbose_name_plural = _("SCIM Mappings")
|
||||
|
||||
|
||||
class SCIMUser(models.Model):
|
||||
"""Mapping of a user and provider to a SCIM user ID"""
|
||||
|
||||
id = models.TextField(primary_key=True)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
provider = models.ForeignKey(SCIMProvider, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("id", "user", "provider"),)
|
||||
|
||||
|
||||
class SCIMGroup(models.Model):
|
||||
"""Mapping of a group and provider to a SCIM user ID"""
|
||||
|
||||
id = models.TextField(primary_key=True)
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
provider = models.ForeignKey(SCIMProvider, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("id", "group", "provider"),)
|
12
authentik/providers/scim/settings.py
Normal file
12
authentik/providers/scim/settings.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
"""SCIM task Settings"""
|
||||
from celery.schedules import crontab
|
||||
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"providers_scim_sync": {
|
||||
"task": "authentik.providers.scim.tasks.scim_sync_all",
|
||||
"schedule": crontab(minute=fqdn_rand("scim_sync_all"), hour="*"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
}
|
41
authentik/providers/scim/signals.py
Normal file
41
authentik/providers/scim/signals.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
"""SCIM provider signals"""
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from pydanticscim.responses import PatchOp
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.providers.scim.models import SCIMProvider
|
||||
from authentik.providers.scim.tasks import scim_signal_direct, scim_signal_m2m, scim_sync
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@receiver(post_save, sender=SCIMProvider)
|
||||
def post_save_provider(sender: type[Model], instance, created: bool, **_):
|
||||
"""Trigger sync when SCIM provider is saved"""
|
||||
scim_sync.delay(instance.pk)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
@receiver(post_save, sender=Group)
|
||||
def post_save_scim(sender: type[Model], instance: User | Group, created: bool, **_):
|
||||
"""Post save handler"""
|
||||
scim_signal_direct.delay(class_to_path(instance.__class__), instance.pk, PatchOp.add.value)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=User)
|
||||
@receiver(pre_delete, sender=Group)
|
||||
def pre_delete_scim(sender: type[Model], instance: User | Group, **_):
|
||||
"""Pre-delete handler"""
|
||||
scim_signal_direct.delay(class_to_path(instance.__class__), instance.pk, PatchOp.remove.value)
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=User.ak_groups.through)
|
||||
def m2m_changed_scim(sender: type[Model], instance, action: str, pk_set: set, **kwargs):
|
||||
"""Sync group membership"""
|
||||
if action not in ["post_add", "post_remove"]:
|
||||
return
|
||||
scim_signal_m2m.delay(str(instance.pk), action, list(pk_set))
|
176
authentik/providers/scim/tasks.py
Normal file
176
authentik/providers/scim/tasks.py
Normal file
|
@ -0,0 +1,176 @@
|
|||
"""SCIM Provider tasks"""
|
||||
from typing import Any
|
||||
|
||||
from celery.result import allow_join_result
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Model
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from pydanticscim.responses import PatchOp
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
from authentik.lib.utils.reflection import path_to_class
|
||||
from authentik.providers.scim.clients import PAGE_SIZE
|
||||
from authentik.providers.scim.clients.base import SCIMClient
|
||||
from authentik.providers.scim.clients.exceptions import SCIMRequestException, StopSync
|
||||
from authentik.providers.scim.clients.group import SCIMGroupClient
|
||||
from authentik.providers.scim.clients.user import SCIMUserClient
|
||||
from authentik.providers.scim.models import SCIMProvider
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
|
||||
def client_for_model(provider: SCIMProvider, model: Model) -> SCIMClient:
|
||||
"""Get SCIM client for model"""
|
||||
if isinstance(model, User):
|
||||
return SCIMUserClient(provider)
|
||||
if isinstance(model, Group):
|
||||
return SCIMGroupClient(provider)
|
||||
raise ValueError(f"Invalid model {model}")
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def scim_sync_all():
|
||||
"""Run sync for all providers"""
|
||||
for provider in SCIMProvider.objects.all():
|
||||
scim_sync.delay(provider.pk)
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
def scim_sync(self: MonitoredTask, provider_pk: int) -> None:
|
||||
"""Run SCIM full sync for provider"""
|
||||
provider: SCIMProvider = SCIMProvider.objects.filter(pk=provider_pk).first()
|
||||
if not provider:
|
||||
return
|
||||
self.set_uid(slugify(provider.name))
|
||||
result = TaskResult(TaskResultStatus.SUCCESSFUL, [])
|
||||
result.messages.append(_("Starting full SCIM sync"))
|
||||
# TODO: Filtering
|
||||
LOGGER.debug("Starting SCIM sync")
|
||||
users_paginator = Paginator(
|
||||
User.objects.all().exclude(pk=get_anonymous_user().pk).order_by("pk"), PAGE_SIZE
|
||||
)
|
||||
groups_paginator = Paginator(Group.objects.all().order_by("pk"), PAGE_SIZE)
|
||||
with allow_join_result():
|
||||
try:
|
||||
for page in users_paginator.page_range:
|
||||
result.messages.append(_("Syncing page %(page)d of users" % {"page": page}))
|
||||
for msg in scim_sync_users.delay(page, provider_pk).get():
|
||||
result.messages.append(msg)
|
||||
for page in groups_paginator.page_range:
|
||||
result.messages.append(_("Syncing page %(page)d of groups" % {"page": page}))
|
||||
for msg in scim_sync_group.delay(page, provider_pk).get():
|
||||
result.messages.append(msg)
|
||||
except StopSync as exc:
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||
return
|
||||
self.set_status(result)
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def scim_sync_users(page: int, provider_pk: int, **kwargs):
|
||||
"""Sync single or multiple users to SCIM"""
|
||||
messages = []
|
||||
provider: SCIMProvider = SCIMProvider.objects.filter(pk=provider_pk).first()
|
||||
if not provider:
|
||||
return messages
|
||||
try:
|
||||
client = SCIMUserClient(provider)
|
||||
except SCIMRequestException:
|
||||
return messages
|
||||
paginator = Paginator(
|
||||
User.objects.all().filter(**kwargs).exclude(pk=get_anonymous_user().pk).order_by("pk"),
|
||||
PAGE_SIZE,
|
||||
)
|
||||
LOGGER.debug("starting user sync for page", page=page)
|
||||
for user in paginator.page(page).object_list:
|
||||
try:
|
||||
client.write(user)
|
||||
except SCIMRequestException as exc:
|
||||
LOGGER.warning("failed to sync user", exc=exc, user=user)
|
||||
messages.append(
|
||||
_(
|
||||
"Failed to sync user due to remote error %(name)s: %(error)s"
|
||||
% {
|
||||
"name": user.username,
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
)
|
||||
except StopSync:
|
||||
break
|
||||
return messages
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def scim_sync_group(page: int, provider_pk: int, **kwargs):
|
||||
"""Sync single or multiple groups to SCIM"""
|
||||
messages = []
|
||||
provider: SCIMProvider = SCIMProvider.objects.filter(pk=provider_pk).first()
|
||||
if not provider:
|
||||
return messages
|
||||
try:
|
||||
client = SCIMGroupClient(provider)
|
||||
except SCIMRequestException:
|
||||
return messages
|
||||
paginator = Paginator(Group.objects.all().filter(**kwargs).order_by("pk"), PAGE_SIZE)
|
||||
LOGGER.debug("starting group sync for page", page=page)
|
||||
for group in paginator.page(page).object_list:
|
||||
try:
|
||||
client.write(group)
|
||||
except SCIMRequestException as exc:
|
||||
LOGGER.warning("failed to sync group", exc=exc, group=group)
|
||||
messages.append(
|
||||
_(
|
||||
"Failed to sync group due to remote error %(name)s: %(error)s"
|
||||
% {
|
||||
"name": group.name,
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
)
|
||||
except StopSync:
|
||||
break
|
||||
return messages
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def scim_signal_direct(model: str, pk: Any, raw_op: str):
|
||||
"""Handler for post_save and pre_delete signal"""
|
||||
model_class: type[Model] = path_to_class(model)
|
||||
instance = model_class.objects.filter(pk=pk).first()
|
||||
if not instance:
|
||||
return
|
||||
operation = PatchOp(raw_op)
|
||||
for provider in SCIMProvider.objects.all():
|
||||
client = client_for_model(provider, instance)
|
||||
try:
|
||||
if operation == PatchOp.add:
|
||||
client.write(instance)
|
||||
if operation == PatchOp.remove:
|
||||
client.delete(instance)
|
||||
except (StopSync, SCIMRequestException) as exc:
|
||||
LOGGER.warning(exc)
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def scim_signal_m2m(group_pk: str, action: str, pk_set: set[int]):
|
||||
"""Update m2m (group membership)"""
|
||||
group = Group.objects.filter(pk=group_pk).first()
|
||||
if not group:
|
||||
return
|
||||
for provider in SCIMProvider.objects.all():
|
||||
client = SCIMGroupClient(provider)
|
||||
try:
|
||||
operation = None
|
||||
if action == "post_add":
|
||||
operation = PatchOp.add
|
||||
if action == "post_remove":
|
||||
operation = PatchOp.remove
|
||||
client.update_group(group, operation, pk_set)
|
||||
except (StopSync, SCIMRequestException) as exc:
|
||||
LOGGER.warning(exc)
|
0
authentik/providers/scim/tests/__init__.py
Normal file
0
authentik/providers/scim/tests/__init__.py
Normal file
78
authentik/providers/scim/tests/test_client.py
Normal file
78
authentik/providers/scim/tests/test_client.py
Normal file
|
@ -0,0 +1,78 @@
|
|||
"""SCIM Client tests"""
|
||||
from django.test import TestCase
|
||||
from requests_mock import Mocker
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.clients.base import SCIMClient
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
||||
|
||||
|
||||
class SCIMClientTests(TestCase):
|
||||
"""SCIM Client tests"""
|
||||
|
||||
@apply_blueprint("system/providers-scim.yaml")
|
||||
def setUp(self) -> None:
|
||||
self.provider: SCIMProvider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
token=generate_id(),
|
||||
)
|
||||
self.provider.property_mappings.add(
|
||||
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
|
||||
)
|
||||
self.provider.property_mappings_group.add(
|
||||
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
|
||||
)
|
||||
|
||||
def test_config(self):
|
||||
"""Test valid config:
|
||||
https://docs.aws.amazon.com/singlesignon/latest/developerguide/serviceproviderconfig.html"""
|
||||
with Mocker() as mock:
|
||||
mock: Mocker
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
||||
"documentationUri": (
|
||||
"https://docs.aws.amazon.com/singlesignon/latest/"
|
||||
"userguide/manage-your-identity-source-idp.html"
|
||||
),
|
||||
"authenticationSchemes": [
|
||||
{
|
||||
"type": "oauthbearertoken",
|
||||
"name": "OAuth Bearer Token",
|
||||
"description": (
|
||||
"Authentication scheme using the OAuth Bearer Token Standard"
|
||||
),
|
||||
"specUri": "https://www.rfc-editor.org/info/rfc6750",
|
||||
"documentationUri": (
|
||||
"https://docs.aws.amazon.com/singlesignon/latest/"
|
||||
"userguide/provision-automatically.html"
|
||||
),
|
||||
"primary": True,
|
||||
}
|
||||
],
|
||||
"patch": {"supported": True},
|
||||
"bulk": {"supported": False, "maxOperations": 1, "maxPayloadSize": 1048576},
|
||||
"filter": {"supported": True, "maxResults": 50},
|
||||
"changePassword": {"supported": False},
|
||||
"sort": {"supported": False},
|
||||
"etag": {"supported": False},
|
||||
},
|
||||
)
|
||||
SCIMClient(self.provider)
|
||||
self.assertEqual(mock.call_count, 1)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
|
||||
def test_config_invalid(self):
|
||||
"""Test invalid config"""
|
||||
with Mocker() as mock:
|
||||
mock: Mocker
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
SCIMClient(self.provider)
|
||||
self.assertEqual(mock.call_count, 1)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
133
authentik/providers/scim/tests/test_group.py
Normal file
133
authentik/providers/scim/tests/test_group.py
Normal file
|
@ -0,0 +1,133 @@
|
|||
"""SCIM Group tests"""
|
||||
from json import loads
|
||||
|
||||
from django.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from jsonschema import validate
|
||||
from requests_mock import Mocker
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
||||
|
||||
|
||||
class SCIMGroupTests(TestCase):
|
||||
"""SCIM Group tests"""
|
||||
|
||||
@apply_blueprint("system/providers-scim.yaml")
|
||||
def setUp(self) -> None:
|
||||
# Delete all users and groups as the mocked HTTP responses only return one ID
|
||||
# which will cause errors with multiple users
|
||||
User.objects.all().exclude(pk=get_anonymous_user().pk).delete()
|
||||
Group.objects.all().delete()
|
||||
self.provider: SCIMProvider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
token=generate_id(),
|
||||
)
|
||||
self.provider.property_mappings.set(
|
||||
[SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")]
|
||||
)
|
||||
self.provider.property_mappings_group.set(
|
||||
[SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")]
|
||||
)
|
||||
|
||||
@Mocker()
|
||||
def test_group_create(self, mock: Mocker):
|
||||
"""Test group creation"""
|
||||
scim_id = generate_id()
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
uid = generate_id()
|
||||
group = Group.objects.create(
|
||||
name=uid,
|
||||
)
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[1].body,
|
||||
{"externalId": str(group.pk), "displayName": group.name},
|
||||
)
|
||||
|
||||
@Mocker()
|
||||
def test_group_create_update(self, mock: Mocker):
|
||||
"""Test group creation and update"""
|
||||
scim_id = generate_id()
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
mock.put(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
uid = generate_id()
|
||||
group = Group.objects.create(
|
||||
name=uid,
|
||||
)
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
body = loads(mock.request_history[1].body)
|
||||
with open("schemas/scim-group.schema.json", encoding="utf-8") as schema:
|
||||
validate(body, loads(schema.read()))
|
||||
self.assertEqual(
|
||||
body,
|
||||
{"externalId": str(group.pk), "displayName": group.name},
|
||||
)
|
||||
group.save()
|
||||
self.assertEqual(mock.call_count, 4)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
self.assertEqual(mock.request_history[2].method, "GET")
|
||||
self.assertEqual(mock.request_history[3].method, "PUT")
|
||||
|
||||
@Mocker()
|
||||
def test_group_create_delete(self, mock: Mocker):
|
||||
"""Test group creation"""
|
||||
scim_id = generate_id()
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
mock.delete("https://localhost/Groups", status_code=204)
|
||||
uid = generate_id()
|
||||
group = Group.objects.create(
|
||||
name=uid,
|
||||
)
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[1].body,
|
||||
{"externalId": str(group.pk), "displayName": group.name},
|
||||
)
|
||||
group.delete()
|
||||
self.assertEqual(mock.call_count, 4)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[3].method, "DELETE")
|
||||
self.assertEqual(mock.request_history[3].url, f"https://localhost/Groups/{scim_id}")
|
228
authentik/providers/scim/tests/test_membership.py
Normal file
228
authentik/providers/scim/tests/test_membership.py
Normal file
|
@ -0,0 +1,228 @@
|
|||
"""SCIM Membership tests"""
|
||||
from django.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from requests_mock import Mocker
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.clients.base import default_service_provider_config
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
||||
from authentik.providers.scim.tasks import scim_sync
|
||||
|
||||
|
||||
class SCIMMembershipTests(TestCase):
|
||||
"""SCIM Membership tests"""
|
||||
|
||||
provider: SCIMProvider
|
||||
|
||||
def setUp(self) -> None:
|
||||
# Delete all users and groups as the mocked HTTP responses only return one ID
|
||||
# which will cause errors with multiple users
|
||||
User.objects.all().exclude(pk=get_anonymous_user().pk).delete()
|
||||
Group.objects.all().delete()
|
||||
|
||||
@apply_blueprint("system/providers-scim.yaml")
|
||||
def configure(self) -> None:
|
||||
"""Configure provider"""
|
||||
self.provider: SCIMProvider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
token=generate_id(),
|
||||
)
|
||||
self.provider.property_mappings.set(
|
||||
[SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")]
|
||||
)
|
||||
self.provider.property_mappings_group.set(
|
||||
[SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")]
|
||||
)
|
||||
|
||||
def test_member_add(self):
|
||||
"""Test member add"""
|
||||
config = default_service_provider_config()
|
||||
config.patch.supported = True
|
||||
user_scim_id = generate_id()
|
||||
group_scim_id = generate_id()
|
||||
uid = generate_id()
|
||||
group = Group.objects.create(
|
||||
name=uid,
|
||||
)
|
||||
|
||||
user = User.objects.create(username=generate_id())
|
||||
|
||||
with Mocker() as mocker:
|
||||
mocker.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json=config.dict(),
|
||||
)
|
||||
mocker.post(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": user_scim_id,
|
||||
},
|
||||
)
|
||||
mocker.post(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": group_scim_id,
|
||||
},
|
||||
)
|
||||
|
||||
self.configure()
|
||||
scim_sync.delay(self.provider.pk).get()
|
||||
|
||||
self.assertEqual(mocker.call_count, 6)
|
||||
self.assertEqual(mocker.request_history[0].method, "GET")
|
||||
self.assertEqual(mocker.request_history[1].method, "GET")
|
||||
self.assertEqual(mocker.request_history[2].method, "GET")
|
||||
self.assertEqual(mocker.request_history[3].method, "POST")
|
||||
self.assertEqual(mocker.request_history[4].method, "GET")
|
||||
self.assertEqual(mocker.request_history[5].method, "POST")
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[3].body,
|
||||
{
|
||||
"emails": [],
|
||||
"externalId": user.uid,
|
||||
"name": {"familyName": "", "formatted": "", "givenName": ""},
|
||||
"photos": [],
|
||||
"userName": user.username,
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[5].body,
|
||||
{"externalId": str(group.pk), "displayName": group.name},
|
||||
)
|
||||
|
||||
with Mocker() as mocker:
|
||||
mocker.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json=config.dict(),
|
||||
)
|
||||
mocker.patch(
|
||||
f"https://localhost/Groups/{group_scim_id}",
|
||||
json={},
|
||||
)
|
||||
group.users.add(user)
|
||||
self.assertEqual(mocker.call_count, 2)
|
||||
self.assertEqual(mocker.request_history[0].method, "GET")
|
||||
self.assertEqual(mocker.request_history[1].method, "PATCH")
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[1].body,
|
||||
{
|
||||
"Operations": [
|
||||
{
|
||||
"op": "add",
|
||||
"path": "members",
|
||||
"value": [{"value": user_scim_id}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def test_member_remove(self):
|
||||
"""Test member remove"""
|
||||
config = default_service_provider_config()
|
||||
config.patch.supported = True
|
||||
user_scim_id = generate_id()
|
||||
group_scim_id = generate_id()
|
||||
uid = generate_id()
|
||||
group = Group.objects.create(
|
||||
name=uid,
|
||||
)
|
||||
|
||||
user = User.objects.create(username=generate_id())
|
||||
|
||||
with Mocker() as mocker:
|
||||
mocker.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json=config.dict(),
|
||||
)
|
||||
mocker.post(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": user_scim_id,
|
||||
},
|
||||
)
|
||||
mocker.post(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": group_scim_id,
|
||||
},
|
||||
)
|
||||
|
||||
self.configure()
|
||||
scim_sync.delay(self.provider.pk).get()
|
||||
|
||||
self.assertEqual(mocker.call_count, 6)
|
||||
self.assertEqual(mocker.request_history[0].method, "GET")
|
||||
self.assertEqual(mocker.request_history[1].method, "GET")
|
||||
self.assertEqual(mocker.request_history[2].method, "GET")
|
||||
self.assertEqual(mocker.request_history[3].method, "POST")
|
||||
self.assertEqual(mocker.request_history[4].method, "GET")
|
||||
self.assertEqual(mocker.request_history[5].method, "POST")
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[3].body,
|
||||
{
|
||||
"emails": [],
|
||||
"externalId": user.uid,
|
||||
"name": {"familyName": "", "formatted": "", "givenName": ""},
|
||||
"photos": [],
|
||||
"userName": user.username,
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[5].body,
|
||||
{"externalId": str(group.pk), "displayName": group.name},
|
||||
)
|
||||
|
||||
with Mocker() as mocker:
|
||||
mocker.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json=config.dict(),
|
||||
)
|
||||
mocker.patch(
|
||||
f"https://localhost/Groups/{group_scim_id}",
|
||||
json={},
|
||||
)
|
||||
group.users.add(user)
|
||||
self.assertEqual(mocker.call_count, 2)
|
||||
self.assertEqual(mocker.request_history[0].method, "GET")
|
||||
self.assertEqual(mocker.request_history[1].method, "PATCH")
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[1].body,
|
||||
{
|
||||
"Operations": [
|
||||
{
|
||||
"op": "add",
|
||||
"path": "members",
|
||||
"value": [{"value": user_scim_id}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
with Mocker() as mocker:
|
||||
mocker.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json=config.dict(),
|
||||
)
|
||||
mocker.patch(
|
||||
f"https://localhost/Groups/{group_scim_id}",
|
||||
json={},
|
||||
)
|
||||
group.users.remove(user)
|
||||
self.assertEqual(mocker.call_count, 2)
|
||||
self.assertEqual(mocker.request_history[0].method, "GET")
|
||||
self.assertEqual(mocker.request_history[1].method, "PATCH")
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[1].body,
|
||||
{
|
||||
"Operations": [
|
||||
{
|
||||
"op": "remove",
|
||||
"path": "members",
|
||||
"value": [{"value": user_scim_id}],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
250
authentik/providers/scim/tests/test_user.py
Normal file
250
authentik/providers/scim/tests/test_user.py
Normal file
|
@ -0,0 +1,250 @@
|
|||
"""SCIM User tests"""
|
||||
from json import loads
|
||||
|
||||
from django.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from jsonschema import validate
|
||||
from requests_mock import Mocker
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.models import SCIMMapping, SCIMProvider
|
||||
from authentik.providers.scim.tasks import scim_sync
|
||||
|
||||
|
||||
class SCIMUserTests(TestCase):
|
||||
"""SCIM User tests"""
|
||||
|
||||
@apply_blueprint("system/providers-scim.yaml")
|
||||
def setUp(self) -> None:
|
||||
# Delete all users and groups as the mocked HTTP responses only return one ID
|
||||
# which will cause errors with multiple users
|
||||
User.objects.all().exclude(pk=get_anonymous_user().pk).delete()
|
||||
Group.objects.all().delete()
|
||||
self.provider: SCIMProvider = SCIMProvider.objects.create(
|
||||
name=generate_id(),
|
||||
url="https://localhost",
|
||||
token=generate_id(),
|
||||
)
|
||||
self.provider.property_mappings.add(
|
||||
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
|
||||
)
|
||||
self.provider.property_mappings_group.add(
|
||||
SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
|
||||
)
|
||||
|
||||
@Mocker()
|
||||
def test_user_create(self, mock: Mocker):
|
||||
"""Test user creation"""
|
||||
scim_id = generate_id()
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
uid = generate_id()
|
||||
user = User.objects.create(
|
||||
username=uid,
|
||||
name=uid,
|
||||
email=f"{uid}@goauthentik.io",
|
||||
)
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[1].body,
|
||||
{
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "other",
|
||||
"value": f"{uid}@goauthentik.io",
|
||||
}
|
||||
],
|
||||
"externalId": user.uid,
|
||||
"name": {
|
||||
"familyName": "",
|
||||
"formatted": uid,
|
||||
"givenName": uid,
|
||||
},
|
||||
"photos": [],
|
||||
"userName": uid,
|
||||
},
|
||||
)
|
||||
|
||||
@Mocker()
|
||||
def test_user_create_update(self, mock: Mocker):
|
||||
"""Test user creation and update"""
|
||||
scim_id = generate_id()
|
||||
mock: Mocker
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
mock.put(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
uid = generate_id()
|
||||
user = User.objects.create(
|
||||
username=uid,
|
||||
name=uid,
|
||||
email=f"{uid}@goauthentik.io",
|
||||
)
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
body = loads(mock.request_history[1].body)
|
||||
with open("schemas/scim-user.schema.json", encoding="utf-8") as schema:
|
||||
validate(body, loads(schema.read()))
|
||||
self.assertEqual(
|
||||
body,
|
||||
{
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "other",
|
||||
"value": f"{uid}@goauthentik.io",
|
||||
}
|
||||
],
|
||||
"externalId": user.uid,
|
||||
"name": {
|
||||
"familyName": "",
|
||||
"formatted": uid,
|
||||
"givenName": uid,
|
||||
},
|
||||
"photos": [],
|
||||
"userName": uid,
|
||||
},
|
||||
)
|
||||
user.save()
|
||||
self.assertEqual(mock.call_count, 4)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
self.assertEqual(mock.request_history[2].method, "GET")
|
||||
self.assertEqual(mock.request_history[3].method, "PUT")
|
||||
|
||||
@Mocker()
|
||||
def test_user_create_delete(self, mock: Mocker):
|
||||
"""Test user creation"""
|
||||
scim_id = generate_id()
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": scim_id,
|
||||
},
|
||||
)
|
||||
mock.delete("https://localhost/Users", status_code=204)
|
||||
uid = generate_id()
|
||||
user = User.objects.create(
|
||||
username=uid,
|
||||
name=uid,
|
||||
email=f"{uid}@goauthentik.io",
|
||||
)
|
||||
self.assertEqual(mock.call_count, 2)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[1].body,
|
||||
{
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "other",
|
||||
"value": f"{uid}@goauthentik.io",
|
||||
}
|
||||
],
|
||||
"externalId": user.uid,
|
||||
"name": {
|
||||
"familyName": "",
|
||||
"formatted": uid,
|
||||
"givenName": uid,
|
||||
},
|
||||
"photos": [],
|
||||
"userName": uid,
|
||||
},
|
||||
)
|
||||
user.delete()
|
||||
self.assertEqual(mock.call_count, 4)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[3].method, "DELETE")
|
||||
self.assertEqual(mock.request_history[3].url, f"https://localhost/Users/{scim_id}")
|
||||
|
||||
@Mocker()
|
||||
def test_sync_task(self, mock: Mocker):
|
||||
"""Test sync tasks"""
|
||||
user_scim_id = generate_id()
|
||||
group_scim_id = generate_id()
|
||||
uid = generate_id()
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Users",
|
||||
json={
|
||||
"id": user_scim_id,
|
||||
},
|
||||
)
|
||||
mock.put(
|
||||
f"https://localhost/Users/{user_scim_id}",
|
||||
json={
|
||||
"id": user_scim_id,
|
||||
},
|
||||
)
|
||||
mock.post(
|
||||
"https://localhost/Groups",
|
||||
json={
|
||||
"id": group_scim_id,
|
||||
},
|
||||
)
|
||||
user = User.objects.create(
|
||||
username=uid,
|
||||
name=uid,
|
||||
email=f"{uid}@goauthentik.io",
|
||||
)
|
||||
|
||||
scim_sync.delay(self.provider.pk).get()
|
||||
|
||||
self.assertEqual(mock.call_count, 5)
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertEqual(mock.request_history[1].method, "POST")
|
||||
self.assertEqual(mock.request_history[-2].method, "PUT")
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[1].body,
|
||||
{
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "other",
|
||||
"value": f"{uid}@goauthentik.io",
|
||||
}
|
||||
],
|
||||
"externalId": user.uid,
|
||||
"name": {
|
||||
"familyName": "",
|
||||
"formatted": uid,
|
||||
"givenName": uid,
|
||||
},
|
||||
"photos": [],
|
||||
"userName": uid,
|
||||
},
|
||||
)
|
|
@ -80,6 +80,7 @@ INSTALLED_APPS = [
|
|||
"authentik.providers.oauth2",
|
||||
"authentik.providers.proxy",
|
||||
"authentik.providers.saml",
|
||||
"authentik.providers.scim",
|
||||
"authentik.recovery",
|
||||
"authentik.sources.ldap",
|
||||
"authentik.sources.oauth",
|
||||
|
|
|
@ -28,7 +28,7 @@ class TestMetadataProcessor(TestCase):
|
|||
xml = MetadataProcessor(self.source, request).build_entity_descriptor()
|
||||
metadata = lxml_from_string(xml)
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("xml/saml-schema-metadata-2.0.xsd")) # nosec
|
||||
schema = etree.XMLSchema(etree.parse("schemas/saml-schema-metadata-2.0.xsd")) # nosec
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
||||
def test_metadata_consistent(self):
|
||||
|
|
|
@ -81,6 +81,8 @@
|
|||
"authentik_providers_proxy.proxyprovider",
|
||||
"authentik_providers_saml.samlpropertymapping",
|
||||
"authentik_providers_saml.samlprovider",
|
||||
"authentik_providers_scim.scimmapping",
|
||||
"authentik_providers_scim.scimprovider",
|
||||
"authentik_sources_ldap.ldappropertymapping",
|
||||
"authentik_sources_ldap.ldapsource",
|
||||
"authentik_sources_oauth.oauthsource",
|
||||
|
|
58
blueprints/system/providers-scim.yaml
Normal file
58
blueprints/system/providers-scim.yaml
Normal file
|
@ -0,0 +1,58 @@
|
|||
version: 1
|
||||
metadata:
|
||||
labels:
|
||||
blueprints.goauthentik.io/system: "true"
|
||||
name: System - SCIM Provider - Mappings
|
||||
entries:
|
||||
- identifiers:
|
||||
managed: goauthentik.io/providers/scim/user
|
||||
model: authentik_providers_scim.scimmapping
|
||||
attrs:
|
||||
name: "authentik default SCIM Mapping: User"
|
||||
expression: |
|
||||
# Some implementations require givenName and familyName to be set
|
||||
givenName, familyName = request.user.name, ""
|
||||
# This default sets givenName to the name before the first space
|
||||
# and the remainder as family name
|
||||
# if the user's name has no space the givenName is the entire name
|
||||
# (this might cause issues with some SCIM implementations)
|
||||
if " " in request.user.name:
|
||||
givenName, _, familyName = request.user.name.partition(" ")
|
||||
|
||||
# photos supports URLs to images, however authentik might return data URIs
|
||||
avatar = request.user.avatar
|
||||
photos = []
|
||||
if "://" in avatar:
|
||||
photos = [{"value": avatar, "type": "photo"}]
|
||||
|
||||
locale = request.user.locale()
|
||||
if locale == "":
|
||||
locale = None
|
||||
|
||||
emails = []
|
||||
if request.user.email != "":
|
||||
emails.append({
|
||||
"value": request.user.email,
|
||||
"type": "other",
|
||||
"primary": True,
|
||||
})
|
||||
return {
|
||||
"userName": request.user.username,
|
||||
"name": {
|
||||
"formatted": request.user.name,
|
||||
"givenName": givenName,
|
||||
"familyName": familyName,
|
||||
},
|
||||
"photos": photos,
|
||||
"locale": locale,
|
||||
"emails": emails,
|
||||
}
|
||||
- identifiers:
|
||||
managed: goauthentik.io/providers/scim/group
|
||||
model: authentik_providers_scim.scimmapping
|
||||
attrs:
|
||||
name: "authentik default SCIM Mapping: Group"
|
||||
expression: |
|
||||
return {
|
||||
"displayName": group.name,
|
||||
}
|
60
poetry.lock
generated
60
poetry.lock
generated
|
@ -1172,6 +1172,27 @@ django = "*"
|
|||
django-guardian = "*"
|
||||
djangorestframework = "*"
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.3.0"
|
||||
description = "DNS toolkit"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7,<4.0"
|
||||
files = [
|
||||
{file = "dnspython-2.3.0-py3-none-any.whl", hash = "sha256:89141536394f909066cabd112e3e1a37e4e654db00a25308b0f130bc3152eb46"},
|
||||
{file = "dnspython-2.3.0.tar.gz", hash = "sha256:224e32b03eb46be70e12ef6d64e0be123a64e621ab4c0822ff6d450d52a540b9"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"]
|
||||
dnssec = ["cryptography (>=2.6,<40.0)"]
|
||||
doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.11.0)"]
|
||||
doq = ["aioquic (>=0.9.20)"]
|
||||
idna = ["idna (>=2.1,<4.0)"]
|
||||
trio = ["trio (>=0.14,<0.23)"]
|
||||
wmi = ["wmi (>=1.5.1,<2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "docker"
|
||||
version = "6.0.1"
|
||||
|
@ -1249,6 +1270,22 @@ files = [
|
|||
setuptools = "*"
|
||||
six = "*"
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "1.3.1"
|
||||
description = "A robust email address syntax and deliverability validation library."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
{file = "email_validator-1.3.1-py2.py3-none-any.whl", hash = "sha256:49a72f5fa6ed26be1c964f0567d931d10bf3fdeeacdf97bc26ef1cd2a44e0bda"},
|
||||
{file = "email_validator-1.3.1.tar.gz", hash = "sha256:d178c5c6fa6c6824e9b04f199cf23e79ac15756786573c190d2ad13089411ad2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
dnspython = ">=1.15.0"
|
||||
idna = ">=2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "facebook-sdk"
|
||||
version = "3.1.0"
|
||||
|
@ -2425,12 +2462,31 @@ files = [
|
|||
]
|
||||
|
||||
[package.dependencies]
|
||||
email-validator = {version = ">=1.0.3", optional = true, markers = "extra == \"email\""}
|
||||
typing-extensions = ">=4.1.0"
|
||||
|
||||
[package.extras]
|
||||
dotenv = ["python-dotenv (>=0.10.4)"]
|
||||
email = ["email-validator (>=1.0.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-scim"
|
||||
version = "0.0.7"
|
||||
description = "Pydantic types for SCIM"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.8.0"
|
||||
files = [
|
||||
{file = "pydantic-scim-0.0.7.tar.gz", hash = "sha256:bc043da51c346051dfd372f12d1837c0846b815236340156d663a8514cba5761"},
|
||||
{file = "pydantic_scim-0.0.7-py3-none-any.whl", hash = "sha256:058eb195f75ef32d04eaf6369c125d5fb7052891694686f8e55e04d184ab1360"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pydantic = [
|
||||
{version = ">=1.8.0"},
|
||||
{version = ">=1.8.0", extras = ["email"]},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.6.0"
|
||||
|
@ -2533,7 +2589,7 @@ files = [
|
|||
cffi = ">=1.4.1"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
|
||||
docs = ["sphinx (>=1.6.5)", "sphinx_rtd_theme"]
|
||||
tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
|
||||
|
||||
[[package]]
|
||||
|
@ -3903,4 +3959,4 @@ files = [
|
|||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.11"
|
||||
content-hash = "47eeb02200cb4980368d3a11c6bee111a19a86d7e4d8ad90ef3bd590493f28b3"
|
||||
content-hash = "2ebb5d81a0b4c0883704dd8b74dc9bf7e8893cd7caadccaa0c47237e0394d54a"
|
||||
|
|
|
@ -161,6 +161,7 @@ wsproto = "*"
|
|||
xmlsec = "*"
|
||||
zxcvbn = "*"
|
||||
watchdog = "*"
|
||||
pydantic-scim = "^0.0.7"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
bandit = "*"
|
||||
|
|
2508
schema.yml
2508
schema.yml
File diff suppressed because it is too large
Load diff
49
schemas/scim-enterpriseUser.schema.json
Normal file
49
schemas/scim-enterpriseUser.schema.json
Normal file
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
|
||||
"title": "EnterpriseUser",
|
||||
"description": "Enterprise User",
|
||||
"properties": {
|
||||
"employeeNumber": {
|
||||
"description": "Numeric or alphanumeric identifier assigned to a person, typically based on order of hire or association with anorganization.",
|
||||
"type": "string"
|
||||
},
|
||||
"costCenter": {
|
||||
"description": "Identifies the name of a cost center.",
|
||||
"type": "string"
|
||||
},
|
||||
"organization": {
|
||||
"description": "Identifies the name of an organization.",
|
||||
"type": "string"
|
||||
},
|
||||
"division": {
|
||||
"description": "Identifies the name of a division.",
|
||||
"type": "string"
|
||||
},
|
||||
"department": {
|
||||
"description": "Numeric or alphanumeric identifier assigned to a person, typically based on order of hire or association with anorganization.",
|
||||
"type": "string"
|
||||
},
|
||||
"manager": {
|
||||
"description": "The User's manager. A complex type that optionally allows service providers to represent organizational hierarchy by referencing the 'id' attribute of another User.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"description": "The id of the SCIM resource representingthe User's manager. REQUIRED.",
|
||||
"type": "string"
|
||||
},
|
||||
"$ref": {
|
||||
"description": "The URI of the SCIM resource representing the User's manager. REQUIRED.",
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"displayName": {
|
||||
"description": "The displayName of the User's manager. OPTIONAL and READ-ONLY.",
|
||||
"type": "string",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
}
|
45
schemas/scim-group.schema.json
Normal file
45
schemas/scim-group.schema.json
Normal file
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "urn:ietf:params:scim:schemas:core:2.0:Group",
|
||||
"title": "Group",
|
||||
"description": "Group",
|
||||
"properties": {
|
||||
"displayName": {
|
||||
"description": "A human-readable name for the Group. REQUIRED.",
|
||||
"type": "string"
|
||||
},
|
||||
"members": {
|
||||
"description": "A list of members of the Group.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"description": "Identifier of the member of this Group.",
|
||||
"type": "string",
|
||||
"readOnly": true
|
||||
},
|
||||
"$ref": {
|
||||
"description": "The URI corresponding to a SCIM resource that is a member of this Group.",
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"readOnly": true
|
||||
},
|
||||
"type": {
|
||||
"description": "A label indicating the type of resource, e.g., 'User' or 'Group'.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"User",
|
||||
"Group"
|
||||
],
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"readOnly": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"displayName"
|
||||
]
|
||||
}
|
72
schemas/scim-resourceType.schema.json
Normal file
72
schemas/scim-resourceType.schema.json
Normal file
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType",
|
||||
"title": "ResourceType",
|
||||
"description": "Specifies the schema that describes a SCIM resource type",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "The resource type's server unique id. May be the same as the 'name' attribute.",
|
||||
"type": "string",
|
||||
"readOnly": true
|
||||
},
|
||||
"name": {
|
||||
"description": "The resource type name. When applicable, service providers MUST specify the name, e.g., 'User'.",
|
||||
"type": "string",
|
||||
"readOnly": true
|
||||
},
|
||||
"description": {
|
||||
"description": "The resource type's human-readable description. When applicable, service providers MUST specify the description.",
|
||||
"type": "string",
|
||||
"readOnly": true
|
||||
},
|
||||
"endpoint": {
|
||||
"description": "The resource type's HTTP-addressable endpoint relative to the Base URL, e.g., '/Users'.",
|
||||
"type": "string",
|
||||
"format": "uri-reference",
|
||||
"readOnly": true
|
||||
},
|
||||
"schema": {
|
||||
"description": "The resource type's primary/base schema URI.",
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"readOnly": true
|
||||
},
|
||||
"schemaExtensions": {
|
||||
"description": "A list of URIs of the resource type's schema extensions.",
|
||||
"type": "array",
|
||||
"items": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"description": "The URI of a schema extension.",
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"readOnly": true
|
||||
},
|
||||
"required": {
|
||||
"description": "A Boolean value that specifies whether or not the schema extension is required for the resource type. If true, a resource of this type MUST include this schema extension and also include any attributes declared as required in this schema extension. If false, a resource of this type MAY omit this schema extension.",
|
||||
"type": "boolean",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schema",
|
||||
"required"
|
||||
],
|
||||
"additionalProperties": true
|
||||
}
|
||||
],
|
||||
"additionalItems": false,
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"endpoint",
|
||||
"schema",
|
||||
"schemaExtensions"
|
||||
],
|
||||
"additionalProperties": true
|
||||
}
|
139
schemas/scim-serviceProviderConfig.schema.json
Normal file
139
schemas/scim-serviceProviderConfig.schema.json
Normal file
|
@ -0,0 +1,139 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig",
|
||||
"title": "Service Provider Configuration",
|
||||
"description": "Schema for representing the service provider's configuration",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"documentationUri": {
|
||||
"description": "An HTTP-addressable URL pointing to the service provider's human-consumable help documentation.",
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"readOnly": true
|
||||
},
|
||||
"patch": {
|
||||
"description": "A complex type that specifies PATCH configuration options.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"supported": {
|
||||
"description": "A Boolean value specifying whether or not the operation is supported.",
|
||||
"type": "boolean",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"supported"
|
||||
],
|
||||
"readOnly": true
|
||||
},
|
||||
"bulk": {
|
||||
"description": "A complex type that specifies bulk configuration options.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"supported": {
|
||||
"description": "A Boolean value specifying whether or not the operation is supported.",
|
||||
"type": "boolean",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"supported"
|
||||
],
|
||||
"readOnly": true
|
||||
},
|
||||
"filter": {
|
||||
"description": "A complex type that specifies FILTER options.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"supported": {
|
||||
"description": "A Boolean value specifying whether or not the operation is supported.",
|
||||
"type": "boolean",
|
||||
"readOnly": true
|
||||
},
|
||||
"maxResults": {
|
||||
"description": "A Boolean value specifying whether or not the operation is supported.",
|
||||
"type": "integer",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"supported"
|
||||
],
|
||||
"readOnly": true
|
||||
},
|
||||
"changePassword": {
|
||||
"description": "A complex type that specifies configuration options related to changing a password.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"supported": {
|
||||
"description": "A Boolean value specifying whether or not the operation is supported.",
|
||||
"type": "boolean",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"supported"
|
||||
],
|
||||
"readOnly": true
|
||||
},
|
||||
"sort": {
|
||||
"description": "A complex type that specifies sort result options.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"supported": {
|
||||
"description": "A Boolean value specifying whether or not the operation is supported.",
|
||||
"type": "boolean",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"supported"
|
||||
],
|
||||
"readOnly": true
|
||||
},
|
||||
"authenticationSchemes": {
|
||||
"description": "A complex type that specifies supported authentication scheme properties.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "The common authentication scheme name, e.g., HTTP Basic.",
|
||||
"type": "string",
|
||||
"readOnly": true
|
||||
},
|
||||
"description": {
|
||||
"description": "A description of the authentication scheme.",
|
||||
"type": "string",
|
||||
"readOnly": true
|
||||
},
|
||||
"specUri": {
|
||||
"description": "An HTTP-addressable URL pointing to the authentication scheme's specification.",
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"readOnly": true
|
||||
},
|
||||
"documentationUri": {
|
||||
"description": "An HTTP-addressable URL pointing to the authentication scheme's usage documentation.",
|
||||
"type": "string",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"readOnly": true
|
||||
},
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"patch",
|
||||
"bulk",
|
||||
"filter",
|
||||
"changePassword",
|
||||
"sort",
|
||||
"authenticationSchemes"
|
||||
]
|
||||
}
|
372
schemas/scim-user.schema.json
Normal file
372
schemas/scim-user.schema.json
Normal file
|
@ -0,0 +1,372 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
"title": "User",
|
||||
"description": "User Account",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userName": {
|
||||
"description": "Unique identifier for the User, typically used by the user to directly authenticate to the service provider. Each User MUST include a non-empty userName value. This identifier MUST be unique across the service provider's entire set of Users. REQUIRED.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "The components of the user's real name. Providers MAY return just the full name as a single string in the formatted sub-attribute, or they MAY return just the individual component attributes using the other sub-attributes, or they MAY return both. If both variants are returned, they SHOULD be describing the same name, with the formatted name indicating how the component attributes should be combined.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"formatted": {
|
||||
"description": "The full name, including all middle names, titles, and suffixes as appropriate, formatted for display (e.g., 'Ms. Barbara J Jensen, III').",
|
||||
"type": "string"
|
||||
},
|
||||
"familyName": {
|
||||
"description": "The family name of the User, or last name in most Western languages (e.g., 'Jensen' given the full name 'Ms. Barbara J Jensen, III').",
|
||||
"type": "string"
|
||||
},
|
||||
"givenName": {
|
||||
"description": "The given name of the User, or first name in most Western languages (e.g., 'Barbara' given the full name 'Ms. Barbara J Jensen, III').",
|
||||
"type": "string"
|
||||
},
|
||||
"middleName": {
|
||||
"description": "The middle name(s) of the User (e.g., 'Jane' given the full name 'Ms. Barbara J Jensen, III').",
|
||||
"type": "string"
|
||||
},
|
||||
"honorificPrefix": {
|
||||
"description": "The honorific prefix(es) of the User, or title in most Western languages (e.g., 'Ms.' given the full name 'Ms. Barbara J Jensen, III').",
|
||||
"type": "string"
|
||||
},
|
||||
"honorificSuffix": {
|
||||
"description": "The honorific suffix(es) of the User, or suffix in most Western languages (e.g., 'III' given the full name 'Ms. Barbara J Jensen, III').",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"displayName": {
|
||||
"description": "The name of the User, suitable for display to end-users. The name SHOULD be the full name of the User being described, if known.",
|
||||
"type": "string"
|
||||
},
|
||||
"nickName": {
|
||||
"description": "The casual way to address the user in real life, e.g., 'Bob' or 'Bobby' instead of 'Robert'. This attribute SHOULD NOT be used to represent a User's username (e.g., 'bjensen' or 'mpepperidge').",
|
||||
"type": "string"
|
||||
},
|
||||
"profileUrl": {
|
||||
"description": "A fully qualified URL pointing to a page representing the User's online profile.",
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"title": {
|
||||
"description": "The user's title, such as \"Vice President.\"",
|
||||
"type": "string"
|
||||
},
|
||||
"userType": {
|
||||
"description": "Used to identify the relationship between the organization and the user. Typical values used might be 'Contractor', 'Employee', 'Intern', 'Temp', 'External', and 'Unknown', but any value may be used.",
|
||||
"type": "string"
|
||||
},
|
||||
"preferredLanguage": {
|
||||
"description": "Indicates the User's preferred written or spoken language. Generally used for selecting a localized user interface; e.g., 'en_US' specifies the language English and country US.",
|
||||
"type": "string"
|
||||
},
|
||||
"locale": {
|
||||
"description": "Used to indicate the User's default location for purposes of localizing items such as currency, date time format, or numerical representations.",
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"description": "The User's time zone in the 'Olson' time zone database format, e.g., 'America/Los_Angeles'.",
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"description": "A Boolean value indicating the User's administrative status.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"password": {
|
||||
"description": "The User's cleartext password. This attribute is intended to be used as a means to specify an initial password when creating a new User or to reset an existing User's password.",
|
||||
"type": "string",
|
||||
"writeOnly": true
|
||||
},
|
||||
"emails": {
|
||||
"description": "Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"description": "Email addresses for the user. The value SHOULD be canonicalized by the service provider, e.g., 'bjensen@example.com' instead of 'bjensen@EXAMPLE.COM'. Canonical type values of 'work', 'home', and 'other'.",
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"display": {
|
||||
"description": "A human-readable name, primarily used for display purposes. READ-ONLY.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "A label indicating the attribute's function, e.g., 'work' or 'home'.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"work",
|
||||
"home",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"primary": {
|
||||
"description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred mailing address or primary email address. The primary attribute value 'true' MUST appear no more than once.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"phoneNumbers": {
|
||||
"description": "Phone numbers for the User. The value SHOULD be canonicalized by the service provider according to the format specified in RFC 3966, e.g., 'tel:+1-201-555-0123'. Canonical type values of 'work', 'home', 'mobile', 'fax', 'pager', and 'other'.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"description": "Phone number of the User.",
|
||||
"type": "string"
|
||||
},
|
||||
"display": {
|
||||
"description": "A human-readable name, primarily used for display purposes. READ-ONLY.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "A label indicating the attribute's function, e.g., 'work', 'home', 'mobile'.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"work",
|
||||
"home",
|
||||
"mobile",
|
||||
"fax",
|
||||
"pager",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"primary": {
|
||||
"description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred phone number or primary phone number. The primary attribute value 'true' MUST appear no more than once.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ims": {
|
||||
"description": "Instant messaging addresses for the User.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"description": "Instant messaging address for the User.",
|
||||
"type": "string"
|
||||
},
|
||||
"display": {
|
||||
"description": "A human-readable name, primarily used for display purposes. READ-ONLY.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "A label indicating the attribute's function, e.g., 'aim', 'gtalk', 'xmpp'.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"aim",
|
||||
"gtalk",
|
||||
"icq",
|
||||
"xmpp",
|
||||
"msn",
|
||||
"skype",
|
||||
"qq",
|
||||
"yahoo"
|
||||
]
|
||||
},
|
||||
"primary": {
|
||||
"description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred messenger or primary messenger. The primary attribute value 'true' MUST appear no more than once.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"photos": {
|
||||
"description": "URLs of photos of the User.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"description": "URL of a photo of the User.",
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"display": {
|
||||
"description": "A human-readable name, primarily used for display purposes. READ-ONLY.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "A label indicating the attribute's function, i.e., 'photo' or 'thumbnail'.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"photo",
|
||||
"thumbnail"
|
||||
]
|
||||
},
|
||||
"primary": {
|
||||
"description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred photo or thumbnail. The primary attribute value 'true' MUST appear no more than once.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"addresses": {
|
||||
"description": "A physical mailing address for this User. Canonical type values of 'work', 'home', and 'other'. This attribute is a complex type with the following sub-attributes.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"formatted": {
|
||||
"description": "The full mailing address, formatted for display or use with a mailing label. This attribute MAY contain newlines.",
|
||||
"type": "string"
|
||||
},
|
||||
"streetAddress": {
|
||||
"description": "The full street address component, which may include house number, street name, P.O. box, and multi-line extended street address information. This attribute MAY contain newlines.",
|
||||
"type": "string"
|
||||
},
|
||||
"locality": {
|
||||
"description": "The city or locality component.",
|
||||
"type": "string"
|
||||
},
|
||||
"region": {
|
||||
"description": "The state or region component.",
|
||||
"type": "string"
|
||||
},
|
||||
"postalCode": {
|
||||
"description": "The zip code or postal code component.",
|
||||
"type": "string"
|
||||
},
|
||||
"country": {
|
||||
"description": "The country name component.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "A label indicating the attribute's function, e.g., 'work' or 'home'.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"work",
|
||||
"home",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"description": "A list of groups to which the user belongs, either through direct membership, through nested groups, or dynamically calculated.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"description": "The identifier of the User's group.",
|
||||
"type": "string",
|
||||
"readOnly": true
|
||||
},
|
||||
"$ref": {
|
||||
"description": "The URI of the corresponding 'Group' resource to which the user belongs.",
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"readOnly": true
|
||||
},
|
||||
"display": {
|
||||
"description": "A human-readable name, primarily used for display purposes. READ-ONLY.",
|
||||
"type": "string",
|
||||
"readOnly": true
|
||||
},
|
||||
"type": {
|
||||
"description": "A label indicating the attribute's function, e.g., 'direct' or 'indirect'.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"direct",
|
||||
"indirect"
|
||||
],
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"readOnly": true
|
||||
},
|
||||
"readOnly": true
|
||||
},
|
||||
"entitlements": {
|
||||
"description": "A list of entitlements for the User that represent a thing the User has.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"description": "The value of an entitlement.",
|
||||
"type": "string"
|
||||
},
|
||||
"display": {
|
||||
"description": "A human-readable name, primarily used for display purposes. READ-ONLY.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "A label indicating the attribute's function.",
|
||||
"type": "string"
|
||||
},
|
||||
"primary": {
|
||||
"description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"description": "A list of roles for the User that collectively represent who the User is, e.g., 'Student', 'Faculty'.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"description": "The value of a role.",
|
||||
"type": "string"
|
||||
},
|
||||
"display": {
|
||||
"description": "A human-readable name, primarily used for display purposes. READ-ONLY.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "A label indicating the attribute's function.",
|
||||
"type": "string"
|
||||
},
|
||||
"primary": {
|
||||
"description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x509Certificates": {
|
||||
"description": "A list of certificates issued to the User.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"description": "The value of an X.509 certificate.",
|
||||
"type": "string",
|
||||
"contentEncoding": "base64",
|
||||
"contentMediaType": "application/octet-stream"
|
||||
},
|
||||
"display": {
|
||||
"description": "A human-readable name, primarily used for display purposes. READ-ONLY.",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"description": "A label indicating the attribute's function.",
|
||||
"type": "string"
|
||||
},
|
||||
"primary": {
|
||||
"description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"userName"
|
||||
]
|
||||
}
|
|
@ -97,7 +97,6 @@ export class ApplicationListPage extends TablePage<Application> {
|
|||
></ak-application-wizard>
|
||||
<div class="pf-c-sidebar__panel pf-m-width-25">
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-card__title">${t`About applications`}</div>
|
||||
<div class="pf-c-card__body">
|
||||
<ak-markdown .md=${MDApplication}></ak-markdown>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm";
|
||||
import "@goauthentik/admin/property-mappings/PropertyMappingNotification";
|
||||
import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm";
|
||||
import "@goauthentik/admin/property-mappings/PropertyMappingSCIMForm";
|
||||
import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm";
|
||||
import "@goauthentik/admin/property-mappings/PropertyMappingTestForm";
|
||||
import "@goauthentik/admin/property-mappings/PropertyMappingWizard";
|
||||
|
|
|
@ -13,7 +13,7 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
|||
import { PropertymappingsApi, SAMLPropertyMapping } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-property-mapping-saml-form")
|
||||
export class PropertyMappingLDAPForm extends ModelForm<SAMLPropertyMapping, string> {
|
||||
export class PropertyMappingSAMLForm extends ModelForm<SAMLPropertyMapping, string> {
|
||||
loadInstance(pk: string): Promise<SAMLPropertyMapping> {
|
||||
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSamlRetrieve({
|
||||
pmUuid: pk,
|
||||
|
|
69
web/src/admin/property-mappings/PropertyMappingSCIMForm.ts
Normal file
69
web/src/admin/property-mappings/PropertyMappingSCIMForm.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { docLink } from "@goauthentik/common/global";
|
||||
import "@goauthentik/elements/CodeMirror";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
|
||||
|
||||
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 { PropertymappingsApi, SCIMMapping } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-property-mapping-scim-form")
|
||||
export class PropertyMappingSCIMForm extends ModelForm<SCIMMapping, string> {
|
||||
loadInstance(pk: string): Promise<SCIMMapping> {
|
||||
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsScimRetrieve({
|
||||
pmUuid: pk,
|
||||
});
|
||||
}
|
||||
|
||||
getSuccessMessage(): string {
|
||||
if (this.instance) {
|
||||
return t`Successfully updated mapping.`;
|
||||
} else {
|
||||
return t`Successfully created mapping.`;
|
||||
}
|
||||
}
|
||||
|
||||
send = (data: SCIMMapping): Promise<SCIMMapping> => {
|
||||
if (this.instance) {
|
||||
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsScimUpdate({
|
||||
pmUuid: this.instance.pk || "",
|
||||
sCIMMappingRequest: data,
|
||||
});
|
||||
} else {
|
||||
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsScimCreate({
|
||||
sCIMMappingRequest: data,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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`Expression`} ?required=${true} name="expression">
|
||||
<ak-codemirror mode="python" value="${ifDefined(this.instance?.expression)}">
|
||||
</ak-codemirror>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Expression using Python.`}
|
||||
<a
|
||||
target="_blank"
|
||||
href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}"
|
||||
>
|
||||
${t`See documentation for a list of all variables.`}
|
||||
</a>
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
</form>`;
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ import "@goauthentik/admin/providers/ldap/LDAPProviderForm";
|
|||
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
|
||||
import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
|
||||
import "@goauthentik/admin/providers/saml/SAMLProviderForm";
|
||||
import "@goauthentik/admin/providers/scim/SCIMProviderForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { uiConfig } from "@goauthentik/common/ui/config";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
|
@ -81,16 +82,23 @@ export class ProviderListPage extends TablePage<Provider> {
|
|||
}
|
||||
|
||||
row(item: Provider): TemplateResult[] {
|
||||
return [
|
||||
html`<a href="#/core/providers/${item.pk}"> ${item.name} </a>`,
|
||||
item.assignedApplicationName
|
||||
? html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
|
||||
let app = html``;
|
||||
if (item.component === "ak-provider-scim-form") {
|
||||
app = html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
|
||||
${t`No application required.`}`;
|
||||
} else if (!item.assignedApplicationName) {
|
||||
app = html`<i class="pf-icon pf-icon-warning-triangle pf-m-warning"></i>
|
||||
${t`Warning: Provider not assigned to any application.`}`;
|
||||
} else {
|
||||
app = html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
|
||||
${t`Assigned to application `}
|
||||
<a href="#/core/applications/${item.assignedApplicationSlug}"
|
||||
>${item.assignedApplicationName}</a
|
||||
>`
|
||||
: html`<i class="pf-icon pf-icon-warning-triangle pf-m-warning"></i>
|
||||
${t`Warning: Provider not assigned to any application.`}`,
|
||||
>`;
|
||||
}
|
||||
return [
|
||||
html`<a href="#/core/providers/${item.pk}"> ${item.name} </a>`,
|
||||
app,
|
||||
html`${item.verboseName}`,
|
||||
html`<ak-forms-modal>
|
||||
<span slot="submit"> ${t`Update`} </span>
|
||||
|
|
|
@ -2,6 +2,7 @@ import "@goauthentik/admin/providers/ldap/LDAPProviderViewPage";
|
|||
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderViewPage";
|
||||
import "@goauthentik/admin/providers/proxy/ProxyProviderViewPage";
|
||||
import "@goauthentik/admin/providers/saml/SAMLProviderViewPage";
|
||||
import "@goauthentik/admin/providers/scim/SCIMProviderViewPage";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/EmptyState";
|
||||
|
@ -56,6 +57,10 @@ export class ProviderViewPage extends AKElement {
|
|||
return html`<ak-provider-ldap-view
|
||||
providerID=${ifDefined(this.provider.pk)}
|
||||
></ak-provider-ldap-view>`;
|
||||
case "ak-provider-scim-form":
|
||||
return html`<ak-provider-scim-view
|
||||
providerID=${ifDefined(this.provider.pk)}
|
||||
></ak-provider-scim-view>`;
|
||||
default:
|
||||
return html`<p>Invalid provider type ${this.provider?.component}</p>`;
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ export class SAMLProviderImportForm extends Form<SAMLProvider> {
|
|||
return new ProvidersApi(DEFAULT_CONFIG).providersSamlImportMetadataCreate({
|
||||
file: file,
|
||||
name: data.name,
|
||||
authorizationFlow: data.authorizationFlow,
|
||||
authorizationFlow: data.authorizationFlow || "",
|
||||
});
|
||||
};
|
||||
|
||||
|
|
178
web/src/admin/providers/scim/SCIMProviderForm.ts
Normal file
178
web/src/admin/providers/scim/SCIMProviderForm.ts
Normal file
|
@ -0,0 +1,178 @@
|
|||
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 { ModelForm } from "@goauthentik/elements/forms/ModelForm";
|
||||
import "@goauthentik/elements/forms/Radio";
|
||||
import "@goauthentik/elements/forms/SearchSelect";
|
||||
|
||||
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 { until } from "lit/directives/until.js";
|
||||
|
||||
import { PropertymappingsApi, ProvidersApi, SCIMProvider } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-provider-scim-form")
|
||||
export class SCIMProviderFormPage extends ModelForm<SCIMProvider, number> {
|
||||
loadInstance(pk: number): Promise<SCIMProvider> {
|
||||
return new ProvidersApi(DEFAULT_CONFIG).providersScimRetrieve({
|
||||
id: pk,
|
||||
});
|
||||
}
|
||||
|
||||
getSuccessMessage(): string {
|
||||
if (this.instance) {
|
||||
return t`Successfully updated provider.`;
|
||||
} else {
|
||||
return t`Successfully created provider.`;
|
||||
}
|
||||
}
|
||||
|
||||
send = (data: SCIMProvider): Promise<SCIMProvider> => {
|
||||
if (this.instance) {
|
||||
return new ProvidersApi(DEFAULT_CONFIG).providersScimUpdate({
|
||||
id: this.instance.pk || 0,
|
||||
sCIMProviderRequest: data,
|
||||
});
|
||||
} else {
|
||||
return new ProvidersApi(DEFAULT_CONFIG).providersScimCreate({
|
||||
sCIMProviderRequest: data,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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-group .expanded=${true}>
|
||||
<span slot="header"> ${t`Protocol settings`} </span>
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-form-element-horizontal label=${t`URL`} ?required=${true} name="url">
|
||||
<input
|
||||
type="text"
|
||||
value="${first(this.instance?.url, "")}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`SCIM base url, usually ends in /v2.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${t`Token`} ?required=${true} name="token">
|
||||
<input
|
||||
type="text"
|
||||
value="${first(this.instance?.token, "")}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Token to authenticate with. Currently only bearer authentication is supported.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
</ak-form-group>
|
||||
<ak-form-group ?expanded=${true}>
|
||||
<span slot="header"> ${t`Attribute mapping`} </span>
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-form-element-horizontal
|
||||
label=${t`User Property Mappings`}
|
||||
?required=${true}
|
||||
name="propertyMappings"
|
||||
>
|
||||
<select class="pf-c-form-control" multiple>
|
||||
${until(
|
||||
new PropertymappingsApi(DEFAULT_CONFIG)
|
||||
.propertymappingsScimList({
|
||||
ordering: "managed",
|
||||
})
|
||||
.then((mappings) => {
|
||||
return mappings.results.map((mapping) => {
|
||||
let selected = false;
|
||||
if (!this.instance?.propertyMappings) {
|
||||
selected =
|
||||
mapping.managed ===
|
||||
"goauthentik.io/providers/scim/user" ||
|
||||
false;
|
||||
} else {
|
||||
selected = Array.from(
|
||||
this.instance?.propertyMappings,
|
||||
).some((su) => {
|
||||
return su == mapping.pk;
|
||||
});
|
||||
}
|
||||
return html`<option
|
||||
value=${ifDefined(mapping.pk)}
|
||||
?selected=${selected}
|
||||
>
|
||||
${mapping.name}
|
||||
</option>`;
|
||||
});
|
||||
}),
|
||||
html`<option>${t`Loading...`}</option>`,
|
||||
)}
|
||||
</select>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Property mappings used to user mapping.`}
|
||||
</p>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Hold control/command to select multiple items.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${t`Group Property Mappings`}
|
||||
?required=${true}
|
||||
name="propertyMappingsGroup"
|
||||
>
|
||||
<select class="pf-c-form-control" multiple>
|
||||
${until(
|
||||
new PropertymappingsApi(DEFAULT_CONFIG)
|
||||
.propertymappingsScimList({
|
||||
ordering: "managed",
|
||||
})
|
||||
.then((mappings) => {
|
||||
return mappings.results.map((mapping) => {
|
||||
let selected = false;
|
||||
if (!this.instance?.propertyMappingsGroup) {
|
||||
selected =
|
||||
mapping.managed ===
|
||||
"goauthentik.io/providers/scim/group";
|
||||
} else {
|
||||
selected = Array.from(
|
||||
this.instance?.propertyMappingsGroup,
|
||||
).some((su) => {
|
||||
return su == mapping.pk;
|
||||
});
|
||||
}
|
||||
return html`<option
|
||||
value=${ifDefined(mapping.pk)}
|
||||
?selected=${selected}
|
||||
>
|
||||
${mapping.name}
|
||||
</option>`;
|
||||
});
|
||||
}),
|
||||
html`<option>${t`Loading...`}</option>`,
|
||||
)}
|
||||
</select>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Property mappings used to group creation.`}
|
||||
</p>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Hold control/command to select multiple items.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
</ak-form-group>
|
||||
</form>`;
|
||||
}
|
||||
}
|
211
web/src/admin/providers/scim/SCIMProviderViewPage.ts
Normal file
211
web/src/admin/providers/scim/SCIMProviderViewPage.ts
Normal file
|
@ -0,0 +1,211 @@
|
|||
import "@goauthentik/admin/providers/scim/SCIMProviderForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import MDSCIMProvider from "@goauthentik/docs/providers/scim/index.md";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/Markdown";
|
||||
import "@goauthentik/elements/Tabs";
|
||||
import "@goauthentik/elements/buttons/ActionButton";
|
||||
import "@goauthentik/elements/buttons/ModalButton";
|
||||
import "@goauthentik/elements/events/ObjectChangelog";
|
||||
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { until } from "lit/directives/until.js";
|
||||
|
||||
import AKGlobal from "@goauthentik/common/styles/authentik.css";
|
||||
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 PFList from "@patternfly/patternfly/components/List/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 { ProvidersApi, SCIMProvider, SessionUser } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-provider-scim-view")
|
||||
export class SCIMProviderViewPage extends AKElement {
|
||||
@property()
|
||||
set args(value: { [key: string]: number }) {
|
||||
this.providerID = value.id;
|
||||
}
|
||||
|
||||
@property({ type: Number })
|
||||
set providerID(value: number) {
|
||||
new ProvidersApi(DEFAULT_CONFIG)
|
||||
.providersScimRetrieve({
|
||||
id: value,
|
||||
})
|
||||
.then((prov) => (this.provider = prov));
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
provider?: SCIMProvider;
|
||||
|
||||
@state()
|
||||
me?: SessionUser;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [
|
||||
PFBase,
|
||||
PFButton,
|
||||
PFBanner,
|
||||
PFForm,
|
||||
PFFormControl,
|
||||
PFList,
|
||||
PFGrid,
|
||||
PFPage,
|
||||
PFContent,
|
||||
PFCard,
|
||||
PFDescriptionList,
|
||||
AKGlobal,
|
||||
];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addEventListener(EVENT_REFRESH, () => {
|
||||
if (!this.provider?.pk) return;
|
||||
this.providerID = this.provider?.pk;
|
||||
});
|
||||
me().then((user) => {
|
||||
this.me = user;
|
||||
});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.provider) {
|
||||
return html``;
|
||||
}
|
||||
return html` <ak-tabs>
|
||||
<section slot="page-overview" data-tab-title="${t`Overview`}">
|
||||
${this.renderTabOverview()}
|
||||
</section>
|
||||
<section
|
||||
slot="page-changelog"
|
||||
data-tab-title="${t`Changelog`}"
|
||||
class="pf-c-page__main-section pf-m-no-padding-mobile"
|
||||
>
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-card__body">
|
||||
<ak-object-changelog
|
||||
targetModelPk=${this.provider?.pk || ""}
|
||||
targetModelName=${this.provider?.metaModelName || ""}
|
||||
>
|
||||
</ak-object-changelog>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ak-tabs>`;
|
||||
}
|
||||
|
||||
renderTabOverview(): TemplateResult {
|
||||
if (!this.provider) {
|
||||
return html``;
|
||||
}
|
||||
return html` <div slot="header" class="pf-c-banner pf-m-info">
|
||||
${t`SCIM provider is in preview.`}
|
||||
</div>
|
||||
<div class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
|
||||
<div class="pf-l-grid__item pf-m-7-col pf-l-grid pf-m-gutter">
|
||||
<div class="pf-c-card pf-m-12-col">
|
||||
<div class="pf-c-card__body">
|
||||
<dl class="pf-c-description-list pf-m-3-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.provider.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`URL`}</span>
|
||||
</dt>
|
||||
<dd class="pf-c-description-list__description">
|
||||
<div class="pf-c-description-list__text">
|
||||
${this.provider.url}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="pf-c-card__footer">
|
||||
<ak-forms-modal>
|
||||
<span slot="submit"> ${t`Update`} </span>
|
||||
<span slot="header"> ${t`Update LDAP Provider`} </span>
|
||||
<ak-provider-ldap-form slot="form" .instancePk=${this.provider.pk}>
|
||||
</ak-provider-ldap-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-primary">
|
||||
${t`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__title">
|
||||
<p>${t`Sync status`}</p>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
${until(
|
||||
new ProvidersApi(DEFAULT_CONFIG)
|
||||
.providersScimSyncStatusRetrieve({
|
||||
id: this.provider.pk,
|
||||
})
|
||||
.then((task) => {
|
||||
return html` <ul class="pf-c-list">
|
||||
${task.messages.map((m) => {
|
||||
return html`<li>${m}</li>`;
|
||||
})}
|
||||
</ul>`;
|
||||
})
|
||||
.catch(() => {
|
||||
return html`${t`Sync not run yet.`}`;
|
||||
}),
|
||||
"loading",
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="pf-c-card__footer">
|
||||
<ak-action-button
|
||||
class="pf-m-secondary"
|
||||
.apiRequest=${() => {
|
||||
return new ProvidersApi(DEFAULT_CONFIG)
|
||||
.providersScimPartialUpdate({
|
||||
id: this.provider?.pk || 0,
|
||||
patchedSCIMProviderRequest: this.provider,
|
||||
})
|
||||
.then(() => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(EVENT_REFRESH, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
${t`Run sync again`}
|
||||
</ak-action-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card pf-l-grid__item pf-m-5-col">
|
||||
<div class="pf-c-card__body">
|
||||
<ak-markdown .md=${MDSCIMProvider}></ak-markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ import "@goauthentik/elements/Alert";
|
|||
import { Level } from "@goauthentik/elements/Alert";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
|
@ -35,7 +35,16 @@ export class Markdown extends AKElement {
|
|||
];
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFList, PFContent, AKGlobal];
|
||||
return [
|
||||
PFList,
|
||||
PFContent,
|
||||
AKGlobal,
|
||||
css`
|
||||
h2:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
replaceAdmonitions(input: string): string {
|
||||
|
|
40
website/docs/providers/scim/index.md
Normal file
40
website/docs/providers/scim/index.md
Normal file
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
title: SCIM Provider
|
||||
---
|
||||
|
||||
SCIM (System for Cross-domain Identity Management) is a set of APIs to provision users and groups. The SCIM provider in authentik supports SCIM 2.0 and can be used to provision and sync users from authentik into other applications.
|
||||
|
||||
:::info
|
||||
The SCIM provider is currently in Preview.
|
||||
:::
|
||||
|
||||
### Configuration
|
||||
|
||||
A SCIM provider requires a base URL and a token. SCIM works via HTTP requests, so authentik must be able to reach the specified endpoint.
|
||||
|
||||
When configuring SCIM, you'll get an endpoint and a token from the application that accepts SCIM data. This endpoint usually ends in `/v2`, which corresponds to the SCIM version supported.
|
||||
|
||||
The token given by the application will be sent with all outgoing SCIM requests to authenticate them.
|
||||
|
||||
### Syncing
|
||||
|
||||
Data is synchronized in multiple ways:
|
||||
|
||||
- When a user/group is created/modified/deleted, that action is sent to all SCIM providers
|
||||
- Periodically (once an hour), all SCIM providers are fully synchronized
|
||||
|
||||
The actual synchronization process is run in the authentik worker. To allow this process to better to scale, a task is started for each 100 users and groups, so when multiple workers are available the workload will be distributed.
|
||||
|
||||
### Supported features
|
||||
|
||||
SCIM defines multiple optional features, some of which are supported by the SCIM provider.
|
||||
|
||||
- Bulk updates
|
||||
- Password changes
|
||||
- Etag
|
||||
|
||||
### Attribute mapping
|
||||
|
||||
Attribute mapping from authentik to SCIM users is done via property mappings as with other providers. The default mappings for users and groups make some assumptions that should work for most setups, but it is also possible to define custom mappings to add fields.
|
||||
|
||||
All selected mappings are applied in the order of their name, and are deeply merged onto the final user data. The final data is then validated against the SCIM schema, and if the data is not valid, the sync is stopped.
|
|
@ -77,6 +77,7 @@ module.exports = {
|
|||
},
|
||||
items: ["providers/ldap/generic_setup"],
|
||||
},
|
||||
"providers/scim/index",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
Reference in a new issue