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
import asyncio
@ -118,14 +122,15 @@ class ActiveDirectoryIdentityBackend:
# Build PowerShell command using JSON output for reliable parsing
escaped_username = self._escape_ps_single_quoted(username)
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) {{
$lastLogon = if ($user.lastLogonTimestamp) {{ [DateTime]::FromFileTime($user.lastLogonTimestamp).ToUniversalTime().ToString('o') }} else {{ $null }}
@{{
username = $user.SamAccountName
first_name = if ($user.GivenName) {{ $user.GivenName }} else {{ '' }}
last_name = if ($user.Surname) {{ $user.Surname }} else {{ '' }}
display_name = if ($user.DisplayName) {{ $user.DisplayName }} else {{ '' }}
display_name = if ($user.displayName) {{$user.displayName}} else {{$user.Name}}
first_name = if ($user.givenName) {{$user.givenName}} else {{''}}
last_name = if ($user.sn) {{$user.sn}} else {{''}}
email = if ($user.mail) {{$user.mail}} else {{''}}
enabled = $user.Enabled
ou = $user.DistinguishedName
description = if ($user.Description) {{$user.Description}} else {{''}}
@ -152,6 +157,7 @@ class ActiveDirectoryIdentityBackend:
"first_name": user_data.get("first_name", "") or "",
"last_name": user_data.get("last_name", "") or "",
"display_name": user_data.get("display_name", "") or "",
"email": user_data.get("email", "") or "",
"enabled": user_data["enabled"],
"ou": user_data["ou"],
"description": user_data["description"] or "",
@ -171,36 +177,34 @@ class ActiveDirectoryIdentityBackend:
max_results = max(1, min(limit, 100))
escaped_query = self._escape_ps_single_quoted(query)
command = f"""
$query = '{escaped_query}'
$tokens = @($query.Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries))
if ($tokens.Count -ge 2) {{
$first = $tokens[0]
$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 {{
$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 |
Sort-Object DisplayName |
Select-Object -First {max_results} |
$users = @(Get-ADUser -Filter $adFilter -Properties displayName,mail,givenName,sn,Enabled,DistinguishedName -ErrorAction Stop |
ForEach-Object {{
@{{
username = $_.SamAccountName
first_name = if ($_.GivenName) {{ $_.GivenName }} else {{ '' }}
last_name = if ($_.Surname) {{ $_.Surname }} else {{ '' }}
display_name = if ($_.DisplayName) {{ $_.DisplayName }} else {{ '' }}
display_name = if ($_.displayName) {{ $_.displayName }} else {{ $_.Name }}
first_name = if ($_.givenName) {{ $_.givenName }} else {{ '' }}
last_name = if ($_.sn) {{ $_.sn }} else {{ '' }}
email = if ($_.mail) {{ $_.mail }} else {{ '' }}
enabled = $_.Enabled
ou = $_.DistinguishedName
}}
}})
}} | Select-Object -First {max_results})
$users | ConvertTo-Json -Compress
"""
result = await self._run_powershell(
command,
{"name_query": name_query, "limit": max_results},
@ -234,6 +238,7 @@ class ActiveDirectoryIdentityBackend:
"first_name": user.get("first_name", "") or "",
"last_name": user.get("last_name", "") or "",
"display_name": user.get("display_name", "") or "",
"email": user.get("email", "") or "",
"enabled": bool(user.get("enabled", False)),
"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
manager_email = None # Would require a separate AD lookup
return CanonicalUser(
email=_get(ad_user, "mail", default=""),
employee_id=_get(ad_user, "employeeID"),
username=_get(ad_user, "sAMAccountName"),
display_name=_get(ad_user, "displayName", default="Unknown"),
first_name=_get(ad_user, "givenName"),
last_name=_get(ad_user, "sn"),
job_title=_get(ad_user, "title"),
email=_get(ad_user, "email", default=""),
employee_id=_get(ad_user, "employee_id"),
username=_get(ad_user, "username"),
display_name=_get(ad_user, "display_name", default="Unknown"),
first_name=_get(ad_user, "first_name"),
last_name=_get(ad_user, "last_name"),
job_title=_get(ad_user, "job_title"),
department=_get(ad_user, "department"),
manager_email=manager_email,
office_location=_get(ad_user, "physicalDeliveryOfficeName"),
office_location=_get(ad_user, "office_location"),
status=status,
is_enabled=not is_disabled,
last_login=_parse_iso_date(_get(ad_user, "lastLogonTimestamp")),
created_date=_parse_iso_date(_get(ad_user, "whenCreated")),
phone=_get(ad_user, "telephoneNumber"),
last_login=_parse_iso_date(_get(ad_user, "last_logon_utc")),
created_date=_parse_iso_date(_get(ad_user, "created_date")),
phone=_get(ad_user, "phone"),
source_system="ActiveDirectory",
source_id=_get(ad_user, "dn"),
source_id=_get(ad_user, "ou"),
)
# ── Microsoft Entra ID Adapters ───────────────────────────────────────────────
class EntraUserAdapter: