I recently had the opportunity to help a client save a considerable amount of time by replacing a time consuming, mostly manual process with a fully automated service. The client has many locations throughout the country and employees a great number of people. As is similar with other businesses of their kind, there are many employees being hired and terminated on a daily basis. The payroll system is the first official record updated whenever employees are gained or lost, but changes also have to be made so that the users are granted or denied access to the various applications within the enterprise. Access to most systems are controlled by Oracle Virtual Directory, which is the "system of record" used by their web portals and other internal applications. The process they were using involved exporting a fixed-width file from the payroll system, converting it to an LDIF formatted file, exporting the entire OVD to LDIF, then manually comparing the files in Excel to determine adds and deletes. The adds and deletes were split into two LDIF files and imported back into OVD for processing. The process was happening about every other week and took upwards of 6 hours to complete. My assignment was to make the process "hands-off" so it could be run more frequently and not consume valuable time.
The solution I designed was a .Net 3.5SP1 application that could be run as a Windows Service or a scheduled Console application. The initial version would read from the fixed-width payroll file and directly update OVD, but it was designed in a way to allow for other "adapters" to be written should either the payroll or directory service change. In addition, the vast majority of the logic is embedded in a class library, leaving only console or Windows Service specific logic in the actual executing assembly. This would allow for other versions (web, Windows Forms, etc.) to be written with very little effort. Finally, a great many options were designed to be configurable via the app.config file, allowing for minor changes to functionality without requiring code updates.
The main flow of the application is pretty straightforward. First, instantiate the appropriate "source" reader (source being the system that the target should be updated to reflect). This is done reflectively based on a configuration setting that specifies the type to be created (all readers extend a common abstract class). Next, instantiate the appropriate "target" reader using the same process (the target being the system to be updated). A "writer" is then instantiated using the same process. The writer is what is used to actually write the records to the target system.
Each reader has a GetPersonnelRecords method that returns a PersonnelRecordsCollection class to represent the directory entries in a generic fashion. Both the source and target are loaded using this method. Next, find all of the "adds", defined as records that exist in the source but not in the target. For each add, the Add method is executed on the writer. Deletes are next, defined as all records that exist in the target but not in the source. Each delete is processed by executing the Delete method on the writer. Events are fired throughout the processing to provide insight into the calling application for logging purposes.
So that is the generic explanation, as it applies to any implementation configured. The potentially tricky parts involve actually connecting to and interacting with the OVD. In this case, OVD is used in both the target reader and the writer. In both cases a few Oracle-specific challenges were encountered, but nothing that couldn't be solved!
For the "reader" portion, we need to retrieve all users within OVD. Here is the code used to do this:
internal SearchResultEntryCollection GetAllUsers()
{
LdapConnection connection = LdapHelper.GetLdapConnection(_ldapUri, _ldapUserName,
_ldapPassword, _requestTimeoutMinutes);
SearchRequest searchRequest = new SearchRequest(_ldapBase, _searchAllFormat,
System.DirectoryServices.Protocols.SearchScope.Subtree, _ldapAttributeDNs);
SearchResponse searchResponse = (SearchResponse)connection.SendRequest(searchRequest);
return searchResponse.Entries;
}
The connection is provided by a helper class. Here is the code for GetLdapConnection:
internal static LdapConnection GetLdapConnection(string ldapUri, string ldapUserName,
string ldapPassword, int requestTimeoutMinutes)
{
LdapDirectoryIdentifier ldapId = new LdapDirectoryIdentifier(ldapUri);
NetworkCredential credentials = new NetworkCredential(ldapUserName, ldapPassword);
LdapConnection connection = new LdapConnection(ldapId, credentials);
connection.AuthType = AuthType.Basic;
// We need to ignore certificate errors because the certificate on the OID server
// is self-signed
connection.SessionOptions.VerifyServerCertificate =
delegate(LdapConnection l, X509Certificate c) { return true; };
connection.SessionOptions.SecureSocketLayer = true;
connection.SessionOptions.SendTimeout = new TimeSpan(0, requestTimeoutMinutes, 0);
connection.Bind();
return connection;
}
Note that you need to add a reference to System.DirectoryServices.Protocols, System.Net, and System.Security.Cryptography.X509Certificates for this code to execute.
Lets break the code down...
First, we need to get a connection. This is accomplished using an LdapDirectoryIdentifier, passing in the Ldap server url like this: "example-ldap-01:456". Note that the port should be specified if using this method. The port used will depend on the authentication method and security protocol used. In our case, we were using Basic Authentication with SSL. The OVD had a setting for the actual port used for LDAPS, which is what I entered above. Next, we need to create a NetworkCredential to be used. It was important in this case to use an admin account as non-admin accounts had search limits imposed (only x number of records would be returned). Also important is to know that the format for username was "cn=admin", not "admin". Using the identifier and credentials we can now create an LdapConnection. Next we set the Auth Type to Basic in this case. Since the client was using a self-signed cert, I needed to instruct the library to ignore certificate errors, or else we would get a "server unavailable" error message when trying to connect. I did this by simply returning True in a ValidateServerCertificate delegate. We set a few Session Options such as SecureSocketLayer and SendTime, then we use Bind to test and establish the connection.
There was a lot of trial and error involved in getting the username and security settings right. One very helpful tool to do this was LDAP Admin, located at http://ldapadmin.sourceforge.net/. By finding parameters that worked within LDAP Admin and finding out how to configure those same parameters in my code I was able to successfully connect to OVD.
Once the connection was established, I needed to actually perform the search. First I created a SearchRequest object. The object was initialized with several parameters. The first parameter specifies the base of the search, such as "ou=OIDUsers,dc=example,dc=ovd". You can also find this using LDAP Admin. The second parameter was the search filter to use. I originally used "objectClass=person", but found that I was getting many records (150 - 200 out of 6000) that were missing all attributes. The weird part about it was that both the count and specifics of the missing attributes were different every time. Only when I ran with "objectClass=*" was I able to get all attributes for all records (it actually ran faster, too). Next I specified a SearchScope of Subtree (I wanted to see everything), and I passed a delimited list of the attributes I wanted to return as the final argument. With these settings I was able to successfully retrieve all users and their attributes.
The Ldap Writer was actually much easier once the connectivity issues were addressed. I used the same helper class to establish a connection. I then used DeleteRequest for performing deletes, and AddRequest for performing adds.
private void DeleteUser(string userId)
{
string dn = _deleteFormat.Replace("{UserId}", userId);
DeleteRequest deleteRequest = new DeleteRequest(dn);
DeleteResponse deleteResponse =
(DeleteResponse)connection.SendRequest(deleteRequest);
if (deleteResponse.ResultCode != ResultCode.Success)
{
OnProcessException("Error deleting user " + userId + ": " +
deleteResponse.ResultCode.ToString());
}
}
private void AddUser(PersonnelRecord user)
{
string dn = LdapHelper.SubstituteAttributeValue(user, _addFormat);
List<DirectoryAttribute> attributes = new List<DirectoryAttribute>();
// Add all attributes
foreach (LdapAttribute ldapAttribute in _ldapAttributes)
{
attributes.Add(
new DirectoryAttribute(ldapAttribute.Name,
LdapHelper.SubstituteAttributeValue(user, ldapAttribute.Value)));
}
AddRequest addRequest = new AddRequest(dn, attributes.ToArray());
AddResponse addResponse =
(AddResponse)connection.SendRequest(addRequest);
if (addResponse.ResultCode != ResultCode.Success)
{
OnProcessException("Error adding user " + user.UserId + ": " +
addResponse.ResultCode.ToString());
}
}
The DeleteFormat was set in the configuration file as "cn={UserId},ou=OIDUsers,dc=example,dc=ovd". I simply replaced the UserId with the appropriate UserId for the record being deleted. NOTE: a delete will return a Success result code even if there is no record to delete!
For the adds, the AddFormat was also set in the configuration file and had the same value. In addition, I created a collection of name-value pairs to hold any other attributes to be added to the new user. As with the UserId, I replaced placeholder values with actual values from the user being added.
That was it! The process went from over a half a day manual effort to 30 seconds, fully automated. With a little extra effort it would be easy enough to plug directly into the Payroll system and avoid current step of creating the payroll file. As designed, the code would only require a new Reader adapter.
In summary:
- Use System.DirectoryServices.Protocols.
- Use "LDAP Admin" to test your connectivity first, then match the settings in your code.
- Use an Admin account, in the format "cn=admin", to avoid issues with throttling or access.
No comments:
Post a Comment