Sunday, August 29, 2021

LDAP: peering behind the curtain.

LDAP is mysterious and opaque to me. Deep dark magic, etc.


Jump down to see perl and ldapsearch examples for querying an ldap server as well as example search filters.

Long rambling background journey

I've tried a few times over the years to poke around -- notably making that Dancer example to authenticte via ldap. But I've never had, you know, authz and approval to futz with an LDAP. Even figuring out the appropriate search and base thingamijig is unclear. This is not an inviting protocol.

I needed to poke around a client LDAP install -- maintained 12 timezones away. I looked at ldapsearch for a few minutes before realizing it didn't enacapsulate any of that complexity. But we'll get back to that.

Next I turned to perl, specifically the CPAN pages for Net::LDAP and Net::LDAPS. This simplified a little of the interface and packaged up the results. Removed just enough complexity for me to get started.

Aside: I love, love, love, the SYNOPSIS section of perl documentation. Please, steal that idea for your language and docs!!! Synopsis gives a code snippet showing usage, and it's probably the usage that brought you to the module in the first place. At least enough of a skeleton to hang your code upon.

To use LDAP: one connects to a server, binds parameters to authenticate if needed, and issues a query. Net::LDAP keeps connect and bind as separate steps. But really the code is the easy bit. Server, user, password, query. What do these even look like?

You'll see examples that skip authentication. Why? Understanding authentication requires understanding the schema for your LDAP data. So we don't just say, "log me in as user foo," no we get to say "log me in as 'uid=foo,ou=Users,o=org,dc=...'". Yikes!

I'm connecting to a managed LDAP service, so they provide some of these settings. Specifically I am connecting to with an organization id. This gives me a base of "ou=Users,o=$org_id,dc=jumpcloud,dc=com". Ldap uses a concept of base_dn that we can use to talk about relative locations of the ldap data. For any query I make to this LDAP server, I'll need this base_dn as a reference. "ou" => organizational unit. "o" => organization. "dn" => distinguished name. "dc" => custom attribute used by this service. See already we are in custom territory.

Perl from the command line is a fun way to test and experiment. "shell is our repl" or something. I define a bunch of env vars (oh c'mon I used hard coded strings while poking). And this got me connected and poking and able to determine what I wanted to use for a filter value.

A filter of "(uid=*)" will return all users (every record with a uid). The parens are important and part of the query filter. My actual use case involved a more complicated query and custom extraction and reporting, so I appreciated having the data available programatically.

My initial problem was finding users that did not have a display name set. This was supposed to be an invariant in the upstream data. The app we were configuring used the displayName to greet the user and failed spectacularly when it was not present.

Filtering this seems simple enough "show me the users that have a blank displayName" "Have a blank displayName" is not a valid idea for a filter. We can ask for all records that do not have a set displayName, using the unary boolean not ! => (!(displayName=*)). Also, we only want user records.
Voila: (&(uid=*)(!(displayName=*))).

Perl one liner

       % export LDAP_USER=ldap_username
       % export LDAP_PASS=t00manys3cr3ts
       % export
       % export LDAP_BASEDN=ou=Users,o=org_id_string,dc=jumpcloud,dc=com
       % perl -MNet::LDAPS -M5.20.0 -E 'my $ldap = Net::LDAPS->new($ENV{LDAP_SERVER}) or die "$@";
           my $dn = $ENV{LDAP_BASEDN}; 
           my $msg = $ldap->bind("uid=$ENV{LDAP_USER},$dn", password=> $ENV{LDAP_PASSWORD}); 
           my $filter = "(uid=*)";
           my $srch = $ldap->search(base=>$dn, filter=>filter, attrs => []); 
           for my $e ($srch->entries) { $e->dump }'



Filter syntax has its own RFC 4515. Key take aways: rather lispy:
  1. parens are always required
  2. operator comes before operands (prefix notation, aka polish notation).

(objectClass=*)      #  a default filter to return all records.
(uid=*)              #  all records with a uid field.
(sn=Smith)           #  all records with last name of "Smith" (probably only user records)
(&(uid=*)(sn=Smith)) #  all users with a last name of "Smith"
(&(sn=Smith)(memberOf=cn=Agents,${LDAP_BASEDN})) #  all Agent Smiths
    - note that the group name is also fully qualified with the base dn.

(&(!(displayname=*))(uid=*))  #  users without a display name set


Now that I have a working query, can I make the same query using ldapsearch? Again, once we figure out the login incantation, it's home sailing.

    % export LDAP_USER=ldap_username
    % export LDAP_PASS=t00manys3cr3ts
    % export
    % export LDAP_BASEDN=ou=Users,o=org_id_string,dc=jumpcloud,dc=com
    # show full record for $LDAP_USER
    #   -w binds the password.  -W would query for password
    #   -D defines the "bind DN", the fully qualified user for authentication
    #   -b defines the "base DN", sets base for filter values, does not affect bind DN
    #      setting LDAP_BASEDN environment var did *not* have any effect upon the base dn.
    #   filter value.  the base DN is appended to the LDAP_USER search value.
    % ldapsearch -h $LDAP_SERVER -w"$LDAP_PASS" -D"uid=${LDAP_USER},${LDAP_BASEDN}" -b"$LDAP_BASEDN" "(uid=${LDAP_USER})"
        # get the groupNumber of the login user:
        #    -LL selects a shorter LDIF format for record output
        #    return only the "gidNumber" field
        % ldapsearch -h $LDAP_SERVER -w"$LDAP_PASS" -D"uid=${LDAP_USER},${LDAP_BASEDN}" -b"$LDAP_BASEDN" -LL "(uid=${LDAP_USER})" gidNumber
         version: 1
           dn: uid=ldap_sername,ou=Users,o=org_id,dc=jumpcloud,dc=com
           gidNumber: 5418     
Future me, you're welcome.