DECISION NODE

December 15, 2023
Decision Nodes

Configurable access controls

Yoel Tadmor
,ㅤ
Founding Engineer @ Eraser

Let's imagine we're working on Pads and Pens, a legal document automation app. Our app has a simple model in which each account can create projects which include one or more documents. We've implemented a few process related features, such as document approval and commenting.  Each account has some users, who can either be Admin or Member of that account. Permissions are fairly straight forward, without any customization:

Pads and Pens is a big hit, but we're starting to hear from our happy users that they're unable to roll it out organization-wide because they need a different configuration of permissions than our model provides. Some example requests which differ for some of our customers

  • Not every team member should be allowed to create a new document within a project
  • Only the team member that created a document is allowed to update it
  • Team members and guests can be added as document reviewersWe aren't sure how many of these requests we want to handle right now, but we are considering what approach we want to take.

This article covers two approaches, exploring the trade-offs for each:

  1. Flags and Rules: Faster to implement, but less flexible and harder to reason about over time.
  2. Permissions as data:  Requires more up-front refactoring and migration, but is more flexible and easier to write new code.

Flags and Rules

This is an approach as old as time - or at least Jan 1st, 1970.

In order to implement the above features, we can simply add some permission flags. If we're being good, we might abstract these as PermissionSettings defined on either a document, project, or account (or combination of all three!).

Our UI will end up looking something like this:

Easy to implement one-off features without new architecture

The clear advantage of this approach is that we can implement individual features as they come in without a lot of up front work. All that we need is a few toggles, a new field in our table and to update one or two permission checks:

Writing permissions is easy

Another major advantage of this approach is that it will closely mirror the UI. If our project settings form includes a checkbox for "Only author can update documents", the code which implements that update is pretty easy to imagine.

Reading permissions gets messy

We were excited to build out or first feature, but now our permissions settings are growing, and we're adding adding settings to both the account and project level:


PermissionSettings {
 membersCanCreateFiles: boolean
 onlyAuthorCanUpdate: boolean
 allowMemberReview: boolean
 allowGuestReview: boolean
}

In order to properly check permissions, we need to load an increasing amount of context. This code (and tests for it) needs to be duplicated across our services and in our UI.

We're also finding that each new change requires adding a lot of bespoke tests for a given action to make sure that the different configurations of settings and roles works properly.

Syncing logic across boundaries is harder.

Our setup might make it difficult to share code between the UI and services or between multiple different services. Because the logic is more involved, there's a larger surface area for bugs. A common example would be showing users buttons, dropdown options, or other UI elements that aren't usable or end up causing 403 errors.

Limited role-based flexibility

While each individual setting isn't too hard to add, this approach doesn't make it easy to add new roles. For example, adding a Document Reviewer role would require updating many different checks. And adding custom roles would require a lot of the abstractions of our second approach but without any of the benefits.

Permissions as data

In this approach, we no longer write a lot of ad-hoc rules to check permissions, and instead store them directly as data.

Note: Exact implementations of this data model can differ. This data might also be stored as either configuration or at the database-level - if you're interested in more of a deep dive here, let us know!

Ideally, our UI will look something like this:

Reading permissions is consistent and simple

One major advantage is that our code for checking permissions becomes much easier to reason about:

This is especially true if we want to implement cascading permissions (e.g. allowing permissions to be defined at the account or project level and apply to all documents inside) - all we need to do here is add an id to check against our data.

This means:

  • No need for bespoke tests on each new feature
  • Sharing logic is much easier (worst case, we re-implement our abstraction once)
  • Code is easier to review and compare to business requirements

Role-based flexibility

The second major advantage here is that we can define new pre-defined roles (such as our Document Reviewer), allow customers to define custom roles, and add user-specific permissions.

Instead of needing to update a lot of rules throughout our code base, we just need to define some handy abstractions and update configuration as we go:

Requires migration and refactor

In order to get to parity with existing functionality, we'll need to move all of our existing permissions checks over to the new model and, if storing permissions at the database level, also run a migration. Depending on our frameworks for running and deploying migrations, this might make it a lot less attractive to roll this out for just one or two features.

Can cause UI - abstraction mismatch

While this approach is great for more flexible and granular permissions, that doesn't mean our UI will necessary allow for that.

It could be that we started going down the Flags and Rules approach and don't have appetite for changing our existing UI.

Even if not, we might decide that we aren't sure what the right roles are and don't want to expose an entire UI for explaining what each role can and cannot do.

In that case, we'll need logic to translate each flag to the appropriate data:

This introduces a whole new surface area for bugs, and counteracts a lot of the advantages of this approach.

TL;DR

When implementing more advanced access configuration, the choice of architectures is often driven just as much by product considerations as any kind of technical considerations.

Flags and rules is a useful approach if:

  • We have a few one-off requests that we want to implement quickly
  • We want to build a (relatively) simple settings-based UI that we can add to over time
  • We aren't committing to adding many new roles, customizable roles, or user-specific permissionsPermissions as data is a more flexible approach and is definitely the right approach from the outset if:
  • Customizations are on the roadmap
  • We have the time and appetite to build out a role-based UI nowTempted to switch from an initial flag-based approach to a data-based one? While it has several advantages, it would also come at the cost of a migration and potentially a difficult refactor. Some concrete considerations would be:
  • Increase in bugs due to server / client implementation mismatches
  • Lengthier development and review cycles because product requirements are harder to verify
  • The roadmap includes several new flags that we want to add, but none is urgent
  • Role customization is increasingly a requestIn this case, working with out stakeholders to carve out the needed time will make everyone happier in the long run!

Subscribe for Updates

Exploring forks in the road of software design with a weekly newsletter.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.