Build you a Google Groups

Build you a Google Groups
NOTE: This post is a companion to the video below. The video is in the form of an exploratory discussion. This post discusses the resulting artifact.

Google Groups is a mailing list discussion service that Google launched back in 2001. Over time it has expanded in scope to also facilitate general group management within Google Workspace (formerly G Suite). There are a number of interesting permissions features which make this a fun service to model. They are:

  • Permissions can require various different levels of permissions on a per-group basis.
  • Some groups are open to posting from anybody, even anonymous users.
  • Groups can hold roles in other groups.
  • Custom roles that can be granted many of the permissions
  • Banned users

As a permissions systems provider, we have developed names for the common solutions to these features. They are, respectively:

  • User defined roles
  • Public resources
  • Recursive membership
  • User defined roles with roles defined in data
  • Exclusions

In this post I will talk about how those features are exposed in Google Groups, how one would model them in Authzed, and how to wire it up with your application.

Per-Group Permissions

The Google Groups UI is rife with sliders that let you pick which role is required to perform an action. Here is an example slider which lets you configure who can view past conversations in a group:

Slider Example

On the far left, and also required, is the group’s owners. The following snippet shows how to always grant owners the view_conversations permission.

definition user {}
 
definition group {
   relation owner: user
   permission view_conversations = owner
}

We can bind a user as the owner of a group by writing the following relationship:

authzed.write(resource="group:test-group", relation="owner", subject="user:the-owner")

Now we’ve handled the static case, where the role always has the permission. Now let’s work through the dynamic cases.

Based on the detents in the slider, we can infer that similar to our group owner relation, there must also be a manager and member relation, and that we must therefore be able to grant view_conversations to all members of the organization, meaning we need a relationship to the organization.

definition user {}
 
definition organization {
   relation member: user
}
 
definition group {
   relation organization: organization
 
   relation owner: user
   relation manager: user
   relation direct_member: user
 
   permission view_conversations = owner + ???
}

If we directly put manager, and direct_member in the permission computation for view_conversations that would make them static rules, and users would always be given that permission, invaliding the use of the slider.

To make the behavior of view_conversations dynamic, and user-defined, we need another layer of indirection. We will add a relation called viewers and use that to bind back to the same group for various positions in the slider.

definition user {}
 
definition organization {
   relation member: user
}
 
definition group {
   relation owner: user
   relation manager: user
   relation direct_member: user
 
   permission member = owner + (manager + direct_member)
 
   relation viewers: group#manager | group#member | organization#member
   permission view_conversations = owner + viewers
}

The following slider images and their corresponding relationships show how one would respond to a slider change:

Slider set to “Group managers”

authzed.write(resource="group:test-group", relation="viewers", subject="group:test-group", subject_relation="manager")

Slider set to “Group members”

authzed.write(resource="group:test-group", relation="viewers", subject="group:test-group", subject_relation="member")

Slider set to “Entire organization”

authzed.write([
  (resource="group:test-group", relation="viewers", subject="group:test-group", subject_relation="member"),
  (resource="group:test-group", relation="viewers", subject="organization:big-company", subject_relation="member"),
])

These snippets presume a bit of hierarchy. First, in our schema we define that group member also includes group manager. This means that when the slider is in the “Group members” position, we do not need to separately write a relationship for “Group managers”. For the “Entire organization” detent, we do not presume that organization member includes all group members and managers, so we write separate relationships for both of those paths in a single batch transaction.

You can basically copy and paste this pattern for most of the other sliders, which have the same detents and capabilities. There are a few sliders, such as those that can post, that include a detent for “Anyone on the web”. For this, we’ll need to head into the wild, wild[card] west.

Public Resources

For those sliders that include an option for “Anyone on the web” to post, we’ll need a different kind of dynamic permission. One that doesn’t just match explicitly stated subjects, but one that can match any subject of a given type.

Slider set to “Anyone on the web”

To handle this, we recently launched a feature called “wildcard permissions”. Wildcard permissions let you write a relationship that will cause any subject of the given type to match. In our specific example, we want to allow the user to say that any user can post to the group. To start, we’ll need to replicate the work we’ve done for view_conversations and adapt it to post.

definition user {}
 
definition organization {
   relation member: user
}
 
definition group {
   relation owner: user
   relation manager: user
   relation direct_member: user
 
   permission member = owner + (manager + direct_member)
 
   relation posters: group#manager | group#member | organization#member
   permission post = owner + posters
}

Now, to extend this for any user, we extend the types allowed for the posters relation to include support for a wildcard over the user object type:

relation posters: group#manager | group#member | organization#member | user:*

Now to extend our model to handle the detent for “Any user on the web” we can write a relationship like the following, where we replace a specific object id with the literal *:

authzed.write(resource="group:test-group", relation="posters", subject="user:*")

This will now match any user object used as a subject, e.g. user:the-owner, user:stacey or user:a-bad-guy. There is still a remaining deficiency. What do we do about truly anonymous users, who haven’t even been given a user id?

For that, we can add an additional object type to use as the subject for anonymous users. Then, in our code, when we want to perform a permissions check for an anonymous user, we just synthesize any object of this new type.

Here is the updated schema for anonymous:

definition user {}
 
definition anonymous_user {}
 
definition organization {
   relation member: user
}
 
definition group {
   relation owner: user
   relation manager: user
   relation direct_member: user
 
   permission member = owner + (manager + direct_member)
 
   relation posters: group#manager | group#member | organization#member | user:* | anonymous_user:*
   permission post = owner + posters
}

Now when we want to open up posting to the public we write these two relationships.

authzed.write([
  (resource="group:test-group", relation="posters", subject="user:*"),
  (resource="group:test-group", relation="posters", subject="anonymous_user:*"),
])

And finally, a check call for an anonymous user would look like the following:

if authzed.check(resource="group:test-group", permission="post", subject="anonymous_user:this-can-be-anything"):
    post()

As we’ve built up this example, I’ve been glossing over the fact that groups can also be members of other groups! Time to get a little recursive.

Groups in Groups (in Groups (in Groups))

Luckily, it’s a relatively easy change to allow groups to be members of other groups! Here is our updated view_conversations example schema:

definition user {}
 
definition organization {
   relation member: user
}
 
definition group {
   relation owner: user | group#member
   relation manager: user | group#member
   relation direct_member: user | group#member
 
   permission member = owner + (manager + direct_member)
 
   relation viewers: group#manager | group#member | organization#member
   permission view_conversations = owner + viewers
}

Notice all we had to do was to add group#member to the allowable types for each of our direct group relations. The following screenshot shows adding a group to another group, and the corresponding relationship write.

Group member of a group example

authzed.write(resource="group:test-group", relation="direct_member", subject="group:security", subject_relation="member")

This ability is very powerful! In fact, it’s one of the core differentiators between a Zanzibar-based permissions system and a more traditional direct role grant model.

Now that we’ve handled all of the built-in roles, let’s delve into custom roles.

Custom Roles

Google Groups has a somewhat hidden feature to be able to define custom roles, and give that role permissions to do various things within a group. For a simple example, we will add support for custom roles, and allow that role to be granted the view_conversations permission.

First, we need to start by adding a custom_role object type, and allow users to be set as members of that role.

definition custom_role {
   relation member: user | group#member
}

Similarly to our groups in groups, custom roles can also have groups as members. Next, we’ll add that custom role type to our group object type, allowing relationships to be written granting that role the view_conversations permission.

NOTE: In the real Google Groups product, custom roles are primarily used for group meta-permissions, e.g. who can change group membership or perform content moderation.
definition group {
   relation owner: user | group#member
   relation manager: user | group#member
   relation direct_member: user | group#member
 
   permission member = owner + (manager + direct_member)
 
   relation viewers: group#manager | group#member | organization#member | custom_role#member
   permission view_conversations = owner + viewers
}
authzed.write(resource="group:test-group", relation="viewers", subject="custom_role:super-secret-people", subject_relation="member")

Now that we’ve got our basic schema fairly well developed these new features are proving relatively easy to add! Now let’s take a look at banning users through exclusions.

Banned Users

Google Groups allows you to ban users from interacting with the group.

UI for banning a user

So far all of our permissions have been computed using the union (+) operation. This is the most commonly used operator, but the schema language also supports intersection (&) and exclusion (-). We’ll use exclusion to set up a new type of user: a banned user.

By now you might be able to guess how this will be added to the schema:

definition group {
   relation owner: user | group#member
   relation manager: user | group#member
   relation direct_member: user | group#member
   relation banned: user | group#member
 
   permission member = owner + (manager + direct_member - banned)
 
   relation viewers: group#manager | group#member | organization#member | custom_role#member
   permission view_conversations = owner + (viewers - banned)
}
NOTE: We’ve used parentheses to enforce an order of operations that will still allow banned users who are also group owners to view conversations. This is in keeping in spirit with the actual Google Groups which uses meta-permissions to prevent owners from being banned in the first place.

Now we can ban a user by writing another simple relationship!

authzed.write(resource="group:test-group", relation="banned", subject="user:villain")

That’s it! After writing that relationship, the user will be magically unable to view any conversations, regardless of whether they otherwise would have been able to.

Putting It All Together

If you found this interesting, you can watch Jimmy and I model permissions (and the meta-permissions) for nearly every feature in Google Groups in the video above. I have also embedded a working playground with the entire example from the video here for posterity.

If you’re experiencing similar permissions problems in your application, I encourage you to join us on Discord where you can collaborate with our team or other users who are using Authzed and SpiceDB to solve permissions in their app. If you think this type of content is valuable for others, you can use the social links below to share it with others!

Recent Blog Posts
blog-image
blog-image
blog-image
blog-image