diff --git a/authentik/enterprise/providers/google_workspace/api/providers.py b/authentik/enterprise/providers/google_workspace/api/providers.py index cae19432e62a..772789ce054a 100644 --- a/authentik/enterprise/providers/google_workspace/api/providers.py +++ b/authentik/enterprise/providers/google_workspace/api/providers.py @@ -37,6 +37,7 @@ class Meta: "user_delete_action", "group_delete_action", "default_group_email_domain", + "dry_run", ] extra_kwargs = {} diff --git a/authentik/enterprise/providers/google_workspace/clients/base.py b/authentik/enterprise/providers/google_workspace/clients/base.py index 8eebe13f6c7f..fc126ef21921 100644 --- a/authentik/enterprise/providers/google_workspace/clients/base.py +++ b/authentik/enterprise/providers/google_workspace/clients/base.py @@ -8,9 +8,10 @@ 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 @@ def __prefetch_domains(self): 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: diff --git a/authentik/enterprise/providers/google_workspace/migrations/0004_googleworkspaceprovider_dry_run.py b/authentik/enterprise/providers/google_workspace/migrations/0004_googleworkspaceprovider_dry_run.py new file mode 100644 index 000000000000..2e9e868be3db --- /dev/null +++ b/authentik/enterprise/providers/google_workspace/migrations/0004_googleworkspaceprovider_dry_run.py @@ -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.", + ), + ), + ] diff --git a/authentik/enterprise/providers/microsoft_entra/api/providers.py b/authentik/enterprise/providers/microsoft_entra/api/providers.py index 40c7576cb168..f1a9396e9d8b 100644 --- a/authentik/enterprise/providers/microsoft_entra/api/providers.py +++ b/authentik/enterprise/providers/microsoft_entra/api/providers.py @@ -36,6 +36,7 @@ class Meta: "filter_group", "user_delete_action", "group_delete_action", + "dry_run", ] extra_kwargs = {} diff --git a/authentik/enterprise/providers/microsoft_entra/clients/base.py b/authentik/enterprise/providers/microsoft_entra/clients/base.py index 3e4d2b6a1325..e29676ddecc5 100644 --- a/authentik/enterprise/providers/microsoft_entra/clients/base.py +++ b/authentik/enterprise/providers/microsoft_entra/clients/base.py @@ -3,6 +3,7 @@ from dataclasses import asdict from typing import Any +import httpx from azure.core.exceptions import ( ClientAuthenticationError, ServiceRequestError, @@ -12,6 +13,7 @@ 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.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 @@ ) -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 @@ def __init__(self, provider: MicrosoftEntraProvider) -> None: 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: diff --git a/authentik/enterprise/providers/microsoft_entra/migrations/0003_microsoftentraprovider_dry_run.py b/authentik/enterprise/providers/microsoft_entra/migrations/0003_microsoftentraprovider_dry_run.py new file mode 100644 index 000000000000..4dda2f61abc3 --- /dev/null +++ b/authentik/enterprise/providers/microsoft_entra/migrations/0003_microsoftentraprovider_dry_run.py @@ -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.", + ), + ), + ] diff --git a/authentik/enterprise/providers/microsoft_entra/tests/test_users.py b/authentik/enterprise/providers/microsoft_entra/tests/test_users.py index aa491f703c80..8c46e998aa57 100644 --- a/authentik/enterprise/providers/microsoft_entra/tests/test_users.py +++ b/authentik/enterprise/providers/microsoft_entra/tests/test_users.py @@ -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 @@ def test_user_create(self): 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() diff --git a/authentik/lib/sync/outgoing/api.py b/authentik/lib/sync/outgoing/api.py index a808535321e3..ee6a3c8e034f 100644 --- a/authentik/lib/sync/outgoing/api.py +++ b/authentik/lib/sync/outgoing/api.py @@ -33,6 +33,7 @@ class SyncObjectSerializer(PassiveSerializer): ) ) sync_object_id = CharField() + override_dry_run = BooleanField(default=False) class SyncObjectResultSerializer(PassiveSerializer): @@ -98,6 +99,7 @@ def sync_object(self, request: Request, pk: int) -> Response: 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) diff --git a/authentik/lib/sync/outgoing/base.py b/authentik/lib/sync/outgoing/base.py index da34b3d65b82..77a70c643c99 100644 --- a/authentik/lib/sync/outgoing/base.py +++ b/authentik/lib/sync/outgoing/base.py @@ -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" ]: diff --git a/authentik/lib/sync/outgoing/exceptions.py b/authentik/lib/sync/outgoing/exceptions.py index 843436821860..133a4904bfbb 100644 --- a/authentik/lib/sync/outgoing/exceptions.py +++ b/authentik/lib/sync/outgoing/exceptions.py @@ -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""" diff --git a/authentik/lib/sync/outgoing/models.py b/authentik/lib/sync/outgoing/models.py index 72657c3302ec..f1dad9de7b81 100644 --- a/authentik/lib/sync/outgoing/models.py +++ b/authentik/lib/sync/outgoing/models.py @@ -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 @@ def get_object_qs[T: User | Group](self, type: type[T]) -> QuerySet[T]: @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, diff --git a/authentik/lib/sync/outgoing/tasks.py b/authentik/lib/sync/outgoing/tasks.py index c2bbce090ede..48cc4a6f31fd 100644 --- a/authentik/lib/sync/outgoing/tasks.py +++ b/authentik/lib/sync/outgoing/tasks.py @@ -20,6 +20,7 @@ from authentik.lib.sync.outgoing.base import Direction from authentik.lib.sync.outgoing.exceptions import ( BadRequestSyncException, + DryRunRejected, StopSync, TransientSyncException, ) @@ -105,7 +106,9 @@ def sync_single( 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 @@ def sync_objects(self, object_type: str, page: int, provider_pk: int, **filter): 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 @@ def sync_objects(self, object_type: str, page: int, provider_pk: int, **filter): 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 @@ def sync_signal_direct(self, model: str, pk: str | int, raw_op: str): 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 @@ def sync_signal_m2m(self, group_pk: str, action: str, pk_set: list[int]): 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) diff --git a/authentik/providers/scim/api/providers.py b/authentik/providers/scim/api/providers.py index bda38e063c7a..197550ded6d4 100644 --- a/authentik/providers/scim/api/providers.py +++ b/authentik/providers/scim/api/providers.py @@ -30,6 +30,7 @@ class Meta: "token", "exclude_users_service_account", "filter_group", + "dry_run", ] extra_kwargs = {} diff --git a/authentik/providers/scim/clients/base.py b/authentik/providers/scim/clients/base.py index 246520114c83..ab7726abd0af 100644 --- a/authentik/providers/scim/clients/base.py +++ b/authentik/providers/scim/clients/base.py @@ -12,8 +12,9 @@ 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 @@ def __init__(self, provider: SCIMProvider): 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, diff --git a/authentik/providers/scim/migrations/0011_scimprovider_dry_run.py b/authentik/providers/scim/migrations/0011_scimprovider_dry_run.py new file mode 100644 index 000000000000..71e3e9b7dd61 --- /dev/null +++ b/authentik/providers/scim/migrations/0011_scimprovider_dry_run.py @@ -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.", + ), + ), + ] diff --git a/authentik/providers/scim/tests/test_user.py b/authentik/providers/scim/tests/test_user.py index a8ca5e813705..c7fc2c7c408c 100644 --- a/authentik/providers/scim/tests/test_user.py +++ b/authentik/providers/scim/tests/test_user.py @@ -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 @@ def test_sync_task(self, mock: Mocker): "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"]) diff --git a/blueprints/schema.json b/blueprints/schema.json index 4ea4ec7f724e..8c6995857b1c 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -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": [] diff --git a/schema.yml b/schema.yml index 67fd74c4c896..f70a5757d8dd 100644 --- a/schema.yml +++ b/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 diff --git a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts index 21ff7945e4fa..19cdd556f3c2 100644 --- a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts +++ b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderForm.ts @@ -161,6 +161,26 @@ export class GoogleWorkspaceProviderFormPage extends BaseProviderForm + + +

+ ${msg( + "When enabled, mutating requests will be dropped and logged instead.", + )} +

+
diff --git a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderViewPage.ts b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderViewPage.ts index 138bcad1f9af..525bf20fe6e5 100644 --- a/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderViewPage.ts +++ b/web/src/admin/providers/google_workspace/GoogleWorkspaceProviderViewPage.ts @@ -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 { +
+
+ ${msg("Dry-run")} +
+
+
+ +
+
+
diff --git a/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage.ts b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage.ts index 43e1809c5d9e..e7d35794c831 100644 --- a/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage.ts +++ b/web/src/admin/providers/microsoft_entra/MicrosoftEntraProviderViewPage.ts @@ -176,6 +176,23 @@ export class MicrosoftEntraProviderViewPage extends AKElement { +
+
+ ${msg("Dry-run")} +
+
+
+ +
+
+
diff --git a/web/src/admin/providers/scim/SCIMProviderViewPage.ts b/web/src/admin/providers/scim/SCIMProviderViewPage.ts index 808d4d853495..cadd9cc62f0d 100644 --- a/web/src/admin/providers/scim/SCIMProviderViewPage.ts +++ b/web/src/admin/providers/scim/SCIMProviderViewPage.ts @@ -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 {
-
+
- +
+
+ ${msg("Dry-run")} +
+
+
+ +
+
+
{ 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} + + +

+ ${msg( + "When enabled, this sync will still execute mutating requests regardless of the dry-run mode in the provider.", + )} +

+
+ ${this.result ? this.renderResult() : html``}`; } }