Hands holding some paper human silhouettes.

Getting Google Groups membership recursively using Apps Script

TL;DR

This post presents an Apps Script function capable of checking deep membership —in a recursive manner— to any existing group in the Groups service of a Google Workspace environment.

TABLE OF CONTENTS

Problem statement

Groups are widely used in Google Workspace environments to organize users inside an organization in a way that Drive, Calendar, Gmail and other built-in services can be efficiently operated.

Google Groups icon.

Groups can be used, much in the same way as organizational units, to help customize service access and settings when complex requirements demand a very granular set of rules.

For his reason, many workflows can benefit from Apps Script automations that manage groups or just perform membership tests to provide granular, custom security controls with either the Groups Service or, better, the advanced AdminSDK Directory Service.

⚠ Unfortunately, both the getGroups() method of the Groups Service and its groups.list counterpart in the advanced service are only capable of handling direct membership to a group, that is, can only tell us about those groups where a given user or group appears explicitly listed in the roster at its People → Members section.

Group roster.
Direct members (users and groups) of a group.

This point is clearly stated by the Apps Script documentation of the Groups Service, but only barely hinted on the Groups REST resource pages of the AdminSDK reference docs (well, if you dare to use an advanced service you may already know that, anyway, I guess 😉).

Excerpt of the Groups Service documentation that confirms that only direct membership is returned.

Let's address this situation ⚡ with some Apps Script Kung-fu! đŸ„‹

đŸ€Š [Updated Nov 28th, 2021] Shortly after publishing this post I realized that the AdminSDK Directory Service also includes the useful members REST resource, which does support direct as well as indirect memberships to groups. It provides methods to retrieve all members of a group (members.list), be they users or groups, as well as to thoroughly check the membership of domain users (but not of external users or groups!) to any given group (members.hasMember). This somehow slipped under my radar, so I went full steam ahead with this writing without taking it into consideration. Since getting the full list of groups a user or group is a member of is not straightforward, even using those two methods, I feel that this post is still relevant... But oh well, lessons learned (again), triple-check the reference documentation to save you time!

A fake organization ready for some testing

No script without some context.

The ICT team of our fake organization, an international school, has set up the following Google Groups hierarchy:

Sample group hierarchy
Fake Google Groups hierarchy for an equally fake educational organization.

Mr. Omb, our test user (onemansbanduser@agilcentros.es) is a direct member of these 3ïžâƒŁ groups:

  • Academic Directors
  • Teachers VET B
  • Human resources

Certain groups are also regular members of some others (please, see group membership arrows in the above diagram). As a result of this, Mr. Omb enjoys indirect membership to these other 4ïžâƒŁ groups, too:

  • Management board
  • Faculty
  • Administration
  • Support Staff

Unnecessarily convoluted? Agreed! But we'll need room to test our function properly.

☝  It is important to notice that, fortunately, cyclic memberships are not allowed. The Google Groups service will prevent users from adding groups to other groups in a way that could create loops in the membership graph.  Similar soothing safety mechanisms are in place to prevent inquisitive Workspace admins from abusing  AdminSDK Directory Service  to circumvent this safety measure.

Specifications and the recursive approach

Recursion is king (or queen?) when trying to algorithmically solve problems that seemingly escape the usual thinking of classic iterative control structures such as while,  for or any of their higher-order functional variants.

Wikipedia defines recursion in computer science in this way:

 A method of solving a problem where the solution depends on solutions to smaller instances of the same problem. Such problems can generally be solved by iteration, but this needs to identify and index the smaller instances at programming time. Recursion solves such recursive problems by using functions that call themselves from within their own code.

It is a well-known fact that any recursive algorithm can be unfolded into an iterative version, that will likely perform better and be more efficient. But the sheer beauty of the concept of recursion and the nature of the problem we are trying to solve, essentially a graph/tree traversal one, which lends itself so well to a recursive approach, will make me stick with this effective technique.

In the next lines of this short post we will show how a recursive Apps Script function can be built to navigate the entire Google Groups hierarchy of our example organization and perform the following two tasks:

1ïžâƒŁÂ  Return an array that contains the email addresses of all the groups that a given user or group belongs to.

2ïžâƒŁ Alternatively, check whether a user or group is a member of a given group, returning true or false accordingly.

We can translate this problem into a graph-like one (fortunately of the non-cyclic type), where any parent/child relationship implies membership to the group represented by the child node. Thus, we just need to collect all nodes in the graph for which the membership check is satisfied.

Graph representation of the problem, groups are nodes.
Our membership-checking problem, as a graph traversal problem (well, sort of).

As you can see, this problem is both wide and deep. Therefore, our intended recursive algorithm will need to perform two different complexity reduction steps to gather all nodes —host groups— in the membership chains:

  • Targeting all direct descendants (siblings) of a given parent node, one by one.
  • Going deeper into any of them to probe further child nodes.

Let there be some real code!

The implementation

Let's break down all the moving parts of our checkMembership() function...

Structure of the checkMembership() function.
Structure of the checkMembership() function.

...and actually review its proposed implementation 👇.

This function expects just one required argument (userGroupEmail), the email address of the user or group to get host groups for. There is an optional param (groupEmail) that, if present, will turn the output of the function into just true, if userGroupEmail can be found inside of groupEmail, or false otherwise.


/**
 * Recursively gets all groups that a given user/group is a member
 * of, either directly or indirectly, in a GWS domain.
 * 
 * -OR-
 * 
 * checks whether user/group is a member of provided group, 
 * both tasks support external users to the domain.
 * Uses the AdminDirectory advanced service, and should be invoked by domain Admins!
 * 
 * @param {string}  userGroupEmail 	Email of user or group
 * @param {string}  [group] 		Group to check membership in (optional, if provided, behaviour & returned results are different)
 * @return {Array|Boolean}  		Sorted array of group email addresses, empty if no memberships found or TRUE/FALSE
 */
function checkMembership(userGroupEmail, groupEmail) {

First, for the sake of convenience,  a private helper function to invoke the AdminDirectory.Groups.list method provided by the Workspace AdminSDK Directory API is declared inside checkMembership().


  /**
   * Helper function to retrieve groups that a given user or group is a direct-member of
   * @param {string} email  Email of user or group
   * @return {Array] Array of group email addresses
   */
  const getDirectGroups = (email) => {

    let token, groups = [];
    try {

      do {
        const response = AdminDirectory.Groups.list({ userKey: email, maxResults: 200, pageToken: token });
        if (response.groups) groups = [...groups, ...response.groups.map(group => group.email)];
        token = response.token
      } while (token);

    } catch (e) {
      // Error handling here!
    }

    return groups;

  };

This function returns an array that contains the email addresses of the groups that the given user or group (email param) is a direct member of. The list method will throw an exception under certain circumstances, for example when the domain part of the email address to check is not valid.

And now we get to the nitty-gritty details of this, the recursion part, which has been implemented using an immediately invoked function expression (IIFE) that triggers as soon as its declaration is finished. 

☝ You can find a more thorough discussion on the use of recursive IIFEs in my previous post about the ACOPLAR() and DESACOPLAR() custom functions for Google Sheets.


 /**
   * IIFE that performs a recursive membership search
   * @param {Array} groups Array of groups to check
   * @return {Array} Array of group email addresses, can contain duplicates 
   */
  let allGroups = (seekGroups = (groups) => {

    if (groups.length == 0) {

      // Base case, we have no problem to solve here!
      console.info('Empty array of groups to inspect, nothing to do.');
      return [];

    } else if (groups.length == 1) {

      // [A] general case → complexity reduction [depth]
      console.info(`Finding memberships for: ${groups[0]}...`);
      return [groups[0], ...seekGroups(getDirectGroups(groups[0]))];

    } else if (groups.length > 1) {

      // [B] general case → complexity reduction [width]
      console.info(`Splitting problem → ${groups.length} groups left, ["${groups[0]}", ...rest].`);
      return [...seekGroups([groups.shift()]), ...seekGroups(groups)];
 
    }
 
  })(getDirectGroups(userGroupEmail));

Even though I feel the code is quite self-explanatory, I'd like to highlight some of the details:

  1. The seekGroups() function has been constructed in a way that requires an array of groups for which host groups have to be obtained. That's why its first invocation uses the result of getDirectGroups(userGroupEmail) as the calling parameter.
  2. If the array argument  is empty  (groups.length == 0) the recursion ends immediately, as the function has no further groups to check in the current graph branch, returning an empty array. This is the base case that stops recursion.
  3. If not, the function deals with the (A) depth and (B) width of the group hierarchy tree in the general case part of the recursion process:
    1. If there is only one group inside the groups array, we go deeper into the tree structure recursively calling seekGroups() over its child nodes, that getDirectGroups(groups[0]) retrieves.
    2. If there are two or more groups inside the groups array, the problem is split into two: getting the groups the first element of the array is a member of and doing the same with the rest of the group-elements in the array.

After the IIFE ends, duplicate groups are removed —seekGroups() does not check for possible multiple memberships to a given group while building the resulting array— and the list of group email addresses (allGroups) is sorted for good measure.

Finally, either a list of groups or a boolean value is returned, depending on the way seekGroups() has been invoked.


  // Remove (possible) duplicates & sort list of group emails
  allGroups = allGroups.filter((group, index, groups) => groups.indexOf(group) == index).sort();

  if (!groupEmail) {

    // Return array of groups that provided user/group is a member of
    console.info(`[END] "${userGroupEmail}" is a member of ${allGroups.length} groups.`, '\n', allGroups);
    return allGroups;

  } else {

    // Return membership to provided group (TRUE/FALSE)
    const isMember = allGroups.some(group => group == groupEmail);
    console.info(`[END] "${userGroupEmail}" ${isMember ? 'is a' : 'is not a'} member of "${groupEmail}"`);
    return isMember;

  }

}

This is a sample execution log of this function when asked to list all group memberships for our onemansbanduser@agilcentros.es test user:

You can find the whole script in 👉 this gist.

Advanced services selector window in the Apps Script IDE

📌 Don't forget to enable the AdminSDK API advanced service in the Apps Script IDE when testing.

Well, we have reached the end, at last, but I can't push the publish button without thanking before GDE Martin Hawksey for his proofreading and insightful tips.

I hope you have enjoyed this post, I'll be glad to read your comments!


Credits: Header image uses a photo by Andrew Moca on Unsplash.

Comentarios