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