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:
Jens L.
2025-03-01 19:44:17 +00:00
committed by GitHub
parent 9b01213990
commit b5a8957720
25 changed files with 469 additions and 31 deletions

View File

@ -37,6 +37,7 @@ class GoogleWorkspaceProviderSerializer(EnterpriseRequiredMixin, ProviderSeriali
"user_delete_action", "user_delete_action",
"group_delete_action", "group_delete_action",
"default_group_email_domain", "default_group_email_domain",
"dry_run",
] ]
extra_kwargs = {} extra_kwargs = {}

View File

@ -8,9 +8,10 @@ from httplib2 import HttpLib2Error, HttpLib2ErrorWithResponse
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
from authentik.lib.sync.outgoing import HTTP_CONFLICT 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 ( from authentik.lib.sync.outgoing.exceptions import (
BadRequestSyncException, BadRequestSyncException,
DryRunRejected,
NotFoundSyncException, NotFoundSyncException,
ObjectExistsSyncException, ObjectExistsSyncException,
StopSync, StopSync,
@ -43,6 +44,8 @@ class GoogleWorkspaceSyncClient[TModel: Model, TConnection: Model, TSchema: dict
self.domains.append(domain_name) self.domains.append(domain_name)
def _request(self, request: HttpRequest): 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: try:
response = request.execute() response = request.execute()
except GoogleAuthError as exc: except GoogleAuthError as exc:

View File

@ -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.",
),
),
]

View File

@ -36,6 +36,7 @@ class MicrosoftEntraProviderSerializer(EnterpriseRequiredMixin, ProviderSerializ
"filter_group", "filter_group",
"user_delete_action", "user_delete_action",
"group_delete_action", "group_delete_action",
"dry_run",
] ]
extra_kwargs = {} extra_kwargs = {}

View File

@ -3,6 +3,7 @@ from collections.abc import Coroutine
from dataclasses import asdict from dataclasses import asdict
from typing import Any from typing import Any
import httpx
from azure.core.exceptions import ( from azure.core.exceptions import (
ClientAuthenticationError, ClientAuthenticationError,
ServiceRequestError, ServiceRequestError,
@ -12,6 +13,7 @@ from azure.identity.aio import ClientSecretCredential
from django.db.models import Model from django.db.models import Model
from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.http import HttpResponseBadRequest, HttpResponseNotFound
from kiota_abstractions.api_error import APIError from kiota_abstractions.api_error import APIError
from kiota_abstractions.request_information import RequestInformation
from kiota_authentication_azure.azure_identity_authentication_provider import ( from kiota_authentication_azure.azure_identity_authentication_provider import (
AzureIdentityAuthenticationProvider, 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_request_adapter import GraphRequestAdapter, options
from msgraph.graph_service_client import GraphServiceClient from msgraph.graph_service_client import GraphServiceClient
from msgraph_core import GraphClientFactory from msgraph_core import GraphClientFactory
from opentelemetry import trace
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
from authentik.events.utils import sanitize_item from authentik.events.utils import sanitize_item
from authentik.lib.sync.outgoing import HTTP_CONFLICT 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 ( from authentik.lib.sync.outgoing.exceptions import (
BadRequestSyncException, BadRequestSyncException,
DryRunRejected,
NotFoundSyncException, NotFoundSyncException,
ObjectExistsSyncException, ObjectExistsSyncException,
StopSync, StopSync,
@ -35,20 +39,24 @@ from authentik.lib.sync.outgoing.exceptions import (
) )
def get_request_adapter( class AuthentikRequestAdapter(GraphRequestAdapter):
credentials: ClientSecretCredential, scopes: list[str] | None = None def __init__(self, auth_provider, provider: MicrosoftEntraProvider, client=None):
) -> GraphRequestAdapter: super().__init__(auth_provider, client)
if scopes: self._provider = provider
auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials, scopes=scopes)
else:
auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials)
return GraphRequestAdapter( async def get_http_response_message(
auth_provider=auth_provider, self,
client=GraphClientFactory.create_with_default_middleware( request_info: RequestInformation,
options=options, client=KiotaClientFactory.get_default_client() 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]( 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.credentials = provider.microsoft_credentials()
self.__prefetch_domains() 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 @property
def client(self): 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: def _request[T](self, request: Coroutine[Any, Any, T]) -> T:
try: try:

View File

@ -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.",
),
),
]

View File

@ -32,7 +32,6 @@ class MicrosoftEntraUserTests(APITestCase):
@apply_blueprint("system/providers-microsoft-entra.yaml") @apply_blueprint("system/providers-microsoft-entra.yaml")
def setUp(self) -> None: def setUp(self) -> None:
# Delete all users and groups as the mocked HTTP responses only return one ID # Delete all users and groups as the mocked HTTP responses only return one ID
# which will cause errors with multiple users # which will cause errors with multiple users
Tenant.objects.update(avatars="none") Tenant.objects.update(avatars="none")
@ -97,6 +96,38 @@ class MicrosoftEntraUserTests(APITestCase):
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists()) self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
user_create.assert_called_once() 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): def test_user_not_created(self):
"""Test without property mappings, no group is created""" """Test without property mappings, no group is created"""
self.provider.property_mappings.clear() self.provider.property_mappings.clear()

View File

@ -33,6 +33,7 @@ class SyncObjectSerializer(PassiveSerializer):
) )
) )
sync_object_id = CharField() sync_object_id = CharField()
override_dry_run = BooleanField(default=False)
class SyncObjectResultSerializer(PassiveSerializer): class SyncObjectResultSerializer(PassiveSerializer):
@ -98,6 +99,7 @@ class OutgoingSyncProviderStatusMixin:
page=1, page=1,
provider_pk=provider.pk, provider_pk=provider.pk,
pk=params.validated_data["sync_object_id"], pk=params.validated_data["sync_object_id"],
override_dry_run=params.validated_data["override_dry_run"],
).get() ).get()
return Response(SyncObjectResultSerializer(instance={"messages": res}).data) return Response(SyncObjectResultSerializer(instance={"messages": res}).data)

View File

@ -28,6 +28,14 @@ class Direction(StrEnum):
remove = "remove" remove = "remove"
SAFE_METHODS = [
"GET",
"HEAD",
"OPTIONS",
"TRACE",
]
class BaseOutgoingSyncClient[ class BaseOutgoingSyncClient[
TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider" TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider"
]: ]:

View File

@ -21,6 +21,22 @@ class BadRequestSyncException(BaseSyncException):
"""Exception when invalid data was sent to the remote system""" """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): class StopSync(BaseSyncException):
"""Exception raised when a configuration error should stop the sync process""" """Exception raised when a configuration error should stop the sync process"""

View File

@ -1,8 +1,9 @@
from typing import Any, Self from typing import Any, Self
import pglock import pglock
from django.db import connection from django.db import connection, models
from django.db.models import Model, QuerySet, TextChoices 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.core.models import Group, User
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
@ -18,6 +19,14 @@ class OutgoingSyncDeleteAction(TextChoices):
class OutgoingSyncProvider(Model): 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: class Meta:
abstract = True abstract = True
@ -32,7 +41,7 @@ class OutgoingSyncProvider(Model):
@property @property
def sync_lock(self) -> pglock.advisory: 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( return pglock.advisory(
lock_id=f"goauthentik.io/{connection.schema_name}/providers/outgoing-sync/{str(self.pk)}", lock_id=f"goauthentik.io/{connection.schema_name}/providers/outgoing-sync/{str(self.pk)}",
timeout=0, timeout=0,

View File

@ -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.base import Direction
from authentik.lib.sync.outgoing.exceptions import ( from authentik.lib.sync.outgoing.exceptions import (
BadRequestSyncException, BadRequestSyncException,
DryRunRejected,
StopSync, StopSync,
TransientSyncException, TransientSyncException,
) )
@ -105,7 +106,9 @@ class SyncTasks:
return return
task.set_status(TaskStatus.SUCCESSFUL, *messages) 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) _object_type = path_to_class(object_type)
self.logger = get_logger().bind( self.logger = get_logger().bind(
provider_type=class_to_path(self._provider_model), provider_type=class_to_path(self._provider_model),
@ -116,6 +119,10 @@ class SyncTasks:
provider = self._provider_model.objects.filter(pk=provider_pk).first() provider = self._provider_model.objects.filter(pk=provider_pk).first()
if not provider: if not provider:
return messages 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: try:
client = provider.client_for_model(_object_type) client = provider.client_for_model(_object_type)
except TransientSyncException: except TransientSyncException:
@ -132,6 +139,22 @@ class SyncTasks:
except SkipObjectException: except SkipObjectException:
self.logger.debug("skipping object due to SkipObject", obj=obj) self.logger.debug("skipping object due to SkipObject", obj=obj)
continue 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: except BadRequestSyncException as exc:
self.logger.warning("failed to sync object", exc=exc, obj=obj) self.logger.warning("failed to sync object", exc=exc, obj=obj)
messages.append( messages.append(
@ -231,8 +254,10 @@ class SyncTasks:
raise Retry() from exc raise Retry() from exc
except SkipObjectException: except SkipObjectException:
continue continue
except DryRunRejected as exc:
self.logger.info("Rejected dry-run event", exc=exc)
except StopSync as 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]): def sync_signal_m2m(self, group_pk: str, action: str, pk_set: list[int]):
self.logger = get_logger().bind( self.logger = get_logger().bind(
@ -263,5 +288,7 @@ class SyncTasks:
raise Retry() from exc raise Retry() from exc
except SkipObjectException: except SkipObjectException:
continue continue
except DryRunRejected as exc:
self.logger.info("Rejected dry-run event", exc=exc)
except StopSync as exc: except StopSync as exc:
self.logger.warning(exc, provider_pk=provider.pk) self.logger.warning("Stopping sync", exc=exc, provider_pk=provider.pk)

View File

@ -30,6 +30,7 @@ class SCIMProviderSerializer(ProviderSerializer):
"token", "token",
"exclude_users_service_account", "exclude_users_service_account",
"filter_group", "filter_group",
"dry_run",
] ]
extra_kwargs = {} extra_kwargs = {}

View File

@ -12,8 +12,9 @@ from authentik.lib.sync.outgoing import (
HTTP_SERVICE_UNAVAILABLE, HTTP_SERVICE_UNAVAILABLE,
HTTP_TOO_MANY_REQUESTS, 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 ( from authentik.lib.sync.outgoing.exceptions import (
DryRunRejected,
NotFoundSyncException, NotFoundSyncException,
ObjectExistsSyncException, ObjectExistsSyncException,
TransientSyncException, TransientSyncException,
@ -54,6 +55,8 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
def _request(self, method: str, path: str, **kwargs) -> dict: def _request(self, method: str, path: str, **kwargs) -> dict:
"""Wrapper to send a request to the full URL""" """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: try:
response = self._session.request( response = self._session.request(
method, method,

View File

@ -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.",
),
),
]

View File

@ -3,12 +3,15 @@
from json import loads from json import loads
from django.test import TestCase from django.test import TestCase
from django.utils.text import slugify
from jsonschema import validate from jsonschema import validate
from requests_mock import Mocker from requests_mock import Mocker
from authentik.blueprints.tests import apply_blueprint from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, Group, User from authentik.core.models import Application, Group, User
from authentik.events.models import SystemTask
from authentik.lib.generators import generate_id 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.models import SCIMMapping, SCIMProvider
from authentik.providers.scim.tasks import scim_sync, sync_tasks from authentik.providers.scim.tasks import scim_sync, sync_tasks
from authentik.tenants.models import Tenant from authentik.tenants.models import Tenant
@ -330,3 +333,59 @@ class SCIMUserTests(TestCase):
"userName": uid, "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"])

View File

@ -6669,6 +6669,11 @@
"type": "string", "type": "string",
"format": "uuid", "format": "uuid",
"title": "Filter group" "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": [] "required": []
@ -14196,6 +14201,11 @@
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"title": "Default group email domain" "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": [] "required": []
@ -14344,6 +14354,11 @@
"suspend" "suspend"
], ],
"title": "Group delete action" "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": [] "required": []

View File

@ -44154,6 +44154,10 @@ components:
$ref: '#/components/schemas/OutgoingSyncDeleteAction' $ref: '#/components/schemas/OutgoingSyncDeleteAction'
default_group_email_domain: default_group_email_domain:
type: string type: string
dry_run:
type: boolean
description: When enabled, provider will not modify or create objects in
the remote system.
required: required:
- assigned_backchannel_application_name - assigned_backchannel_application_name
- assigned_backchannel_application_slug - assigned_backchannel_application_slug
@ -44317,6 +44321,10 @@ components:
default_group_email_domain: default_group_email_domain:
type: string type: string
minLength: 1 minLength: 1
dry_run:
type: boolean
description: When enabled, provider will not modify or create objects in
the remote system.
required: required:
- credentials - credentials
- default_group_email_domain - default_group_email_domain
@ -46397,6 +46405,10 @@ components:
$ref: '#/components/schemas/OutgoingSyncDeleteAction' $ref: '#/components/schemas/OutgoingSyncDeleteAction'
group_delete_action: group_delete_action:
$ref: '#/components/schemas/OutgoingSyncDeleteAction' $ref: '#/components/schemas/OutgoingSyncDeleteAction'
dry_run:
type: boolean
description: When enabled, provider will not modify or create objects in
the remote system.
required: required:
- assigned_backchannel_application_name - assigned_backchannel_application_name
- assigned_backchannel_application_slug - assigned_backchannel_application_slug
@ -46557,6 +46569,10 @@ components:
$ref: '#/components/schemas/OutgoingSyncDeleteAction' $ref: '#/components/schemas/OutgoingSyncDeleteAction'
group_delete_action: group_delete_action:
$ref: '#/components/schemas/OutgoingSyncDeleteAction' $ref: '#/components/schemas/OutgoingSyncDeleteAction'
dry_run:
type: boolean
description: When enabled, provider will not modify or create objects in
the remote system.
required: required:
- client_id - client_id
- client_secret - client_secret
@ -50679,6 +50695,10 @@ components:
default_group_email_domain: default_group_email_domain:
type: string type: string
minLength: 1 minLength: 1
dry_run:
type: boolean
description: When enabled, provider will not modify or create objects in
the remote system.
PatchedGroupKerberosSourceConnectionRequest: PatchedGroupKerberosSourceConnectionRequest:
type: object type: object
description: OAuth Group-Source connection Serializer description: OAuth Group-Source connection Serializer
@ -51260,6 +51280,10 @@ components:
$ref: '#/components/schemas/OutgoingSyncDeleteAction' $ref: '#/components/schemas/OutgoingSyncDeleteAction'
group_delete_action: group_delete_action:
$ref: '#/components/schemas/OutgoingSyncDeleteAction' $ref: '#/components/schemas/OutgoingSyncDeleteAction'
dry_run:
type: boolean
description: When enabled, provider will not modify or create objects in
the remote system.
PatchedNotificationRequest: PatchedNotificationRequest:
type: object type: object
description: Notification Serializer description: Notification Serializer
@ -52427,6 +52451,10 @@ components:
type: string type: string
format: uuid format: uuid
nullable: true nullable: true
dry_run:
type: boolean
description: When enabled, provider will not modify or create objects in
the remote system.
PatchedSCIMSourceGroupRequest: PatchedSCIMSourceGroupRequest:
type: object type: object
description: SCIMSourceGroup Serializer description: SCIMSourceGroup Serializer
@ -55823,6 +55851,10 @@ components:
type: string type: string
format: uuid format: uuid
nullable: true nullable: true
dry_run:
type: boolean
description: When enabled, provider will not modify or create objects in
the remote system.
required: required:
- assigned_backchannel_application_name - assigned_backchannel_application_name
- assigned_backchannel_application_slug - assigned_backchannel_application_slug
@ -55909,6 +55941,10 @@ components:
type: string type: string
format: uuid format: uuid
nullable: true nullable: true
dry_run:
type: boolean
description: When enabled, provider will not modify or create objects in
the remote system.
required: required:
- name - name
- token - token
@ -57105,6 +57141,9 @@ components:
sync_object_id: sync_object_id:
type: string type: string
minLength: 1 minLength: 1
override_dry_run:
type: boolean
default: false
required: required:
- sync_object_id - sync_object_id
- sync_object_model - sync_object_model

View File

@ -161,6 +161,26 @@ export class GoogleWorkspaceProviderFormPage extends BaseProviderForm<GoogleWork
help=${msg("Determines what authentik will do when a Group is deleted.")} help=${msg("Determines what authentik will do when a Group is deleted.")}
> >
</ak-radio-input> </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> </div>
</ak-form-group> </ak-form-group>
<ak-form-group ?expanded=${true}> <ak-form-group ?expanded=${true}>

View File

@ -4,6 +4,7 @@ import "@goauthentik/admin/providers/google_workspace/GoogleWorkspaceProviderUse
import "@goauthentik/admin/rbac/ObjectPermissionsPage"; import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import "@goauthentik/components/ak-status-label";
import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Markdown"; import "@goauthentik/elements/Markdown";
@ -176,6 +177,23 @@ export class GoogleWorkspaceProviderViewPage extends AKElement {
</div> </div>
</dd> </dd>
</div> </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> </dl>
</div> </div>
<div class="pf-c-card__footer"> <div class="pf-c-card__footer">

View File

@ -150,6 +150,26 @@ export class MicrosoftEntraProviderFormPage extends BaseProviderForm<MicrosoftEn
help=${msg("Determines what authentik will do when a Group is deleted.")} help=${msg("Determines what authentik will do when a Group is deleted.")}
> >
</ak-radio-input> </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> </div>
</ak-form-group> </ak-form-group>
<ak-form-group ?expanded=${true}> <ak-form-group ?expanded=${true}>

View File

@ -176,6 +176,23 @@ export class MicrosoftEntraProviderViewPage extends AKElement {
</div> </div>
</dd> </dd>
</div> </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> </dl>
</div> </div>
<div class="pf-c-card__footer"> <div class="pf-c-card__footer">

View File

@ -61,6 +61,26 @@ export function renderForm(provider?: Partial<SCIMProvider>, errors: ValidationE
)} )}
inputHint="code" inputHint="code"
></ak-text-input> ></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> </div>
</ak-form-group> </ak-form-group>
<ak-form-group expanded> <ak-form-group expanded>

View File

@ -5,6 +5,7 @@ import "@goauthentik/admin/providers/scim/SCIMProviderUserList";
import "@goauthentik/admin/rbac/ObjectPermissionsPage"; import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import "@goauthentik/components/ak-status-label";
import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/ObjectChangelog";
import MDSCIMProvider from "@goauthentik/docs/add-secure-apps/providers/scim/index.md"; import MDSCIMProvider from "@goauthentik/docs/add-secure-apps/providers/scim/index.md";
import { AKElement } from "@goauthentik/elements/Base"; 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-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 pf-m-12-col pf-l-stack__item">
<div class="pf-c-card__body"> <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"> <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term"> <dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text" <span class="pf-c-description-list__text"
@ -178,7 +179,23 @@ export class SCIMProviderViewPage extends AKElement {
</div> </div>
</dd> </dd>
</div> </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"> <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term"> <dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text" <span class="pf-c-description-list__text"

View File

@ -119,12 +119,28 @@ export class SyncObjectForm extends Form<SyncObjectRequest> {
renderForm() { renderForm() {
return html` ${this.model === SyncObjectModelEnum.AuthentikCoreModelsUser return html` ${this.model === SyncObjectModelEnum.AuthentikCoreModelsUser
? this.renderSelectUser() ? this.renderSelectUser()
: nothing} : nothing}
${this.model === SyncObjectModelEnum.AuthentikCoreModelsGroup ${this.model === SyncObjectModelEnum.AuthentikCoreModelsGroup
? this.renderSelectGroup() ? this.renderSelectGroup()
: nothing} : nothing}
${this.result ? this.renderResult() : html``}`; <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``}`;
} }
} }