Skip to content

Commit fdfcc50

Browse files
committed
feat: Sync LDAP attributes for SSO/SAML users to fix prefill sources
- Add sync_user_ldap_attributes() function in sso_backends.py that queries LDAP directly for user attributes (mail, department, title, phone, etc.) and syncs them to UserProfile during SSO login - Update SAML ACS view to call sync_user_ldap_attributes() after group sync - Enhance LDAPDataSource.get_value() to: - Check user.email first for mail attribute (from Google SSO) - Query LDAP directly for SSO users without cached ldap_user object - Add _query_ldap_attribute() method for direct LDAP queries This fixes prefill sources (email, department, etc.) not working for users who authenticate via Google SAML SSO instead of direct LDAP authentication. Bump version to 0.6.2
1 parent 5b8313e commit fdfcc50

5 files changed

Lines changed: 277 additions & 27 deletions

File tree

django_forms_workflows/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Enterprise-grade, database-driven form builder with approval workflows
44
"""
55

6-
__version__ = "0.6.1"
6+
__version__ = "0.6.2"
77
__author__ = "Django Forms Workflows Contributors"
88
__license__ = "LGPL-3.0-only"
99

django_forms_workflows/data_sources/ldap_source.py

Lines changed: 101 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def get_value(self, user, field_name: str, **kwargs) -> Any | None:
5252
5353
Args:
5454
user: Django User object
55-
field_name: LDAP attribute name (e.g., 'department', 'title')
55+
field_name: LDAP attribute name (e.g., 'department', 'title', 'mail')
5656
**kwargs: Unused
5757
5858
Returns:
@@ -61,13 +61,15 @@ def get_value(self, user, field_name: str, **kwargs) -> Any | None:
6161
if not user or not user.is_authenticated:
6262
return None
6363

64-
if not self.is_available():
65-
logger.warning(
66-
"LDAP data source is not available (django-auth-ldap not configured)"
67-
)
68-
return None
64+
# Map friendly name to LDAP attribute
65+
ldap_attr = self.ATTRIBUTE_MAP.get(field_name, field_name)
6966

7067
try:
68+
# For 'mail', try user.email first (usually synced from LDAP/SSO)
69+
if field_name == "mail" or ldap_attr == "mail":
70+
if user.email:
71+
return user.email
72+
7173
# Try to get from user profile first (cached LDAP data)
7274
if hasattr(user, "forms_profile"):
7375
profile = user.forms_profile
@@ -76,33 +78,107 @@ def get_value(self, user, field_name: str, **kwargs) -> Any | None:
7678
if value:
7779
return value
7880

79-
# Try to get from LDAP backend directly
81+
# Try to get from LDAP backend directly (for LDAP-authenticated users)
8082
ldap_user = getattr(user, "ldap_user", None)
81-
if not ldap_user:
82-
logger.debug(f"User {user.username} has no LDAP user object")
83-
return None
83+
if ldap_user:
84+
# Special handling for manager email
85+
if field_name == "manager_email":
86+
return self._get_manager_email(ldap_user)
87+
88+
# Get attribute from cached LDAP attrs
89+
if hasattr(ldap_user, "attrs") and ldap_attr in ldap_user.attrs:
90+
value = ldap_user.attrs[ldap_attr]
91+
# LDAP attributes are often lists
92+
if isinstance(value, list) and value:
93+
return value[0]
94+
return value
95+
96+
# For SSO users without ldap_user, query LDAP directly
97+
return self._query_ldap_attribute(user.username, ldap_attr)
8498

85-
# Map friendly name to LDAP attribute
86-
ldap_attr = self.ATTRIBUTE_MAP.get(field_name, field_name)
99+
except Exception as e:
100+
logger.error(f"Error getting LDAP attribute {field_name}: {e}")
101+
return None
87102

88-
# Special handling for manager email
89-
if field_name == "manager_email":
90-
return self._get_manager_email(ldap_user)
103+
def _query_ldap_attribute(self, username: str, ldap_attr: str) -> Any | None:
104+
"""
105+
Query LDAP directly for a user attribute.
91106
92-
# Get attribute from LDAP
93-
if hasattr(ldap_user, "attrs") and ldap_attr in ldap_user.attrs:
94-
value = ldap_user.attrs[ldap_attr]
95-
# LDAP attributes are often lists
96-
if isinstance(value, list) and value:
97-
return value[0]
98-
return value
107+
This is used for SSO users who don't have a cached ldap_user object.
108+
"""
109+
import os
99110

100-
logger.debug(f"LDAP attribute not found: {ldap_attr}")
111+
try:
112+
import ldap
113+
from ldap.filter import escape_filter_chars
114+
except ImportError:
115+
logger.debug("python-ldap not installed")
101116
return None
102117

103-
except Exception as e:
104-
logger.error(f"Error getting LDAP attribute {field_name}: {e}")
118+
ldap_server = getattr(settings, "AUTH_LDAP_SERVER_URI", None)
119+
if not ldap_server:
120+
return None
121+
122+
bind_dn = getattr(settings, "AUTH_LDAP_BIND_DN", "")
123+
bind_password = getattr(settings, "AUTH_LDAP_BIND_PASSWORD", "")
124+
125+
# Get search base
126+
user_search = getattr(settings, "AUTH_LDAP_USER_SEARCH", None)
127+
search_base = None
128+
if user_search and hasattr(user_search, "base_dn"):
129+
search_base = user_search.base_dn
130+
if not search_base:
131+
search_base = getattr(settings, "AUTH_LDAP_USER_SEARCH_BASE", None)
132+
if not search_base and bind_dn:
133+
parts = bind_dn.split(",")
134+
dc_parts = [p for p in parts if p.strip().upper().startswith("DC=")]
135+
if dc_parts:
136+
search_base = ",".join(dc_parts)
137+
138+
if not search_base:
139+
logger.debug("Could not determine LDAP search base")
140+
return None
141+
142+
try:
143+
conn = ldap.initialize(ldap_server)
144+
conn.set_option(ldap.OPT_REFERRALS, 0)
145+
conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION3)
146+
147+
# Configure TLS
148+
tls_require_cert = os.getenv("LDAP_TLS_REQUIRE_CERT", "demand").lower()
149+
if tls_require_cert == "never":
150+
conn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
151+
152+
if bind_dn and bind_password:
153+
conn.simple_bind_s(bind_dn, bind_password)
154+
else:
155+
conn.simple_bind_s("", "")
156+
157+
search_filter = f"(sAMAccountName={escape_filter_chars(username)})"
158+
result = conn.search_s(
159+
search_base, ldap.SCOPE_SUBTREE, search_filter, [ldap_attr]
160+
)
161+
162+
if result and result[0][0]:
163+
attrs = result[0][1]
164+
if ldap_attr in attrs:
165+
value = attrs[ldap_attr]
166+
if isinstance(value, list) and value:
167+
val = value[0]
168+
if isinstance(val, bytes):
169+
return val.decode("utf-8")
170+
return val
171+
172+
return None
173+
174+
except ldap.LDAPError as e:
175+
logger.debug(f"LDAP query failed for {username}.{ldap_attr}: {e}")
105176
return None
177+
finally:
178+
try:
179+
conn.unbind_s()
180+
except Exception:
181+
pass
106182

107183
def _get_manager_email(self, ldap_user) -> str | None:
108184
"""

django_forms_workflows/sso_backends.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,166 @@ def sync_user_ldap_groups(user):
550550
conn.unbind_s()
551551

552552

553+
def sync_user_ldap_attributes(user):
554+
"""
555+
Sync LDAP attributes to UserProfile for a specific user.
556+
557+
This fetches key attributes from LDAP (mail, department, title, etc.)
558+
and stores them in the user's UserProfile. This is especially useful
559+
for SSO users who authenticate via Google/SAML but need LDAP attributes
560+
for form prefilling.
561+
562+
Args:
563+
user: Django User object
564+
565+
Returns:
566+
dict: Dictionary of synced attributes, or None if LDAP unavailable
567+
"""
568+
from django_forms_workflows.models import UserProfile
569+
570+
# Check if LDAP is configured
571+
ldap_server = getattr(settings, "AUTH_LDAP_SERVER_URI", None)
572+
if not ldap_server:
573+
logger.debug("LDAP not configured, skipping attribute sync")
574+
return None
575+
576+
try:
577+
import ldap
578+
import os
579+
from ldap.filter import escape_filter_chars
580+
except ImportError:
581+
logger.debug("python-ldap not installed, skipping attribute sync")
582+
return None
583+
584+
# Get LDAP connection settings
585+
bind_dn = getattr(settings, "AUTH_LDAP_BIND_DN", "")
586+
bind_password = getattr(settings, "AUTH_LDAP_BIND_PASSWORD", "")
587+
user_search_base = getattr(settings, "AUTH_LDAP_USER_SEARCH", None)
588+
589+
# Try to get search base from user search configuration
590+
search_base = None
591+
if user_search_base:
592+
if hasattr(user_search_base, "base_dn"):
593+
search_base = user_search_base.base_dn
594+
elif isinstance(user_search_base, tuple) and len(user_search_base) >= 1:
595+
search_base = user_search_base[0]
596+
597+
if not search_base:
598+
search_base = getattr(settings, "AUTH_LDAP_USER_SEARCH_BASE", None)
599+
if not search_base and bind_dn:
600+
parts = bind_dn.split(",")
601+
dc_parts = [p for p in parts if p.strip().upper().startswith("DC=")]
602+
if dc_parts:
603+
search_base = ",".join(dc_parts)
604+
605+
if not search_base:
606+
logger.warning("Could not determine LDAP search base, skipping attribute sync")
607+
return None
608+
609+
# Connect to LDAP
610+
try:
611+
conn = ldap.initialize(ldap_server)
612+
conn.set_option(ldap.OPT_REFERRALS, 0)
613+
conn.set_option(ldap.OPT_PROTOCOL_VERSION, ldap.VERSION3)
614+
615+
# Configure TLS
616+
tls_require_cert = os.getenv("LDAP_TLS_REQUIRE_CERT", "demand").lower()
617+
if tls_require_cert == "never":
618+
conn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
619+
620+
if bind_dn and bind_password:
621+
conn.simple_bind_s(bind_dn, bind_password)
622+
else:
623+
conn.simple_bind_s("", "")
624+
625+
except ldap.LDAPError as e:
626+
logger.error(f"LDAP connection failed for attribute sync: {e}")
627+
raise
628+
629+
try:
630+
# Attributes to fetch from LDAP
631+
ldap_attrs = [
632+
"mail",
633+
"department",
634+
"title",
635+
"telephoneNumber",
636+
"mobile",
637+
"employeeID",
638+
"physicalDeliveryOfficeName",
639+
"company",
640+
"givenName",
641+
"sn",
642+
]
643+
644+
# Search for the user
645+
search_filter = f"(sAMAccountName={escape_filter_chars(user.username)})"
646+
result = conn.search_s(
647+
search_base,
648+
ldap.SCOPE_SUBTREE,
649+
search_filter,
650+
ldap_attrs,
651+
)
652+
653+
if not result:
654+
logger.debug(f"User '{user.username}' not found in LDAP for attribute sync")
655+
return {}
656+
657+
# Get user's attributes
658+
user_dn, user_attrs = result[0]
659+
synced_attrs = {}
660+
661+
def get_attr(attrs, name):
662+
"""Helper to get first value of an LDAP attribute."""
663+
val = attrs.get(name, [])
664+
if val:
665+
if isinstance(val[0], bytes):
666+
return val[0].decode("utf-8")
667+
return val[0]
668+
return None
669+
670+
# Map LDAP attributes
671+
synced_attrs["mail"] = get_attr(user_attrs, "mail")
672+
synced_attrs["department"] = get_attr(user_attrs, "department")
673+
synced_attrs["title"] = get_attr(user_attrs, "title")
674+
synced_attrs["phone"] = get_attr(user_attrs, "telephoneNumber")
675+
synced_attrs["mobile"] = get_attr(user_attrs, "mobile")
676+
synced_attrs["employee_id"] = get_attr(user_attrs, "employeeID")
677+
synced_attrs["office_location"] = get_attr(user_attrs, "physicalDeliveryOfficeName")
678+
synced_attrs["company"] = get_attr(user_attrs, "company")
679+
synced_attrs["first_name"] = get_attr(user_attrs, "givenName")
680+
synced_attrs["last_name"] = get_attr(user_attrs, "sn")
681+
682+
# Update or create UserProfile
683+
profile, created = UserProfile.objects.get_or_create(user=user)
684+
685+
# Sync attributes to profile
686+
profile_fields = {
687+
"department": synced_attrs.get("department"),
688+
"title": synced_attrs.get("title"),
689+
"phone": synced_attrs.get("phone"),
690+
"employee_id": synced_attrs.get("employee_id"),
691+
"office_location": synced_attrs.get("office_location"),
692+
}
693+
694+
updated = False
695+
for field, value in profile_fields.items():
696+
if value and hasattr(profile, field):
697+
setattr(profile, field, value)
698+
updated = True
699+
700+
if updated or created:
701+
profile.save()
702+
logger.info(
703+
f"Synced LDAP attributes for user '{user.username}': "
704+
f"{[k for k, v in synced_attrs.items() if v]}"
705+
)
706+
707+
return synced_attrs
708+
709+
finally:
710+
conn.unbind_s()
711+
712+
553713
# Pipeline for social-auth-app-django
554714
# Recommended pipeline configuration:
555715
#

django_forms_workflows/sso_views.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,20 @@ def post(self, request):
254254
f"Failed to sync LDAP groups for SAML user '{user.username}': {e}"
255255
)
256256

257+
# Sync LDAP attributes (mail, department, etc.) to UserProfile
258+
try:
259+
from .sso_backends import sync_user_ldap_attributes
260+
261+
ldap_attrs = sync_user_ldap_attributes(user)
262+
if ldap_attrs:
263+
logger.info(
264+
f"Synced LDAP attributes for SAML user '{user.username}'"
265+
)
266+
except Exception as e:
267+
logger.warning(
268+
f"Failed to sync LDAP attributes for SAML user '{user.username}': {e}"
269+
)
270+
257271
# Log user in
258272
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
259273
logger.info(f"SAML login successful for: {user.username}")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-forms-workflows"
3-
version = "0.6.1"
3+
version = "0.6.2"
44
description = "Enterprise-grade, database-driven form builder with approval workflows and external data integration"
55
license = "LGPL-3.0-only"
66
readme = "README.md"

0 commit comments

Comments
 (0)