One of my apps is using Zend Framework to integrate with Windows Networking for security. In other words, you use your network login to login to the application, and the system administrators can grant granular access to the application via network groups and arbitrary application roles. For example, user "joe" may belong to the "hr" group on the network, and the "hr" group can "manage resumes" in the application. So when "joe" logs in, we need to determine if his account can "manage resumes". This is the requirement I was asked to implement.
As it turns out, the Zend_Acl object was built for this type of thing. But, making use of LDAP to talk to Windows Active Directory, and building the structure needed to map the Active Directory entries into the application roles is a little beyond the "classic use case" the docs cover. So, here is a quick run down of how I implemented this, and a nice function to tie it all together.
First, you need to understand some terms:
- Resource: something that you want to control access to. This *may* be a database table, a controller or action in your app. It could also be an arbitrary string that describes what you are trying to do. i.e. "manage resumes".
- Permission: related to this is specific permissions on a given resource. For instance "add", "edit", "delete" related to the "manage resumes" resource. Permissions are linked to a resource via a "Rule"
- Role: something that can receive permissions. "the HR group", "joe", etc. Roles can be an arbitrary string which means we can tie a role to specific application items, or open it up and let the user define the roles as part of the application.
- Parent/Child Roles: roles can be a tree type structure showing the relationship between roles. For instance, we might say "joe" is a child of the "hr" group. Or more pertinent (due to how roles are added to our ACLs) "joe has a parent role of 'hr' ". A role can have more than one parent, however those parents must exist in our ACL object before they are referenced.
- Rules: a rule indicates what role can do access what action. You can set "default" rules to set the starting point and then use additional rules to indicate the exceptions to the default condition. More on this below.
My app allowed arbitrary roles access to arbitrary resources. The user defined the roles, while I am defining the resources as the application evolves. The resources are part of the application itself. So, I need some way to store the resources and roles, AND in addition I need to associate the Active Directory records with roles.
I made a few decisions to simplify the problem:
- I decided to simplify the problem a little and determined that ONLY application roles can be given access to the application resources. The Active Directory records had to be associated with an application role.
- I decided I did not need "add", "edit", "delete" type of permission for each resource. Instead, my resources are named something like "manage resumes", which implies those permissions. So if a role is allowed a particular resource, they can do everything needed. (I put in the pertinent checks on each Action of my controllers and models that implement the actual work.)
- Active Directory allows complex group memberships. While we track the group memberships for the users, I decided that we will not allow an Active Directory Group to inherit from another group. so we end up with an inheritance path of "user -> group -> application role" or "user -> application role". This avoids issues where a group may reference another group that has not been added to the ACL yet.
This means my data model becomes pretty simple:
- Resources: identifier, name (in reality, I have this stored as an array in the app)
- Roles: id, name, parent role
- Role_Members: role_id, member_id (where member_id can be the Distinguished Name of an Active Directory record, or a simple database ID)
- Rules: role_id, resource, access
Now, accessing Active Directory is a whole separate block of code and effort. I've blogged about using LDAP with PHP for this in the past, so I'll let you look there if you need it.
Assuming you have the data model, an interface for populating the data, and a connection to your Active Directory records, the routine I've worked out is as follows:
- Get a list of your resources
- Get a list of your application roles
- Get a list of the Active Directory groups (in the Builtin and Users directories)
- Get a list of the Active Directory users
- Get a list of the Rules for the application
- Build the ACL
- Add the application roles
- For each group, build an array of application roles it is linked to. Then add the group, with the parent roles to the ACL.
- For each user:
- create a "parent roles" array
- add each group the user belongs to to the parent roles array
- add each application role the user belongs to to the parent roles array
- add the user, with the parent roles to the ACL.
- Add the application resources to the ACL
- Set the rules for the ACL
- set the default rule (deny all is a good starting point)
- add each rule to the ACL
Phew! A lot of steps, but not that difficult. Hehe, until you start writing the code. I can get you started though. I have a function that does exactly this algorithm. The parts I'll leave up to you though is creating the function to populate the initial data. Here is my function:
public function buildAcl()
{
$a = new Model_DbTable_Acls();
$r = new Model_DbTable_Roles();
$u = new Model_DbTable_RoleUsers();
//collect our data
// ----------------------
// NOTE: code from here to the next set of -----'s will need to be modified to match your app.
// get all resources
$resources = Model_Resources::getResources();
// get application roles
$roles = $r->fetchAll()->toArray();
// get Active Directory groups
$groups = Model_Security::getADGroups();
// find the parent application roles for the active directory groups (if any)
foreach ($groups as &$grp)
{
$grp["roles"] = $u->membersRoles($grp["distinguishedname"]);
}
// get the list of Active Directory users
$users = Model_Security::getADUsers();
// find the parent application roles for the users (if any)
foreach ($users as &$usr)
{
$usr["roles"] = $u->membersRoles($usr["distinguishedname"]);
}
//get the application access control rules
$rules = $a->getAcls();
// --------------------------
//build the ACL
$acl = new Zend_Acl();
//add the roles/groups/users
foreach($roles as $role)
{
if (!$acl->hasRole( $role["id"] ))
{
$acl->addRole( new Zend_Acl_Role( $role["id"] ) );
}
}
foreach($groups as $grp)
{
if (!$acl->hasRole( $grp["distinguishedname"] ))
{
$acl->addRole( new Zend_Acl_Role( $grp["distinguishedname"]), $grp["roles"] );
}
}
foreach($users as $usr)
{
if (!$acl->hasRole( $usr["distinguishedname"] ))
{
$parentroles = array();
// add network groups the user is a member of
if (array_key_exists("memberof", $usr))
{
if (!is_array($usr["memberof"]))
{
if (trim(strlen($usr["memberof"])) > 0)
{
$parentroles[] = $usr["memberof"];
}
}
else
{
foreach ($usr["memberof"] as $key => $mbr)
{
if ($key == "count") { continue; } //skip the "count" entry
$parentroles[] = $mbr;
}
}
}
//add application roles the user is a member of
$userroles = $u->membersRoles($usr["distinguishedname"]);
foreach ($userroles as $ur) {
$parentroles[] = $ur;
}
$acl->addRole( new Zend_Acl_Role( $usr["distinguishedname"] ), $parentroles );
}
}
//add the resources
if (!empty($resources))
{
foreach($resources as $res)
{
$acl->add(new Zend_Acl_Resource( $res["id"] ));
}
}
//setup rules
$acl->deny(); //deny everyone by default
foreach($rules as $rule)
{
$acl->allow($rule['role_id'], $rule['resource']);
}
return $acl;
}
And a quick walktrhough of the app.
- First, we get a reference to some classes we use to collect our data
- Next we get our data
- we get our roles
- we get our Active Directory Groups (as an array that contains the distinguished name). We also add a "roles" index to each group's array and this roles array contains the ID of any of the application roles the group may belong to.
- We then get an array of our Active Directory Users (an array containing the distinguished name, and memberof values). The memberof item contains a list of the network groups a user may belong to. We also add a "roles" directory to each user that contains the ID of any application roles the user is associated with.
- And finally, we get the list of rules.
- The remainder of the code follows the algorithm defined above. There is one important point though: An error will occur if a role is added more than once. So we use the "$acl->hasRole()" method to determine if the role already exists.
- Finally, we have a complete ACL object so we return it to let the application work with it.
And to use this we might do code something like this:
$acl = buildAcl();
$currentUser = Zend_Registry::get("session")->userid;
if ($acl->isAllowed($currentUser, "Manage Resumes")) {
// can add/edit/delete resumes
}
Final Issue:
Anytime we need to query Active Directory, we will see a delay - more if we need to query it many times. This can lead to unacceptable delays when rendering your pages (assuming you are doing an MVC web application). In my case, I'm seeing approx 30 second periods to build the ACL. Obviously too long to do on every page request. So, what to do?
First, we probably do not need to regenerate the ACL on every single page request. Zend_Acl is "serializable" (to the layman, that simply means we can dump it to text, and recreate the original object later by "unserializing" that text later). So, we can create the ACL, then dump it to a file. Then on each page request we can read the already populated ACL from file and see a drastic decrease in the rendering times (compared to creating the ACL from scratch on each request). A sample of this:
$acl = buildAcl();
$fh = fopen('/path/to/acl/file', "w");
fwrite($fh, serialize($acl));
fclose($fh);
//and later we can restore the $acl object by using unserialize
$acl = unserialize(file_get_contents('/path/to/acl/file'));
The important part here is that the file will need to be recreated periodically, or when application changes occur. Perfect for a cron job.
This works well, though there is still approx 1 second of delay while the code unserializes the ACL object. The process may be futher streamlined by making use of Zend_Cache though or storing the ACL directly in memory.
If you know of any way to make this code better, please feel free to contact me.
Update - 29 Jul 2009
Loading the ACL from file on each request is kinda brain dead. I must have been tired when I wrote this up. Loading the ACL from file when a user logs in and then storing that ACL object in a session variable makes much more sense. Though this would mean the user needs to log out and then log in to see changes. So combining this with Zend_Cache in the bootstrap process makes sense. I'll be creating a plugin in a short bit to implement this. The logic will go something like this:
if the user id is in the session, then
user is logged in
Use Zend_Cache to get the ACL object. (Loading from file if it does not exist or has timed out)
otherwise
user is NOT logged in
redirect to login page
end if
In this way, I should be seeing a delay ONLY on login and every half hour or hour after that (depending on how long I decide to cache the ACL). In addition, if I add a "Reload ACL" functionality on the administrative interface, then the site admin can trigger a manual update of the ACL file AND this routine can easily expire the cached ACL objects to force a reload from file.
While this is not as elegant as a dynamic, on the fly creation of the ACLs, I feel it is a reasonable compromise in terms of minimizing load times. Time will tell.