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:
Jens L 2023-03-06 19:39:08 +01:00 committed by GitHub
parent dbc07f55f4
commit 28ddeb124f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 5422 additions and 192 deletions

View File

@ -16,7 +16,8 @@
"passwordless", "passwordless",
"kubernetes", "kubernetes",
"sso", "sso",
"slo" "slo",
"scim",
], ],
"python.linting.pylintEnabled": true, "python.linting.pylintEnabled": true,
"todo-tree.tree.showCountsInTree": true, "todo-tree.tree.showCountsInTree": true,

View File

@ -96,7 +96,7 @@ RUN apt-get update && \
COPY ./authentik/ /authentik COPY ./authentik/ /authentik
COPY ./pyproject.toml / COPY ./pyproject.toml /
COPY ./xml /xml COPY ./schemas /schemas
COPY ./locale /locale COPY ./locale /locale
COPY ./tests /tests COPY ./tests /tests
COPY ./manage.py / COPY ./manage.py /

View File

@ -58,6 +58,8 @@ from authentik.providers.oauth2.api.tokens import (
from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet
from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet
from authentik.providers.saml.api.providers import SAMLProviderViewSet 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.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
from authentik.sources.oauth.api.source import OAuthSourceViewSet from authentik.sources.oauth.api.source import OAuthSourceViewSet
from authentik.sources.oauth.api.source_connection import UserOAuthSourceConnectionViewSet 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/proxy", ProxyProviderViewSet)
router.register("providers/oauth2", OAuth2ProviderViewSet) router.register("providers/oauth2", OAuth2ProviderViewSet)
router.register("providers/saml", SAMLProviderViewSet) router.register("providers/saml", SAMLProviderViewSet)
router.register("providers/scim", SCIMProviderViewSet)
router.register("oauth2/authorization_codes", AuthorizationCodeViewSet) router.register("oauth2/authorization_codes", AuthorizationCodeViewSet)
router.register("oauth2/refresh_tokens", RefreshTokenViewSet) router.register("oauth2/refresh_tokens", RefreshTokenViewSet)
@ -173,6 +176,7 @@ router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
router.register("propertymappings/saml", SAMLPropertyMappingViewSet) router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
router.register("propertymappings/scope", ScopeMappingViewSet) router.register("propertymappings/scope", ScopeMappingViewSet)
router.register("propertymappings/notification", NotificationWebhookMappingViewSet) router.register("propertymappings/notification", NotificationWebhookMappingViewSet)
router.register("propertymappings/scim", SCIMMappingViewSet)
router.register("authenticators/all", DeviceViewSet, basename="device") router.register("authenticators/all", DeviceViewSet, basename="device")
router.register("authenticators/duo", DuoDeviceViewSet) router.register("authenticators/duo", DuoDeviceViewSet)

View File

@ -44,6 +44,9 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"verbose_name_plural", "verbose_name_plural",
"meta_model_name", "meta_model_name",
] ]
extra_kwargs = {
"authorization_flow": {"required": True, "allow_null": False},
}
class ProviderViewSet( class ProviderViewSet(

View File

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

View File

@ -248,6 +248,7 @@ class Provider(SerializerModel):
authorization_flow = models.ForeignKey( authorization_flow = models.ForeignKey(
"authentik_flows.Flow", "authentik_flows.Flow",
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True,
help_text=_("Flow used when authorizing this provider."), help_text=_("Flow used when authorizing this provider."),
related_name="provider_authorization", related_name="provider_authorization",
) )
@ -630,7 +631,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
try: try:
return evaluator.evaluate(self.expression) return evaluator.evaluate(self.expression)
except Exception as exc: except Exception as exc:
raise PropertyMappingExpressionException(str(exc)) from exc raise PropertyMappingExpressionException(exc) from exc
def __str__(self): def __str__(self):
return f"Property Mapping {self.name}" return f"Property Mapping {self.name}"

View File

@ -29,6 +29,7 @@ from authentik.lib.utils.errors import exception_to_string
from authentik.outposts.models import OutpostServiceConnection from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel from authentik.policies.models import Policy, PolicyBindingModel
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
from authentik.providers.scim.models import SCIMGroup, SCIMUser
IGNORED_MODELS = ( IGNORED_MODELS = (
Event, Event,
@ -49,6 +50,8 @@ IGNORED_MODELS = (
AuthorizationCode, AuthorizationCode,
AccessToken, AccessToken,
RefreshToken, RefreshToken,
SCIMUser,
SCIMGroup,
) )
@ -188,7 +191,7 @@ class AuditMiddleware:
user: User, request: HttpRequest, sender, instance: Model, action: str, **_ user: User, request: HttpRequest, sender, instance: Model, action: str, **_
): ):
"""Signal handler for all object's m2m_changed""" """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 return
if not should_log_m2m(instance): if not should_log_m2m(instance):
return return

View File

@ -16,7 +16,6 @@ from authentik.outposts.controllers.k8s.triggers import NeedsRecreate, NeedsUpda
if TYPE_CHECKING: if TYPE_CHECKING:
from authentik.outposts.controllers.kubernetes import KubernetesController from authentik.outposts.controllers.kubernetes import KubernetesController
# pylint: disable=invalid-name
T = TypeVar("T", V1Pod, V1Deployment) T = TypeVar("T", V1Pod, V1Deployment)
@ -56,6 +55,7 @@ class KubernetesObjectReconciler(Generic[T]):
} }
).lower() ).lower()
# pylint: disable=invalid-name
def up(self): def up(self):
"""Create object if it doesn't exist, update if needed or recreate if needed.""" """Create object if it doesn't exist, update if needed or recreate if needed."""
current = None current = None

View File

@ -73,9 +73,9 @@ class AssertionProcessor:
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions # https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
attribute_statement = Element(f"{{{NS_SAML_ASSERTION}}}AttributeStatement") attribute_statement = Element(f"{{{NS_SAML_ASSERTION}}}AttributeStatement")
user = self.http_request.user user = self.http_request.user
for mapping in self.provider.property_mappings.all().select_subclasses(): for mapping in SAMLPropertyMapping.objects.filter(provider=self.provider).order_by(
if not isinstance(mapping, SAMLPropertyMapping): "saml_name"
continue ):
try: try:
mapping: SAMLPropertyMapping mapping: SAMLPropertyMapping
value = mapping.evaluate( value = mapping.evaluate(

View File

@ -1,6 +0,0 @@
"""saml provider settings"""
AUTHENTIK_PROVIDERS_SAML_PROCESSORS = [
"authentik.providers.saml.processors.generic",
"authentik.providers.saml.processors.salesforce",
]

View File

@ -59,7 +59,7 @@ class TestServiceProviderMetadataParser(TestCase):
request = self.factory.get("/") request = self.factory.get("/")
metadata = lxml_from_string(MetadataProcessor(provider, request).build_entity_descriptor()) 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)) self.assertTrue(schema.validate(metadata))
def test_simple(self): def test_simple(self):

View File

@ -46,7 +46,7 @@ class TestSchema(TestCase):
metadata = lxml_from_string(request) 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)) self.assertTrue(schema.validate(metadata))
def test_response_schema(self): def test_response_schema(self):
@ -67,5 +67,5 @@ class TestSchema(TestCase):
metadata = lxml_from_string(response) 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)) self.assertTrue(schema.validate(metadata))

View File

View File

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

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

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

View File

@ -0,0 +1,2 @@
"""SCIM constants"""
PAGE_SIZE = 100

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

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

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

View 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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

View File

@ -80,6 +80,7 @@ INSTALLED_APPS = [
"authentik.providers.oauth2", "authentik.providers.oauth2",
"authentik.providers.proxy", "authentik.providers.proxy",
"authentik.providers.saml", "authentik.providers.saml",
"authentik.providers.scim",
"authentik.recovery", "authentik.recovery",
"authentik.sources.ldap", "authentik.sources.ldap",
"authentik.sources.oauth", "authentik.sources.oauth",

View File

@ -28,7 +28,7 @@ class TestMetadataProcessor(TestCase):
xml = MetadataProcessor(self.source, request).build_entity_descriptor() xml = MetadataProcessor(self.source, request).build_entity_descriptor()
metadata = lxml_from_string(xml) 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)) self.assertTrue(schema.validate(metadata))
def test_metadata_consistent(self): def test_metadata_consistent(self):

View File

@ -81,6 +81,8 @@
"authentik_providers_proxy.proxyprovider", "authentik_providers_proxy.proxyprovider",
"authentik_providers_saml.samlpropertymapping", "authentik_providers_saml.samlpropertymapping",
"authentik_providers_saml.samlprovider", "authentik_providers_saml.samlprovider",
"authentik_providers_scim.scimmapping",
"authentik_providers_scim.scimprovider",
"authentik_sources_ldap.ldappropertymapping", "authentik_sources_ldap.ldappropertymapping",
"authentik_sources_ldap.ldapsource", "authentik_sources_ldap.ldapsource",
"authentik_sources_oauth.oauthsource", "authentik_sources_oauth.oauthsource",

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

@ -1172,6 +1172,27 @@ django = "*"
django-guardian = "*" django-guardian = "*"
djangorestframework = "*" 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]] [[package]]
name = "docker" name = "docker"
version = "6.0.1" version = "6.0.1"
@ -1249,6 +1270,22 @@ files = [
setuptools = "*" setuptools = "*"
six = "*" 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]] [[package]]
name = "facebook-sdk" name = "facebook-sdk"
version = "3.1.0" version = "3.1.0"
@ -2425,12 +2462,31 @@ files = [
] ]
[package.dependencies] [package.dependencies]
email-validator = {version = ">=1.0.3", optional = true, markers = "extra == \"email\""}
typing-extensions = ">=4.1.0" typing-extensions = ">=4.1.0"
[package.extras] [package.extras]
dotenv = ["python-dotenv (>=0.10.4)"] dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"] 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]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.6.0" version = "2.6.0"
@ -2533,7 +2589,7 @@ files = [
cffi = ">=1.4.1" cffi = ">=1.4.1"
[package.extras] [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)"] tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
[[package]] [[package]]
@ -3903,4 +3959,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "47eeb02200cb4980368d3a11c6bee111a19a86d7e4d8ad90ef3bd590493f28b3" content-hash = "2ebb5d81a0b4c0883704dd8b74dc9bf7e8893cd7caadccaa0c47237e0394d54a"

View File

@ -161,6 +161,7 @@ wsproto = "*"
xmlsec = "*" xmlsec = "*"
zxcvbn = "*" zxcvbn = "*"
watchdog = "*" watchdog = "*"
pydantic-scim = "^0.0.7"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
bandit = "*" bandit = "*"

2508
schema.yml

File diff suppressed because it is too large Load Diff

View 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": []
}
}
}

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

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

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

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

View File

@ -97,7 +97,6 @@ export class ApplicationListPage extends TablePage<Application> {
></ak-application-wizard> ></ak-application-wizard>
<div class="pf-c-sidebar__panel pf-m-width-25"> <div class="pf-c-sidebar__panel pf-m-width-25">
<div class="pf-c-card"> <div class="pf-c-card">
<div class="pf-c-card__title">${t`About applications`}</div>
<div class="pf-c-card__body"> <div class="pf-c-card__body">
<ak-markdown .md=${MDApplication}></ak-markdown> <ak-markdown .md=${MDApplication}></ak-markdown>
</div> </div>

View File

@ -1,6 +1,7 @@
import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm"; import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm";
import "@goauthentik/admin/property-mappings/PropertyMappingNotification"; import "@goauthentik/admin/property-mappings/PropertyMappingNotification";
import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm"; import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm";
import "@goauthentik/admin/property-mappings/PropertyMappingSCIMForm";
import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm"; import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm";
import "@goauthentik/admin/property-mappings/PropertyMappingTestForm"; import "@goauthentik/admin/property-mappings/PropertyMappingTestForm";
import "@goauthentik/admin/property-mappings/PropertyMappingWizard"; import "@goauthentik/admin/property-mappings/PropertyMappingWizard";

View File

@ -13,7 +13,7 @@ import { ifDefined } from "lit/directives/if-defined.js";
import { PropertymappingsApi, SAMLPropertyMapping } from "@goauthentik/api"; import { PropertymappingsApi, SAMLPropertyMapping } from "@goauthentik/api";
@customElement("ak-property-mapping-saml-form") @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> { loadInstance(pk: string): Promise<SAMLPropertyMapping> {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSamlRetrieve({ return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSamlRetrieve({
pmUuid: pk, pmUuid: pk,

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

View File

@ -3,6 +3,7 @@ import "@goauthentik/admin/providers/ldap/LDAPProviderForm";
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm"; import "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
import "@goauthentik/admin/providers/proxy/ProxyProviderForm"; import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/providers/saml/SAMLProviderForm"; import "@goauthentik/admin/providers/saml/SAMLProviderForm";
import "@goauthentik/admin/providers/scim/SCIMProviderForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config"; import { uiConfig } from "@goauthentik/common/ui/config";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
@ -81,16 +82,23 @@ export class ProviderListPage extends TablePage<Provider> {
} }
row(item: Provider): TemplateResult[] { row(item: Provider): TemplateResult[] {
return [ let app = html``;
html`<a href="#/core/providers/${item.pk}"> ${item.name} </a>`, if (item.component === "ak-provider-scim-form") {
item.assignedApplicationName app = html`<i class="pf-icon pf-icon-ok pf-m-success"></i>
? 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 `} ${t`Assigned to application `}
<a href="#/core/applications/${item.assignedApplicationSlug}" <a href="#/core/applications/${item.assignedApplicationSlug}"
>${item.assignedApplicationName}</a >${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`${item.verboseName}`,
html`<ak-forms-modal> html`<ak-forms-modal>
<span slot="submit"> ${t`Update`} </span> <span slot="submit"> ${t`Update`} </span>

View File

@ -2,6 +2,7 @@ import "@goauthentik/admin/providers/ldap/LDAPProviderViewPage";
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderViewPage"; import "@goauthentik/admin/providers/oauth2/OAuth2ProviderViewPage";
import "@goauthentik/admin/providers/proxy/ProxyProviderViewPage"; import "@goauthentik/admin/providers/proxy/ProxyProviderViewPage";
import "@goauthentik/admin/providers/saml/SAMLProviderViewPage"; import "@goauthentik/admin/providers/saml/SAMLProviderViewPage";
import "@goauthentik/admin/providers/scim/SCIMProviderViewPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
@ -56,6 +57,10 @@ export class ProviderViewPage extends AKElement {
return html`<ak-provider-ldap-view return html`<ak-provider-ldap-view
providerID=${ifDefined(this.provider.pk)} providerID=${ifDefined(this.provider.pk)}
></ak-provider-ldap-view>`; ></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: default:
return html`<p>Invalid provider type ${this.provider?.component}</p>`; return html`<p>Invalid provider type ${this.provider?.component}</p>`;
} }

View File

@ -32,7 +32,7 @@ export class SAMLProviderImportForm extends Form<SAMLProvider> {
return new ProvidersApi(DEFAULT_CONFIG).providersSamlImportMetadataCreate({ return new ProvidersApi(DEFAULT_CONFIG).providersSamlImportMetadataCreate({
file: file, file: file,
name: data.name, name: data.name,
authorizationFlow: data.authorizationFlow, authorizationFlow: data.authorizationFlow || "",
}); });
}; };

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

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

View File

@ -3,7 +3,7 @@ import "@goauthentik/elements/Alert";
import { Level } from "@goauthentik/elements/Alert"; import { Level } from "@goauthentik/elements/Alert";
import { AKElement } from "@goauthentik/elements/Base"; 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 { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
@ -35,7 +35,16 @@ export class Markdown extends AKElement {
]; ];
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [PFList, PFContent, AKGlobal]; return [
PFList,
PFContent,
AKGlobal,
css`
h2:first-of-type {
margin-top: 0;
}
`,
];
} }
replaceAdmonitions(input: string): string { replaceAdmonitions(input: string): string {

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

View File

@ -77,6 +77,7 @@ module.exports = {
}, },
items: ["providers/ldap/generic_setup"], items: ["providers/ldap/generic_setup"],
}, },
"providers/scim/index",
], ],
}, },
{ {