Close

LDAP to JSON function #JoelKallmanDay

I’ve found myself needing to search LDAP data more often and each time I do it’s been sort of obnoxious to have to rewrite variations of mostly similar DBMS_LDAP routines. Frequently I then need to use that data in a SQL query of some sort to combine it with other data found in one or more of our Oracle databases.

So I thought it would be helpful if I could query the LDAP data via a single function. My initial thought was a pipelined table function; but that doesn’t work for multiple reasons. First, the row contents vary based on what you’re searching for so I couldn’t declare an object type to present distinct columns. Second, piping the results back would entail keeping a connection to the LDAP server open. While that’s not necessarily a show stopper, it would be an unusual usage and could certainly cause timeout problems if the consuming code had significant delays between reading the rows.

I also considered a polymorphic table function, but that too has problems because as you traverse an LDAP hierarchy each level can have different sets of attributes. So, in a SQL result set that would be like each row having a different set of columns. Also, I wanted the function to be usable with some older systems and using this would restrict it 18c and higher.

This led me to an XML or JSON type output. Choosing between XML and JSON, I went with JSON simply because it’s more compact, thus the output is smaller, so less data to transport. Native JSON support started in 11g and that was as far back as I felt reasonable to support; but even if I did find myself on an ancient 10g or older db I could still manually parse out JSON values without too much trouble if I had to.

Next I needed to decide what I wanted my output to look like. This I based mostly on the results of an LDAP search. Whether your search scope is BASE, ONE LEVEL, or SUB-TREE the results are all flat. That is, the hierarchy is not inherent in the output. Rather, it must be deciphered from the distinguished names of each entry.

For instance if I search the whole subtree of the zflexldap sample server I might get results like this:

o=company1,dc=zflexsoftware,dc=com
ou=london,o=company1,dc=zflexsoftware,dc=com
ou=usa,o=company1,dc=zflexsoftware,dc=com
ou=group,ou=london,o=company1,dc=zflexsoftware,dc=com
ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com
ou=users,ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com
ou=users,ou=london,o=company1,dc=zflexsoftware,dc=com
...

The results are not sorted by their distinguished names or by their hierarchy. Furthermore, other than reading the dn, there is no designation of which entries are under others. Since the results won’t be sorted coming out of LDAP, it will be necessary to do that within the SQL context. Distinguished Names can be a little tricky though since they are concatenated in an order that is not conducive for normal text-based sorting. However, by using an object type (ldap_dn source code is available at the end of article) with an ORDER member function we can handle DN-based sorting.

An example usage with the sample DNs from above:

  SELECT LPAD(dn, 65)
    FROM (SELECT 'o=company1,dc=zflexsoftware,dc=com' dn FROM DUAL
          UNION ALL
          SELECT 'ou=london,o=company1,dc=zflexsoftware,dc=com' FROM DUAL
          UNION ALL
          SELECT 'ou=usa,o=company1,dc=zflexsoftware,dc=com' FROM DUAL
          UNION ALL
          SELECT 'ou=group,ou=usa,o=company1,dc=zflexsoftware,dc=com' FROM DUAL
          UNION ALL
          SELECT 'ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com'
            FROM DUAL
          UNION ALL
          SELECT 'ou=users,ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com'
            FROM DUAL
          UNION ALL
          SELECT 'ou=users,ou=london,o=company1,dc=zflexsoftware,dc=com' FROM DUAL)
ORDER BY ldap_dn(dn);

Left-padding the output makes it easier to see the sorting in action

LPAD(DN,65)                                                      
-----------------------------------------------------------------
                               o=company1,dc=zflexsoftware,dc=com
                     ou=london,o=company1,dc=zflexsoftware,dc=com
            ou=users,ou=london,o=company1,dc=zflexsoftware,dc=com
                        ou=usa,o=company1,dc=zflexsoftware,dc=com
         ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com
ou=users,ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com
               ou=group,ou=usa,o=company1,dc=zflexsoftware,dc=com

Once I had the entries coming back in SQL and I could sort them into their hierarchical order I next needed to decide how the attributes would be included. Since each entry could have any number of attributes a JSON array would accommodate them. Furthermore, each attribute can have more than one value, similar to a nested-table column within a database table, so that’s another array. Finally, I decided to include the search parameters that generated the results in the final output, so the final JSON object could be self-describing. The final format I settled on will be structured as one of the following, depending on the attribute search criteria specified.

{
   "base": "...",
   "scope": "...",
   "filter": "...",
   "attributes": ["...","..."],
   "attributes_only": "N",
   "results": [
      {
         "dn": "...",
         "attributes": [
            {
              "objectclass": ["top","organization"]
            },
            {...}
           ]    
      },
      {...}
   ]
}

Or, if the search specifies an attributes only result (i.e. names, but not values) then the attributes array will consist of an array of json strings rather than array of json objects.

{
   "base": "...",
   "scope": "...",
   "filter": "...",
   "attributes": ["...","..."],
   "attributes_only": "Y",
   "results": [
      {
         "dn": "...",
         "attributes": ["...","..."]
      },
      {...}
   ]
}

And finally if the search specifies that no attributes will be returned the attribute arrays will be excluded entirely, as will the attributes_only key/value which, obviously, won’t apply.

{
   "base": "...",
   "scope": "...",
   "filter": "...",
   "attributes": ["1.1"],
   "results": [
      {"dn": "..."},
      {"dn": "..."}
   ]
}

If an error is encountered, the function will still return but will include an “error” key/value pair, for example:

{
   "base": "o=company1,dc=zflexsoftware,dc=com",
   "scope": "subtree",
   "filter": "objectclass=*",
   "attributes": "1.1",
   "error": "ORA-31202: DBMS_LDAP: LDAP client/server error: Invalid credentials"
}

The structures above are formatted for easier reading. The actual CLOB values will be a single line without extraneous white space. It is generated by the TO_CLOB member function of the pl/sql JSON_OBJECT_T type.

The search criteria correspond to the base, scope, filter, attrs, and attronly parameters of the DBMS_LDAP.search_s function and are reflected in the ldap_to_json (source code found at end of this article) parameters as well.

CREATE OR REPLACE FUNCTION ldap_to_json(
    p_host              IN VARCHAR2,
    p_base              IN VARCHAR2,
    p_scope             IN VARCHAR2,
    p_filter            IN VARCHAR2,
    p_attributes        IN VARCHAR2 DEFAULT '*',
    p_attributes_only   IN VARCHAR2 DEFAULT NULL,
    p_port              IN NUMBER DEFAULT DBMS_LDAP.port,
    p_user_dn           IN VARCHAR2 DEFAULT NULL,
    p_password          IN VARCHAR2 DEFAULT NULL)
    RETURN CLOB
  • P_HOST – the host name or ip address of the LDAP server
  • P_BASE – the portion of the LDAP tree that will be searched
  • P_SCOPE – ‘BASE’, ‘ONELEVEL’, or ‘SUBTREE’ (not case sensitive), it will also accept the dbms_ldap numeric values corresponding to those scopes ‘0’,’1′, or ‘2’ respectively.
  • P_FILTER – LDAP syntax for search criteria, essentially the “WHERE clause” looking for entries with certain attributes, (note the values of the attributes is not part of the search criteria)
  • P_ATTRIBUTES – the list of attributes (defaults to all) that will be returned from matching entries. Recognizes the LDAP special values ‘*’, ‘+’, and ‘1.1’. If names are needed they should be passed in as a json array text regardless of the number of names passed in (e.g. ‘[“uid”]’ or ‘[“objectclass”,”ou”,”cn”]’)
  • P_ATTRIBUTES_ONLY – determines if the attribute values will be returned, or just the attribute names. If the P_ATTRIBUTES value is special LDAPvalue ‘1.1’, then this parameter is not used.
  • P_PORT – specifies the LDAP server port
  • P_USER_DN – if the LDAP server requires binding (authentication) the user DN of the bind
  • P_PASSWORD – the password for binding with the specified user DN

Sample Usage with an anonymous connection

Here I’m querying against an LDAP server used for Oracle TNS service name lookup.

SELECT *
  FROM JSON_TABLE(
           ldap_to_json(p_host         => 'myldapserver',
                        p_base         => 'cn=OracleContext,dc=sds,dc=home',
                        p_scope        => 'onelevel',
                        p_filter       => 'objectclass=*',
                        p_attributes   => '["cn","orclNetDescString"]'),
           '$'
           COLUMNS(
               NESTED PATH '$.results[*].attributes'
                   COLUMNS(
                       tns_service_name PATH '$.cn[0]',
                       connection_description PATH '$.orclNetDescString[0]')))
       jt
 WHERE tns_service_name LIKE 'test%';


TNS_SERVICE_NAME     CONNECTION_DESCRIPTION                                                                                                       
-------------------- -----------------------------------------------------------------------------------------------------------------------------
test                 (DESCRIPTION= (ADDRESS= (PROTOCOL=TCP) (HOST=mydbserver) (PORT=1521)) (CONNECT_DATA= (SERVER=dedicated) (SERVICE_NAME=test))) 
testxe               (DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=testpc)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=xe)))  

Sample Usage with authentication (binding)

This example uses the public ZFLEXLDAP test server with one of their sample logins. I’m viewing the results as just the full json text, not parsing the fields into rows and columns

SELECT ldap_to_json(
           p_host              => 'www.zflexldap.com',
           p_user_dn           => 'cn=ro_admin,ou=sysadmins,dc=zflexsoftware,dc=com',
           p_password          => 'zflexpass',
           p_base              => 'o=company1,dc=zflexsoftware,dc=com',
           p_scope             => 'subtree',
           p_filter            => 'objectclass=*'
)
  FROM DUAL

Results (after formatting and truncating):

{
  "base": "o=company1,dc=zflexsoftware,dc=com",
  "scope": "subtree",
  "filter": "objectclass=*",
  "attributes": "*",
  "attributes_only": "N",
  "results": [
    {
      "dn": "o=company1,dc=zflexsoftware,dc=com",
      "attributes": [
        {
          "objectclass": [
            "top",
            "organization"
          ]
        },
        {
          "o": [
            "company1"
          ]
        }
      ]
    },
    {
      "dn": "ou=london,o=company1,dc=zflexsoftware,dc=com",
      "attributes": [
        {
          "ou": [
            "london"
          ]
        },
        {
          "objectclass": [
            "top",
            "organizationalUnit"
          ]
        }
      ]
    },
    
... results truncated

    {
      "dn": "uid=consultant_tony,ou=users,ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com",
      "attributes": [
        {
          "objectClass": [
            "top",
            "person",
            "organizationalPerson",
            "inetOrgPerson"
          ]
        },
        {
          "givenName": [
            "tony"
          ]
        },
        {
          "uid": [
            "consultant_tony"
          ]
        },
        {
          "sn": [
            "mcc"
          ]
        },
        {
          "cn": [
            "tony mcc"
          ]
        }
      ]
    }
  ]
}

Sample Usage with authentication, DN return, attribute extraction, and DN-sorted output

  SELECT LPAD(dn, 90) dn, givenname, sn
    FROM JSON_TABLE(
             ldap_to_json(
                 p_host         => 'www.zflexldap.com',
                 p_user_dn      => 'cn=ro_admin,ou=sysadmins,dc=zflexsoftware,dc=com',
                 p_password     => 'zflexpass',
                 p_base         => 'o=company1,dc=zflexsoftware,dc=com',
                 p_scope        => 'subtree',
                 p_filter       => 'objectclass=*',
                 p_attributes   => '["givenName","sn"]'),
             '$'
             COLUMNS(
                 NESTED PATH '$.results[*]'
                     COLUMNS(
                         dn VARCHAR2(100) PATH '$.dn',
                         givenname VARCHAR2(10) PATH '$.attributes.givenName[0]',
                         sn VARCHAR2(10) PATH '$.attributes.sn[0]'))) jt
   WHERE givenname IS NOT NULL
ORDER BY ldap_dn(dn);
DN                                                                                         GIVENNAME  SN        
------------------------------------------------------------------------------------------ ---------- ----------
      uid=consultant_Bob,ou=users,ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com Bobby      Greenatta 
      uid=consultant_Ted,ou=users,ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com Teddy      Johnson   
     uid=consultant_Mike,ou=users,ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com Mike       Fisnessa  
     uid=consultant_tony,ou=users,ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com tony       mcc       
  uid=consultant_Sathish,ou=users,ou=consultants,ou=usa,o=company1,dc=zflexsoftware,dc=com Sathish    Kumar     
                 uid=staff_Joe,ou=users,ou=staff,ou=usa,o=company1,dc=zflexsoftware,dc=com Joe        Smith     
               uid=staff_susan,ou=users,ou=staff,ou=usa,o=company1,dc=zflexsoftware,dc=com Susan      Mills     
              uid=staffmember1,ou=users,ou=staff,ou=usa,o=company1,dc=zflexsoftware,dc=com Joanna     Barns

The LDAP_TO_JSON function

CREATE OR REPLACE FUNCTION ldap_to_json(
    p_host              IN VARCHAR2,
    p_base              IN VARCHAR2,
    p_scope             IN VARCHAR2,
    p_filter            IN VARCHAR2,
    p_attributes        IN VARCHAR2 DEFAULT '*',
    p_attributes_only   IN VARCHAR2 DEFAULT NULL,
    p_port              IN NUMBER DEFAULT DBMS_LDAP.port,
    p_user_dn           IN VARCHAR2 DEFAULT NULL,
    p_password          IN VARCHAR2 DEFAULT NULL)
    RETURN CLOB
IS
    --                   .///.
    --                  (0 o)
    ---------------0000--(_)--0000---------------
    --
    --  Sean D. Stuber
    --  sean.stuber@gmail.com
    --
    --             oooO      Oooo
    --------------(   )-----(   )---------------
    --             \ (       ) /
    --              \_)     (_/

    -- Translate scope names to dbms_ldap scope numbers
    -- if given valid scope numbers, use them
    -- If no match, then treat as a BASE scope
    v_scope                NUMBER
        := CASE UPPER(TRIM(p_scope))
               WHEN 'BASE' THEN 0
               WHEN 'ONELEVEL' THEN 1
               WHEN 'SUBTREE' THEN 2
               WHEN '0' THEN 0
               WHEN '1' THEN 1
               WHEN '2' THEN 2
               ELSE 0
           END;

    -- If the requested search attributes are null
    -- treat it as the wildcard for everything
    v_attributes           VARCHAR2(32767) := NVL(p_attributes, '*');

    -- Translate Y or 1 into Boolean TRUE, all else false
    v_attributes_only      BOOLEAN
        := UPPER(TRIM(NVL(p_attributes_only, 'N'))) IN ('Y', '1');

    v_error_code           PLS_INTEGER;
    v_session              DBMS_LDAP.session;
    v_search_attrs         DBMS_LDAP.string_collection;
    v_search_results       DBMS_LDAP.MESSAGE;
    v_entry                DBMS_LDAP.MESSAGE;
    v_distinguished_name   VARCHAR2(256);
    v_attribute_name       VARCHAR2(256);
    v_attribute_values     DBMS_LDAP.string_collection;
    v_ber                  DBMS_LDAP.ber_element;

    v_out                  json_object_t := json_object_t;
    v_json_attributes      json_array_t;
    v_json_key_value       json_object_t;
    v_json_values          json_array_t;
    v_json_entries         json_array_t := json_array_t();
    v_json_entry           json_object_t := json_object_t;
BEGIN
    BEGIN
        -- Create a header for the results that defines the search critera.
        v_out.put('base', p_base);
        v_out.put('scope', p_scope);
        v_out.put('filter', p_filter);

        CASE
            WHEN v_attributes IN ('*', '+', '1.1')
            THEN
                -- LDAP special values
                --   *   Return all user attributes
                --   +   Return all operational attributes
                --   1.1 Return NO attributes
                v_search_attrs(1) := v_attributes;
                v_out.put('attributes', v_attributes);
            ELSE
                -- If not one of the LDAP special values
                -- then parse it as a JSON array
                v_json_attributes := json_array_t(v_attributes);

                FOR i IN 0 .. v_json_attributes.get_size - 1
                LOOP
                    v_search_attrs(i) := v_json_attributes.get_string(i);
                END LOOP;

                v_out.put('attributes', v_json_attributes);
        END CASE;

        IF v_attributes != '1.1'
        THEN
            -- If the no attributes are being returned the
            -- attributes only parameter is irrelevant
            v_out.put('attributes_only', p_attributes_only);
        END IF;

        -- The LDAP return codes will all be 0 for success
        -- If an error occurs, the functions will raise an exception
        -- rather than return an error code.
        DBMS_LDAP.use_exception := TRUE;

        -- Connect to LDAP server
        v_session := DBMS_LDAP.init(hostname => p_host, portnum => p_port);

        IF p_user_dn IS NOT NULL
        THEN
            -- If a username is provided then
            -- authenticate ("bind" in LDAP jargon)
            v_error_code :=
                DBMS_LDAP.simple_bind_s(ld       => v_session,
                                        dn       => p_user_dn,
                                        passwd   => p_password);
        END IF;

        -- Conduct search
        -- Note, DBMS_LDAP does not support paginated results
        -- So if the result is too big a SIZELIMIT_EXCEEDED error will be raised
        v_error_code :=
            DBMS_LDAP.search_s(
                ld         => v_session,
                base       => p_base,
                scope      => v_scope,
                filter     => p_filter,
                attrs      => v_search_attrs,
                attronly   => CASE WHEN v_attributes_only THEN 1 ELSE 0 END,
                res        => v_search_results);

        -- Loop through entries until they run out (NULL reached)
        v_entry := DBMS_LDAP.first_entry(v_session, v_search_results);

        WHILE v_entry IS NOT NULL
        LOOP
            -- Create a JSON object for this entry
            -- Identify it with a key value of its distinguished name
            v_distinguished_name := DBMS_LDAP.get_dn(v_session, v_entry);
            v_json_entry := json_object_t();
            v_json_entry.put('dn', v_distinguished_name);

            -- Create a new array to hold the attributes, with or without values
            v_json_attributes := json_array_t();

            v_attribute_name :=
                DBMS_LDAP.first_attribute(v_session, v_entry, v_ber);

            -- Loop through entries until they run out (NULL reached)
            IF v_attribute_name IS NOT NULL
            THEN
                WHILE v_attribute_name IS NOT NULL
                LOOP
                    CASE
                        WHEN v_attributes_only
                        THEN
                            -- Add the name of this attribute to the array
                            v_json_attributes.append(v_attribute_name);
                        ELSE
                            -- Attributes can have multiple values
                            -- If the values are being returned create an array
                            -- to hold them.
                            -- Then loop through the values appending to array.
                            v_attribute_values :=
                                DBMS_LDAP.get_values(v_session,
                                                     v_entry,
                                                     v_attribute_name);
                            v_json_values := json_array_t();

                            IF v_attribute_values.COUNT > 0
                            THEN
                                FOR i IN v_attribute_values.FIRST ..
                                         v_attribute_values.LAST
                                LOOP
                                    v_json_values.append(v_attribute_values(i));
                                END LOOP;
                            END IF;

                            -- Create a JSON object to hold the array
                            -- with a key of the attribute's name
                            v_json_key_value := json_object_t();
                            v_json_key_value.put(v_attribute_name,
                                                 v_json_values);

                            -- Append the key/array object
                            -- to the array of attributes
                            v_json_attributes.append(v_json_key_value);
                    END CASE;

                    v_attribute_name :=
                        DBMS_LDAP.next_attribute(v_session, v_entry, v_ber);
                END LOOP;

                DBMS_LDAP.ber_free(v_ber, 1);

                -- Append the attributes to the entry
                v_json_entry.put('attributes', v_json_attributes);
            END IF;

            -- Append the completed JSON object for this LDAP entry
            -- to the array of search results
            v_json_entries.append(v_json_entry);

            v_entry := DBMS_LDAP.next_entry(v_session, v_entry);
        END LOOP;

        -- Unbind closes the session with the LDAP server
        v_error_code := DBMS_LDAP.unbind_s(v_session);

        -- Append the entries to the result header
        v_out.put('results', v_json_entries);
    EXCEPTION
        WHEN OTHERS
        THEN
            -- If something went wrong include the error in the output
            -- and try to clean up
            v_out.put('error', SQLERRM);

            BEGIN
                v_error_code := DBMS_LDAP.unbind_s(v_session);
            EXCEPTION
                WHEN DBMS_LDAP.invalid_session
                THEN
                    -- If the session isn't valid, then nothing to unbind
                    -- So it's safe to ignore
                    NULL;
                WHEN OTHERS
                THEN
                    -- If the clean up itself failed then
                    -- report that in the output as well
                    v_out.put('unbind_error', SQLERRM);
            END;
    END;

    -- Return the output as JSON text
    RETURN v_out.TO_CLOB();
END;

The LDAP_DN object

CREATE OR REPLACE TYPE ldap_dn AS OBJECT
(
    -- Depending on the LDAP server implementation
    -- the length of the DN might be limited to a few hundred bytes
    -- or unlimited, or some where in between.
    dn VARCHAR2(32767),
    ORDER MEMBER FUNCTION sorter(p_obj IN ldap_dn)
        RETURN INTEGER
);
/

CREATE OR REPLACE TYPE BODY ldap_dn
AS
    ORDER MEMBER FUNCTION sorter(p_obj IN ldap_dn)
        RETURN INTEGER
    IS
        v_index          INTEGER := 0;
        v_diff           INTEGER := 0;
        self_elements    DBMS_LDAP.string_collection;
        other_elements   DBMS_LDAP.string_collection;
        -- As with the DN in its entirety
        -- individual elements of the DN may be limited 
        -- to as low as a few dozen characters
        -- or a couple thousand or unlimited
        self_val         VARCHAR2(32767);
        other_val        VARCHAR2(32767);
    BEGIN
        self_elements := DBMS_LDAP.explode_dn(dn, 0);
        other_elements := DBMS_LDAP.explode_dn(p_obj.dn, 0);

        -- Walk backwards from the end of each dn
        -- Compare each element until one differs
        -- or until we run out of elements in at least one of them
        WHILE v_diff = 0
          AND v_index < LEAST(self_elements.COUNT, other_elements.COUNT)
        LOOP
            self_val := self_elements(self_elements.COUNT - v_index - 1);
            other_val := other_elements(other_elements.COUNT - v_index - 1);
            v_diff :=
                CASE
                    WHEN self_val > other_val THEN 1
                    WHEN self_val = other_val THEN 0
                    ELSE -1
                END;

            v_index := v_index + 1;
        END LOOP;

        -- If all elements match to this point
        -- then whichever DN has fewer should sort first
        IF v_diff = 0
        THEN
            v_diff := self_elements.COUNT - other_elements.COUNT;
        END IF;

        RETURN v_diff;
    END;
END;
/

Some possible future enhancements I’d like to explore:

  • Add support for wallets to enable SSL communication
  • Put the function in a package and overload it with parameters to take a dbms_ldap.string_collection for the attributes list instead of parsing the inputs and generating the collection internally
  • Put it in a package with XML formatted output
  • If a list of attributes is specified or no attributes is specified then the issues of the polymorphic table function are ameliorated, making a direct ldap to rows/columns possible.

I hope you find this useful and I welcome suggestions. I’m always happy to share content that I think might make someone else’s job a little easier and even more so to publish this article in tribute to Joel Kallman, a man I admired and definitely miss for his charm, technical proficiency, and of course, his passion for building a community around Oracle products.