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",
|
"user_delete_action",
|
||||||
"group_delete_action",
|
"group_delete_action",
|
||||||
"default_group_email_domain",
|
"default_group_email_domain",
|
||||||
|
"dry_run",
|
||||||
]
|
]
|
||||||
extra_kwargs = {}
|
extra_kwargs = {}
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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",
|
"filter_group",
|
||||||
"user_delete_action",
|
"user_delete_action",
|
||||||
"group_delete_action",
|
"group_delete_action",
|
||||||
|
"dry_run",
|
||||||
]
|
]
|
||||||
extra_kwargs = {}
|
extra_kwargs = {}
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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")
|
@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()
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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"
|
||||||
]:
|
]:
|
||||||
|
@ -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"""
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
|
@ -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 = {}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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 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"])
|
||||||
|
@ -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": []
|
||||||
|
39
schema.yml
39
schema.yml
@ -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
|
||||||
|
@ -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}>
|
||||||
|
@ -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">
|
||||||
|
@ -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}>
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
|
@ -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``}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user