Authorization is the mechanism that controls who can do what on which resource in an application. Although it is a critical part of an application, there are limited resources available on how to build authorization into an app effectively. In this post, I’ll be illustrating how to set up authorization in a GraphQL API using a custom directive and Oso, an open-source authorization library. This tutorial covers the NodeJS variant of Oso, but it also supports Python and other languages.

Requirements

There are a number of users and each of them belongs to one or more user groups. The groups are guest, member and admin. Also, a user can be given escalated permission on one or more projects if he/she belongs to a certain project user group (e.g. contributor).

Depending on the membership, users have varying levels of permission on user, project and indicator resources. Specifically

User

  • All users can be fetched if a user belongs to the admin user group.

Project

  • A project or all permitted projects can be queried if a user belongs to the _admin or member _user group or to the _contributor _user project group.
    • For a project record, the contract_sum field can be queried only if a user belongs to the _admin _user group or contributor user project group.
  • The project status can be updated if a user belongs to the admin user group or contributor user project group.

Indicator

  • All permitted project indicators can be fetched if a user belongs to the admin user group or contributor user project group.

Building Blocks

Permission Specification on Directive

A directive decorates part of a GraphQL schema or operation with additional configuration. Tools like Apollo Server (and Apollo Client) can read a GraphQL document’s directives and perform custom logic as appropriate.

A directive can be useful to define permission. Below shows the type definitions used to meet the authorization requirements listed above. For example, the auth directive (@auth) is applied to the project query where admin and member are required for the user groups and contributor for the project user group.

 1// src/schema.js
 2const typeDefs = gql`
 3  directive @auth(
 4    userGroups: [UserGroup]
 5    projGroups: [ProjectGroup]
 6  ) on OBJECT | FIELD_DEFINITION
 7
 8  ...
 9
10  type User {
11    id: ID!
12    name: String
13    groups: [String]
14  }
15
16  type Project {
17    id: ID!
18    name: String
19    status: String
20    contract_sum: Int @auth(userGroups: [admin], projGroups: [contributor])
21  }
22
23  type Indicator {
24    id: ID!
25    project_id: Int
26    risk: Int
27    quality: Int
28  }
29
30  type Query {
31    users: [User] @auth(userGroups: [admin])
32    project(projectId: ID!): Project
33      @auth(userGroups: [admin, member], projGroups: [contributor])
34    projects: [Project]
35      @auth(userGroups: [admin, member], projGroups: [contributor])
36    indicators: [Indicator]
37      @auth(userGroups: [admin], projGroups: [contributor])
38  }
39
40  type Mutation {
41    updateProjectStatus(projectId: ID!, status: String!): Project
42      @auth(userGroups: [admin], projGroups: [contributor])
43  }
44`;

Policy Building Using Oso

Oso is a batteries-included library for building authorization in your application. Oso gives you a mental model and an authorization system – a set of APIs built on top of a declarative policy language called Polar, plus a debugger and REPL – to define who can do what in your application. You can express common concepts from “users can see their own data” and role-based access control, to others like multi-tenancy, organizations and teams, hierarchies and relationships.

An authorization policy is a set of logical rules for who is allowed to access what resources in an application. For example, the policy that describes the get:project action allows the actor (user) to perform it on the project resource if he/she belongs to required user or project groups. The actor and resource can be either a custom class or one of the built-in classes (Dictionary, List, String …). Note methods of a custom class can be used instead of built-in operations as well.

 1# src/polars/policy.polar
 2allow(user: User, "list:users", _: String) if
 3  user.isRequiredUserGroup();
 4
 5allow(user: User, "get:project", project: Dictionary) if
 6  user.isRequiredUserGroup()
 7  or
 8  user.isRequiredProjectGroup(project);
 9
10allow(user: User, "update:project", projectId: Integer) if
11  user.isRequiredUserGroup()
12  or
13  projectId in user.filterAllowedProjectIds();
14
15allow(user: User, "list:indicators", _: String) if
16  user.isRequiredUserGroup()
17  or
18  user.filterAllowedProjectIds().length > 0;

Policy Enforcement

Within Directive

The auth directive collects the user and project group configuration on an object or field definition. Then it updates the user object in the context and passes it to the resolver. In this way, policy enforcement for queries and mutations can be performed within the resolver, and it is more manageable while the number of queries and mutations increases.

On the other hand, the policy of an object field (e.g. contract_sum) is enforced within the directive. It is because, once a query (e.g. project) or mutation is resolved, and its parent object is returned, the directive is executed for the field with different configuration values.

 1// src/utils/directive.js
 2class AuthDirective extends SchemaDirectiveVisitor {
 3  ...
 4
 5  ensureFieldsWrapped(objectType) {
 6    if (objectType._authFieldsWrapped) return;
 7    objectType._authFieldsWrapped = true;
 8
 9    const fields = objectType.getFields();
10
11    Object.keys(fields).forEach((fieldName) => {
12      const field = fields[fieldName];
13      const { resolve = defaultFieldResolver } = field;
14      field.resolve = async function (...args) {
15        const userGroups = field._userGroups || objectType._userGroups;
16        const projGroups = field._projGroups || objectType._projGroups;
17        if (!userGroups && !projGroups) {
18          return resolve.apply(this, args);
19        }
20
21        const context = args[2];
22        context.user.requires = { userGroups, projGroups };
23
24        // check permission of fields that have a specific parent type
25        if (args[3].parentType.name == "Project") {
26          const user = User.clone(context.user);
27          if (!(await context.oso.isAllowed(user, "get:project", args[0]))) {
28            throw new ForbiddenError(
29             JSON.stringify({ requires: user.requires, groups: user.groups })
30            );
31          }
32        }
33
34        return resolve.apply(this, args);
35      };
36    });
37  }
38}

Within Resolver

The Oso object is instantiated and stored in the context. Then a policy can be enforced with the corresponding actor, action and _resource _triples. For list endpoints, different strategies can be employed. For example, the projects query fetches all records, but returns only authorized records. On the other hand, the indicators query is set to fetch only permitted records, which is more effective when dealing with sensitive data or a large amount of data.

 1// src/resolvers.js
 2const resolvers = {
 3  Query: {
 4    users: async (_, __, context) => {
 5      const user = User.clone(context.user);
 6      if (await context.oso.isAllowed(user, "list:users", "_")) {
 7        return await User.fetchUsers();
 8      } else {
 9        throw new ForbiddenError(
10          JSON.stringify({ requires: user.requires, groups: user.groups })
11        );
12      }
13    },
14    project: async (_, args, context) => {
15      const user = User.clone(context.user);
16      const result = await Project.fetchProjects([args.projectId]);
17      if (await context.oso.isAllowed(user, "get:project", result[0])) {
18        return result[0];
19      } else {
20        throw new ForbiddenError(...);
21      }
22    },
23    projects: async (_, __, context) => {
24      const user = User.clone(context.user);
25      const results = await Project.fetchProjects();
26      const authorizedResults = [];
27      for (const result of results) {
28        if (await context.oso.isAllowed(user, "get:project", result)) {
29          authorizedResults.push(result);
30        }
31      }
32      return authorizedResults;
33    },
34    indicators: async (_, __, context) => {
35      const user = User.clone(context.user);
36      if (await context.oso.isAllowed(user, "list:indicators", "_")) {
37        let projectIds;
38        if (user.isRequiredUserGroup()) {
39          projectIds = [];
40        } else {
41          projectIds = user.filterAllowedProjectIds();
42          if (projectIds.length == 0) {
43            throw new Error("fails to populate project ids");
44          }
45        }
46        return await Project.fetchProjectIndicators(projectIds);
47      } else {
48        throw new ForbiddenError(...);
49      }
50    },
51  },
52  Mutation: {
53    updateProjectStatus: async (_, args, context) => {
54      const user = User.clone(context.user);
55      if (
56        await context.oso.isAllowed(
57          User, "update:project", parseInt(args.projectId)
58        )
59      ) {
60        return Project.updateProjectStatus(args.projectId, args.status);
61      } else {
62        throw new ForbiddenError(...);
63      }
64    },
65  },
66};

Examples

The application source can be found in this GitHub repository, and it can be started as follows.

1docker-compose up
2# if first time
3docker-compose up --build

Apollo Studio can be used to query the example API. Note the server is running on port 5000, and it is expected to have one of the following values in the name request header.

  • guest-user
    • user group: guest
  • member-user
    • user group: member
    • user project group: contributor of project 1 and 3
  • admin-user
    • user group: admin
  • contributor-user
    • user group: guest
    • user project group: contributor of project 1, 3, 5, 8 and 12

The member user can query the project thanks to her user group membership. Also, as the user is a contributor of project 1 and 3, she has access to contract_sum.

The query returns an error if a project that she is not a contributor is requested. The project query is resolved because of her user group membership while contract_sum turns to null.

The contributor user can query all permitted projects without an error as shown below.

Conclusion

In this post, it is illustrated how to build authorization in a GraphQL API using a custom directive and an open source authorization library, Oso. A custom directive is effective to define permission on a schema, to pass configuration to the resolver and even to enforce policies directly. The Oso library helps build policies in a declarative way while expressing common concepts. Although it’s not covered in this post, the library supports building common authorization models such as role-based access control, multi-tenancy, hierarchies and relationships. It has a huge potential! I hope you find this post useful when building authorization in an application.