authentik.providers.scim.clients.users

User client

  1"""User client"""
  2
  3from typing import Any
  4
  5from django.db import transaction
  6from django.db.models import Q
  7from django.utils.http import urlencode
  8from orjson import dumps
  9from pydantic import ValidationError
 10
 11from authentik.core.models import User
 12from authentik.lib.merge import MERGE_LIST_UNIQUE
 13from authentik.lib.sync.mapper import PropertyMappingManager
 14from authentik.lib.sync.outgoing.exceptions import ObjectExistsSyncException, StopSync
 15from authentik.policies.utils import delete_none_values
 16from authentik.providers.scim.clients.base import SCIMClient
 17from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
 18from authentik.providers.scim.clients.schema import User as SCIMUserSchema
 19from authentik.providers.scim.models import SCIMMapping, SCIMProvider, SCIMProviderUser
 20
 21
 22class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
 23    """SCIM client for users"""
 24
 25    connection_type = SCIMProviderUser
 26    connection_type_query = "user"
 27    mapper: PropertyMappingManager
 28
 29    def __init__(self, provider: SCIMProvider):
 30        super().__init__(provider)
 31        self.mapper = PropertyMappingManager(
 32            self.provider.property_mappings.all().order_by("name").select_subclasses(),
 33            SCIMMapping,
 34            ["provider", "connection"],
 35        )
 36
 37    def to_schema(self, obj: User, connection: SCIMProviderUser) -> SCIMUserSchema:
 38        """Convert authentik user into SCIM"""
 39        raw_scim_user = super().to_schema(obj, connection)
 40        try:
 41            scim_user = SCIMUserSchema.model_validate(delete_none_values(raw_scim_user))
 42        except ValidationError as exc:
 43            raise StopSync(exc, obj) from exc
 44        if SCIM_USER_SCHEMA not in scim_user.schemas:
 45            scim_user.schemas.insert(0, SCIM_USER_SCHEMA)
 46        # As this might be unset, we need to tell pydantic it's set so ensure the schemas
 47        # are included, even if its just the defaults
 48        scim_user.schemas = list(scim_user.schemas)
 49        if not scim_user.externalId:
 50            scim_user.externalId = str(obj.uid)
 51        return scim_user
 52
 53    def delete(self, identifier: str):
 54        """Delete user"""
 55        SCIMProviderUser.objects.filter(provider=self.provider, scim_id=identifier).delete()
 56        return self._request("DELETE", f"/Users/{identifier}")
 57
 58    def create(self, user: User):
 59        """Create user from scratch and create a connection object"""
 60        scim_user = self.to_schema(user, None)
 61        with transaction.atomic():
 62            try:
 63                response = self._request(
 64                    "POST",
 65                    "/Users",
 66                    json=scim_user.model_dump(
 67                        mode="json",
 68                        exclude_unset=True,
 69                    ),
 70                )
 71            except ObjectExistsSyncException as exc:
 72                if not self._config.filter.supported:
 73                    raise exc
 74                users = self._request(
 75                    "GET",
 76                    f"/Users?{urlencode({'filter': f'userName eq "{scim_user.userName}"'})}",
 77                )
 78                users_res = users.get("Resources", [])
 79                if len(users_res) < 1:
 80                    raise exc
 81                return SCIMProviderUser.objects.create(
 82                    provider=self.provider,
 83                    user=user,
 84                    scim_id=users_res[0]["id"],
 85                    attributes=users_res[0],
 86                )
 87            else:
 88                scim_id = response.get("id")
 89                if not scim_id or scim_id == "":
 90                    raise StopSync("SCIM Response with missing or invalid `id`")
 91                return SCIMProviderUser.objects.create(
 92                    provider=self.provider, user=user, scim_id=scim_id, attributes=response
 93                )
 94
 95    def diff(self, local_created: dict[str, Any], connection: SCIMProviderUser):
 96        """Check if a user is different than what we last wrote to the remote system.
 97        Returns true if there is a difference in data."""
 98        local_known = connection.attributes
 99        local_updated = {}
100        MERGE_LIST_UNIQUE.merge(local_updated, local_known)
101        MERGE_LIST_UNIQUE.merge(local_updated, local_created)
102        return dumps(local_updated) != dumps(local_known)
103
104    def update(self, user: User, connection: SCIMProviderUser):
105        """Update existing user"""
106        scim_user = self.to_schema(user, connection)
107        scim_user.id = connection.scim_id
108        payload = scim_user.model_dump(
109            mode="json",
110            exclude_unset=True,
111        )
112        if not self.diff(payload, connection):
113            self.logger.debug("Skipping user write as data has not changed")
114            return
115        response = self._request(
116            "PUT",
117            f"/Users/{connection.scim_id}",
118            json=payload,
119        )
120        connection.attributes = response
121        connection.save()
122
123    def discover(self):
124        res = self._request("GET", "/Users")
125        seen_items = 0
126        expected_items = int(res["totalResults"])
127        while True:
128            for user in res["Resources"]:
129                self._discover_user_single(user)
130                seen_items += 1
131            if seen_items >= expected_items:
132                break
133            res = self._request("GET", f"/Users?startIndex={seen_items+1}")
134
135    def _discover_user_single(self, user: dict):
136        scim_user = SCIMUserSchema.model_validate(user)
137        if SCIMProviderUser.objects.filter(scim_id=scim_user.id, provider=self.provider).exists():
138            return
139        user_query = Q(username=scim_user.userName)
140        for email in scim_user.emails:
141            user_query |= Q(username=email.value) | Q(email=email.value)
142        ak_user = User.objects.filter(user_query).first()
143        if not ak_user:
144            return
145        SCIMProviderUser.objects.create(
146            provider=self.provider,
147            user=ak_user,
148            scim_id=scim_user.id,
149            attributes=user,
150        )
 23class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
 24    """SCIM client for users"""
 25
 26    connection_type = SCIMProviderUser
 27    connection_type_query = "user"
 28    mapper: PropertyMappingManager
 29
 30    def __init__(self, provider: SCIMProvider):
 31        super().__init__(provider)
 32        self.mapper = PropertyMappingManager(
 33            self.provider.property_mappings.all().order_by("name").select_subclasses(),
 34            SCIMMapping,
 35            ["provider", "connection"],
 36        )
 37
 38    def to_schema(self, obj: User, connection: SCIMProviderUser) -> SCIMUserSchema:
 39        """Convert authentik user into SCIM"""
 40        raw_scim_user = super().to_schema(obj, connection)
 41        try:
 42            scim_user = SCIMUserSchema.model_validate(delete_none_values(raw_scim_user))
 43        except ValidationError as exc:
 44            raise StopSync(exc, obj) from exc
 45        if SCIM_USER_SCHEMA not in scim_user.schemas:
 46            scim_user.schemas.insert(0, SCIM_USER_SCHEMA)
 47        # As this might be unset, we need to tell pydantic it's set so ensure the schemas
 48        # are included, even if its just the defaults
 49        scim_user.schemas = list(scim_user.schemas)
 50        if not scim_user.externalId:
 51            scim_user.externalId = str(obj.uid)
 52        return scim_user
 53
 54    def delete(self, identifier: str):
 55        """Delete user"""
 56        SCIMProviderUser.objects.filter(provider=self.provider, scim_id=identifier).delete()
 57        return self._request("DELETE", f"/Users/{identifier}")
 58
 59    def create(self, user: User):
 60        """Create user from scratch and create a connection object"""
 61        scim_user = self.to_schema(user, None)
 62        with transaction.atomic():
 63            try:
 64                response = self._request(
 65                    "POST",
 66                    "/Users",
 67                    json=scim_user.model_dump(
 68                        mode="json",
 69                        exclude_unset=True,
 70                    ),
 71                )
 72            except ObjectExistsSyncException as exc:
 73                if not self._config.filter.supported:
 74                    raise exc
 75                users = self._request(
 76                    "GET",
 77                    f"/Users?{urlencode({'filter': f'userName eq "{scim_user.userName}"'})}",
 78                )
 79                users_res = users.get("Resources", [])
 80                if len(users_res) < 1:
 81                    raise exc
 82                return SCIMProviderUser.objects.create(
 83                    provider=self.provider,
 84                    user=user,
 85                    scim_id=users_res[0]["id"],
 86                    attributes=users_res[0],
 87                )
 88            else:
 89                scim_id = response.get("id")
 90                if not scim_id or scim_id == "":
 91                    raise StopSync("SCIM Response with missing or invalid `id`")
 92                return SCIMProviderUser.objects.create(
 93                    provider=self.provider, user=user, scim_id=scim_id, attributes=response
 94                )
 95
 96    def diff(self, local_created: dict[str, Any], connection: SCIMProviderUser):
 97        """Check if a user is different than what we last wrote to the remote system.
 98        Returns true if there is a difference in data."""
 99        local_known = connection.attributes
100        local_updated = {}
101        MERGE_LIST_UNIQUE.merge(local_updated, local_known)
102        MERGE_LIST_UNIQUE.merge(local_updated, local_created)
103        return dumps(local_updated) != dumps(local_known)
104
105    def update(self, user: User, connection: SCIMProviderUser):
106        """Update existing user"""
107        scim_user = self.to_schema(user, connection)
108        scim_user.id = connection.scim_id
109        payload = scim_user.model_dump(
110            mode="json",
111            exclude_unset=True,
112        )
113        if not self.diff(payload, connection):
114            self.logger.debug("Skipping user write as data has not changed")
115            return
116        response = self._request(
117            "PUT",
118            f"/Users/{connection.scim_id}",
119            json=payload,
120        )
121        connection.attributes = response
122        connection.save()
123
124    def discover(self):
125        res = self._request("GET", "/Users")
126        seen_items = 0
127        expected_items = int(res["totalResults"])
128        while True:
129            for user in res["Resources"]:
130                self._discover_user_single(user)
131                seen_items += 1
132            if seen_items >= expected_items:
133                break
134            res = self._request("GET", f"/Users?startIndex={seen_items+1}")
135
136    def _discover_user_single(self, user: dict):
137        scim_user = SCIMUserSchema.model_validate(user)
138        if SCIMProviderUser.objects.filter(scim_id=scim_user.id, provider=self.provider).exists():
139            return
140        user_query = Q(username=scim_user.userName)
141        for email in scim_user.emails:
142            user_query |= Q(username=email.value) | Q(email=email.value)
143        ak_user = User.objects.filter(user_query).first()
144        if not ak_user:
145            return
146        SCIMProviderUser.objects.create(
147            provider=self.provider,
148            user=ak_user,
149            scim_id=scim_user.id,
150            attributes=user,
151        )

SCIM client for users

SCIMUserClient(provider: authentik.providers.scim.models.SCIMProvider)
30    def __init__(self, provider: SCIMProvider):
31        super().__init__(provider)
32        self.mapper = PropertyMappingManager(
33            self.provider.property_mappings.all().order_by("name").select_subclasses(),
34            SCIMMapping,
35            ["provider", "connection"],
36        )
connection_type_query = 'user'
38    def to_schema(self, obj: User, connection: SCIMProviderUser) -> SCIMUserSchema:
39        """Convert authentik user into SCIM"""
40        raw_scim_user = super().to_schema(obj, connection)
41        try:
42            scim_user = SCIMUserSchema.model_validate(delete_none_values(raw_scim_user))
43        except ValidationError as exc:
44            raise StopSync(exc, obj) from exc
45        if SCIM_USER_SCHEMA not in scim_user.schemas:
46            scim_user.schemas.insert(0, SCIM_USER_SCHEMA)
47        # As this might be unset, we need to tell pydantic it's set so ensure the schemas
48        # are included, even if its just the defaults
49        scim_user.schemas = list(scim_user.schemas)
50        if not scim_user.externalId:
51            scim_user.externalId = str(obj.uid)
52        return scim_user

Convert authentik user into SCIM

def delete(self, identifier: str):
54    def delete(self, identifier: str):
55        """Delete user"""
56        SCIMProviderUser.objects.filter(provider=self.provider, scim_id=identifier).delete()
57        return self._request("DELETE", f"/Users/{identifier}")

Delete user

def create(self, user: authentik.core.models.User):
59    def create(self, user: User):
60        """Create user from scratch and create a connection object"""
61        scim_user = self.to_schema(user, None)
62        with transaction.atomic():
63            try:
64                response = self._request(
65                    "POST",
66                    "/Users",
67                    json=scim_user.model_dump(
68                        mode="json",
69                        exclude_unset=True,
70                    ),
71                )
72            except ObjectExistsSyncException as exc:
73                if not self._config.filter.supported:
74                    raise exc
75                users = self._request(
76                    "GET",
77                    f"/Users?{urlencode({'filter': f'userName eq "{scim_user.userName}"'})}",
78                )
79                users_res = users.get("Resources", [])
80                if len(users_res) < 1:
81                    raise exc
82                return SCIMProviderUser.objects.create(
83                    provider=self.provider,
84                    user=user,
85                    scim_id=users_res[0]["id"],
86                    attributes=users_res[0],
87                )
88            else:
89                scim_id = response.get("id")
90                if not scim_id or scim_id == "":
91                    raise StopSync("SCIM Response with missing or invalid `id`")
92                return SCIMProviderUser.objects.create(
93                    provider=self.provider, user=user, scim_id=scim_id, attributes=response
94                )

Create user from scratch and create a connection object

def diff( self, local_created: dict[str, typing.Any], connection: authentik.providers.scim.models.SCIMProviderUser):
 96    def diff(self, local_created: dict[str, Any], connection: SCIMProviderUser):
 97        """Check if a user is different than what we last wrote to the remote system.
 98        Returns true if there is a difference in data."""
 99        local_known = connection.attributes
100        local_updated = {}
101        MERGE_LIST_UNIQUE.merge(local_updated, local_known)
102        MERGE_LIST_UNIQUE.merge(local_updated, local_created)
103        return dumps(local_updated) != dumps(local_known)

Check if a user is different than what we last wrote to the remote system. Returns true if there is a difference in data.

def update( self, user: authentik.core.models.User, connection: authentik.providers.scim.models.SCIMProviderUser):
105    def update(self, user: User, connection: SCIMProviderUser):
106        """Update existing user"""
107        scim_user = self.to_schema(user, connection)
108        scim_user.id = connection.scim_id
109        payload = scim_user.model_dump(
110            mode="json",
111            exclude_unset=True,
112        )
113        if not self.diff(payload, connection):
114            self.logger.debug("Skipping user write as data has not changed")
115            return
116        response = self._request(
117            "PUT",
118            f"/Users/{connection.scim_id}",
119            json=payload,
120        )
121        connection.attributes = response
122        connection.save()

Update existing user

def discover(self):
124    def discover(self):
125        res = self._request("GET", "/Users")
126        seen_items = 0
127        expected_items = int(res["totalResults"])
128        while True:
129            for user in res["Resources"]:
130                self._discover_user_single(user)
131                seen_items += 1
132            if seen_items >= expected_items:
133                break
134            res = self._request("GET", f"/Users?startIndex={seen_items+1}")

Optional method. Can be used to implement a "discovery" where upon creation of this provider, this function will be called and can pre-link any users/groups in the remote system with the respective object in authentik based on a common identifier