How to model hierarchical access with AppSync

I have been working with a US client to build a first-of-its-kind app for managing medical consents. It falls under HIPAA compliance and it’s paramount that we do not allow unauthorized access to user data.

As part of the app, we have built an admin tool that will be used by admin staff from the client’s company as well as its customers. Essentially, we have three roles in the admin tool:

  • Admin: these are admin staffs from the client’s company. This group of user have access to all the functionalities, including adding new tenants.
  • Tenant: these are admin staffs from the client’s customers which are typically Health Information Exchanges (HIEs). An HIE would collaborate with multiple healthcare providers (e.g. hospitals, pharmacies) to facilitate data exchange between them. An HIE’s admin staffs should therefore have access to information related to their own organization as well as any healthcare providers that it collaborates with.
  • Service Area: these are admin staffs from the healthcare providers that collaborate with the HIEs above. They only have access to information related to their own organization as well as its patients.

This is a drastic oversimplification of the entities that are involved, but it serves to illustrate the different tiers of access that we need to model:

  • Admin users can access everything.
  • Tenant users can access its own data plus data that belongs to its subsidiary service areas.
  • Service Area users can access only its own data.

The good news is that AppSync makes it easy to implement group-based authentication with Cognito. Which is one of the reasons why we decided to go with AppSync over API Gateway for this project.

However, modelling these overlapping actions in the GraphQL schema was still a big challenge in and of itself.

The problem

The root of the problem is that there are many overlapping actions that each group can perform.

For example, Admin users can add new service areas and associate them with any Tenant. A Tenant user can also add new service areas, but cannot associate the service area with anyone but itself.

As such, we need two separate mutations with different inputs and lock down their access to different Cognito groups. As you can see from below, as a Tenant user, I only have access to the addServiceAreaAsTenant mutation where I cannot override the tenantId the service area is associated with.

type Mutation {
  addServiceAreaAsAdmin(tenantId: ID!, name: String!): ServiceArea!
  @aws_auth(cognito_groups: ["Admin"])

  addServiceAreaAsTenant(name: String!): ServiceArea!
  @aws_auth(cognito_groups: ["Tenant"])
}

As we add more and more queries and mutations this quickly becomes messy.

There are a lot of duplications for the @aws_auth(cognito_groups: …) clauses, and it’s not easy to see at a glance what each group of users can do. Also, we have to get increasingly creative about how we name these queries and mutations in order to distinguish them from one another.

Copy-and-paste errors can easily creep in (it almost did), and it’s also confusing for the frontend developers to keep track of which queries and mutations they should use for which users.

schema {
  query: Query
  mutation: Mutation
}

type Query {
  getTenantById(id: ID!): Tenant!
  @aws_auth(cognito_groups: ["Admin"])

  getServiceAreaById(id: ID!): ServiceArea!
  @aws_auth(cognito_groups: ["Admin"])

  getMyProfileAsTenant: Tenant!
  @aws_auth(cognito_groups: ["Tenant"])

  getMyServiceArea(id: ID!): ServiceArea!
  @aws_auth(cognito_groups: ["Tenant"])

  getMyProfileAsServiceArea: ServiceArea!
  @aws_auth(cognito_groups: ["ServiceArea"])
}

type Mutation {
  addTenant(name: String!): Tenant!
  @aws_auth(cognito_groups: ["Admin"])

  addServiceAreaAsAdmin(tenantId: ID!, name: String!): ServiceArea!
  @aws_auth(cognito_groups: ["Admin"])

  updateMyProfileAsTenant(name: String!): Tenant!
  @aws_auth(cognito_groups: ["Tenant"])

  addServiceAreaAsTenant(name: String!): ServiceArea!
  @aws_auth(cognito_groups: ["Tenant"])

  updateMyProfileAsServiceArea(name: String!): ServiceArea!
  @aws_auth(cognito_groups: ["ServiceArea"])
}

type Tenant {
  id: ID!
  name: String!
}

type ServiceArea {
  id: ID!
  name: String!
  tenantId: ID!
}

Using nested Query/Mutation types

To make it easier to scale the complexity of this project, we decided to encapsulate each group into its own Query and Mutation types.

As you can see below, each group of users gets its own Query and Mutation types. Access is controlled in a single place, and it’s easy to see everything a group of users can do at a glance. Also, we don’t need to rely on naming conventions on overlapping actions anymore.

schema { 
  query: Query
  mutation: Mutation
}

type Query {
  asAdmin: AdminQuery
  @aws_auth(cognito_groups: ["Admin"])
  
  asTenant: TenantQuery
  @aws_auth(cognito_groups: ["Tenant"])

  asServiceArea: ServiceAreaQuery
  @aws_auth(cognito_groups: ["ServiceArea"])
}

type Mutation {
  asAdmin: AdminMutation
  @aws_auth(cognito_groups: ["Admin"])

  asTenant: TenantMutation
  @aws_auth(cognito_groups: ["Tenant"])

  asServiceArea: ServiceAreaMutation
  @aws_auth(cognito_groups: ["ServiceArea"])
}

type AdminQuery {
  getTenant(id: ID!): Tenant!
  getServiceArea(id: ID!): ServiceArea!
}

type TenantQuery {
  getMyProfile: Tenant!
  getMyServiceArea(id: ID!): ServiceArea!
}

type ServiceAreaQuery {
  getMyProfile: ServiceArea!
}

type AdminMutation {
  addTenant(name: String!): Tenant!
  addServiceArea(tenantId: ID!, name: String!): ServiceArea!
}

type TenantMutation {
  updateMyProfile(name: String!): Tenant!
  addServiceArea(name: String!): ServiceArea!
}

type ServiceAreaMutation {
  updateMyProfile(name: String!): ServiceArea!
}

type Tenant {
  id: ID!
  name: String!
}

type ServiceArea {
  id: ID!
  name: String!
  tenantId: ID!
}

This makes the GraphQL schema much easier to maintain as the project continues to grow, without breaking the existing security model.

As an Admin user, I’m able to perform the queries and mutations specified in the AdminQuery and AdminMutation types.

As a Tenant user, the tenantId of my organization is captured as a custom attribute on my Cognito user.

The queries and mutations for a Tenant user never accept tenantId as an argument, and it’s always taken from $context.identity. This ensures tenants can only access its own data, without us having to litter our code with custom validation logic.

And since I don’t belong to the Admin group, I’m not authorized to access its queries and mutations.

But I’m able to act on my own data, as well as access data for service areas that are associated with my organization.

And the same goes to Service Area users, whose access will be restricted to its own data.

In addition to this, HIPAA compliance also requires us to have an audit trail of the data a user has accessed. So we also had to implement full request-response logging, which sadly does not come out-of-the-box with AppSync. We’ll dive into this particular topic in a later post.

In the meantime, if you want to see how this hierarchical model works for yourself, then check out this repo and try it out.

If you want to learn GraphQL and AppSync with a hands-on tutorial then please check out my new AppSync course where you will build a Twitter clone using a combination of AppSync, Lambda, DynamoDB, Cognito and Vue.js. You will learn about different GraphQL modelling techniques, how to debug resolvers, CI/CD, monitoring and alerting and much more.

 

Learn to build Production-Ready Serverless applications

Want to learn how to build Serverless applications and follow best practices? Subscribe to my newsletter and join over 3,000 AWS & Serverless enthusiasts who have signed up already.
As a BONUS, you will receive early access and discount for my new AppSync course.