diff --git a/authentik/core/models.py b/authentik/core/models.py
index c33fe8c6a2..1f66829a8e 100644
--- a/authentik/core/models.py
+++ b/authentik/core/models.py
@@ -152,7 +152,7 @@ class AttributesMixin(models.Model):
@classmethod
def update_or_create_attributes(
cls, query: dict[str, Any], properties: dict[str, Any]
- ) -> tuple[models.Model, bool]:
+ ) -> tuple[Self, bool]:
"""Same as django's update_or_create but correctly updates attributes by merging dicts"""
instance = cls.objects.filter(**query).first()
if not instance:
diff --git a/authentik/core/tests/test_models.py b/authentik/core/tests/test_models.py
index 6f95fcce9e..91d9467fa9 100644
--- a/authentik/core/tests/test_models.py
+++ b/authentik/core/tests/test_models.py
@@ -39,7 +39,7 @@ def source_tester_factory(test_model: type[Source]) -> Callable:
def tester(self: TestModels):
model_class = None
if test_model._meta.abstract:
- model_class = [x for x in test_model.__bases__ if issubclass(x, Source)][0]()
+ return
else:
model_class = test_model()
model_class.slug = "test"
diff --git a/authentik/lib/sync/incoming/__init__.py b/authentik/lib/sync/incoming/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/authentik/lib/sync/incoming/models.py b/authentik/lib/sync/incoming/models.py
new file mode 100644
index 0000000000..ebf4f9d57d
--- /dev/null
+++ b/authentik/lib/sync/incoming/models.py
@@ -0,0 +1,25 @@
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from authentik.core.models import Source
+from authentik.tasks.schedules.models import ScheduledModel
+
+
+class SyncOutgoingTriggerMode(models.TextChoices):
+ # Do not trigger outgoing syncs
+ NONE = "none"
+ # Trigger immediately after object changed
+ IMMEDIATE = "immediate"
+ # Trigger at the end of full sync
+ DEFERRED_END = "deferred_end"
+
+
+class IncomingSyncSource(ScheduledModel, Source):
+ sync_outgoing_trigger_mode = models.TextField(
+ choices=SyncOutgoingTriggerMode.choices,
+ default=SyncOutgoingTriggerMode.DEFERRED_END,
+ help_text=_("When to trigger sync for outgoing providers"),
+ )
+
+ class Meta:
+ abstract = True
diff --git a/authentik/lib/sync/outgoing/models.py b/authentik/lib/sync/outgoing/models.py
index 6319eacea5..ead7e5291b 100644
--- a/authentik/lib/sync/outgoing/models.py
+++ b/authentik/lib/sync/outgoing/models.py
@@ -83,6 +83,10 @@ class OutgoingSyncProvider(ScheduledModel, Model):
def sync_actor(self) -> Actor:
raise NotImplementedError
+ def sync_dispatch(self) -> None:
+ for schedule in self.schedules:
+ schedule.send()
+
@property
def schedule_specs(self) -> list[ScheduleSpec]:
return [
diff --git a/authentik/lib/sync/outgoing/signals.py b/authentik/lib/sync/outgoing/signals.py
index 268bc09e0d..e5b5daf768 100644
--- a/authentik/lib/sync/outgoing/signals.py
+++ b/authentik/lib/sync/outgoing/signals.py
@@ -1,3 +1,6 @@
+from contextlib import contextmanager
+from contextvars import ContextVar
+
from django.db.models import Model
from django.db.models.signals import m2m_changed, post_save, pre_delete
from dramatiq.actor import Actor
@@ -7,6 +10,23 @@ from authentik.lib.sync.outgoing.base import Direction
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.utils.reflection import class_to_path
+_CTX_INHIBIT_DISPATCH = ContextVar[bool](
+ "authentik_sync_outgoing_inhibit_dispatch",
+ default=False,
+)
+
+
+@contextmanager
+def sync_outgoing_inhibit_dispatch():
+ """
+ Prevent direct and m2m tasks from being dispatched when User/Group/membership change
+ """
+ _CTX_INHIBIT_DISPATCH.set(True)
+ try:
+ yield
+ finally:
+ _CTX_INHIBIT_DISPATCH.set(False)
+
def register_signals(
provider_type: type[OutgoingSyncProvider],
@@ -28,6 +48,8 @@ def register_signals(
# This primarily happens during user login
if sender == User and update_fields == {"last_login"}:
return
+ if _CTX_INHIBIT_DISPATCH.get():
+ return
if not provider_type.objects.exists():
return
task_sync_direct_dispatch.send(
@@ -41,6 +63,8 @@ def register_signals(
def model_pre_delete(sender: type[Model], instance: User | Group, **_):
"""Pre-delete handler"""
+ if _CTX_INHIBIT_DISPATCH.get():
+ return
if not provider_type.objects.exists():
return
task_sync_direct_dispatch.send(
@@ -58,6 +82,8 @@ def register_signals(
"""Sync group membership"""
if action not in ["post_add", "post_remove"]:
return
+ if _CTX_INHIBIT_DISPATCH.get():
+ return
if not provider_type.objects.exists():
return
task_sync_m2m_dispatch.send(instance.pk, action, list(pk_set), reverse)
diff --git a/authentik/sources/kerberos/api/source.py b/authentik/sources/kerberos/api/source.py
index 4ea983ee35..538bcb4e3f 100644
--- a/authentik/sources/kerberos/api/source.py
+++ b/authentik/sources/kerberos/api/source.py
@@ -44,6 +44,7 @@ class KerberosSourceSerializer(SourceSerializer):
"spnego_keytab",
"spnego_ccache",
"password_login_update_internal_password",
+ "sync_outgoing_trigger_mode",
]
extra_kwargs = {
"sync_password": {"write_only": True},
diff --git a/authentik/sources/kerberos/migrations/0004_kerberossource_sync_outgoing_trigger_mode.py b/authentik/sources/kerberos/migrations/0004_kerberossource_sync_outgoing_trigger_mode.py
new file mode 100644
index 0000000000..f9451aaf22
--- /dev/null
+++ b/authentik/sources/kerberos/migrations/0004_kerberossource_sync_outgoing_trigger_mode.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.2.9 on 2025-12-08 13:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_kerberos", "0003_migrate_userkerberossourceconnection_identifier"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="kerberossource",
+ name="sync_outgoing_trigger_mode",
+ field=models.TextField(
+ choices=[
+ ("none", "None"),
+ ("immediate", "Immediate"),
+ ("deferred_end", "Deferred End"),
+ ],
+ default="deferred_end",
+ help_text="When to trigger sync for outgoing providers",
+ ),
+ ),
+ ]
diff --git a/authentik/sources/kerberos/models.py b/authentik/sources/kerberos/models.py
index 3cab12b1a3..334d2595a5 100644
--- a/authentik/sources/kerberos/models.py
+++ b/authentik/sources/kerberos/models.py
@@ -22,15 +22,14 @@ from structlog.stdlib import get_logger
from authentik.core.models import (
GroupSourceConnection,
PropertyMapping,
- Source,
UserSourceConnection,
UserTypes,
)
from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.flows.challenge import RedirectChallenge
+from authentik.lib.sync.incoming.models import IncomingSyncSource
from authentik.lib.utils.time import fqdn_rand
from authentik.tasks.schedules.common import ScheduleSpec
-from authentik.tasks.schedules.models import ScheduledModel
LOGGER = get_logger()
@@ -46,7 +45,7 @@ class KAdminType(models.TextChoices):
OTHER = "other"
-class KerberosSource(ScheduledModel, Source):
+class KerberosSource(IncomingSyncSource):
"""Federate Kerberos realm with authentik"""
realm = models.TextField(help_text=_("Kerberos realm"), unique=True)
diff --git a/authentik/sources/kerberos/tasks.py b/authentik/sources/kerberos/tasks.py
index d56488a253..b1a972446b 100644
--- a/authentik/sources/kerberos/tasks.py
+++ b/authentik/sources/kerberos/tasks.py
@@ -6,7 +6,11 @@ from dramatiq.actor import actor
from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG
+from authentik.lib.sync.incoming.models import SyncOutgoingTriggerMode
from authentik.lib.sync.outgoing.exceptions import StopSync
+from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
+from authentik.lib.sync.outgoing.signals import sync_outgoing_inhibit_dispatch
+from authentik.lib.utils.reflection import all_subclasses
from authentik.sources.kerberos.models import KerberosSource
from authentik.sources.kerberos.sync import KerberosSync
from authentik.tasks.middleware import CurrentTask
@@ -45,7 +49,15 @@ def kerberos_sync(pk: str):
)
return
syncer = KerberosSync(source, self)
- syncer.sync()
+ if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.IMMEDIATE:
+ syncer.sync()
+ else:
+ with sync_outgoing_inhibit_dispatch():
+ syncer.sync()
+ if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.DEFERRED_END:
+ for outgoing_sync_provider_cls in all_subclasses(OutgoingSyncProvider):
+ for provider in outgoing_sync_provider_cls.objects.all():
+ provider.sync_dispatch()
except StopSync as exc:
LOGGER.warning("Error syncing kerberos", exc=exc, source=source)
self.error(exc)
diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py
index cca24bbc3c..b0bea3113d 100644
--- a/authentik/sources/ldap/api.py
+++ b/authentik/sources/ldap/api.py
@@ -114,6 +114,7 @@ class LDAPSourceSerializer(SourceSerializer):
"connectivity",
"lookup_groups_from_user",
"delete_not_found_objects",
+ "sync_outgoing_trigger_mode",
]
extra_kwargs = {"bind_password": {"write_only": True}}
diff --git a/authentik/sources/ldap/migrations/0011_ldapsource_sync_outgoing_trigger_mode.py b/authentik/sources/ldap/migrations/0011_ldapsource_sync_outgoing_trigger_mode.py
new file mode 100644
index 0000000000..93d771d499
--- /dev/null
+++ b/authentik/sources/ldap/migrations/0011_ldapsource_sync_outgoing_trigger_mode.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.2.9 on 2025-12-08 13:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_ldap", "0010_ldapsource_user_membership_attribute"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="ldapsource",
+ name="sync_outgoing_trigger_mode",
+ field=models.TextField(
+ choices=[
+ ("none", "None"),
+ ("immediate", "Immediate"),
+ ("deferred_end", "Deferred End"),
+ ],
+ default="deferred_end",
+ help_text="When to trigger sync for outgoing providers",
+ ),
+ ),
+ ]
diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py
index e064abbc93..9d66c7ad79 100644
--- a/authentik/sources/ldap/models.py
+++ b/authentik/sources/ldap/models.py
@@ -19,15 +19,14 @@ from authentik.core.models import (
Group,
GroupSourceConnection,
PropertyMapping,
- Source,
UserSourceConnection,
)
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.config import CONFIG
from authentik.lib.models import DomainlessURLValidator
+from authentik.lib.sync.incoming.models import IncomingSyncSource
from authentik.lib.utils.time import fqdn_rand
from authentik.tasks.schedules.common import ScheduleSpec
-from authentik.tasks.schedules.models import ScheduledModel
LDAP_TIMEOUT = 15
LDAP_UNIQUENESS = "ldap_uniq"
@@ -56,7 +55,7 @@ class MultiURLValidator(DomainlessURLValidator):
super().__call__(value)
-class LDAPSource(ScheduledModel, Source):
+class LDAPSource(IncomingSyncSource):
"""Federate LDAP Directory with authentik, or create new accounts in LDAP."""
server_uri = models.TextField(
diff --git a/authentik/sources/ldap/tasks.py b/authentik/sources/ldap/tasks.py
index 797f090eb0..2b6ecaa4a0 100644
--- a/authentik/sources/ldap/tasks.py
+++ b/authentik/sources/ldap/tasks.py
@@ -11,8 +11,11 @@ from ldap3.core.exceptions import LDAPException
from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG
+from authentik.lib.sync.incoming.models import SyncOutgoingTriggerMode
from authentik.lib.sync.outgoing.exceptions import StopSync
-from authentik.lib.utils.reflection import class_to_path, path_to_class
+from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
+from authentik.lib.sync.outgoing.signals import sync_outgoing_inhibit_dispatch
+from authentik.lib.utils.reflection import all_subclasses, class_to_path, path_to_class
from authentik.sources.ldap.models import LDAPSource
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
from authentik.sources.ldap.sync.forward_delete_groups import GroupLDAPForwardDeletion
@@ -102,6 +105,11 @@ def ldap_sync(source_pk: str):
timeout=60 * 60 * CONFIG.get_int("ldap.task_timeout_hours") * 1000,
)
+ if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.DEFERRED_END:
+ for outgoing_sync_provider_cls in all_subclasses(OutgoingSyncProvider):
+ for provider in outgoing_sync_provider_cls.objects.all():
+ provider.sync_dispatch()
+
def ldap_sync_paginator(
task: Task, source: LDAPSource, sync: type[BaseLDAPSynchronizer]
@@ -147,7 +155,11 @@ def ldap_sync_page(source_pk: str, sync_class: str, page_cache_key: str):
self.error(error_message)
return
cache.touch(page_cache_key)
- count = sync_inst.sync(page)
+ if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.IMMEDIATE:
+ count = sync_inst.sync(page)
+ else:
+ with sync_outgoing_inhibit_dispatch():
+ count = sync_inst.sync(page)
self.info(f"Synced {count} objects.")
cache.delete(page_cache_key)
except (LDAPException, StopSync) as exc:
diff --git a/blueprints/schema.json b/blueprints/schema.json
index afb142a8b9..48fd7df390 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -11334,6 +11334,16 @@
"type": "boolean",
"title": "Password login update internal password",
"description": "If enabled, the authentik-stored password will be updated upon login with the Kerberos password backend"
+ },
+ "sync_outgoing_trigger_mode": {
+ "type": "string",
+ "enum": [
+ "none",
+ "immediate",
+ "deferred_end"
+ ],
+ "title": "Sync outgoing trigger mode",
+ "description": "When to trigger sync for outgoing providers"
}
},
"required": []
@@ -11699,6 +11709,16 @@
"type": "boolean",
"title": "Delete not found objects",
"description": "Delete authentik users and groups which were previously supplied by this source, but are now missing from it."
+ },
+ "sync_outgoing_trigger_mode": {
+ "type": "string",
+ "enum": [
+ "none",
+ "immediate",
+ "deferred_end"
+ ],
+ "title": "Sync outgoing trigger mode",
+ "description": "When to trigger sync for outgoing providers"
}
},
"required": []
diff --git a/schema.yml b/schema.yml
index 452d913a8e..fc7539535d 100644
--- a/schema.yml
+++ b/schema.yml
@@ -39803,6 +39803,10 @@ components:
type: boolean
description: If enabled, the authentik-stored password will be updated upon
login with the Kerberos password backend
+ sync_outgoing_trigger_mode:
+ allOf:
+ - $ref: '#/components/schemas/SyncOutgoingTriggerModeEnum'
+ description: When to trigger sync for outgoing providers
required:
- component
- connectivity
@@ -39987,6 +39991,10 @@ components:
type: boolean
description: If enabled, the authentik-stored password will be updated upon
login with the Kerberos password backend
+ sync_outgoing_trigger_mode:
+ allOf:
+ - $ref: '#/components/schemas/SyncOutgoingTriggerModeEnum'
+ description: When to trigger sync for outgoing providers
required:
- name
- realm
@@ -40511,6 +40519,10 @@ components:
type: boolean
description: Delete authentik users and groups which were previously supplied
by this source, but are now missing from it.
+ sync_outgoing_trigger_mode:
+ allOf:
+ - $ref: '#/components/schemas/SyncOutgoingTriggerModeEnum'
+ description: When to trigger sync for outgoing providers
required:
- base_dn
- component
@@ -40725,6 +40737,10 @@ components:
type: boolean
description: Delete authentik users and groups which were previously supplied
by this source, but are now missing from it.
+ sync_outgoing_trigger_mode:
+ allOf:
+ - $ref: '#/components/schemas/SyncOutgoingTriggerModeEnum'
+ description: When to trigger sync for outgoing providers
required:
- base_dn
- name
@@ -46867,6 +46883,10 @@ components:
type: boolean
description: If enabled, the authentik-stored password will be updated upon
login with the Kerberos password backend
+ sync_outgoing_trigger_mode:
+ allOf:
+ - $ref: '#/components/schemas/SyncOutgoingTriggerModeEnum'
+ description: When to trigger sync for outgoing providers
PatchedKubernetesServiceConnectionRequest:
type: object
description: KubernetesServiceConnection Serializer
@@ -47101,6 +47121,10 @@ components:
type: boolean
description: Delete authentik users and groups which were previously supplied
by this source, but are now missing from it.
+ sync_outgoing_trigger_mode:
+ allOf:
+ - $ref: '#/components/schemas/SyncOutgoingTriggerModeEnum'
+ description: When to trigger sync for outgoing providers
PatchedLicenseRequest:
type: object
description: License Serializer
@@ -53702,6 +53726,12 @@ components:
readOnly: true
required:
- messages
+ SyncOutgoingTriggerModeEnum:
+ enum:
+ - none
+ - immediate
+ - deferred_end
+ type: string
SyncStatus:
type: object
description: Provider/source sync status
diff --git a/web/src/admin/sources/kerberos/KerberosSourceForm.ts b/web/src/admin/sources/kerberos/KerberosSourceForm.ts
index 3ad8a3a07c..0d7c252c0d 100644
--- a/web/src/admin/sources/kerberos/KerberosSourceForm.ts
+++ b/web/src/admin/sources/kerberos/KerberosSourceForm.ts
@@ -2,6 +2,7 @@ import "#admin/common/ak-flow-search/ak-source-flow-search";
import "#components/ak-secret-text-input";
import "#components/ak-secret-textarea-input";
import "#components/ak-slug-input";
+import "#components/ak-radio-input";
import "#components/ak-file-search-input";
import "#components/ak-switch-input";
import "#components/ak-text-input";
@@ -15,6 +16,8 @@ import { propertyMappingsProvider, propertyMappingsSelector } from "./KerberosSo
import { DEFAULT_CONFIG } from "#common/api/config";
+import { RadioOption } from "#elements/forms/Radio";
+
import { iconHelperText, placeholderHelperText } from "#admin/helperText";
import { BaseSourceForm } from "#admin/sources/BaseSourceForm";
import { GroupMatchingModeToLabel, UserMatchingModeToLabel } from "#admin/sources/oauth/utils";
@@ -27,6 +30,7 @@ import {
KerberosSource,
KerberosSourceRequest,
SourcesApi,
+ SyncOutgoingTriggerModeEnum,
UserMatchingModeEnum,
} from "@goauthentik/api";
@@ -35,6 +39,31 @@ import { html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
+function createSyncOutgoingTriggerModeOptions(): RadioOption