authentik.providers.scim.tests.test_user

SCIM User tests

  1"""SCIM User tests"""
  2
  3from json import loads
  4from unittest.mock import patch
  5
  6from django.test import TestCase
  7from jsonschema import validate
  8from requests_mock import Mocker
  9
 10from authentik.blueprints.tests import apply_blueprint
 11from authentik.core.models import Application, Group, User, UserTypes
 12from authentik.lib.generators import generate_id
 13from authentik.lib.sync.outgoing.base import SAFE_METHODS
 14from authentik.lib.sync.outgoing.exceptions import TransientSyncException
 15from authentik.providers.scim.models import SCIMMapping, SCIMProvider, SCIMProviderUser
 16from authentik.providers.scim.tasks import scim_sync, scim_sync_objects, sync_tasks
 17from authentik.tasks.models import Task
 18from authentik.tenants.models import Tenant
 19
 20
 21class SCIMUserTests(TestCase):
 22    """SCIM User tests"""
 23
 24    @apply_blueprint("system/providers-scim.yaml")
 25    def setUp(self) -> None:
 26        # Delete all users and groups as the mocked HTTP responses only return one ID
 27        # which will cause errors with multiple users
 28        Tenant.objects.update(avatars="none")
 29        User.objects.all().exclude_anonymous().delete()
 30        Group.objects.all().delete()
 31        self.provider: SCIMProvider = SCIMProvider.objects.create(
 32            name=generate_id(),
 33            url="https://localhost",
 34            token=generate_id(),
 35            exclude_users_service_account=True,
 36        )
 37        self.app: Application = Application.objects.create(
 38            name=generate_id(),
 39            slug=generate_id(),
 40        )
 41        self.app.backchannel_providers.add(self.provider)
 42        self.provider.property_mappings.add(
 43            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
 44        )
 45        self.provider.property_mappings_group.add(
 46            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
 47        )
 48
 49    @Mocker()
 50    def test_user_create(self, mock: Mocker):
 51        """Test user creation"""
 52        scim_id = generate_id()
 53        mock.get(
 54            "https://localhost/ServiceProviderConfig",
 55            json={},
 56        )
 57        mock.post(
 58            "https://localhost/Users",
 59            json={
 60                "id": scim_id,
 61            },
 62        )
 63        uid = generate_id()
 64        user = User.objects.create(
 65            username=uid,
 66            name=f"{uid} {uid}",
 67            email=f"{uid}@goauthentik.io",
 68        )
 69        self.assertEqual(mock.call_count, 2)
 70        self.assertEqual(mock.request_history[0].method, "GET")
 71        self.assertEqual(mock.request_history[1].method, "POST")
 72        self.assertJSONEqual(
 73            mock.request_history[1].body,
 74            {
 75                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
 76                "active": True,
 77                "emails": [
 78                    {
 79                        "primary": True,
 80                        "type": "other",
 81                        "value": f"{uid}@goauthentik.io",
 82                    }
 83                ],
 84                "externalId": user.uid,
 85                "name": {
 86                    "familyName": uid,
 87                    "formatted": f"{uid} {uid}",
 88                    "givenName": uid,
 89                },
 90                "displayName": f"{uid} {uid}",
 91                "userName": uid,
 92            },
 93        )
 94
 95    @Mocker()
 96    def test_user_create_custom_schema(self, mock: Mocker):
 97        """Test user creation with custom schema"""
 98        schema = SCIMMapping.objects.create(
 99            name="custom_schema",
100            expression="""return {
101                "schemas": ["urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User"],
102                "urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
103                    "startDate": "2024-04-10T00:00:00+0000",
104                },
105            }""",
106        )
107        self.provider.property_mappings.add(schema)
108        scim_id = generate_id()
109        mock.get(
110            "https://localhost/ServiceProviderConfig",
111            json={},
112        )
113        mock.post(
114            "https://localhost/Users",
115            json={
116                "id": scim_id,
117            },
118        )
119        uid = generate_id()
120        user = User.objects.create(
121            username=uid,
122            name=f"{uid} {uid}",
123            email=f"{uid}@goauthentik.io",
124        )
125        self.assertEqual(mock.call_count, 2)
126        self.assertEqual(mock.request_history[0].method, "GET")
127        self.assertEqual(mock.request_history[1].method, "POST")
128        self.assertJSONEqual(
129            mock.request_history[1].body,
130            {
131                "schemas": [
132                    "urn:ietf:params:scim:schemas:core:2.0:User",
133                    "urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User",
134                ],
135                "active": True,
136                "emails": [
137                    {
138                        "primary": True,
139                        "type": "other",
140                        "value": f"{uid}@goauthentik.io",
141                    }
142                ],
143                "externalId": user.uid,
144                "name": {
145                    "familyName": uid,
146                    "formatted": f"{uid} {uid}",
147                    "givenName": uid,
148                },
149                "displayName": f"{uid} {uid}",
150                "userName": uid,
151                "urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
152                    "startDate": "2024-04-10T00:00:00+0000",
153                },
154            },
155        )
156
157    @Mocker()
158    def test_user_create_different_provider_same_id(self, mock: Mocker):
159        """Test user creation with multiple providers that happen
160        to return the same object ID"""
161        # Create duplicate provider
162        provider: SCIMProvider = SCIMProvider.objects.create(
163            name=generate_id(),
164            url="https://localhost",
165            token=generate_id(),
166            exclude_users_service_account=True,
167        )
168        app: Application = Application.objects.create(
169            name=generate_id(),
170            slug=generate_id(),
171        )
172        app.backchannel_providers.add(provider)
173        provider.property_mappings.add(
174            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
175        )
176        provider.property_mappings_group.add(
177            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
178        )
179
180        scim_id = generate_id()
181        mock.get(
182            "https://localhost/ServiceProviderConfig",
183            json={},
184        )
185        mock.post(
186            "https://localhost/Users",
187            json={
188                "id": scim_id,
189            },
190        )
191        uid = generate_id()
192        user = User.objects.create(
193            username=uid,
194            name=f"{uid} {uid}",
195            email=f"{uid}@goauthentik.io",
196        )
197        self.assertEqual(mock.call_count, 4)
198        self.assertEqual(mock.request_history[0].method, "GET")
199        self.assertEqual(mock.request_history[1].method, "POST")
200        self.assertJSONEqual(
201            mock.request_history[1].body,
202            {
203                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
204                "active": True,
205                "emails": [
206                    {
207                        "primary": True,
208                        "type": "other",
209                        "value": f"{uid}@goauthentik.io",
210                    }
211                ],
212                "externalId": user.uid,
213                "name": {
214                    "familyName": uid,
215                    "formatted": f"{uid} {uid}",
216                    "givenName": uid,
217                },
218                "displayName": f"{uid} {uid}",
219                "userName": uid,
220            },
221        )
222
223    @Mocker()
224    def test_user_create_update(self, mock: Mocker):
225        """Test user creation and update"""
226        scim_id = generate_id()
227        mock: Mocker
228        mock.get(
229            "https://localhost/ServiceProviderConfig",
230            json={},
231        )
232        mock.post(
233            "https://localhost/Users",
234            json={
235                "id": scim_id,
236            },
237        )
238        mock.put(
239            "https://localhost/Users",
240            json={
241                "id": scim_id,
242            },
243        )
244        uid = generate_id()
245        user = User.objects.create(
246            username=uid,
247            name=f"{uid} {uid}",
248            email=f"{uid}@goauthentik.io",
249        )
250        self.assertEqual(mock.call_count, 2)
251        self.assertEqual(mock.request_history[0].method, "GET")
252        self.assertEqual(mock.request_history[1].method, "POST")
253        body = loads(mock.request_history[1].body)
254        with open("schemas/scim-user.schema.json", encoding="utf-8") as schema:
255            validate(body, loads(schema.read()))
256        self.assertEqual(
257            body,
258            {
259                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
260                "active": True,
261                "emails": [
262                    {
263                        "primary": True,
264                        "type": "other",
265                        "value": f"{uid}@goauthentik.io",
266                    }
267                ],
268                "displayName": f"{uid} {uid}",
269                "externalId": user.uid,
270                "name": {
271                    "familyName": uid,
272                    "formatted": f"{uid} {uid}",
273                    "givenName": uid,
274                },
275                "userName": uid,
276            },
277        )
278        # Update user
279        user.name = "foo bar"
280        user.save()
281        self.assertEqual(mock.call_count, 3)
282        self.assertEqual(mock.request_history[0].method, "GET")
283        self.assertEqual(mock.request_history[1].method, "POST")
284        self.assertEqual(mock.request_history[2].method, "PUT")
285
286    @Mocker()
287    def test_user_create_delete(self, mock: Mocker):
288        """Test user creation"""
289        scim_id = generate_id()
290        mock.get(
291            "https://localhost/ServiceProviderConfig",
292            json={},
293        )
294        mock.post(
295            "https://localhost/Users",
296            json={
297                "id": scim_id,
298            },
299        )
300        mock.delete(f"https://localhost/Users/{scim_id}", status_code=204)
301        uid = generate_id()
302        user = User.objects.create(
303            username=uid,
304            name=f"{uid} {uid}",
305            email=f"{uid}@goauthentik.io",
306        )
307        self.assertEqual(mock.call_count, 2)
308        self.assertEqual(mock.request_history[0].method, "GET")
309        self.assertEqual(mock.request_history[1].method, "POST")
310        self.assertJSONEqual(
311            mock.request_history[1].body,
312            {
313                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
314                "active": True,
315                "emails": [
316                    {
317                        "primary": True,
318                        "type": "other",
319                        "value": f"{uid}@goauthentik.io",
320                    }
321                ],
322                "externalId": user.uid,
323                "name": {
324                    "familyName": uid,
325                    "formatted": f"{uid} {uid}",
326                    "givenName": uid,
327                },
328                "displayName": f"{uid} {uid}",
329                "userName": uid,
330            },
331        )
332        user.delete()
333        self.assertEqual(mock.call_count, 3)
334        self.assertEqual(mock.request_history[0].method, "GET")
335        self.assertEqual(mock.request_history[1].method, "POST")
336        self.assertEqual(mock.request_history[2].method, "DELETE")
337        self.assertEqual(mock.request_history[2].url, f"https://localhost/Users/{scim_id}")
338
339    @Mocker()
340    def test_sync_task(self, mock: Mocker):
341        """Test sync tasks"""
342        user_scim_id = generate_id()
343        group_scim_id = generate_id()
344        uid = generate_id()
345        mock.get(
346            "https://localhost/ServiceProviderConfig",
347            json={},
348        )
349        mock.post(
350            "https://localhost/Users",
351            json={
352                "id": user_scim_id,
353            },
354        )
355        mock.put(
356            f"https://localhost/Users/{user_scim_id}",
357            json={
358                "id": user_scim_id,
359            },
360        )
361        mock.post(
362            "https://localhost/Groups",
363            json={
364                "id": group_scim_id,
365            },
366        )
367        user = User.objects.create(
368            username=uid,
369            name=f"{uid} {uid}",
370            email=f"{uid}@goauthentik.io",
371        )
372
373        scim_sync.send(self.provider.pk)
374
375        self.assertEqual(mock.call_count, 3)
376        self.assertEqual(mock.request_history[0].method, "GET")
377        self.assertEqual(mock.request_history[1].method, "POST")
378        self.assertEqual(mock.request_history[2].method, "PUT")
379        self.assertJSONEqual(
380            mock.request_history[1].body,
381            {
382                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
383                "active": True,
384                "emails": [
385                    {
386                        "primary": True,
387                        "type": "other",
388                        "value": f"{uid}@goauthentik.io",
389                    }
390                ],
391                "externalId": user.uid,
392                "name": {
393                    "familyName": uid,
394                    "formatted": f"{uid} {uid}",
395                    "givenName": uid,
396                },
397                "displayName": f"{uid} {uid}",
398                "userName": uid,
399            },
400        )
401
402    def test_user_create_dry_run(self):
403        """Test user creation (dry_run)"""
404        # Update the provider before we start mocking as saving the provider triggers a full sync
405        self.provider.dry_run = True
406        self.provider.save()
407        with Mocker() as mock:
408            scim_id = generate_id()
409            mock.get(
410                "https://localhost/ServiceProviderConfig",
411                json={},
412            )
413            mock.post(
414                "https://localhost/Users",
415                json={
416                    "id": scim_id,
417                },
418            )
419            uid = generate_id()
420            User.objects.create(
421                username=uid,
422                name=f"{uid} {uid}",
423                email=f"{uid}@goauthentik.io",
424            )
425            self.assertEqual(mock.call_count, 1, mock.request_history)
426            self.assertEqual(mock.request_history[0].method, "GET")
427
428    def test_sync_task_dry_run(self):
429        """Test sync tasks"""
430        # Update the provider before we start mocking as saving the provider triggers a full sync
431        self.provider.dry_run = True
432        self.provider.save()
433        with Mocker() as mock:
434            uid = generate_id()
435            mock.get(
436                "https://localhost/ServiceProviderConfig",
437                json={},
438            )
439            User.objects.create(
440                username=uid,
441                name=f"{uid} {uid}",
442                email=f"{uid}@goauthentik.io",
443            )
444
445            scim_sync.send(self.provider.pk)
446
447            self.assertEqual(mock.call_count, 1)
448            for request in mock.request_history:
449                self.assertIn(request.method, SAFE_METHODS)
450        task = list(
451            Task.objects.filter(
452                actor_name=scim_sync_objects.actor_name,
453                _uid__startswith=self.provider.name,
454            ).order_by("-mtime")
455        )[1]
456        self.assertIsNotNone(task)
457        log = task.tasklogs.filter(event="Dropping mutating request due to dry run").first()
458        self.assertIsNotNone(log)
459        self.assertIsNotNone(log.attributes["url"])
460        self.assertIsNotNone(log.attributes["body"])
461        self.assertIsNotNone(log.attributes["method"])
462
463    @Mocker()
464    def test_user_create_update_noop(self, mock: Mocker):
465        """Test user creation and update"""
466        scim_id = generate_id()
467        mock.get(
468            "https://localhost/ServiceProviderConfig",
469            json={},
470        )
471        mock.post(
472            "https://localhost/Users",
473            json={
474                "id": scim_id,
475            },
476        )
477        mock.put(
478            "https://localhost/Users",
479            json={
480                "id": scim_id,
481            },
482        )
483        uid = generate_id()
484        user = User.objects.create(
485            username=uid,
486            name=f"{uid} {uid}",
487            email=f"{uid}@goauthentik.io",
488        )
489        self.assertEqual(mock.call_count, 2)
490        self.assertEqual(mock.request_history[0].method, "GET")
491        self.assertEqual(mock.request_history[1].method, "POST")
492        body = loads(mock.request_history[1].body)
493        self.assertEqual(
494            body,
495            {
496                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
497                "active": True,
498                "emails": [
499                    {
500                        "primary": True,
501                        "type": "other",
502                        "value": f"{uid}@goauthentik.io",
503                    }
504                ],
505                "displayName": f"{uid} {uid}",
506                "externalId": user.uid,
507                "name": {
508                    "familyName": uid,
509                    "formatted": f"{uid} {uid}",
510                    "givenName": uid,
511                },
512                "userName": uid,
513            },
514        )
515        conn = SCIMProviderUser.objects.filter(user=user).first()
516        conn.attributes = {
517            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
518            "active": True,
519            "emails": [
520                {
521                    "primary": True,
522                    "type": "other",
523                    "value": f"{uid}@goauthentik.io",
524                }
525            ],
526            "displayName": f"{uid} {uid}",
527            "externalId": user.uid,
528            "name": {
529                "familyName": uid,
530                "formatted": f"{uid} {uid}",
531                "givenName": uid,
532            },
533            "userName": uid,
534            "id": scim_id,
535        }
536        conn.save()
537        user.save()
538        self.assertEqual(mock.call_count, 2)
539        self.assertEqual(mock.request_history[0].method, "GET")
540        self.assertEqual(mock.request_history[1].method, "POST")
541
542    @Mocker()
543    def test_discover(self, mock: Mocker):
544        user = User.objects.create(username="admin@goauthentik.io")
545        mock.get(
546            "https://localhost/ServiceProviderConfig",
547            json={},
548        )
549        mock.get(
550            "https://localhost/Users",
551            json={
552                "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
553                "totalResults": 2,
554                "startIndex": 1,
555                "itemsPerPage": 1,
556                "Resources": [
557                    {
558                        "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
559                        "id": "1",
560                        "userName": "admin@goauthentik.io",
561                        "name": {"givenName": "N/A", "familyName": "N/A"},
562                        "emails": [
563                            {"primary": True, "value": "admin@goauthentik.io", "type": "work"}
564                        ],
565                        "meta": {"resourceType": "User"},
566                        "sentryOrgRole": "owner",
567                        "active": True,
568                    },
569                ],
570            },
571        )
572        mock.get(
573            "https://localhost/Users?startIndex=2",
574            json={
575                "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
576                "totalResults": 2,
577                "startIndex": 2,
578                "itemsPerPage": 1,
579                "Resources": [
580                    {
581                        "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
582                        "id": "2",
583                        "userName": "jens@goauthentik.io",
584                        "name": {"givenName": "N/A", "familyName": "N/A"},
585                        "emails": [
586                            {"primary": True, "value": "jens@goauthentik.io", "type": "work"}
587                        ],
588                        "meta": {"resourceType": "User"},
589                        "sentryOrgRole": "member",
590                        "active": True,
591                    },
592                ],
593            },
594        )
595        self.provider.client_for_model(User).discover()
596        connection = SCIMProviderUser.objects.filter(provider=self.provider, user=user).first()
597        self.assertIsNotNone(connection)
598        self.assertEqual(connection.scim_id, "1")
599
600    def _create_stale_provider_user(self, scim_id: str, uid: str) -> User:
601        """Create a service-account user (excluded from provider scope) with an existing
602        SCIMProviderUser, simulating a previously synced user that is now out of scope."""
603        user = User.objects.create(
604            username=uid,
605            name=f"{uid} {uid}",
606            email=f"{uid}@goauthentik.io",
607            type=UserTypes.SERVICE_ACCOUNT,
608        )
609        SCIMProviderUser.objects.create(provider=self.provider, user=user, scim_id=scim_id)
610        return user
611
612    @Mocker()
613    def test_sync_cleanup_stale_user_delete(self, mock: Mocker):
614        """Stale (out-of-scope) users are deleted during full sync cleanup"""
615        scim_id = generate_id()
616        uid = generate_id()
617        mock.get("https://localhost/ServiceProviderConfig", json={})
618        mock.delete(f"https://localhost/Users/{scim_id}", status_code=204)
619        self._create_stale_provider_user(scim_id, uid)
620
621        scim_sync.send(self.provider.pk).get_result()
622
623        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
624        self.assertEqual(len(delete_reqs), 1)
625        self.assertEqual(delete_reqs[0].url, f"https://localhost/Users/{scim_id}")
626        self.assertFalse(
627            SCIMProviderUser.objects.filter(provider=self.provider, scim_id=scim_id).exists()
628        )
629
630    @Mocker()
631    def test_sync_cleanup_stale_user_not_found(self, mock: Mocker):
632        """Stale user cleanup handles 404 from the remote gracefully"""
633        scim_id = generate_id()
634        uid = generate_id()
635        mock.get("https://localhost/ServiceProviderConfig", json={})
636        mock.delete(f"https://localhost/Users/{scim_id}", status_code=404)
637        self._create_stale_provider_user(scim_id, uid)
638
639        scim_sync.send(self.provider.pk).get_result()
640
641        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
642        self.assertEqual(len(delete_reqs), 1)
643
644        self.assertFalse(
645            SCIMProviderUser.objects.filter(provider=self.provider, scim_id=scim_id).exists()
646        )
647
648    @Mocker()
649    def test_sync_cleanup_stale_user_transient_error(self, mock: Mocker):
650        """Stale user cleanup logs and retries on transient HTTP errors"""
651        scim_id = generate_id()
652        uid = generate_id()
653        mock.get("https://localhost/ServiceProviderConfig", json={})
654        mock.delete(f"https://localhost/Users/{scim_id}", status_code=429)
655        self._create_stale_provider_user(scim_id, uid)
656
657        scim_sync.send(self.provider.pk)
658
659        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
660        self.assertEqual(len(delete_reqs), 1)
661
662    @Mocker()
663    def test_sync_cleanup_stale_user_dry_run(self, mock: Mocker):
664        """Stale user cleanup skips HTTP DELETE in dry_run mode"""
665        self.provider.dry_run = True
666        self.provider.save()
667        scim_id = generate_id()
668        uid = generate_id()
669        mock.get("https://localhost/ServiceProviderConfig", json={})
670        self._create_stale_provider_user(scim_id, uid)
671
672        scim_sync.send(self.provider.pk)
673
674        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
675        self.assertEqual(len(delete_reqs), 0)
676
677    def test_sync_cleanup_client_for_model_transient(self):
678        """Cleanup silently skips an object type when client_for_model raises
679        TransientSyncException"""
680        with Mocker() as mock:
681            mock.get("https://localhost/ServiceProviderConfig", json={})
682            with patch.object(
683                SCIMProvider,
684                "client_for_model",
685                side_effect=TransientSyncException("connection failed"),
686            ):
687                scim_sync.send(self.provider.pk).get_result()
688
689    def test_sync_transient_exception(self):
690        """TransientSyncException in _sync_cleanup is caught by sync() which then
691        schedules a retry"""
692        with Mocker() as mock:
693            mock.get("https://localhost/ServiceProviderConfig", json={})
694            with patch.object(
695                sync_tasks,
696                "_sync_cleanup",
697                side_effect=TransientSyncException("connection failed"),
698            ):
699                scim_sync.send(self.provider.pk)
class SCIMUserTests(django.test.testcases.TestCase):
 22class SCIMUserTests(TestCase):
 23    """SCIM User tests"""
 24
 25    @apply_blueprint("system/providers-scim.yaml")
 26    def setUp(self) -> None:
 27        # Delete all users and groups as the mocked HTTP responses only return one ID
 28        # which will cause errors with multiple users
 29        Tenant.objects.update(avatars="none")
 30        User.objects.all().exclude_anonymous().delete()
 31        Group.objects.all().delete()
 32        self.provider: SCIMProvider = SCIMProvider.objects.create(
 33            name=generate_id(),
 34            url="https://localhost",
 35            token=generate_id(),
 36            exclude_users_service_account=True,
 37        )
 38        self.app: Application = Application.objects.create(
 39            name=generate_id(),
 40            slug=generate_id(),
 41        )
 42        self.app.backchannel_providers.add(self.provider)
 43        self.provider.property_mappings.add(
 44            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
 45        )
 46        self.provider.property_mappings_group.add(
 47            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
 48        )
 49
 50    @Mocker()
 51    def test_user_create(self, mock: Mocker):
 52        """Test user creation"""
 53        scim_id = generate_id()
 54        mock.get(
 55            "https://localhost/ServiceProviderConfig",
 56            json={},
 57        )
 58        mock.post(
 59            "https://localhost/Users",
 60            json={
 61                "id": scim_id,
 62            },
 63        )
 64        uid = generate_id()
 65        user = User.objects.create(
 66            username=uid,
 67            name=f"{uid} {uid}",
 68            email=f"{uid}@goauthentik.io",
 69        )
 70        self.assertEqual(mock.call_count, 2)
 71        self.assertEqual(mock.request_history[0].method, "GET")
 72        self.assertEqual(mock.request_history[1].method, "POST")
 73        self.assertJSONEqual(
 74            mock.request_history[1].body,
 75            {
 76                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
 77                "active": True,
 78                "emails": [
 79                    {
 80                        "primary": True,
 81                        "type": "other",
 82                        "value": f"{uid}@goauthentik.io",
 83                    }
 84                ],
 85                "externalId": user.uid,
 86                "name": {
 87                    "familyName": uid,
 88                    "formatted": f"{uid} {uid}",
 89                    "givenName": uid,
 90                },
 91                "displayName": f"{uid} {uid}",
 92                "userName": uid,
 93            },
 94        )
 95
 96    @Mocker()
 97    def test_user_create_custom_schema(self, mock: Mocker):
 98        """Test user creation with custom schema"""
 99        schema = SCIMMapping.objects.create(
100            name="custom_schema",
101            expression="""return {
102                "schemas": ["urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User"],
103                "urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
104                    "startDate": "2024-04-10T00:00:00+0000",
105                },
106            }""",
107        )
108        self.provider.property_mappings.add(schema)
109        scim_id = generate_id()
110        mock.get(
111            "https://localhost/ServiceProviderConfig",
112            json={},
113        )
114        mock.post(
115            "https://localhost/Users",
116            json={
117                "id": scim_id,
118            },
119        )
120        uid = generate_id()
121        user = User.objects.create(
122            username=uid,
123            name=f"{uid} {uid}",
124            email=f"{uid}@goauthentik.io",
125        )
126        self.assertEqual(mock.call_count, 2)
127        self.assertEqual(mock.request_history[0].method, "GET")
128        self.assertEqual(mock.request_history[1].method, "POST")
129        self.assertJSONEqual(
130            mock.request_history[1].body,
131            {
132                "schemas": [
133                    "urn:ietf:params:scim:schemas:core:2.0:User",
134                    "urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User",
135                ],
136                "active": True,
137                "emails": [
138                    {
139                        "primary": True,
140                        "type": "other",
141                        "value": f"{uid}@goauthentik.io",
142                    }
143                ],
144                "externalId": user.uid,
145                "name": {
146                    "familyName": uid,
147                    "formatted": f"{uid} {uid}",
148                    "givenName": uid,
149                },
150                "displayName": f"{uid} {uid}",
151                "userName": uid,
152                "urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
153                    "startDate": "2024-04-10T00:00:00+0000",
154                },
155            },
156        )
157
158    @Mocker()
159    def test_user_create_different_provider_same_id(self, mock: Mocker):
160        """Test user creation with multiple providers that happen
161        to return the same object ID"""
162        # Create duplicate provider
163        provider: SCIMProvider = SCIMProvider.objects.create(
164            name=generate_id(),
165            url="https://localhost",
166            token=generate_id(),
167            exclude_users_service_account=True,
168        )
169        app: Application = Application.objects.create(
170            name=generate_id(),
171            slug=generate_id(),
172        )
173        app.backchannel_providers.add(provider)
174        provider.property_mappings.add(
175            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
176        )
177        provider.property_mappings_group.add(
178            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
179        )
180
181        scim_id = generate_id()
182        mock.get(
183            "https://localhost/ServiceProviderConfig",
184            json={},
185        )
186        mock.post(
187            "https://localhost/Users",
188            json={
189                "id": scim_id,
190            },
191        )
192        uid = generate_id()
193        user = User.objects.create(
194            username=uid,
195            name=f"{uid} {uid}",
196            email=f"{uid}@goauthentik.io",
197        )
198        self.assertEqual(mock.call_count, 4)
199        self.assertEqual(mock.request_history[0].method, "GET")
200        self.assertEqual(mock.request_history[1].method, "POST")
201        self.assertJSONEqual(
202            mock.request_history[1].body,
203            {
204                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
205                "active": True,
206                "emails": [
207                    {
208                        "primary": True,
209                        "type": "other",
210                        "value": f"{uid}@goauthentik.io",
211                    }
212                ],
213                "externalId": user.uid,
214                "name": {
215                    "familyName": uid,
216                    "formatted": f"{uid} {uid}",
217                    "givenName": uid,
218                },
219                "displayName": f"{uid} {uid}",
220                "userName": uid,
221            },
222        )
223
224    @Mocker()
225    def test_user_create_update(self, mock: Mocker):
226        """Test user creation and update"""
227        scim_id = generate_id()
228        mock: Mocker
229        mock.get(
230            "https://localhost/ServiceProviderConfig",
231            json={},
232        )
233        mock.post(
234            "https://localhost/Users",
235            json={
236                "id": scim_id,
237            },
238        )
239        mock.put(
240            "https://localhost/Users",
241            json={
242                "id": scim_id,
243            },
244        )
245        uid = generate_id()
246        user = User.objects.create(
247            username=uid,
248            name=f"{uid} {uid}",
249            email=f"{uid}@goauthentik.io",
250        )
251        self.assertEqual(mock.call_count, 2)
252        self.assertEqual(mock.request_history[0].method, "GET")
253        self.assertEqual(mock.request_history[1].method, "POST")
254        body = loads(mock.request_history[1].body)
255        with open("schemas/scim-user.schema.json", encoding="utf-8") as schema:
256            validate(body, loads(schema.read()))
257        self.assertEqual(
258            body,
259            {
260                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
261                "active": True,
262                "emails": [
263                    {
264                        "primary": True,
265                        "type": "other",
266                        "value": f"{uid}@goauthentik.io",
267                    }
268                ],
269                "displayName": f"{uid} {uid}",
270                "externalId": user.uid,
271                "name": {
272                    "familyName": uid,
273                    "formatted": f"{uid} {uid}",
274                    "givenName": uid,
275                },
276                "userName": uid,
277            },
278        )
279        # Update user
280        user.name = "foo bar"
281        user.save()
282        self.assertEqual(mock.call_count, 3)
283        self.assertEqual(mock.request_history[0].method, "GET")
284        self.assertEqual(mock.request_history[1].method, "POST")
285        self.assertEqual(mock.request_history[2].method, "PUT")
286
287    @Mocker()
288    def test_user_create_delete(self, mock: Mocker):
289        """Test user creation"""
290        scim_id = generate_id()
291        mock.get(
292            "https://localhost/ServiceProviderConfig",
293            json={},
294        )
295        mock.post(
296            "https://localhost/Users",
297            json={
298                "id": scim_id,
299            },
300        )
301        mock.delete(f"https://localhost/Users/{scim_id}", status_code=204)
302        uid = generate_id()
303        user = User.objects.create(
304            username=uid,
305            name=f"{uid} {uid}",
306            email=f"{uid}@goauthentik.io",
307        )
308        self.assertEqual(mock.call_count, 2)
309        self.assertEqual(mock.request_history[0].method, "GET")
310        self.assertEqual(mock.request_history[1].method, "POST")
311        self.assertJSONEqual(
312            mock.request_history[1].body,
313            {
314                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
315                "active": True,
316                "emails": [
317                    {
318                        "primary": True,
319                        "type": "other",
320                        "value": f"{uid}@goauthentik.io",
321                    }
322                ],
323                "externalId": user.uid,
324                "name": {
325                    "familyName": uid,
326                    "formatted": f"{uid} {uid}",
327                    "givenName": uid,
328                },
329                "displayName": f"{uid} {uid}",
330                "userName": uid,
331            },
332        )
333        user.delete()
334        self.assertEqual(mock.call_count, 3)
335        self.assertEqual(mock.request_history[0].method, "GET")
336        self.assertEqual(mock.request_history[1].method, "POST")
337        self.assertEqual(mock.request_history[2].method, "DELETE")
338        self.assertEqual(mock.request_history[2].url, f"https://localhost/Users/{scim_id}")
339
340    @Mocker()
341    def test_sync_task(self, mock: Mocker):
342        """Test sync tasks"""
343        user_scim_id = generate_id()
344        group_scim_id = generate_id()
345        uid = generate_id()
346        mock.get(
347            "https://localhost/ServiceProviderConfig",
348            json={},
349        )
350        mock.post(
351            "https://localhost/Users",
352            json={
353                "id": user_scim_id,
354            },
355        )
356        mock.put(
357            f"https://localhost/Users/{user_scim_id}",
358            json={
359                "id": user_scim_id,
360            },
361        )
362        mock.post(
363            "https://localhost/Groups",
364            json={
365                "id": group_scim_id,
366            },
367        )
368        user = User.objects.create(
369            username=uid,
370            name=f"{uid} {uid}",
371            email=f"{uid}@goauthentik.io",
372        )
373
374        scim_sync.send(self.provider.pk)
375
376        self.assertEqual(mock.call_count, 3)
377        self.assertEqual(mock.request_history[0].method, "GET")
378        self.assertEqual(mock.request_history[1].method, "POST")
379        self.assertEqual(mock.request_history[2].method, "PUT")
380        self.assertJSONEqual(
381            mock.request_history[1].body,
382            {
383                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
384                "active": True,
385                "emails": [
386                    {
387                        "primary": True,
388                        "type": "other",
389                        "value": f"{uid}@goauthentik.io",
390                    }
391                ],
392                "externalId": user.uid,
393                "name": {
394                    "familyName": uid,
395                    "formatted": f"{uid} {uid}",
396                    "givenName": uid,
397                },
398                "displayName": f"{uid} {uid}",
399                "userName": uid,
400            },
401        )
402
403    def test_user_create_dry_run(self):
404        """Test user creation (dry_run)"""
405        # Update the provider before we start mocking as saving the provider triggers a full sync
406        self.provider.dry_run = True
407        self.provider.save()
408        with Mocker() as mock:
409            scim_id = generate_id()
410            mock.get(
411                "https://localhost/ServiceProviderConfig",
412                json={},
413            )
414            mock.post(
415                "https://localhost/Users",
416                json={
417                    "id": scim_id,
418                },
419            )
420            uid = generate_id()
421            User.objects.create(
422                username=uid,
423                name=f"{uid} {uid}",
424                email=f"{uid}@goauthentik.io",
425            )
426            self.assertEqual(mock.call_count, 1, mock.request_history)
427            self.assertEqual(mock.request_history[0].method, "GET")
428
429    def test_sync_task_dry_run(self):
430        """Test sync tasks"""
431        # Update the provider before we start mocking as saving the provider triggers a full sync
432        self.provider.dry_run = True
433        self.provider.save()
434        with Mocker() as mock:
435            uid = generate_id()
436            mock.get(
437                "https://localhost/ServiceProviderConfig",
438                json={},
439            )
440            User.objects.create(
441                username=uid,
442                name=f"{uid} {uid}",
443                email=f"{uid}@goauthentik.io",
444            )
445
446            scim_sync.send(self.provider.pk)
447
448            self.assertEqual(mock.call_count, 1)
449            for request in mock.request_history:
450                self.assertIn(request.method, SAFE_METHODS)
451        task = list(
452            Task.objects.filter(
453                actor_name=scim_sync_objects.actor_name,
454                _uid__startswith=self.provider.name,
455            ).order_by("-mtime")
456        )[1]
457        self.assertIsNotNone(task)
458        log = task.tasklogs.filter(event="Dropping mutating request due to dry run").first()
459        self.assertIsNotNone(log)
460        self.assertIsNotNone(log.attributes["url"])
461        self.assertIsNotNone(log.attributes["body"])
462        self.assertIsNotNone(log.attributes["method"])
463
464    @Mocker()
465    def test_user_create_update_noop(self, mock: Mocker):
466        """Test user creation and update"""
467        scim_id = generate_id()
468        mock.get(
469            "https://localhost/ServiceProviderConfig",
470            json={},
471        )
472        mock.post(
473            "https://localhost/Users",
474            json={
475                "id": scim_id,
476            },
477        )
478        mock.put(
479            "https://localhost/Users",
480            json={
481                "id": scim_id,
482            },
483        )
484        uid = generate_id()
485        user = User.objects.create(
486            username=uid,
487            name=f"{uid} {uid}",
488            email=f"{uid}@goauthentik.io",
489        )
490        self.assertEqual(mock.call_count, 2)
491        self.assertEqual(mock.request_history[0].method, "GET")
492        self.assertEqual(mock.request_history[1].method, "POST")
493        body = loads(mock.request_history[1].body)
494        self.assertEqual(
495            body,
496            {
497                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
498                "active": True,
499                "emails": [
500                    {
501                        "primary": True,
502                        "type": "other",
503                        "value": f"{uid}@goauthentik.io",
504                    }
505                ],
506                "displayName": f"{uid} {uid}",
507                "externalId": user.uid,
508                "name": {
509                    "familyName": uid,
510                    "formatted": f"{uid} {uid}",
511                    "givenName": uid,
512                },
513                "userName": uid,
514            },
515        )
516        conn = SCIMProviderUser.objects.filter(user=user).first()
517        conn.attributes = {
518            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
519            "active": True,
520            "emails": [
521                {
522                    "primary": True,
523                    "type": "other",
524                    "value": f"{uid}@goauthentik.io",
525                }
526            ],
527            "displayName": f"{uid} {uid}",
528            "externalId": user.uid,
529            "name": {
530                "familyName": uid,
531                "formatted": f"{uid} {uid}",
532                "givenName": uid,
533            },
534            "userName": uid,
535            "id": scim_id,
536        }
537        conn.save()
538        user.save()
539        self.assertEqual(mock.call_count, 2)
540        self.assertEqual(mock.request_history[0].method, "GET")
541        self.assertEqual(mock.request_history[1].method, "POST")
542
543    @Mocker()
544    def test_discover(self, mock: Mocker):
545        user = User.objects.create(username="admin@goauthentik.io")
546        mock.get(
547            "https://localhost/ServiceProviderConfig",
548            json={},
549        )
550        mock.get(
551            "https://localhost/Users",
552            json={
553                "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
554                "totalResults": 2,
555                "startIndex": 1,
556                "itemsPerPage": 1,
557                "Resources": [
558                    {
559                        "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
560                        "id": "1",
561                        "userName": "admin@goauthentik.io",
562                        "name": {"givenName": "N/A", "familyName": "N/A"},
563                        "emails": [
564                            {"primary": True, "value": "admin@goauthentik.io", "type": "work"}
565                        ],
566                        "meta": {"resourceType": "User"},
567                        "sentryOrgRole": "owner",
568                        "active": True,
569                    },
570                ],
571            },
572        )
573        mock.get(
574            "https://localhost/Users?startIndex=2",
575            json={
576                "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
577                "totalResults": 2,
578                "startIndex": 2,
579                "itemsPerPage": 1,
580                "Resources": [
581                    {
582                        "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
583                        "id": "2",
584                        "userName": "jens@goauthentik.io",
585                        "name": {"givenName": "N/A", "familyName": "N/A"},
586                        "emails": [
587                            {"primary": True, "value": "jens@goauthentik.io", "type": "work"}
588                        ],
589                        "meta": {"resourceType": "User"},
590                        "sentryOrgRole": "member",
591                        "active": True,
592                    },
593                ],
594            },
595        )
596        self.provider.client_for_model(User).discover()
597        connection = SCIMProviderUser.objects.filter(provider=self.provider, user=user).first()
598        self.assertIsNotNone(connection)
599        self.assertEqual(connection.scim_id, "1")
600
601    def _create_stale_provider_user(self, scim_id: str, uid: str) -> User:
602        """Create a service-account user (excluded from provider scope) with an existing
603        SCIMProviderUser, simulating a previously synced user that is now out of scope."""
604        user = User.objects.create(
605            username=uid,
606            name=f"{uid} {uid}",
607            email=f"{uid}@goauthentik.io",
608            type=UserTypes.SERVICE_ACCOUNT,
609        )
610        SCIMProviderUser.objects.create(provider=self.provider, user=user, scim_id=scim_id)
611        return user
612
613    @Mocker()
614    def test_sync_cleanup_stale_user_delete(self, mock: Mocker):
615        """Stale (out-of-scope) users are deleted during full sync cleanup"""
616        scim_id = generate_id()
617        uid = generate_id()
618        mock.get("https://localhost/ServiceProviderConfig", json={})
619        mock.delete(f"https://localhost/Users/{scim_id}", status_code=204)
620        self._create_stale_provider_user(scim_id, uid)
621
622        scim_sync.send(self.provider.pk).get_result()
623
624        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
625        self.assertEqual(len(delete_reqs), 1)
626        self.assertEqual(delete_reqs[0].url, f"https://localhost/Users/{scim_id}")
627        self.assertFalse(
628            SCIMProviderUser.objects.filter(provider=self.provider, scim_id=scim_id).exists()
629        )
630
631    @Mocker()
632    def test_sync_cleanup_stale_user_not_found(self, mock: Mocker):
633        """Stale user cleanup handles 404 from the remote gracefully"""
634        scim_id = generate_id()
635        uid = generate_id()
636        mock.get("https://localhost/ServiceProviderConfig", json={})
637        mock.delete(f"https://localhost/Users/{scim_id}", status_code=404)
638        self._create_stale_provider_user(scim_id, uid)
639
640        scim_sync.send(self.provider.pk).get_result()
641
642        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
643        self.assertEqual(len(delete_reqs), 1)
644
645        self.assertFalse(
646            SCIMProviderUser.objects.filter(provider=self.provider, scim_id=scim_id).exists()
647        )
648
649    @Mocker()
650    def test_sync_cleanup_stale_user_transient_error(self, mock: Mocker):
651        """Stale user cleanup logs and retries on transient HTTP errors"""
652        scim_id = generate_id()
653        uid = generate_id()
654        mock.get("https://localhost/ServiceProviderConfig", json={})
655        mock.delete(f"https://localhost/Users/{scim_id}", status_code=429)
656        self._create_stale_provider_user(scim_id, uid)
657
658        scim_sync.send(self.provider.pk)
659
660        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
661        self.assertEqual(len(delete_reqs), 1)
662
663    @Mocker()
664    def test_sync_cleanup_stale_user_dry_run(self, mock: Mocker):
665        """Stale user cleanup skips HTTP DELETE in dry_run mode"""
666        self.provider.dry_run = True
667        self.provider.save()
668        scim_id = generate_id()
669        uid = generate_id()
670        mock.get("https://localhost/ServiceProviderConfig", json={})
671        self._create_stale_provider_user(scim_id, uid)
672
673        scim_sync.send(self.provider.pk)
674
675        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
676        self.assertEqual(len(delete_reqs), 0)
677
678    def test_sync_cleanup_client_for_model_transient(self):
679        """Cleanup silently skips an object type when client_for_model raises
680        TransientSyncException"""
681        with Mocker() as mock:
682            mock.get("https://localhost/ServiceProviderConfig", json={})
683            with patch.object(
684                SCIMProvider,
685                "client_for_model",
686                side_effect=TransientSyncException("connection failed"),
687            ):
688                scim_sync.send(self.provider.pk).get_result()
689
690    def test_sync_transient_exception(self):
691        """TransientSyncException in _sync_cleanup is caught by sync() which then
692        schedules a retry"""
693        with Mocker() as mock:
694            mock.get("https://localhost/ServiceProviderConfig", json={})
695            with patch.object(
696                sync_tasks,
697                "_sync_cleanup",
698                side_effect=TransientSyncException("connection failed"),
699            ):
700                scim_sync.send(self.provider.pk)

SCIM User tests

@apply_blueprint('system/providers-scim.yaml')
def setUp(self) -> None:
25    @apply_blueprint("system/providers-scim.yaml")
26    def setUp(self) -> None:
27        # Delete all users and groups as the mocked HTTP responses only return one ID
28        # which will cause errors with multiple users
29        Tenant.objects.update(avatars="none")
30        User.objects.all().exclude_anonymous().delete()
31        Group.objects.all().delete()
32        self.provider: SCIMProvider = SCIMProvider.objects.create(
33            name=generate_id(),
34            url="https://localhost",
35            token=generate_id(),
36            exclude_users_service_account=True,
37        )
38        self.app: Application = Application.objects.create(
39            name=generate_id(),
40            slug=generate_id(),
41        )
42        self.app.backchannel_providers.add(self.provider)
43        self.provider.property_mappings.add(
44            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
45        )
46        self.provider.property_mappings_group.add(
47            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
48        )

Hook method for setting up the test fixture before exercising it.

@Mocker()
def test_user_create(self, mock: requests_mock.mocker.Mocker):
50    @Mocker()
51    def test_user_create(self, mock: Mocker):
52        """Test user creation"""
53        scim_id = generate_id()
54        mock.get(
55            "https://localhost/ServiceProviderConfig",
56            json={},
57        )
58        mock.post(
59            "https://localhost/Users",
60            json={
61                "id": scim_id,
62            },
63        )
64        uid = generate_id()
65        user = User.objects.create(
66            username=uid,
67            name=f"{uid} {uid}",
68            email=f"{uid}@goauthentik.io",
69        )
70        self.assertEqual(mock.call_count, 2)
71        self.assertEqual(mock.request_history[0].method, "GET")
72        self.assertEqual(mock.request_history[1].method, "POST")
73        self.assertJSONEqual(
74            mock.request_history[1].body,
75            {
76                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
77                "active": True,
78                "emails": [
79                    {
80                        "primary": True,
81                        "type": "other",
82                        "value": f"{uid}@goauthentik.io",
83                    }
84                ],
85                "externalId": user.uid,
86                "name": {
87                    "familyName": uid,
88                    "formatted": f"{uid} {uid}",
89                    "givenName": uid,
90                },
91                "displayName": f"{uid} {uid}",
92                "userName": uid,
93            },
94        )

Test user creation

@Mocker()
def test_user_create_custom_schema(self, mock: requests_mock.mocker.Mocker):
 96    @Mocker()
 97    def test_user_create_custom_schema(self, mock: Mocker):
 98        """Test user creation with custom schema"""
 99        schema = SCIMMapping.objects.create(
100            name="custom_schema",
101            expression="""return {
102                "schemas": ["urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User"],
103                "urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
104                    "startDate": "2024-04-10T00:00:00+0000",
105                },
106            }""",
107        )
108        self.provider.property_mappings.add(schema)
109        scim_id = generate_id()
110        mock.get(
111            "https://localhost/ServiceProviderConfig",
112            json={},
113        )
114        mock.post(
115            "https://localhost/Users",
116            json={
117                "id": scim_id,
118            },
119        )
120        uid = generate_id()
121        user = User.objects.create(
122            username=uid,
123            name=f"{uid} {uid}",
124            email=f"{uid}@goauthentik.io",
125        )
126        self.assertEqual(mock.call_count, 2)
127        self.assertEqual(mock.request_history[0].method, "GET")
128        self.assertEqual(mock.request_history[1].method, "POST")
129        self.assertJSONEqual(
130            mock.request_history[1].body,
131            {
132                "schemas": [
133                    "urn:ietf:params:scim:schemas:core:2.0:User",
134                    "urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User",
135                ],
136                "active": True,
137                "emails": [
138                    {
139                        "primary": True,
140                        "type": "other",
141                        "value": f"{uid}@goauthentik.io",
142                    }
143                ],
144                "externalId": user.uid,
145                "name": {
146                    "familyName": uid,
147                    "formatted": f"{uid} {uid}",
148                    "givenName": uid,
149                },
150                "displayName": f"{uid} {uid}",
151                "userName": uid,
152                "urn:ietf:params:scim:schemas:extension:slack:profile:2.0:User": {
153                    "startDate": "2024-04-10T00:00:00+0000",
154                },
155            },
156        )

Test user creation with custom schema

@Mocker()
def test_user_create_different_provider_same_id(self, mock: requests_mock.mocker.Mocker):
158    @Mocker()
159    def test_user_create_different_provider_same_id(self, mock: Mocker):
160        """Test user creation with multiple providers that happen
161        to return the same object ID"""
162        # Create duplicate provider
163        provider: SCIMProvider = SCIMProvider.objects.create(
164            name=generate_id(),
165            url="https://localhost",
166            token=generate_id(),
167            exclude_users_service_account=True,
168        )
169        app: Application = Application.objects.create(
170            name=generate_id(),
171            slug=generate_id(),
172        )
173        app.backchannel_providers.add(provider)
174        provider.property_mappings.add(
175            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/user")
176        )
177        provider.property_mappings_group.add(
178            SCIMMapping.objects.get(managed="goauthentik.io/providers/scim/group")
179        )
180
181        scim_id = generate_id()
182        mock.get(
183            "https://localhost/ServiceProviderConfig",
184            json={},
185        )
186        mock.post(
187            "https://localhost/Users",
188            json={
189                "id": scim_id,
190            },
191        )
192        uid = generate_id()
193        user = User.objects.create(
194            username=uid,
195            name=f"{uid} {uid}",
196            email=f"{uid}@goauthentik.io",
197        )
198        self.assertEqual(mock.call_count, 4)
199        self.assertEqual(mock.request_history[0].method, "GET")
200        self.assertEqual(mock.request_history[1].method, "POST")
201        self.assertJSONEqual(
202            mock.request_history[1].body,
203            {
204                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
205                "active": True,
206                "emails": [
207                    {
208                        "primary": True,
209                        "type": "other",
210                        "value": f"{uid}@goauthentik.io",
211                    }
212                ],
213                "externalId": user.uid,
214                "name": {
215                    "familyName": uid,
216                    "formatted": f"{uid} {uid}",
217                    "givenName": uid,
218                },
219                "displayName": f"{uid} {uid}",
220                "userName": uid,
221            },
222        )

Test user creation with multiple providers that happen to return the same object ID

@Mocker()
def test_user_create_update(self, mock: requests_mock.mocker.Mocker):
224    @Mocker()
225    def test_user_create_update(self, mock: Mocker):
226        """Test user creation and update"""
227        scim_id = generate_id()
228        mock: Mocker
229        mock.get(
230            "https://localhost/ServiceProviderConfig",
231            json={},
232        )
233        mock.post(
234            "https://localhost/Users",
235            json={
236                "id": scim_id,
237            },
238        )
239        mock.put(
240            "https://localhost/Users",
241            json={
242                "id": scim_id,
243            },
244        )
245        uid = generate_id()
246        user = User.objects.create(
247            username=uid,
248            name=f"{uid} {uid}",
249            email=f"{uid}@goauthentik.io",
250        )
251        self.assertEqual(mock.call_count, 2)
252        self.assertEqual(mock.request_history[0].method, "GET")
253        self.assertEqual(mock.request_history[1].method, "POST")
254        body = loads(mock.request_history[1].body)
255        with open("schemas/scim-user.schema.json", encoding="utf-8") as schema:
256            validate(body, loads(schema.read()))
257        self.assertEqual(
258            body,
259            {
260                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
261                "active": True,
262                "emails": [
263                    {
264                        "primary": True,
265                        "type": "other",
266                        "value": f"{uid}@goauthentik.io",
267                    }
268                ],
269                "displayName": f"{uid} {uid}",
270                "externalId": user.uid,
271                "name": {
272                    "familyName": uid,
273                    "formatted": f"{uid} {uid}",
274                    "givenName": uid,
275                },
276                "userName": uid,
277            },
278        )
279        # Update user
280        user.name = "foo bar"
281        user.save()
282        self.assertEqual(mock.call_count, 3)
283        self.assertEqual(mock.request_history[0].method, "GET")
284        self.assertEqual(mock.request_history[1].method, "POST")
285        self.assertEqual(mock.request_history[2].method, "PUT")

Test user creation and update

@Mocker()
def test_user_create_delete(self, mock: requests_mock.mocker.Mocker):
287    @Mocker()
288    def test_user_create_delete(self, mock: Mocker):
289        """Test user creation"""
290        scim_id = generate_id()
291        mock.get(
292            "https://localhost/ServiceProviderConfig",
293            json={},
294        )
295        mock.post(
296            "https://localhost/Users",
297            json={
298                "id": scim_id,
299            },
300        )
301        mock.delete(f"https://localhost/Users/{scim_id}", status_code=204)
302        uid = generate_id()
303        user = User.objects.create(
304            username=uid,
305            name=f"{uid} {uid}",
306            email=f"{uid}@goauthentik.io",
307        )
308        self.assertEqual(mock.call_count, 2)
309        self.assertEqual(mock.request_history[0].method, "GET")
310        self.assertEqual(mock.request_history[1].method, "POST")
311        self.assertJSONEqual(
312            mock.request_history[1].body,
313            {
314                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
315                "active": True,
316                "emails": [
317                    {
318                        "primary": True,
319                        "type": "other",
320                        "value": f"{uid}@goauthentik.io",
321                    }
322                ],
323                "externalId": user.uid,
324                "name": {
325                    "familyName": uid,
326                    "formatted": f"{uid} {uid}",
327                    "givenName": uid,
328                },
329                "displayName": f"{uid} {uid}",
330                "userName": uid,
331            },
332        )
333        user.delete()
334        self.assertEqual(mock.call_count, 3)
335        self.assertEqual(mock.request_history[0].method, "GET")
336        self.assertEqual(mock.request_history[1].method, "POST")
337        self.assertEqual(mock.request_history[2].method, "DELETE")
338        self.assertEqual(mock.request_history[2].url, f"https://localhost/Users/{scim_id}")

Test user creation

@Mocker()
def test_sync_task(self, mock: requests_mock.mocker.Mocker):
340    @Mocker()
341    def test_sync_task(self, mock: Mocker):
342        """Test sync tasks"""
343        user_scim_id = generate_id()
344        group_scim_id = generate_id()
345        uid = generate_id()
346        mock.get(
347            "https://localhost/ServiceProviderConfig",
348            json={},
349        )
350        mock.post(
351            "https://localhost/Users",
352            json={
353                "id": user_scim_id,
354            },
355        )
356        mock.put(
357            f"https://localhost/Users/{user_scim_id}",
358            json={
359                "id": user_scim_id,
360            },
361        )
362        mock.post(
363            "https://localhost/Groups",
364            json={
365                "id": group_scim_id,
366            },
367        )
368        user = User.objects.create(
369            username=uid,
370            name=f"{uid} {uid}",
371            email=f"{uid}@goauthentik.io",
372        )
373
374        scim_sync.send(self.provider.pk)
375
376        self.assertEqual(mock.call_count, 3)
377        self.assertEqual(mock.request_history[0].method, "GET")
378        self.assertEqual(mock.request_history[1].method, "POST")
379        self.assertEqual(mock.request_history[2].method, "PUT")
380        self.assertJSONEqual(
381            mock.request_history[1].body,
382            {
383                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
384                "active": True,
385                "emails": [
386                    {
387                        "primary": True,
388                        "type": "other",
389                        "value": f"{uid}@goauthentik.io",
390                    }
391                ],
392                "externalId": user.uid,
393                "name": {
394                    "familyName": uid,
395                    "formatted": f"{uid} {uid}",
396                    "givenName": uid,
397                },
398                "displayName": f"{uid} {uid}",
399                "userName": uid,
400            },
401        )

Test sync tasks

def test_user_create_dry_run(self):
403    def test_user_create_dry_run(self):
404        """Test user creation (dry_run)"""
405        # Update the provider before we start mocking as saving the provider triggers a full sync
406        self.provider.dry_run = True
407        self.provider.save()
408        with Mocker() as mock:
409            scim_id = generate_id()
410            mock.get(
411                "https://localhost/ServiceProviderConfig",
412                json={},
413            )
414            mock.post(
415                "https://localhost/Users",
416                json={
417                    "id": scim_id,
418                },
419            )
420            uid = generate_id()
421            User.objects.create(
422                username=uid,
423                name=f"{uid} {uid}",
424                email=f"{uid}@goauthentik.io",
425            )
426            self.assertEqual(mock.call_count, 1, mock.request_history)
427            self.assertEqual(mock.request_history[0].method, "GET")

Test user creation (dry_run)

def test_sync_task_dry_run(self):
429    def test_sync_task_dry_run(self):
430        """Test sync tasks"""
431        # Update the provider before we start mocking as saving the provider triggers a full sync
432        self.provider.dry_run = True
433        self.provider.save()
434        with Mocker() as mock:
435            uid = generate_id()
436            mock.get(
437                "https://localhost/ServiceProviderConfig",
438                json={},
439            )
440            User.objects.create(
441                username=uid,
442                name=f"{uid} {uid}",
443                email=f"{uid}@goauthentik.io",
444            )
445
446            scim_sync.send(self.provider.pk)
447
448            self.assertEqual(mock.call_count, 1)
449            for request in mock.request_history:
450                self.assertIn(request.method, SAFE_METHODS)
451        task = list(
452            Task.objects.filter(
453                actor_name=scim_sync_objects.actor_name,
454                _uid__startswith=self.provider.name,
455            ).order_by("-mtime")
456        )[1]
457        self.assertIsNotNone(task)
458        log = task.tasklogs.filter(event="Dropping mutating request due to dry run").first()
459        self.assertIsNotNone(log)
460        self.assertIsNotNone(log.attributes["url"])
461        self.assertIsNotNone(log.attributes["body"])
462        self.assertIsNotNone(log.attributes["method"])

Test sync tasks

@Mocker()
def test_user_create_update_noop(self, mock: requests_mock.mocker.Mocker):
464    @Mocker()
465    def test_user_create_update_noop(self, mock: Mocker):
466        """Test user creation and update"""
467        scim_id = generate_id()
468        mock.get(
469            "https://localhost/ServiceProviderConfig",
470            json={},
471        )
472        mock.post(
473            "https://localhost/Users",
474            json={
475                "id": scim_id,
476            },
477        )
478        mock.put(
479            "https://localhost/Users",
480            json={
481                "id": scim_id,
482            },
483        )
484        uid = generate_id()
485        user = User.objects.create(
486            username=uid,
487            name=f"{uid} {uid}",
488            email=f"{uid}@goauthentik.io",
489        )
490        self.assertEqual(mock.call_count, 2)
491        self.assertEqual(mock.request_history[0].method, "GET")
492        self.assertEqual(mock.request_history[1].method, "POST")
493        body = loads(mock.request_history[1].body)
494        self.assertEqual(
495            body,
496            {
497                "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
498                "active": True,
499                "emails": [
500                    {
501                        "primary": True,
502                        "type": "other",
503                        "value": f"{uid}@goauthentik.io",
504                    }
505                ],
506                "displayName": f"{uid} {uid}",
507                "externalId": user.uid,
508                "name": {
509                    "familyName": uid,
510                    "formatted": f"{uid} {uid}",
511                    "givenName": uid,
512                },
513                "userName": uid,
514            },
515        )
516        conn = SCIMProviderUser.objects.filter(user=user).first()
517        conn.attributes = {
518            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
519            "active": True,
520            "emails": [
521                {
522                    "primary": True,
523                    "type": "other",
524                    "value": f"{uid}@goauthentik.io",
525                }
526            ],
527            "displayName": f"{uid} {uid}",
528            "externalId": user.uid,
529            "name": {
530                "familyName": uid,
531                "formatted": f"{uid} {uid}",
532                "givenName": uid,
533            },
534            "userName": uid,
535            "id": scim_id,
536        }
537        conn.save()
538        user.save()
539        self.assertEqual(mock.call_count, 2)
540        self.assertEqual(mock.request_history[0].method, "GET")
541        self.assertEqual(mock.request_history[1].method, "POST")

Test user creation and update

@Mocker()
def test_discover(self, mock: requests_mock.mocker.Mocker):
543    @Mocker()
544    def test_discover(self, mock: Mocker):
545        user = User.objects.create(username="admin@goauthentik.io")
546        mock.get(
547            "https://localhost/ServiceProviderConfig",
548            json={},
549        )
550        mock.get(
551            "https://localhost/Users",
552            json={
553                "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
554                "totalResults": 2,
555                "startIndex": 1,
556                "itemsPerPage": 1,
557                "Resources": [
558                    {
559                        "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
560                        "id": "1",
561                        "userName": "admin@goauthentik.io",
562                        "name": {"givenName": "N/A", "familyName": "N/A"},
563                        "emails": [
564                            {"primary": True, "value": "admin@goauthentik.io", "type": "work"}
565                        ],
566                        "meta": {"resourceType": "User"},
567                        "sentryOrgRole": "owner",
568                        "active": True,
569                    },
570                ],
571            },
572        )
573        mock.get(
574            "https://localhost/Users?startIndex=2",
575            json={
576                "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
577                "totalResults": 2,
578                "startIndex": 2,
579                "itemsPerPage": 1,
580                "Resources": [
581                    {
582                        "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
583                        "id": "2",
584                        "userName": "jens@goauthentik.io",
585                        "name": {"givenName": "N/A", "familyName": "N/A"},
586                        "emails": [
587                            {"primary": True, "value": "jens@goauthentik.io", "type": "work"}
588                        ],
589                        "meta": {"resourceType": "User"},
590                        "sentryOrgRole": "member",
591                        "active": True,
592                    },
593                ],
594            },
595        )
596        self.provider.client_for_model(User).discover()
597        connection = SCIMProviderUser.objects.filter(provider=self.provider, user=user).first()
598        self.assertIsNotNone(connection)
599        self.assertEqual(connection.scim_id, "1")
@Mocker()
def test_sync_cleanup_stale_user_delete(self, mock: requests_mock.mocker.Mocker):
613    @Mocker()
614    def test_sync_cleanup_stale_user_delete(self, mock: Mocker):
615        """Stale (out-of-scope) users are deleted during full sync cleanup"""
616        scim_id = generate_id()
617        uid = generate_id()
618        mock.get("https://localhost/ServiceProviderConfig", json={})
619        mock.delete(f"https://localhost/Users/{scim_id}", status_code=204)
620        self._create_stale_provider_user(scim_id, uid)
621
622        scim_sync.send(self.provider.pk).get_result()
623
624        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
625        self.assertEqual(len(delete_reqs), 1)
626        self.assertEqual(delete_reqs[0].url, f"https://localhost/Users/{scim_id}")
627        self.assertFalse(
628            SCIMProviderUser.objects.filter(provider=self.provider, scim_id=scim_id).exists()
629        )

Stale (out-of-scope) users are deleted during full sync cleanup

@Mocker()
def test_sync_cleanup_stale_user_not_found(self, mock: requests_mock.mocker.Mocker):
631    @Mocker()
632    def test_sync_cleanup_stale_user_not_found(self, mock: Mocker):
633        """Stale user cleanup handles 404 from the remote gracefully"""
634        scim_id = generate_id()
635        uid = generate_id()
636        mock.get("https://localhost/ServiceProviderConfig", json={})
637        mock.delete(f"https://localhost/Users/{scim_id}", status_code=404)
638        self._create_stale_provider_user(scim_id, uid)
639
640        scim_sync.send(self.provider.pk).get_result()
641
642        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
643        self.assertEqual(len(delete_reqs), 1)
644
645        self.assertFalse(
646            SCIMProviderUser.objects.filter(provider=self.provider, scim_id=scim_id).exists()
647        )

Stale user cleanup handles 404 from the remote gracefully

@Mocker()
def test_sync_cleanup_stale_user_transient_error(self, mock: requests_mock.mocker.Mocker):
649    @Mocker()
650    def test_sync_cleanup_stale_user_transient_error(self, mock: Mocker):
651        """Stale user cleanup logs and retries on transient HTTP errors"""
652        scim_id = generate_id()
653        uid = generate_id()
654        mock.get("https://localhost/ServiceProviderConfig", json={})
655        mock.delete(f"https://localhost/Users/{scim_id}", status_code=429)
656        self._create_stale_provider_user(scim_id, uid)
657
658        scim_sync.send(self.provider.pk)
659
660        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
661        self.assertEqual(len(delete_reqs), 1)

Stale user cleanup logs and retries on transient HTTP errors

@Mocker()
def test_sync_cleanup_stale_user_dry_run(self, mock: requests_mock.mocker.Mocker):
663    @Mocker()
664    def test_sync_cleanup_stale_user_dry_run(self, mock: Mocker):
665        """Stale user cleanup skips HTTP DELETE in dry_run mode"""
666        self.provider.dry_run = True
667        self.provider.save()
668        scim_id = generate_id()
669        uid = generate_id()
670        mock.get("https://localhost/ServiceProviderConfig", json={})
671        self._create_stale_provider_user(scim_id, uid)
672
673        scim_sync.send(self.provider.pk)
674
675        delete_reqs = [r for r in mock.request_history if r.method == "DELETE"]
676        self.assertEqual(len(delete_reqs), 0)

Stale user cleanup skips HTTP DELETE in dry_run mode

def test_sync_cleanup_client_for_model_transient(self):
678    def test_sync_cleanup_client_for_model_transient(self):
679        """Cleanup silently skips an object type when client_for_model raises
680        TransientSyncException"""
681        with Mocker() as mock:
682            mock.get("https://localhost/ServiceProviderConfig", json={})
683            with patch.object(
684                SCIMProvider,
685                "client_for_model",
686                side_effect=TransientSyncException("connection failed"),
687            ):
688                scim_sync.send(self.provider.pk).get_result()

Cleanup silently skips an object type when client_for_model raises TransientSyncException

def test_sync_transient_exception(self):
690    def test_sync_transient_exception(self):
691        """TransientSyncException in _sync_cleanup is caught by sync() which then
692        schedules a retry"""
693        with Mocker() as mock:
694            mock.get("https://localhost/ServiceProviderConfig", json={})
695            with patch.object(
696                sync_tasks,
697                "_sync_cleanup",
698                side_effect=TransientSyncException("connection failed"),
699            ):
700                scim_sync.send(self.provider.pk)

TransientSyncException in _sync_cleanup is caught by sync() which then schedules a retry