diff --git a/authentik/sources/ldap/api.py b/authentik/sources/ldap/api.py
index 014c75cf2..22dfcb572 100644
--- a/authentik/sources/ldap/api.py
+++ b/authentik/sources/ldap/api.py
@@ -1,10 +1,13 @@
"""Source API Views"""
+from typing import Any
+
from django.http.response import Http404
from django.utils.text import slugify
from django_filters.filters import AllValuesMultipleFilter
from django_filters.filterset import FilterSet
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action
+from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
@@ -20,6 +23,16 @@ from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
class LDAPSourceSerializer(SourceSerializer):
"""LDAP Source Serializer"""
+ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
+ """Check that only a single source has password_sync on"""
+ sync_users_password = attrs.get("sync_users_password", True)
+ if sync_users_password:
+ if LDAPSource.objects.filter(sync_users_password=True).exists():
+ raise ValidationError(
+ "Only a single LDAP Source with password synchronization is allowed"
+ )
+ return super().validate(attrs)
+
class Meta:
model = LDAPSource
fields = SourceSerializer.Meta.fields + [
diff --git a/authentik/sources/ldap/migrations/0012_auto_20210812_1703.py b/authentik/sources/ldap/migrations/0012_auto_20210812_1703.py
new file mode 100644
index 000000000..fc440c38e
--- /dev/null
+++ b/authentik/sources/ldap/migrations/0012_auto_20210812_1703.py
@@ -0,0 +1,31 @@
+# Generated by Django 3.2.5 on 2021-08-12 17:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_sources_ldap", "0011_ldapsource_property_mappings_group"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="ldapsource",
+ name="bind_cn",
+ field=models.TextField(blank=True, verbose_name="Bind CN"),
+ ),
+ migrations.AlterField(
+ model_name="ldapsource",
+ name="bind_password",
+ field=models.TextField(blank=True),
+ ),
+ migrations.AlterField(
+ model_name="ldapsource",
+ name="sync_users_password",
+ field=models.BooleanField(
+ default=True,
+ help_text="When a user changes their password, sync it back to LDAP. This can only be enabled on a single LDAP source.",
+ ),
+ ),
+ ]
diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py
index d9e0d60f9..fb83adf98 100644
--- a/authentik/sources/ldap/models.py
+++ b/authentik/sources/ldap/models.py
@@ -17,8 +17,8 @@ class LDAPSource(Source):
validators=[DomainlessURLValidator(schemes=["ldap", "ldaps"])],
verbose_name=_("Server URI"),
)
- bind_cn = models.TextField(verbose_name=_("Bind CN"))
- bind_password = models.TextField()
+ bind_cn = models.TextField(verbose_name=_("Bind CN"), blank=True)
+ bind_password = models.TextField(blank=True)
start_tls = models.BooleanField(default=False, verbose_name=_("Enable Start TLS"))
base_dn = models.TextField(verbose_name=_("Base DN"))
@@ -64,7 +64,6 @@ class LDAPSource(Source):
"This can only be enabled on a single LDAP source."
)
),
- unique=True,
)
sync_groups = models.BooleanField(default=True)
sync_parent_group = models.ForeignKey(
diff --git a/authentik/sources/ldap/tests/test_api.py b/authentik/sources/ldap/tests/test_api.py
new file mode 100644
index 000000000..5419a6df8
--- /dev/null
+++ b/authentik/sources/ldap/tests/test_api.py
@@ -0,0 +1,51 @@
+"""LDAP Source API tests"""
+from rest_framework.test import APITestCase
+
+from authentik.providers.oauth2.generators import generate_client_secret
+from authentik.sources.ldap.api import LDAPSourceSerializer
+from authentik.sources.ldap.models import LDAPSource
+
+LDAP_PASSWORD = generate_client_secret()
+
+
+class LDAPAPITests(APITestCase):
+ """LDAP API tests"""
+
+ def test_sync_users_password_valid(self):
+ """Check that single source with sync_users_password is valid"""
+ serializer = LDAPSourceSerializer(
+ data={
+ "name": "foo",
+ "slug": " foo",
+ "server_uri": "ldaps://1.2.3.4",
+ "bind_cn": "",
+ "bind_password": LDAP_PASSWORD,
+ "base_dn": "dc=foo",
+ "sync_users_password": True,
+ }
+ )
+ self.assertTrue(serializer.is_valid())
+
+ def test_sync_users_password_invalid(self):
+ """Ensure only a single source with password sync can be created"""
+ LDAPSource.objects.create(
+ name="foo",
+ slug="foo",
+ server_uri="ldaps://1.2.3.4",
+ bind_cn="",
+ bind_password=LDAP_PASSWORD,
+ base_dn="dc=foo",
+ sync_users_password=True,
+ )
+ serializer = LDAPSourceSerializer(
+ data={
+ "name": "foo",
+ "slug": " foo",
+ "server_uri": "ldaps://1.2.3.4",
+ "bind_cn": "",
+ "bind_password": LDAP_PASSWORD,
+ "base_dn": "dc=foo",
+ "sync_users_password": False,
+ }
+ )
+ self.assertFalse(serializer.is_valid())
diff --git a/schema.yml b/schema.yml
index 7d00e2d5a..e1a57ceae 100644
--- a/schema.yml
+++ b/schema.yml
@@ -22468,7 +22468,6 @@ components:
description: Property mappings used for group creation/updating.
required:
- base_dn
- - bind_cn
- component
- name
- pk
@@ -22565,8 +22564,6 @@ components:
description: Property mappings used for group creation/updating.
required:
- base_dn
- - bind_cn
- - bind_password
- name
- server_uri
- slug
diff --git a/web/src/pages/sources/ldap/LDAPSourceForm.ts b/web/src/pages/sources/ldap/LDAPSourceForm.ts
index bac9ac5d2..84dde8e6a 100644
--- a/web/src/pages/sources/ldap/LDAPSourceForm.ts
+++ b/web/src/pages/sources/ldap/LDAPSourceForm.ts
@@ -129,21 +129,19 @@ export class LDAPSourceForm extends ModelForm