lib/sync/outgoing: add dry run (#13244)
* lib/sync/outgoing: add dry run Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add option to temporarily override dry run Signed-off-by: Jens Langhammer <jens@goauthentik.io> * web a Signed-off-by: Jens Langhammer <jens@goauthentik.io> * web b Signed-off-by: Jens Langhammer <jens@goauthentik.io> * format Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add some test Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add more tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add dry run label Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add support for entra too Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add web Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add entra test and improve error handling Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
		| @ -37,6 +37,7 @@ class GoogleWorkspaceProviderSerializer(EnterpriseRequiredMixin, ProviderSeriali | ||||
|             "user_delete_action", | ||||
|             "group_delete_action", | ||||
|             "default_group_email_domain", | ||||
|             "dry_run", | ||||
|         ] | ||||
|         extra_kwargs = {} | ||||
|  | ||||
|  | ||||
| @ -8,9 +8,10 @@ from httplib2 import HttpLib2Error, HttpLib2ErrorWithResponse | ||||
|  | ||||
| from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider | ||||
| from authentik.lib.sync.outgoing import HTTP_CONFLICT | ||||
| from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient | ||||
| from authentik.lib.sync.outgoing.base import SAFE_METHODS, BaseOutgoingSyncClient | ||||
| from authentik.lib.sync.outgoing.exceptions import ( | ||||
|     BadRequestSyncException, | ||||
|     DryRunRejected, | ||||
|     NotFoundSyncException, | ||||
|     ObjectExistsSyncException, | ||||
|     StopSync, | ||||
| @ -43,6 +44,8 @@ class GoogleWorkspaceSyncClient[TModel: Model, TConnection: Model, TSchema: dict | ||||
|             self.domains.append(domain_name) | ||||
|  | ||||
|     def _request(self, request: HttpRequest): | ||||
|         if self.provider.dry_run and request.method.upper() not in SAFE_METHODS: | ||||
|             raise DryRunRejected(request.uri, request.method, request.body) | ||||
|         try: | ||||
|             response = request.execute() | ||||
|         except GoogleAuthError as exc: | ||||
|  | ||||
| @ -0,0 +1,24 @@ | ||||
| # Generated by Django 5.0.12 on 2025-02-24 19:43 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ( | ||||
|             "authentik_providers_google_workspace", | ||||
|             "0003_googleworkspaceprovidergroup_attributes_and_more", | ||||
|         ), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="googleworkspaceprovider", | ||||
|             name="dry_run", | ||||
|             field=models.BooleanField( | ||||
|                 default=False, | ||||
|                 help_text="When enabled, provider will not modify or create objects in the remote system.", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -36,6 +36,7 @@ class MicrosoftEntraProviderSerializer(EnterpriseRequiredMixin, ProviderSerializ | ||||
|             "filter_group", | ||||
|             "user_delete_action", | ||||
|             "group_delete_action", | ||||
|             "dry_run", | ||||
|         ] | ||||
|         extra_kwargs = {} | ||||
|  | ||||
|  | ||||
| @ -3,6 +3,7 @@ from collections.abc import Coroutine | ||||
| from dataclasses import asdict | ||||
| from typing import Any | ||||
|  | ||||
| import httpx | ||||
| from azure.core.exceptions import ( | ||||
|     ClientAuthenticationError, | ||||
|     ServiceRequestError, | ||||
| @ -12,6 +13,7 @@ from azure.identity.aio import ClientSecretCredential | ||||
| from django.db.models import Model | ||||
| from django.http import HttpResponseBadRequest, HttpResponseNotFound | ||||
| from kiota_abstractions.api_error import APIError | ||||
| from kiota_abstractions.request_information import RequestInformation | ||||
| from kiota_authentication_azure.azure_identity_authentication_provider import ( | ||||
|     AzureIdentityAuthenticationProvider, | ||||
| ) | ||||
| @ -21,13 +23,15 @@ from msgraph.generated.models.o_data_errors.o_data_error import ODataError | ||||
| from msgraph.graph_request_adapter import GraphRequestAdapter, options | ||||
| from msgraph.graph_service_client import GraphServiceClient | ||||
| from msgraph_core import GraphClientFactory | ||||
| from opentelemetry import trace | ||||
|  | ||||
| from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider | ||||
| from authentik.events.utils import sanitize_item | ||||
| from authentik.lib.sync.outgoing import HTTP_CONFLICT | ||||
| from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient | ||||
| from authentik.lib.sync.outgoing.base import SAFE_METHODS, BaseOutgoingSyncClient | ||||
| from authentik.lib.sync.outgoing.exceptions import ( | ||||
|     BadRequestSyncException, | ||||
|     DryRunRejected, | ||||
|     NotFoundSyncException, | ||||
|     ObjectExistsSyncException, | ||||
|     StopSync, | ||||
| @ -35,20 +39,24 @@ from authentik.lib.sync.outgoing.exceptions import ( | ||||
| ) | ||||
|  | ||||
|  | ||||
| def get_request_adapter( | ||||
|     credentials: ClientSecretCredential, scopes: list[str] | None = None | ||||
| ) -> GraphRequestAdapter: | ||||
|     if scopes: | ||||
|         auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials, scopes=scopes) | ||||
|     else: | ||||
|         auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials) | ||||
| class AuthentikRequestAdapter(GraphRequestAdapter): | ||||
|     def __init__(self, auth_provider, provider: MicrosoftEntraProvider, client=None): | ||||
|         super().__init__(auth_provider, client) | ||||
|         self._provider = provider | ||||
|  | ||||
|     return GraphRequestAdapter( | ||||
|         auth_provider=auth_provider, | ||||
|         client=GraphClientFactory.create_with_default_middleware( | ||||
|             options=options, client=KiotaClientFactory.get_default_client() | ||||
|         ), | ||||
|     ) | ||||
|     async def get_http_response_message( | ||||
|         self, | ||||
|         request_info: RequestInformation, | ||||
|         parent_span: trace.Span, | ||||
|         claims: str = "", | ||||
|     ) -> httpx.Response: | ||||
|         if self._provider.dry_run and request_info.http_method.value.upper() not in SAFE_METHODS: | ||||
|             raise DryRunRejected( | ||||
|                 url=request_info.url, | ||||
|                 method=request_info.http_method.value, | ||||
|                 body=request_info.content.decode("utf-8"), | ||||
|             ) | ||||
|         return await super().get_http_response_message(request_info, parent_span, claims=claims) | ||||
|  | ||||
|  | ||||
| class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict]( | ||||
| @ -63,9 +71,27 @@ class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict] | ||||
|         self.credentials = provider.microsoft_credentials() | ||||
|         self.__prefetch_domains() | ||||
|  | ||||
|     def get_request_adapter( | ||||
|         self, credentials: ClientSecretCredential, scopes: list[str] | None = None | ||||
|     ) -> AuthentikRequestAdapter: | ||||
|         if scopes: | ||||
|             auth_provider = AzureIdentityAuthenticationProvider( | ||||
|                 credentials=credentials, scopes=scopes | ||||
|             ) | ||||
|         else: | ||||
|             auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials) | ||||
|  | ||||
|         return AuthentikRequestAdapter( | ||||
|             auth_provider=auth_provider, | ||||
|             provider=self.provider, | ||||
|             client=GraphClientFactory.create_with_default_middleware( | ||||
|                 options=options, client=KiotaClientFactory.get_default_client() | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     @property | ||||
|     def client(self): | ||||
|         return GraphServiceClient(request_adapter=get_request_adapter(**self.credentials)) | ||||
|         return GraphServiceClient(request_adapter=self.get_request_adapter(**self.credentials)) | ||||
|  | ||||
|     def _request[T](self, request: Coroutine[Any, Any, T]) -> T: | ||||
|         try: | ||||
|  | ||||
| @ -0,0 +1,24 @@ | ||||
| # Generated by Django 5.0.12 on 2025-02-24 19:43 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ( | ||||
|             "authentik_providers_microsoft_entra", | ||||
|             "0002_microsoftentraprovidergroup_attributes_and_more", | ||||
|         ), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="microsoftentraprovider", | ||||
|             name="dry_run", | ||||
|             field=models.BooleanField( | ||||
|                 default=False, | ||||
|                 help_text="When enabled, provider will not modify or create objects in the remote system.", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -32,7 +32,6 @@ class MicrosoftEntraUserTests(APITestCase): | ||||
|  | ||||
|     @apply_blueprint("system/providers-microsoft-entra.yaml") | ||||
|     def setUp(self) -> None: | ||||
|  | ||||
|         # Delete all users and groups as the mocked HTTP responses only return one ID | ||||
|         # which will cause errors with multiple users | ||||
|         Tenant.objects.update(avatars="none") | ||||
| @ -97,6 +96,38 @@ class MicrosoftEntraUserTests(APITestCase): | ||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) | ||||
|             user_create.assert_called_once() | ||||
|  | ||||
|     def test_user_create_dry_run(self): | ||||
|         """Test user creation (dry run)""" | ||||
|         self.provider.dry_run = True | ||||
|         self.provider.save() | ||||
|         uid = generate_id() | ||||
|         with ( | ||||
|             patch( | ||||
|                 "authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials", | ||||
|                 MagicMock(return_value={"credentials": self.creds}), | ||||
|             ), | ||||
|             patch( | ||||
|                 "msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get", | ||||
|                 AsyncMock( | ||||
|                     return_value=OrganizationCollectionResponse( | ||||
|                         value=[ | ||||
|                             Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")]) | ||||
|                         ] | ||||
|                     ) | ||||
|                 ), | ||||
|             ), | ||||
|         ): | ||||
|             user = User.objects.create( | ||||
|                 username=uid, | ||||
|                 name=f"{uid} {uid}", | ||||
|                 email=f"{uid}@goauthentik.io", | ||||
|             ) | ||||
|             microsoft_user = MicrosoftEntraProviderUser.objects.filter( | ||||
|                 provider=self.provider, user=user | ||||
|             ).first() | ||||
|             self.assertIsNone(microsoft_user) | ||||
|             self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) | ||||
|  | ||||
|     def test_user_not_created(self): | ||||
|         """Test without property mappings, no group is created""" | ||||
|         self.provider.property_mappings.clear() | ||||
|  | ||||
| @ -33,6 +33,7 @@ class SyncObjectSerializer(PassiveSerializer): | ||||
|         ) | ||||
|     ) | ||||
|     sync_object_id = CharField() | ||||
|     override_dry_run = BooleanField(default=False) | ||||
|  | ||||
|  | ||||
| class SyncObjectResultSerializer(PassiveSerializer): | ||||
| @ -98,6 +99,7 @@ class OutgoingSyncProviderStatusMixin: | ||||
|             page=1, | ||||
|             provider_pk=provider.pk, | ||||
|             pk=params.validated_data["sync_object_id"], | ||||
|             override_dry_run=params.validated_data["override_dry_run"], | ||||
|         ).get() | ||||
|         return Response(SyncObjectResultSerializer(instance={"messages": res}).data) | ||||
|  | ||||
|  | ||||
| @ -28,6 +28,14 @@ class Direction(StrEnum): | ||||
|     remove = "remove" | ||||
|  | ||||
|  | ||||
| SAFE_METHODS = [ | ||||
|     "GET", | ||||
|     "HEAD", | ||||
|     "OPTIONS", | ||||
|     "TRACE", | ||||
| ] | ||||
|  | ||||
|  | ||||
| class BaseOutgoingSyncClient[ | ||||
|     TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider" | ||||
| ]: | ||||
|  | ||||
| @ -21,6 +21,22 @@ class BadRequestSyncException(BaseSyncException): | ||||
|     """Exception when invalid data was sent to the remote system""" | ||||
|  | ||||
|  | ||||
| class DryRunRejected(BaseSyncException): | ||||
|     """When dry_run is enabled and a provider dropped a mutating request""" | ||||
|  | ||||
|     def __init__(self, url: str, method: str, body: dict): | ||||
|         super().__init__() | ||||
|         self.url = url | ||||
|         self.method = method | ||||
|         self.body = body | ||||
|  | ||||
|     def __repr__(self): | ||||
|         return self.__str__() | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"Dry-run rejected request: {self.method} {self.url}" | ||||
|  | ||||
|  | ||||
| class StopSync(BaseSyncException): | ||||
|     """Exception raised when a configuration error should stop the sync process""" | ||||
|  | ||||
|  | ||||
| @ -1,8 +1,9 @@ | ||||
| from typing import Any, Self | ||||
|  | ||||
| import pglock | ||||
| from django.db import connection | ||||
| from django.db import connection, models | ||||
| from django.db.models import Model, QuerySet, TextChoices | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| from authentik.core.models import Group, User | ||||
| from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient | ||||
| @ -18,6 +19,14 @@ class OutgoingSyncDeleteAction(TextChoices): | ||||
|  | ||||
|  | ||||
| class OutgoingSyncProvider(Model): | ||||
|     """Base abstract models for providers implementing outgoing sync""" | ||||
|  | ||||
|     dry_run = models.BooleanField( | ||||
|         default=False, | ||||
|         help_text=_( | ||||
|             "When enabled, provider will not modify or create objects in the remote system." | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         abstract = True | ||||
| @ -32,7 +41,7 @@ class OutgoingSyncProvider(Model): | ||||
|  | ||||
|     @property | ||||
|     def sync_lock(self) -> pglock.advisory: | ||||
|         """Postgres lock for syncing SCIM to prevent multiple parallel syncs happening""" | ||||
|         """Postgres lock for syncing to prevent multiple parallel syncs happening""" | ||||
|         return pglock.advisory( | ||||
|             lock_id=f"goauthentik.io/{connection.schema_name}/providers/outgoing-sync/{str(self.pk)}", | ||||
|             timeout=0, | ||||
|  | ||||
| @ -20,6 +20,7 @@ from authentik.lib.sync.outgoing import PAGE_SIZE, PAGE_TIMEOUT | ||||
| from authentik.lib.sync.outgoing.base import Direction | ||||
| from authentik.lib.sync.outgoing.exceptions import ( | ||||
|     BadRequestSyncException, | ||||
|     DryRunRejected, | ||||
|     StopSync, | ||||
|     TransientSyncException, | ||||
| ) | ||||
| @ -105,7 +106,9 @@ class SyncTasks: | ||||
|                 return | ||||
|         task.set_status(TaskStatus.SUCCESSFUL, *messages) | ||||
|  | ||||
|     def sync_objects(self, object_type: str, page: int, provider_pk: int, **filter): | ||||
|     def sync_objects( | ||||
|         self, object_type: str, page: int, provider_pk: int, override_dry_run=False, **filter | ||||
|     ): | ||||
|         _object_type = path_to_class(object_type) | ||||
|         self.logger = get_logger().bind( | ||||
|             provider_type=class_to_path(self._provider_model), | ||||
| @ -116,6 +119,10 @@ class SyncTasks: | ||||
|         provider = self._provider_model.objects.filter(pk=provider_pk).first() | ||||
|         if not provider: | ||||
|             return messages | ||||
|         # Override dry run mode if requested, however don't save the provider | ||||
|         # so that scheduled sync tasks still run in dry_run mode | ||||
|         if override_dry_run: | ||||
|             provider.dry_run = False | ||||
|         try: | ||||
|             client = provider.client_for_model(_object_type) | ||||
|         except TransientSyncException: | ||||
| @ -132,6 +139,22 @@ class SyncTasks: | ||||
|             except SkipObjectException: | ||||
|                 self.logger.debug("skipping object due to SkipObject", obj=obj) | ||||
|                 continue | ||||
|             except DryRunRejected as exc: | ||||
|                 messages.append( | ||||
|                     asdict( | ||||
|                         LogEvent( | ||||
|                             _("Dropping mutating request due to dry run"), | ||||
|                             log_level="info", | ||||
|                             logger=f"{provider._meta.verbose_name}@{object_type}", | ||||
|                             attributes={ | ||||
|                                 "obj": sanitize_item(obj), | ||||
|                                 "method": exc.method, | ||||
|                                 "url": exc.url, | ||||
|                                 "body": exc.body, | ||||
|                             }, | ||||
|                         ) | ||||
|                     ) | ||||
|                 ) | ||||
|             except BadRequestSyncException as exc: | ||||
|                 self.logger.warning("failed to sync object", exc=exc, obj=obj) | ||||
|                 messages.append( | ||||
| @ -231,8 +254,10 @@ class SyncTasks: | ||||
|                 raise Retry() from exc | ||||
|             except SkipObjectException: | ||||
|                 continue | ||||
|             except DryRunRejected as exc: | ||||
|                 self.logger.info("Rejected dry-run event", exc=exc) | ||||
|             except StopSync as exc: | ||||
|                 self.logger.warning(exc, provider_pk=provider.pk) | ||||
|                 self.logger.warning("Stopping sync", exc=exc, provider_pk=provider.pk) | ||||
|  | ||||
|     def sync_signal_m2m(self, group_pk: str, action: str, pk_set: list[int]): | ||||
|         self.logger = get_logger().bind( | ||||
| @ -263,5 +288,7 @@ class SyncTasks: | ||||
|                 raise Retry() from exc | ||||
|             except SkipObjectException: | ||||
|                 continue | ||||
|             except DryRunRejected as exc: | ||||
|                 self.logger.info("Rejected dry-run event", exc=exc) | ||||
|             except StopSync as exc: | ||||
|                 self.logger.warning(exc, provider_pk=provider.pk) | ||||
|                 self.logger.warning("Stopping sync", exc=exc, provider_pk=provider.pk) | ||||
|  | ||||
| @ -30,6 +30,7 @@ class SCIMProviderSerializer(ProviderSerializer): | ||||
|             "token", | ||||
|             "exclude_users_service_account", | ||||
|             "filter_group", | ||||
|             "dry_run", | ||||
|         ] | ||||
|         extra_kwargs = {} | ||||
|  | ||||
|  | ||||
| @ -12,8 +12,9 @@ from authentik.lib.sync.outgoing import ( | ||||
|     HTTP_SERVICE_UNAVAILABLE, | ||||
|     HTTP_TOO_MANY_REQUESTS, | ||||
| ) | ||||
| from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient | ||||
| from authentik.lib.sync.outgoing.base import SAFE_METHODS, BaseOutgoingSyncClient | ||||
| from authentik.lib.sync.outgoing.exceptions import ( | ||||
|     DryRunRejected, | ||||
|     NotFoundSyncException, | ||||
|     ObjectExistsSyncException, | ||||
|     TransientSyncException, | ||||
| @ -54,6 +55,8 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"]( | ||||
|  | ||||
|     def _request(self, method: str, path: str, **kwargs) -> dict: | ||||
|         """Wrapper to send a request to the full URL""" | ||||
|         if self.provider.dry_run and method.upper() not in SAFE_METHODS: | ||||
|             raise DryRunRejected(f"{self.base_url}{path}", method, body=kwargs.get("json")) | ||||
|         try: | ||||
|             response = self._session.request( | ||||
|                 method, | ||||
|  | ||||
| @ -0,0 +1,21 @@ | ||||
| # Generated by Django 5.0.12 on 2025-02-24 19:43 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_providers_scim", "0010_scimprovider_verify_certificates"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="scimprovider", | ||||
|             name="dry_run", | ||||
|             field=models.BooleanField( | ||||
|                 default=False, | ||||
|                 help_text="When enabled, provider will not modify or create objects in the remote system.", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -3,12 +3,15 @@ | ||||
| from json import loads | ||||
|  | ||||
| from django.test import TestCase | ||||
| from django.utils.text import slugify | ||||
| from jsonschema import validate | ||||
| from requests_mock import Mocker | ||||
|  | ||||
| from authentik.blueprints.tests import apply_blueprint | ||||
| from authentik.core.models import Application, Group, User | ||||
| from authentik.events.models import SystemTask | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.sync.outgoing.base import SAFE_METHODS | ||||
| from authentik.providers.scim.models import SCIMMapping, SCIMProvider | ||||
| from authentik.providers.scim.tasks import scim_sync, sync_tasks | ||||
| from authentik.tenants.models import Tenant | ||||
| @ -330,3 +333,59 @@ class SCIMUserTests(TestCase): | ||||
|                 "userName": uid, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_user_create_dry_run(self): | ||||
|         """Test user creation (dry_run)""" | ||||
|         # Update the provider before we start mocking as saving the provider triggers a full sync | ||||
|         self.provider.dry_run = True | ||||
|         self.provider.save() | ||||
|         with Mocker() as mock: | ||||
|             scim_id = generate_id() | ||||
|             mock.get( | ||||
|                 "https://localhost/ServiceProviderConfig", | ||||
|                 json={}, | ||||
|             ) | ||||
|             mock.post( | ||||
|                 "https://localhost/Users", | ||||
|                 json={ | ||||
|                     "id": scim_id, | ||||
|                 }, | ||||
|             ) | ||||
|             uid = generate_id() | ||||
|             User.objects.create( | ||||
|                 username=uid, | ||||
|                 name=f"{uid} {uid}", | ||||
|                 email=f"{uid}@goauthentik.io", | ||||
|             ) | ||||
|             self.assertEqual(mock.call_count, 1, mock.request_history) | ||||
|             self.assertEqual(mock.request_history[0].method, "GET") | ||||
|  | ||||
|     def test_sync_task_dry_run(self): | ||||
|         """Test sync tasks""" | ||||
|         # Update the provider before we start mocking as saving the provider triggers a full sync | ||||
|         self.provider.dry_run = True | ||||
|         self.provider.save() | ||||
|         with Mocker() as mock: | ||||
|             uid = generate_id() | ||||
|             mock.get( | ||||
|                 "https://localhost/ServiceProviderConfig", | ||||
|                 json={}, | ||||
|             ) | ||||
|             User.objects.create( | ||||
|                 username=uid, | ||||
|                 name=f"{uid} {uid}", | ||||
|                 email=f"{uid}@goauthentik.io", | ||||
|             ) | ||||
|  | ||||
|             sync_tasks.trigger_single_task(self.provider, scim_sync).get() | ||||
|  | ||||
|             self.assertEqual(mock.call_count, 3) | ||||
|             for request in mock.request_history: | ||||
|                 self.assertIn(request.method, SAFE_METHODS) | ||||
|         task = SystemTask.objects.filter(uid=slugify(self.provider.name)).first() | ||||
|         self.assertIsNotNone(task) | ||||
|         drop_msg = task.messages[2] | ||||
|         self.assertEqual(drop_msg["event"], "Dropping mutating request due to dry run") | ||||
|         self.assertIsNotNone(drop_msg["attributes"]["url"]) | ||||
|         self.assertIsNotNone(drop_msg["attributes"]["body"]) | ||||
|         self.assertIsNotNone(drop_msg["attributes"]["method"]) | ||||
|  | ||||
| @ -6669,6 +6669,11 @@ | ||||
|                     "type": "string", | ||||
|                     "format": "uuid", | ||||
|                     "title": "Filter group" | ||||
|                 }, | ||||
|                 "dry_run": { | ||||
|                     "type": "boolean", | ||||
|                     "title": "Dry run", | ||||
|                     "description": "When enabled, provider will not modify or create objects in the remote system." | ||||
|                 } | ||||
|             }, | ||||
|             "required": [] | ||||
| @ -14196,6 +14201,11 @@ | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
|                     "title": "Default group email domain" | ||||
|                 }, | ||||
|                 "dry_run": { | ||||
|                     "type": "boolean", | ||||
|                     "title": "Dry run", | ||||
|                     "description": "When enabled, provider will not modify or create objects in the remote system." | ||||
|                 } | ||||
|             }, | ||||
|             "required": [] | ||||
| @ -14344,6 +14354,11 @@ | ||||
|                         "suspend" | ||||
|                     ], | ||||
|                     "title": "Group delete action" | ||||
|                 }, | ||||
|                 "dry_run": { | ||||
|                     "type": "boolean", | ||||
|                     "title": "Dry run", | ||||
|                     "description": "When enabled, provider will not modify or create objects in the remote system." | ||||
|                 } | ||||
|             }, | ||||
|             "required": [] | ||||
|  | ||||
							
								
								
									
										39
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										39
									
								
								schema.yml
									
									
									
									
									
								
							| @ -44154,6 +44154,10 @@ components: | ||||
|           $ref: '#/components/schemas/OutgoingSyncDeleteAction' | ||||
|         default_group_email_domain: | ||||
|           type: string | ||||
|         dry_run: | ||||
|           type: boolean | ||||
|           description: When enabled, provider will not modify or create objects in | ||||
|             the remote system. | ||||
|       required: | ||||
|       - assigned_backchannel_application_name | ||||
|       - assigned_backchannel_application_slug | ||||
| @ -44317,6 +44321,10 @@ components: | ||||
|         default_group_email_domain: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|         dry_run: | ||||
|           type: boolean | ||||
|           description: When enabled, provider will not modify or create objects in | ||||
|             the remote system. | ||||
|       required: | ||||
|       - credentials | ||||
|       - default_group_email_domain | ||||
| @ -46397,6 +46405,10 @@ components: | ||||
|           $ref: '#/components/schemas/OutgoingSyncDeleteAction' | ||||
|         group_delete_action: | ||||
|           $ref: '#/components/schemas/OutgoingSyncDeleteAction' | ||||
|         dry_run: | ||||
|           type: boolean | ||||
|           description: When enabled, provider will not modify or create objects in | ||||
|             the remote system. | ||||
|       required: | ||||
|       - assigned_backchannel_application_name | ||||
|       - assigned_backchannel_application_slug | ||||
| @ -46557,6 +46569,10 @@ components: | ||||
|           $ref: '#/components/schemas/OutgoingSyncDeleteAction' | ||||
|         group_delete_action: | ||||
|           $ref: '#/components/schemas/OutgoingSyncDeleteAction' | ||||
|         dry_run: | ||||
|           type: boolean | ||||
|           description: When enabled, provider will not modify or create objects in | ||||
|             the remote system. | ||||
|       required: | ||||
|       - client_id | ||||
|       - client_secret | ||||
| @ -50679,6 +50695,10 @@ components: | ||||
|         default_group_email_domain: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|         dry_run: | ||||
|           type: boolean | ||||
|           description: When enabled, provider will not modify or create objects in | ||||
|             the remote system. | ||||
|     PatchedGroupKerberosSourceConnectionRequest: | ||||
|       type: object | ||||
|       description: OAuth Group-Source connection Serializer | ||||
| @ -51260,6 +51280,10 @@ components: | ||||
|           $ref: '#/components/schemas/OutgoingSyncDeleteAction' | ||||
|         group_delete_action: | ||||
|           $ref: '#/components/schemas/OutgoingSyncDeleteAction' | ||||
|         dry_run: | ||||
|           type: boolean | ||||
|           description: When enabled, provider will not modify or create objects in | ||||
|             the remote system. | ||||
|     PatchedNotificationRequest: | ||||
|       type: object | ||||
|       description: Notification Serializer | ||||
| @ -52427,6 +52451,10 @@ components: | ||||
|           type: string | ||||
|           format: uuid | ||||
|           nullable: true | ||||
|         dry_run: | ||||
|           type: boolean | ||||
|           description: When enabled, provider will not modify or create objects in | ||||
|             the remote system. | ||||
|     PatchedSCIMSourceGroupRequest: | ||||
|       type: object | ||||
|       description: SCIMSourceGroup Serializer | ||||
| @ -55823,6 +55851,10 @@ components: | ||||
|           type: string | ||||
|           format: uuid | ||||
|           nullable: true | ||||
|         dry_run: | ||||
|           type: boolean | ||||
|           description: When enabled, provider will not modify or create objects in | ||||
|             the remote system. | ||||
|       required: | ||||
|       - assigned_backchannel_application_name | ||||
|       - assigned_backchannel_application_slug | ||||
| @ -55909,6 +55941,10 @@ components: | ||||
|           type: string | ||||
|           format: uuid | ||||
|           nullable: true | ||||
|         dry_run: | ||||
|           type: boolean | ||||
|           description: When enabled, provider will not modify or create objects in | ||||
|             the remote system. | ||||
|       required: | ||||
|       - name | ||||
|       - token | ||||
| @ -57105,6 +57141,9 @@ components: | ||||
|         sync_object_id: | ||||
|           type: string | ||||
|           minLength: 1 | ||||
|         override_dry_run: | ||||
|           type: boolean | ||||
|           default: false | ||||
|       required: | ||||
|       - sync_object_id | ||||
|       - sync_object_model | ||||
|  | ||||
| @ -161,6 +161,26 @@ export class GoogleWorkspaceProviderFormPage extends BaseProviderForm<GoogleWork | ||||
|                         help=${msg("Determines what authentik will do when a Group is deleted.")} | ||||
|                     > | ||||
|                     </ak-radio-input> | ||||
|                     <ak-form-element-horizontal name="dryRun"> | ||||
|                         <label class="pf-c-switch"> | ||||
|                             <input | ||||
|                                 class="pf-c-switch__input" | ||||
|                                 type="checkbox" | ||||
|                                 ?checked=${first(this.instance?.dryRun, false)} | ||||
|                             /> | ||||
|                             <span class="pf-c-switch__toggle"> | ||||
|                                 <span class="pf-c-switch__toggle-icon"> | ||||
|                                     <i class="fas fa-check" aria-hidden="true"></i> | ||||
|                                 </span> | ||||
|                             </span> | ||||
|                             <span class="pf-c-switch__label">${msg("Enable dry-run mode")}</span> | ||||
|                         </label> | ||||
|                         <p class="pf-c-form__helper-text"> | ||||
|                             ${msg( | ||||
|                                 "When enabled, mutating requests will be dropped and logged instead.", | ||||
|                             )} | ||||
|                         </p> | ||||
|                     </ak-form-element-horizontal> | ||||
|                 </div> | ||||
|             </ak-form-group> | ||||
|             <ak-form-group ?expanded=${true}> | ||||
|  | ||||
| @ -4,6 +4,7 @@ import "@goauthentik/admin/providers/google_workspace/GoogleWorkspaceProviderUse | ||||
| import "@goauthentik/admin/rbac/ObjectPermissionsPage"; | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
| import { EVENT_REFRESH } from "@goauthentik/common/constants"; | ||||
| import "@goauthentik/components/ak-status-label"; | ||||
| import "@goauthentik/components/events/ObjectChangelog"; | ||||
| import { AKElement } from "@goauthentik/elements/Base"; | ||||
| import "@goauthentik/elements/Markdown"; | ||||
| @ -176,6 +177,23 @@ export class GoogleWorkspaceProviderViewPage extends AKElement { | ||||
|                                     </div> | ||||
|                                 </dd> | ||||
|                             </div> | ||||
|                             <div class="pf-c-description-list__group"> | ||||
|                                 <dt class="pf-c-description-list__term"> | ||||
|                                     <span class="pf-c-description-list__text" | ||||
|                                         >${msg("Dry-run")}</span | ||||
|                                     > | ||||
|                                 </dt> | ||||
|                                 <dd class="pf-c-description-list__description"> | ||||
|                                     <div class="pf-c-description-list__text"> | ||||
|                                         <ak-status-label | ||||
|                                             ?good=${!this.provider.dryRun} | ||||
|                                             type="info" | ||||
|                                             good-label=${msg("No")} | ||||
|                                             bad-label=${msg("Yes")} | ||||
|                                         ></ak-status-label> | ||||
|                                     </div> | ||||
|                                 </dd> | ||||
|                             </div> | ||||
|                         </dl> | ||||
|                     </div> | ||||
|                     <div class="pf-c-card__footer"> | ||||
|  | ||||
| @ -150,6 +150,26 @@ export class MicrosoftEntraProviderFormPage extends BaseProviderForm<MicrosoftEn | ||||
|                         help=${msg("Determines what authentik will do when a Group is deleted.")} | ||||
|                     > | ||||
|                     </ak-radio-input> | ||||
|                     <ak-form-element-horizontal name="dryRun"> | ||||
|                         <label class="pf-c-switch"> | ||||
|                             <input | ||||
|                                 class="pf-c-switch__input" | ||||
|                                 type="checkbox" | ||||
|                                 ?checked=${first(this.instance?.dryRun, false)} | ||||
|                             /> | ||||
|                             <span class="pf-c-switch__toggle"> | ||||
|                                 <span class="pf-c-switch__toggle-icon"> | ||||
|                                     <i class="fas fa-check" aria-hidden="true"></i> | ||||
|                                 </span> | ||||
|                             </span> | ||||
|                             <span class="pf-c-switch__label">${msg("Enable dry-run mode")}</span> | ||||
|                         </label> | ||||
|                         <p class="pf-c-form__helper-text"> | ||||
|                             ${msg( | ||||
|                                 "When enabled, mutating requests will be dropped and logged instead.", | ||||
|                             )} | ||||
|                         </p> | ||||
|                     </ak-form-element-horizontal> | ||||
|                 </div> | ||||
|             </ak-form-group> | ||||
|             <ak-form-group ?expanded=${true}> | ||||
|  | ||||
| @ -176,6 +176,23 @@ export class MicrosoftEntraProviderViewPage extends AKElement { | ||||
|                                     </div> | ||||
|                                 </dd> | ||||
|                             </div> | ||||
|                             <div class="pf-c-description-list__group"> | ||||
|                                 <dt class="pf-c-description-list__term"> | ||||
|                                     <span class="pf-c-description-list__text" | ||||
|                                         >${msg("Dry-run")}</span | ||||
|                                     > | ||||
|                                 </dt> | ||||
|                                 <dd class="pf-c-description-list__description"> | ||||
|                                     <div class="pf-c-description-list__text"> | ||||
|                                         <ak-status-label | ||||
|                                             ?good=${!this.provider.dryRun} | ||||
|                                             type="info" | ||||
|                                             good-label=${msg("No")} | ||||
|                                             bad-label=${msg("Yes")} | ||||
|                                         ></ak-status-label> | ||||
|                                     </div> | ||||
|                                 </dd> | ||||
|                             </div> | ||||
|                         </dl> | ||||
|                     </div> | ||||
|                     <div class="pf-c-card__footer"> | ||||
|  | ||||
| @ -61,6 +61,26 @@ export function renderForm(provider?: Partial<SCIMProvider>, errors: ValidationE | ||||
|                     )} | ||||
|                     inputHint="code" | ||||
|                 ></ak-text-input> | ||||
|                 <ak-form-element-horizontal name="dryRun"> | ||||
|                     <label class="pf-c-switch"> | ||||
|                         <input | ||||
|                             class="pf-c-switch__input" | ||||
|                             type="checkbox" | ||||
|                             ?checked=${first(provider?.dryRun, false)} | ||||
|                         /> | ||||
|                         <span class="pf-c-switch__toggle"> | ||||
|                             <span class="pf-c-switch__toggle-icon"> | ||||
|                                 <i class="fas fa-check" aria-hidden="true"></i> | ||||
|                             </span> | ||||
|                         </span> | ||||
|                         <span class="pf-c-switch__label">${msg("Enable dry-run mode")}</span> | ||||
|                     </label> | ||||
|                     <p class="pf-c-form__helper-text"> | ||||
|                         ${msg( | ||||
|                             "When enabled, mutating requests will be dropped and logged instead.", | ||||
|                         )} | ||||
|                     </p> | ||||
|                 </ak-form-element-horizontal> | ||||
|             </div> | ||||
|         </ak-form-group> | ||||
|         <ak-form-group expanded> | ||||
|  | ||||
| @ -5,6 +5,7 @@ import "@goauthentik/admin/providers/scim/SCIMProviderUserList"; | ||||
| import "@goauthentik/admin/rbac/ObjectPermissionsPage"; | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
| import { EVENT_REFRESH } from "@goauthentik/common/constants"; | ||||
| import "@goauthentik/components/ak-status-label"; | ||||
| import "@goauthentik/components/events/ObjectChangelog"; | ||||
| import MDSCIMProvider from "@goauthentik/docs/add-secure-apps/providers/scim/index.md"; | ||||
| import { AKElement } from "@goauthentik/elements/Base"; | ||||
| @ -151,7 +152,7 @@ export class SCIMProviderViewPage extends AKElement { | ||||
|                 <div class="pf-l-grid__item pf-m-7-col pf-l-stack pf-m-gutter"> | ||||
|                     <div class="pf-c-card pf-m-12-col pf-l-stack__item"> | ||||
|                         <div class="pf-c-card__body"> | ||||
|                             <dl class="pf-c-description-list pf-m-3-col-on-lg"> | ||||
|                             <dl class="pf-c-description-list pf-m-4-col-on-lg"> | ||||
|                                 <div class="pf-c-description-list__group"> | ||||
|                                     <dt class="pf-c-description-list__term"> | ||||
|                                         <span class="pf-c-description-list__text" | ||||
| @ -178,7 +179,23 @@ export class SCIMProviderViewPage extends AKElement { | ||||
|                                         </div> | ||||
|                                     </dd> | ||||
|                                 </div> | ||||
|  | ||||
|                                 <div class="pf-c-description-list__group"> | ||||
|                                     <dt class="pf-c-description-list__term"> | ||||
|                                         <span class="pf-c-description-list__text" | ||||
|                                             >${msg("Dry-run")}</span | ||||
|                                         > | ||||
|                                     </dt> | ||||
|                                     <dd class="pf-c-description-list__description"> | ||||
|                                         <div class="pf-c-description-list__text"> | ||||
|                                             <ak-status-label | ||||
|                                                 ?good=${!this.provider.dryRun} | ||||
|                                                 type="info" | ||||
|                                                 good-label=${msg("No")} | ||||
|                                                 bad-label=${msg("Yes")} | ||||
|                                             ></ak-status-label> | ||||
|                                         </div> | ||||
|                                     </dd> | ||||
|                                 </div> | ||||
|                                 <div class="pf-c-description-list__group"> | ||||
|                                     <dt class="pf-c-description-list__term"> | ||||
|                                         <span class="pf-c-description-list__text" | ||||
|  | ||||
| @ -119,12 +119,28 @@ export class SyncObjectForm extends Form<SyncObjectRequest> { | ||||
|  | ||||
|     renderForm() { | ||||
|         return html` ${this.model === SyncObjectModelEnum.AuthentikCoreModelsUser | ||||
|             ? this.renderSelectUser() | ||||
|             : nothing} | ||||
|         ${this.model === SyncObjectModelEnum.AuthentikCoreModelsGroup | ||||
|             ? this.renderSelectGroup() | ||||
|             : nothing} | ||||
|         ${this.result ? this.renderResult() : html``}`; | ||||
|                 ? this.renderSelectUser() | ||||
|                 : nothing} | ||||
|             ${this.model === SyncObjectModelEnum.AuthentikCoreModelsGroup | ||||
|                 ? this.renderSelectGroup() | ||||
|                 : nothing} | ||||
|             <ak-form-element-horizontal name="overrideDryRun"> | ||||
|                 <label class="pf-c-switch"> | ||||
|                     <input class="pf-c-switch__input" type="checkbox" /> | ||||
|                     <span class="pf-c-switch__toggle"> | ||||
|                         <span class="pf-c-switch__toggle-icon"> | ||||
|                             <i class="fas fa-check" aria-hidden="true"></i> | ||||
|                         </span> | ||||
|                     </span> | ||||
|                     <span class="pf-c-switch__label">${msg("Override dry-run mode")}</span> | ||||
|                 </label> | ||||
|                 <p class="pf-c-form__helper-text"> | ||||
|                     ${msg( | ||||
|                         "When enabled, this sync will still execute mutating requests regardless of the dry-run mode in the provider.", | ||||
|                     )} | ||||
|                 </p> | ||||
|             </ak-form-element-horizontal> | ||||
|             ${this.result ? this.renderResult() : html``}`; | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens L.
					Jens L.