Rocking roles and permissions infrastructure

Rocking roles and permissions infrastructure

Creating a reliable yet customizable authorization process within a "many companies"-to-"many users" SaaS solution

Introduction

I work for an up-and-coming SaaS startup company. Our main focus is a scheduling solution aimed at solving the day-to-day problems of small and medium-sized businesses of all shapes that build their business on taking reservations. Think hair salons, massage parlors, repair shops, vets, psychologists, chiropractors, etc. Our revenue comes in the form of monthly subscription fees, paid by these companies.

The problem

Staff permission dynamics of our customers' companies vary greatly depending on factors such as their size and field of operation. Hairdressers and car shop staff for example don't share the same need for privacy restrictions regarding personal data as chiropractors and psychologists. Meanwhile, a hair salon owner might want to restrict interns or contractors from being able to view income-related data in our integrated ePOS system. A rogue psychologist might work as a contractor at two or more different clinics.

These scenarios weren't apparent from the beginning of our operation, but have surfaced with our customer base growing along with our ever-growing set of features. At first, the scheduling solution was developed for the needs of a single Icelandic hair salon. For their purposes, the two obvious roles of 'owner' and 'employee' were the power couple needed to address all permissions and restrictions-related problems. But their powers didn't last forever.

To meet the needs of our growing customer base and build a solid foundation for future scenarios we had to upgrade our roles and permission infrastructure. Now that you're remotely familiar with the why. I want to share with you what we did, and how.

Our tech stack revolves around MeteorJS; a chunk of legacy Blaze templates, Typescript supported React and Node.js along with MongoDB for data storage.

The way it was

When a company initially signs up with us, a Company and a single User documents are created. Before the big change, the created user would be assigned the global hardwired role of 'owner'. The owner could then (and still can) add employees to their company, ultimately creating more User documents that relate to the Company in our database. These users would be assigned the only other global hardwired role of 'employee'. There was no way for the owner to assign the owner role to an employee. There was nothing in the Company document that controlled what it entailed to be an employee or an owner. This obviously has major limitations.

Getting it right

We wanted to give company owners complete control of staff permissions. To do so, the first change we saw we had to do, was to give the company initial roles that owners could assign to their users and manipulate at any given time. To match the current role feature set, we came up with the following addition to our Company interface:

interface Company {
  //...  
  roles: {
    _id: string;
    title: string;
    allowedOperations: OperationReadableId[];
    root?: boolean;  
  }[];
  //...
}

OperationReadableId is restricted to predefined strings that represent operations that can be performed within our product and might need to be restricted. We then initialized the Company document as follows:

{
  //...  
  roles: [
    {
      _id: Random.id(),
      root: true,
      title: 'Owner',
      allowedOperations: ['manageAllCustomers','manageAllStaff',...];
    },
    {
      _id: Random.id(),
      title: 'Employee',
      allowedOperations: ['manageOwnCustomers','viewAllStaff',...];
    }
  ],
  //...
}

Notice the root attribute that we use to distinguish the initial owner role from all other roles. This role allows all possible operations and can't be mutated apart from its title. Our application makes sure there is at least one root user at any given time. To further customize the companies' permission structure, users with corresponding permissions are able to add and configure additional roles that they can then apply to each user to fully control their abilities within our application. Applying a role to users render our User interface to be structured in the following manner:

interface User {
  //...
  companyId: string;
  roleId: string;
  //...
}

Permission enforcement in practice

Enforcing the permissions defined by the allowedOperations array within our application is then as simple as 1, 2, 3:

  1. Fetching the current User.
  2. Fetching a Company document by the current User.
  3. Allow or deny the operation in question depending on the content of the company's allowedOperations array.

As I mentioned, the final curveball from the domain we wanted to hit home (for now) is that each user needs to be able to access more than one company since people tend to be employed at more than one place at any given time. If those workplaces use our software, up until now, this would require these individuals to have separate accounts. This means multiple sets of login information. Not an ideal scenario, since remembering a single set of login info is hard enough. This would also be true for owners of multiple workplaces.

We came up with a solution for this that we've found to be effective and simple at the same time. We extended our User interface to include a companies dictionary in the following way:

interface User {
  //...
  companies: {
    [companyId]: {
      //...
      roleId: string;
      //...
    };
  }
  //...
}

Using this structure we can give our users the ability to easily switch between the companies they have access to. By maintaining a user's active company in one way or another and pairing the user-company relation with a roleId, we are able to enforce the permissions using the same steps as I explained above.

Following is a simplified version of the method that does all the heavy lifting for us:

const userIsAllowedTo = function(
    operation: OperationReadableId, 
    user: User = Meteor.user()
  ): boolean {

  const company: Company = getCompany();

  const roleId = user.companies[company._id].roleId;

  const role = company.roles.find(role => role._id === roleId);

  if (role?.allowedOperations.find(allowedOperation => allowedOperation === operation)) {
    return true;
  }

  return false;
};

Final words

Our solution, as simple and obvious as it may seem now (as always when looking back, right?) took great brainstorming, discussions, and planning. Getting internal authorization right is of extreme importance for the extended usability of our product.

I'm sure many others out there share our concerns and I truly hope someone will find advice in my writing. Feel free to comment below or message me if you have any questions regarding our implementation, planning process, or anything at all!