fix(ad): normalize ADUserAdapter to snake_case contract (#4)

- ad_adapter.py: emit snake_case keys from PS queries and surface
  email via the `mail` attribute in both get_user and search paths
- adapters.py: update ADUserAdapter.to_canonical to consume
  normalized keys (e.g. `username`, `last_logon_utc`, `ou`) instead
  of raw LDAP names (sAMAccountName, lastLogonTimestamp, dn)
- Resolves field-name alignment tech debt noted in SESSION_SNAPSHOT_2026-04-15
This commit is contained in:
Nathan Castaldi 2026-04-15 13:29:04 -04:00 committed by GitHub
parent f6cfd17e30
commit 6bf5d8dd05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 57 additions and 52 deletions

View File

@ -1,3 +1,7 @@
# Rule:
# ad_adapter.py outputs normalized snake_case fields
# All adapters (ADUserAdapter, EntraUserAdapter, etc.) consume that contract
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -118,17 +122,18 @@ class ActiveDirectoryIdentityBackend:
# Build PowerShell command using JSON output for reliable parsing # Build PowerShell command using JSON output for reliable parsing
escaped_username = self._escape_ps_single_quoted(username) escaped_username = self._escape_ps_single_quoted(username)
command = f""" command = f"""
$user = Get-ADUser -Filter "samAccountName -eq '{escaped_username}'" -Properties GivenName,Surname,DisplayName,Enabled,DistinguishedName,Description,lastLogonTimestamp -ErrorAction Stop $user = Get-ADUser -Filter "samAccountName -eq '{escaped_username}'" -Properties displayName,mail,givenName,sn,Enabled,DistinguishedName,Description,lastLogonTimestamp -ErrorAction Stop
if ($user) {{ if ($user) {{
$lastLogon = if ($user.lastLogonTimestamp) {{ [DateTime]::FromFileTime($user.lastLogonTimestamp).ToUniversalTime().ToString('o') }} else {{ $null }} $lastLogon = if ($user.lastLogonTimestamp) {{ [DateTime]::FromFileTime($user.lastLogonTimestamp).ToUniversalTime().ToString('o') }} else {{ $null }}
@{{ @{{
username = $user.SamAccountName username = $user.SamAccountName
first_name = if ($user.GivenName) {{ $user.GivenName }} else {{ '' }} display_name = if ($user.displayName) {{$user.displayName}} else {{$user.Name}}
last_name = if ($user.Surname) {{ $user.Surname }} else {{ '' }} first_name = if ($user.givenName) {{$user.givenName}} else {{''}}
display_name = if ($user.DisplayName) {{ $user.DisplayName }} else {{ '' }} last_name = if ($user.sn) {{$user.sn}} else {{''}}
email = if ($user.mail) {{$user.mail}} else {{''}}
enabled = $user.Enabled enabled = $user.Enabled
ou = $user.DistinguishedName ou = $user.DistinguishedName
description = if ($user.Description) {{ $user.Description }} else {{ '' }} description = if ($user.Description) {{$user.Description}} else {{''}}
last_logon_utc = $lastLogon last_logon_utc = $lastLogon
}} | ConvertTo-Json -Compress }} | ConvertTo-Json -Compress
}} }}
@ -152,6 +157,7 @@ class ActiveDirectoryIdentityBackend:
"first_name": user_data.get("first_name", "") or "", "first_name": user_data.get("first_name", "") or "",
"last_name": user_data.get("last_name", "") or "", "last_name": user_data.get("last_name", "") or "",
"display_name": user_data.get("display_name", "") or "", "display_name": user_data.get("display_name", "") or "",
"email": user_data.get("email", "") or "",
"enabled": user_data["enabled"], "enabled": user_data["enabled"],
"ou": user_data["ou"], "ou": user_data["ou"],
"description": user_data["description"] or "", "description": user_data["description"] or "",
@ -171,36 +177,34 @@ class ActiveDirectoryIdentityBackend:
max_results = max(1, min(limit, 100)) max_results = max(1, min(limit, 100))
escaped_query = self._escape_ps_single_quoted(query) escaped_query = self._escape_ps_single_quoted(query)
command = f""" command = f"""
$query = '{escaped_query}' $query = '{escaped_query}'
$tokens = @($query.Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)) $tokens = @($query.Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries))
if ($tokens.Count -ge 2) {{ if ($tokens.Count -ge 2) {{
$first = $tokens[0] $first = $tokens[0]
$last = $tokens[1] $last = $tokens[1]
$adFilter = "(givenName -like '$($first)*' -and surname -like '$($last)*') -or (displayName -like '$query*')" $adFilter = "(givenName -like '$($first)*' -and sn -like '$($last)*') -or (displayName -like '$query*')"
}} else {{ }} else {{
$adFilter = "givenName -like '$query*' -or surname -like '$query*' -or displayName -like '$query*'" $adFilter = "givenName -like '$query*' -or sn -like '$query*' -or displayName -like '$query*'"
}} }}
$users = @(Get-ADUser -Filter $adFilter -Properties GivenName,Surname,DisplayName,Enabled,DistinguishedName -ErrorAction Stop | $users = @(Get-ADUser -Filter $adFilter -Properties displayName,mail,givenName,sn,Enabled,DistinguishedName -ErrorAction Stop |
Sort-Object DisplayName |
Select-Object -First {max_results} |
ForEach-Object {{ ForEach-Object {{
@{{ @{{
username = $_.SamAccountName username = $_.SamAccountName
first_name = if ($_.GivenName) {{ $_.GivenName }} else {{ '' }} display_name = if ($_.displayName) {{ $_.displayName }} else {{ $_.Name }}
last_name = if ($_.Surname) {{ $_.Surname }} else {{ '' }} first_name = if ($_.givenName) {{ $_.givenName }} else {{ '' }}
display_name = if ($_.DisplayName) {{ $_.DisplayName }} else {{ '' }} last_name = if ($_.sn) {{ $_.sn }} else {{ '' }}
email = if ($_.mail) {{ $_.mail }} else {{ '' }}
enabled = $_.Enabled enabled = $_.Enabled
ou = $_.DistinguishedName ou = $_.DistinguishedName
}} }}
}}) }} | Select-Object -First {max_results})
$users | ConvertTo-Json -Compress $users | ConvertTo-Json -Compress
""" """
result = await self._run_powershell( result = await self._run_powershell(
command, command,
{"name_query": name_query, "limit": max_results}, {"name_query": name_query, "limit": max_results},
@ -234,6 +238,7 @@ class ActiveDirectoryIdentityBackend:
"first_name": user.get("first_name", "") or "", "first_name": user.get("first_name", "") or "",
"last_name": user.get("last_name", "") or "", "last_name": user.get("last_name", "") or "",
"display_name": user.get("display_name", "") or "", "display_name": user.get("display_name", "") or "",
"email": user.get("email", "") or "",
"enabled": bool(user.get("enabled", False)), "enabled": bool(user.get("enabled", False)),
"ou": user.get("ou", "") or "", "ou": user.get("ou", "") or "",
} }

View File

@ -114,27 +114,27 @@ class ADUserAdapter:
# This is a simplification; in reality we'd need to look up the manager's email # This is a simplification; in reality we'd need to look up the manager's email
manager_email = None # Would require a separate AD lookup manager_email = None # Would require a separate AD lookup
return CanonicalUser( return CanonicalUser(
email=_get(ad_user, "mail", default=""), email=_get(ad_user, "email", default=""),
employee_id=_get(ad_user, "employeeID"), employee_id=_get(ad_user, "employee_id"),
username=_get(ad_user, "sAMAccountName"), username=_get(ad_user, "username"),
display_name=_get(ad_user, "displayName", default="Unknown"), display_name=_get(ad_user, "display_name", default="Unknown"),
first_name=_get(ad_user, "givenName"), first_name=_get(ad_user, "first_name"),
last_name=_get(ad_user, "sn"), last_name=_get(ad_user, "last_name"),
job_title=_get(ad_user, "title"), job_title=_get(ad_user, "job_title"),
department=_get(ad_user, "department"), department=_get(ad_user, "department"),
manager_email=manager_email, manager_email=manager_email,
office_location=_get(ad_user, "physicalDeliveryOfficeName"), office_location=_get(ad_user, "office_location"),
status=status, status=status,
is_enabled=not is_disabled, is_enabled=not is_disabled,
last_login=_parse_iso_date(_get(ad_user, "lastLogonTimestamp")), last_login=_parse_iso_date(_get(ad_user, "last_logon_utc")),
created_date=_parse_iso_date(_get(ad_user, "whenCreated")), created_date=_parse_iso_date(_get(ad_user, "created_date")),
phone=_get(ad_user, "telephoneNumber"), phone=_get(ad_user, "phone"),
source_system="ActiveDirectory", source_system="ActiveDirectory",
source_id=_get(ad_user, "dn"), source_id=_get(ad_user, "ou"),
) )
# ── Microsoft Entra ID Adapters ─────────────────────────────────────────────── # ── Microsoft Entra ID Adapters ───────────────────────────────────────────────
class EntraUserAdapter: class EntraUserAdapter: