Cascading permissions for complex content relationships in Drupal 8
Jordan GrahamFri, 04/23/2021 - 14:37 Drupal

Aten loves libraries. We’ve built a range of software solutions for libraries all over the country, including the John D. Rockefeller Jr. Library in Williamsburg, VA, the Denver Public Library in our own Denver county, the Richland Library in South Carolina, Nashville Public Library in Tennessee and the Reaching Across Illinois Library System (“RAILS”). It’s remarkable just how many commonalities libraries share when it comes to the features, tools, and considerations their websites need to better serve their users.

Some of those similarities are a no-contest justification for building common, configurable library solutions — solutions like Intercept: a reimagined, generalized approach to library event management, room and equipment reservation, and customer tracking. We co-developed Intercept for Drupal with Richland Library, reused it with L2 / RAILS and actively maintain it in the hopes that it goes on serving libraries for years to come. But for all of the similarities we see between libraries and their digital needs, there are some key differences, too.

Complex permissions needs

Library Directory and Learning Calendar (“L2”) — a project of RAILS — had unique permissions needs. Their staff needed custom view, edit, and delete permissions to website content managed at different organizational levels of the library system. Those organizational levels, structured hierarchically from Illinois State Library, to Regional Library System, through Catalog Consortium, Agency and finally Location, needed associated cascading permissions — i.e., permissions granted at a higher organizational level should cascade to associated content in lower organizational levels. An administrator for an Illinois State Library piece of content, for example, should be able to exercise their administrative permissions on Regional, Consortium, Agency, and Location content associated with that State Library content. An administrator for an Agency would have administrative permissions for that Agency’s Locations.

Granular role based Drupal permissions wouldn’t cut it. That’s because with standard Drupal permissions, each user role can be assigned view, edit, and delete permissions to each content type (say any Regional Library System Page), but we needed to assign those permissions to the appropriate instances of a content type — like the specific Regional Library System Page that belongs to the west-central region, for example.

There are plenty of contributed modules that start down the right path towards (for lack of a better term) cascading permissions by content affiliation, but they wouldn’t have gotten us all the way there. Both the Group and Permissions by term modules, for example, can be incredibly useful in similar situations. In this case, given the features and functionality contributed modules would introduce that we don’t need, plus the level of modification necessary to achieve our goals, we decided on a lightweight, custom solution.

Role and affiliation based custom permissions for L2

Permissions for L2 staff are established using a custom affiliation entity, which stores data about the role a particular user has in relation to a specific piece of content. The custom affiliation entity references a user, a role (a taxonomy term like Admin, Manager, or Staff, for example), and a specific piece of content (a node). A variety of other fields are established in the same affiliation entity in order to store additional metadata about the relationship like contact information, job title, job description, or other details.

Custom affiliation entity field configuration screen, Drupal 8
Affiliation, Role / Access Group, and User fields establish a permission relationship. Metadata fields (blurred here) can provide some arbitrary data about the relationship.

The custom affiliation entities are organized into their own bundles, one for each of the hierarchically structured organizational levels previously described: Illinois State Library, Regional Library System, Catalog Consortium, Agency, and finally Location. This way each individual type of affiliation can contain the metadata fields appropriate for its specific organizational level. Finally, there is an arbitrary Group affiliation which affiliates a user with a piece of content without granting the cascading permissions that accompany standard affiliations.

Custom entity affiliation entity types, Drupal 8
Each organizational level is represented by its own custom affiliation entity type.

The content items (read nodes) whose permissions we’re controlling are organized along the same organizational levels: Illinois State Library, Regional Library System, etc. Each of these content types uses a unique entity reference field to establish a parent / child relationship along the organizational levels. Locations are associated with Agencies, Catalog Consortia, Regional Library Systems or directly with the Illinois State Library; Agencies are associated with other Agencies and / or Regional Library Systems; Catalog Consortia are associated with Regional Library Systems; and Regional Library Systems with the single parent Illinois State Library. It’s a complicated web!

Cascading permissions diagram
Permissions granted on content at any one organizational level cascade to associated content in the lower levels.

Once the content for L2 was developed and properly structured with the appropriate parent / child relationships, granting a user specific view, edit, and delete permissions for a particular region of the structured content tree was simple. Simply create an affiliation that assigns the user a role in relation to content at a specific level of the organization, and voila! — the user gains access to that content and all of its children at each of the lower levels.

The permission grid: Tying it all together

One last element ties our whole permissions system together: a robust permissions map that associates view, edit, and delete permissions per custom defined Role / Access Group in relation to various entities. Unlike the unwieldy Drupal permissions grid that assigns roles to broadly defined permissions with the help of about a million radio buttons, our definitions can be static (think code, not GUI) and only have to deal with view, edit, and delete permissions for entities or entity fields. Each Role / Access Group has its view, edit and delete permissions defined per bundle or per specific bundle / field combination, resulting in very cleancut — and extremely granular — permission control.

[
  {
    "entity": "node",                         // Entity type we're granting access to
    "field": "",                              // Field for this entity, left blank we're defining with entity level access
    "affiliation bundle": "location",         // Affiliation entity that controls access, in this case the location affiliation type
    "target bundle": "location",              // Entity bundle we're granting access to, in this case a location node
    "role_access_group": "location_manager",  // Custom defined Role / Access Group that grants this permission
    "edit": 1,                                // Edit permission
    "view": "",                               // View permission
    "delete": ""                              // Delete permission
  }
]

Our “permissions grid” is made up of about 500 similar declarations in a single JSON file, which range through a variety of Roles / Access Groups, a couple of entity types, and tons of bundle / field combinations for some of the more complex field level permissions.

Individual permissions grants are then handled through hook_ENTITY_TYPE_access() and hook_entity_field_access(), which use a a custom Service to load all of the requesting user’s affiliation entities, determine their role in relation to the content (node) in question, then find that role’s particular permissions using our custom JSON permissions map. Here’s an example for node access.

/**
 * Determines if the operation is allowed for a node.
 *
 * @param object $relationship
 *   The relationship object.
 * @param string $operation
 *   The operation being attempted.
 * @param Drupal\node\Entity\Node $node
 *   The node on which the operation is being attempted.
 *
 * @return bool
 *   True if the operation is allowed.
 */
public function nodePermission(object $relationship, $operation, Node $node) {
  // Create an array of data from l2_access_permissions.json, each element
  // of which is an array with elements matching $relationship object
  // properties.
  $module_path = drupal_get_path('module', 'l2_access');
  $matrix = json_decode(file_get_contents($module_path . '/l2_access_permissions.json'),
    TRUE);
 
  // Create an array matching the structure of $matrix elements to see if
  // it matches any $matrix elements (which would mean that there might be
  // a permission that allows this $operation.)
  $relationship_array = [
    'entity' => 'node',
    'field' => '',
    'affiliation bundle' => $relationship->affiliation_bundle,
    'target bundle' => $relationship->target_bundle,
    'role_access_group' => $relationship->role_access_group,
  ];
 
  // Set the $relationship_array's 'edit' and 'view' elements based on the
  // $operation's value.
  $operation = ($operation == 'update') ? 'edit' : $operation;
  $operations = ['view', 'edit', 'delete'];
  foreach ($operations as $op) {
    $relationship_array[$op] = ($op == $operation) ? 1 : "";
  }
 
  // Handy here that array_search() can test whether an array is an element
  // in another array. Here: is $relationship_array an element of $matrix?
  $match = array_search($relationship_array, $matrix);
  if (!$match) {
    return FALSE;
  }
 
  // Found a match. Does it allow access?
  switch ($operation) {
    case 'edit':
      if ($matrix[$match]['edit'] == 1) {
        return TRUE;
      }
      break;
 
    case 'update':
      if ($matrix[$match]['edit'] == 1) {
        return TRUE;
      }
      break;
 
    case 'view':
      if ($matrix[$match]['view'] == 1) {
        return TRUE;
      }
      break;
  }
 
  // If we're here, this $relationship doesn't provide $operation access.
  return FALSE;
}

The end result is powerful and flexible. Our JSON permissions map tells us which Roles / Access Groups have which permissions per entity or entity / field combination. The custom affiliation entities grant users a specific Role / Access Group in relation to a specific piece of content, and that content’s entity references to other entities allow the permissions to cascade to entities in lower organizational levels.

That may sound like a lot, but it’s surprisingly simple. The solution boils down to a handful of entity access hook implementations that use a custom service to lookup the current user’s permissions by affiliation via a JSON permissions map. The total footprint sits at around 1000 lines of code — not counting the permissions map itself — and flexibly manages thousands of users’ permissions across thousands of complex, hierarchical content relationships down to the field level. Pretty neat.

Jordan Graham