Skip to content

Commit b5a8957

Browse files
authored
lib/sync/outgoing: add dry run (#13244)
* lib/sync/outgoing: add dry run Signed-off-by: Jens Langhammer <[email protected]> * add option to temporarily override dry run Signed-off-by: Jens Langhammer <[email protected]> * web a Signed-off-by: Jens Langhammer <[email protected]> * web b Signed-off-by: Jens Langhammer <[email protected]> * format Signed-off-by: Jens Langhammer <[email protected]> * add some test Signed-off-by: Jens Langhammer <[email protected]> * add more tests Signed-off-by: Jens Langhammer <[email protected]> * add dry run label Signed-off-by: Jens Langhammer <[email protected]> * add support for entra too Signed-off-by: Jens Langhammer <[email protected]> * add web Signed-off-by: Jens Langhammer <[email protected]> * add entra test and improve error handling Signed-off-by: Jens Langhammer <[email protected]> --------- Signed-off-by: Jens Langhammer <[email protected]>
1 parent 9b01213 commit b5a8957

25 files changed

+469
-31
lines changed

authentik/enterprise/providers/google_workspace/api/providers.py

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class Meta:
3737
"user_delete_action",
3838
"group_delete_action",
3939
"default_group_email_domain",
40+
"dry_run",
4041
]
4142
extra_kwargs = {}
4243

authentik/enterprise/providers/google_workspace/clients/base.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88

99
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
1010
from authentik.lib.sync.outgoing import HTTP_CONFLICT
11-
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
11+
from authentik.lib.sync.outgoing.base import SAFE_METHODS, BaseOutgoingSyncClient
1212
from authentik.lib.sync.outgoing.exceptions import (
1313
BadRequestSyncException,
14+
DryRunRejected,
1415
NotFoundSyncException,
1516
ObjectExistsSyncException,
1617
StopSync,
@@ -43,6 +44,8 @@ def __prefetch_domains(self):
4344
self.domains.append(domain_name)
4445

4546
def _request(self, request: HttpRequest):
47+
if self.provider.dry_run and request.method.upper() not in SAFE_METHODS:
48+
raise DryRunRejected(request.uri, request.method, request.body)
4649
try:
4750
response = request.execute()
4851
except GoogleAuthError as exc:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 5.0.12 on 2025-02-24 19:43
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
(
10+
"authentik_providers_google_workspace",
11+
"0003_googleworkspaceprovidergroup_attributes_and_more",
12+
),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="googleworkspaceprovider",
18+
name="dry_run",
19+
field=models.BooleanField(
20+
default=False,
21+
help_text="When enabled, provider will not modify or create objects in the remote system.",
22+
),
23+
),
24+
]

authentik/enterprise/providers/microsoft_entra/api/providers.py

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class Meta:
3636
"filter_group",
3737
"user_delete_action",
3838
"group_delete_action",
39+
"dry_run",
3940
]
4041
extra_kwargs = {}
4142

authentik/enterprise/providers/microsoft_entra/clients/base.py

+41-15
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from dataclasses import asdict
44
from typing import Any
55

6+
import httpx
67
from azure.core.exceptions import (
78
ClientAuthenticationError,
89
ServiceRequestError,
@@ -12,6 +13,7 @@
1213
from django.db.models import Model
1314
from django.http import HttpResponseBadRequest, HttpResponseNotFound
1415
from kiota_abstractions.api_error import APIError
16+
from kiota_abstractions.request_information import RequestInformation
1517
from kiota_authentication_azure.azure_identity_authentication_provider import (
1618
AzureIdentityAuthenticationProvider,
1719
)
@@ -21,34 +23,40 @@
2123
from msgraph.graph_request_adapter import GraphRequestAdapter, options
2224
from msgraph.graph_service_client import GraphServiceClient
2325
from msgraph_core import GraphClientFactory
26+
from opentelemetry import trace
2427

2528
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
2629
from authentik.events.utils import sanitize_item
2730
from authentik.lib.sync.outgoing import HTTP_CONFLICT
28-
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
31+
from authentik.lib.sync.outgoing.base import SAFE_METHODS, BaseOutgoingSyncClient
2932
from authentik.lib.sync.outgoing.exceptions import (
3033
BadRequestSyncException,
34+
DryRunRejected,
3135
NotFoundSyncException,
3236
ObjectExistsSyncException,
3337
StopSync,
3438
TransientSyncException,
3539
)
3640

3741

38-
def get_request_adapter(
39-
credentials: ClientSecretCredential, scopes: list[str] | None = None
40-
) -> GraphRequestAdapter:
41-
if scopes:
42-
auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials, scopes=scopes)
43-
else:
44-
auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials)
42+
class AuthentikRequestAdapter(GraphRequestAdapter):
43+
def __init__(self, auth_provider, provider: MicrosoftEntraProvider, client=None):
44+
super().__init__(auth_provider, client)
45+
self._provider = provider
4546

46-
return GraphRequestAdapter(
47-
auth_provider=auth_provider,
48-
client=GraphClientFactory.create_with_default_middleware(
49-
options=options, client=KiotaClientFactory.get_default_client()
50-
),
51-
)
47+
async def get_http_response_message(
48+
self,
49+
request_info: RequestInformation,
50+
parent_span: trace.Span,
51+
claims: str = "",
52+
) -> httpx.Response:
53+
if self._provider.dry_run and request_info.http_method.value.upper() not in SAFE_METHODS:
54+
raise DryRunRejected(
55+
url=request_info.url,
56+
method=request_info.http_method.value,
57+
body=request_info.content.decode("utf-8"),
58+
)
59+
return await super().get_http_response_message(request_info, parent_span, claims=claims)
5260

5361

5462
class MicrosoftEntraSyncClient[TModel: Model, TConnection: Model, TSchema: dict](
@@ -63,9 +71,27 @@ def __init__(self, provider: MicrosoftEntraProvider) -> None:
6371
self.credentials = provider.microsoft_credentials()
6472
self.__prefetch_domains()
6573

74+
def get_request_adapter(
75+
self, credentials: ClientSecretCredential, scopes: list[str] | None = None
76+
) -> AuthentikRequestAdapter:
77+
if scopes:
78+
auth_provider = AzureIdentityAuthenticationProvider(
79+
credentials=credentials, scopes=scopes
80+
)
81+
else:
82+
auth_provider = AzureIdentityAuthenticationProvider(credentials=credentials)
83+
84+
return AuthentikRequestAdapter(
85+
auth_provider=auth_provider,
86+
provider=self.provider,
87+
client=GraphClientFactory.create_with_default_middleware(
88+
options=options, client=KiotaClientFactory.get_default_client()
89+
),
90+
)
91+
6692
@property
6793
def client(self):
68-
return GraphServiceClient(request_adapter=get_request_adapter(**self.credentials))
94+
return GraphServiceClient(request_adapter=self.get_request_adapter(**self.credentials))
6995

7096
def _request[T](self, request: Coroutine[Any, Any, T]) -> T:
7197
try:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 5.0.12 on 2025-02-24 19:43
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
(
10+
"authentik_providers_microsoft_entra",
11+
"0002_microsoftentraprovidergroup_attributes_and_more",
12+
),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="microsoftentraprovider",
18+
name="dry_run",
19+
field=models.BooleanField(
20+
default=False,
21+
help_text="When enabled, provider will not modify or create objects in the remote system.",
22+
),
23+
),
24+
]

authentik/enterprise/providers/microsoft_entra/tests/test_users.py

+32-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ class MicrosoftEntraUserTests(APITestCase):
3232

3333
@apply_blueprint("system/providers-microsoft-entra.yaml")
3434
def setUp(self) -> None:
35-
3635
# Delete all users and groups as the mocked HTTP responses only return one ID
3736
# which will cause errors with multiple users
3837
Tenant.objects.update(avatars="none")
@@ -97,6 +96,38 @@ def test_user_create(self):
9796
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
9897
user_create.assert_called_once()
9998

99+
def test_user_create_dry_run(self):
100+
"""Test user creation (dry run)"""
101+
self.provider.dry_run = True
102+
self.provider.save()
103+
uid = generate_id()
104+
with (
105+
patch(
106+
"authentik.enterprise.providers.microsoft_entra.models.MicrosoftEntraProvider.microsoft_credentials",
107+
MagicMock(return_value={"credentials": self.creds}),
108+
),
109+
patch(
110+
"msgraph.generated.organization.organization_request_builder.OrganizationRequestBuilder.get",
111+
AsyncMock(
112+
return_value=OrganizationCollectionResponse(
113+
value=[
114+
Organization(verified_domains=[VerifiedDomain(name="goauthentik.io")])
115+
]
116+
)
117+
),
118+
),
119+
):
120+
user = User.objects.create(
121+
username=uid,
122+
name=f"{uid} {uid}",
123+
email=f"{uid}@goauthentik.io",
124+
)
125+
microsoft_user = MicrosoftEntraProviderUser.objects.filter(
126+
provider=self.provider, user=user
127+
).first()
128+
self.assertIsNone(microsoft_user)
129+
self.assertFalse(Event.objects.filter(action=EventAction.SYSTEM_EXCEPTION).exists())
130+
100131
def test_user_not_created(self):
101132
"""Test without property mappings, no group is created"""
102133
self.provider.property_mappings.clear()

authentik/lib/sync/outgoing/api.py

+2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class SyncObjectSerializer(PassiveSerializer):
3333
)
3434
)
3535
sync_object_id = CharField()
36+
override_dry_run = BooleanField(default=False)
3637

3738

3839
class SyncObjectResultSerializer(PassiveSerializer):
@@ -98,6 +99,7 @@ def sync_object(self, request: Request, pk: int) -> Response:
9899
page=1,
99100
provider_pk=provider.pk,
100101
pk=params.validated_data["sync_object_id"],
102+
override_dry_run=params.validated_data["override_dry_run"],
101103
).get()
102104
return Response(SyncObjectResultSerializer(instance={"messages": res}).data)
103105

authentik/lib/sync/outgoing/base.py

+8
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ class Direction(StrEnum):
2828
remove = "remove"
2929

3030

31+
SAFE_METHODS = [
32+
"GET",
33+
"HEAD",
34+
"OPTIONS",
35+
"TRACE",
36+
]
37+
38+
3139
class BaseOutgoingSyncClient[
3240
TModel: "Model", TConnection: "Model", TSchema: dict, TProvider: "OutgoingSyncProvider"
3341
]:

authentik/lib/sync/outgoing/exceptions.py

+16
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@ class BadRequestSyncException(BaseSyncException):
2121
"""Exception when invalid data was sent to the remote system"""
2222

2323

24+
class DryRunRejected(BaseSyncException):
25+
"""When dry_run is enabled and a provider dropped a mutating request"""
26+
27+
def __init__(self, url: str, method: str, body: dict):
28+
super().__init__()
29+
self.url = url
30+
self.method = method
31+
self.body = body
32+
33+
def __repr__(self):
34+
return self.__str__()
35+
36+
def __str__(self):
37+
return f"Dry-run rejected request: {self.method} {self.url}"
38+
39+
2440
class StopSync(BaseSyncException):
2541
"""Exception raised when a configuration error should stop the sync process"""
2642

authentik/lib/sync/outgoing/models.py

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from typing import Any, Self
22

33
import pglock
4-
from django.db import connection
4+
from django.db import connection, models
55
from django.db.models import Model, QuerySet, TextChoices
6+
from django.utils.translation import gettext_lazy as _
67

78
from authentik.core.models import Group, User
89
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
@@ -18,6 +19,14 @@ class OutgoingSyncDeleteAction(TextChoices):
1819

1920

2021
class OutgoingSyncProvider(Model):
22+
"""Base abstract models for providers implementing outgoing sync"""
23+
24+
dry_run = models.BooleanField(
25+
default=False,
26+
help_text=_(
27+
"When enabled, provider will not modify or create objects in the remote system."
28+
),
29+
)
2130

2231
class Meta:
2332
abstract = True
@@ -32,7 +41,7 @@ def get_object_qs[T: User | Group](self, type: type[T]) -> QuerySet[T]:
3241

3342
@property
3443
def sync_lock(self) -> pglock.advisory:
35-
"""Postgres lock for syncing SCIM to prevent multiple parallel syncs happening"""
44+
"""Postgres lock for syncing to prevent multiple parallel syncs happening"""
3645
return pglock.advisory(
3746
lock_id=f"goauthentik.io/{connection.schema_name}/providers/outgoing-sync/{str(self.pk)}",
3847
timeout=0,

authentik/lib/sync/outgoing/tasks.py

+30-3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from authentik.lib.sync.outgoing.base import Direction
2121
from authentik.lib.sync.outgoing.exceptions import (
2222
BadRequestSyncException,
23+
DryRunRejected,
2324
StopSync,
2425
TransientSyncException,
2526
)
@@ -105,7 +106,9 @@ def sync_single(
105106
return
106107
task.set_status(TaskStatus.SUCCESSFUL, *messages)
107108

108-
def sync_objects(self, object_type: str, page: int, provider_pk: int, **filter):
109+
def sync_objects(
110+
self, object_type: str, page: int, provider_pk: int, override_dry_run=False, **filter
111+
):
109112
_object_type = path_to_class(object_type)
110113
self.logger = get_logger().bind(
111114
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):
116119
provider = self._provider_model.objects.filter(pk=provider_pk).first()
117120
if not provider:
118121
return messages
122+
# Override dry run mode if requested, however don't save the provider
123+
# so that scheduled sync tasks still run in dry_run mode
124+
if override_dry_run:
125+
provider.dry_run = False
119126
try:
120127
client = provider.client_for_model(_object_type)
121128
except TransientSyncException:
@@ -132,6 +139,22 @@ def sync_objects(self, object_type: str, page: int, provider_pk: int, **filter):
132139
except SkipObjectException:
133140
self.logger.debug("skipping object due to SkipObject", obj=obj)
134141
continue
142+
except DryRunRejected as exc:
143+
messages.append(
144+
asdict(
145+
LogEvent(
146+
_("Dropping mutating request due to dry run"),
147+
log_level="info",
148+
logger=f"{provider._meta.verbose_name}@{object_type}",
149+
attributes={
150+
"obj": sanitize_item(obj),
151+
"method": exc.method,
152+
"url": exc.url,
153+
"body": exc.body,
154+
},
155+
)
156+
)
157+
)
135158
except BadRequestSyncException as exc:
136159
self.logger.warning("failed to sync object", exc=exc, obj=obj)
137160
messages.append(
@@ -231,8 +254,10 @@ def sync_signal_direct(self, model: str, pk: str | int, raw_op: str):
231254
raise Retry() from exc
232255
except SkipObjectException:
233256
continue
257+
except DryRunRejected as exc:
258+
self.logger.info("Rejected dry-run event", exc=exc)
234259
except StopSync as exc:
235-
self.logger.warning(exc, provider_pk=provider.pk)
260+
self.logger.warning("Stopping sync", exc=exc, provider_pk=provider.pk)
236261

237262
def sync_signal_m2m(self, group_pk: str, action: str, pk_set: list[int]):
238263
self.logger = get_logger().bind(
@@ -263,5 +288,7 @@ def sync_signal_m2m(self, group_pk: str, action: str, pk_set: list[int]):
263288
raise Retry() from exc
264289
except SkipObjectException:
265290
continue
291+
except DryRunRejected as exc:
292+
self.logger.info("Rejected dry-run event", exc=exc)
266293
except StopSync as exc:
267-
self.logger.warning(exc, provider_pk=provider.pk)
294+
self.logger.warning("Stopping sync", exc=exc, provider_pk=provider.pk)

0 commit comments

Comments
 (0)