Yan Cui
I help clients go faster for less using serverless technologies.
This article is brought to you by
MongoDB 8.0 is here to change the game. Faster reads and inserts, and brand-new vector search to support modern AI-powered apps.
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.
Related Posts
Whenever you’re ready, here are 3 ways I can help you:
- Production-Ready Serverless: Join 20+ AWS Heroes & Community Builders and 1000+ other students in levelling up your serverless game. This is your one-stop shop for quickly levelling up your serverless skills.
- I help clients launch product ideas, improve their development processes and upskill their teams. If you’d like to work together, then let’s get in touch.
- Join my community on Discord, ask questions, and join the discussion on all things AWS and Serverless.