Using Capabilities to Secure our APIs

At Iora Health we are on a mission to transform healthcare, starting with primary care. Part of that transformation is powered by our home-grown collaborative care platform, which we affectionately refer to as Chirp. Chirp contains all of our patients’ medical records, so our Engineering team works hard to keep those records secure.

Today I’m going to share some of the techniques we use to ensure that our Chirp APIs only reveal appropriate information to whoever’s asking. We follow software industry best practices such as HITRUST, AWS Well Architected, and OWASP, but we have to make sure we’re implementing those principles correctly.

We think about securing our APIs and web pages as three steps that happen before allowing the business logic of the request to run.

  1. Authentication — Who are you?
  2. Authorization — Are you allowed to do this action?
  3. Scoping — Are you allowed to do it to that patient or that note?

The rest of this article will talk through each of the steps, showing how they are each involved in every request.

Every Chirp Request Authenticates, Authorizes, and Scopes access

Every Chirp Request Authenticates, Authorizes, and Scopes access

Authentication

In contrast to many web applications, there’s nothing about a patient’s medical chart that an anonymous user (or the Google crawler) needs to access. Chirp contains a wide set of features, from measurements and vitals to chart notes to population health dashboards. Some of these features are intended for both patients and staff members; others are used primarily by our staff members or internal operations teams.

We secure every one of our APIs using OAuth so we know who is making each request and can prevent anyone who isn’t an authorized user from getting in the front door.

Most of our services are written in Rails so there’s a before_action we put in front of each request as a gate to start the authentication process. If the request includes an access_token we confirm it’s validity with our OAuth provider and proceed. Otherwise, we redirect them to the login page or, if it’s an API request, return a 401 Unauthorized. Our OAuth response gives us some information about who is making the request so that no request can make it through until we know the identity of the requester.

Authorization

Once we know who the user is, we need to decide whether the user is allowed to perform the requested action. For example, view a patient’s note can be done by a patient or one of our practice users, while sign a patient’s note is limited to only licensed medical providers.

Our first attempt to implement this used Pundit to build policy classes that granted access to each controller action — note index, note show, note update, etc. Pretty quickly we started to get overwhelmed and realized we were being forced into too granular a level to understand or even think about the rules we were building. We’re running many services, each of which exposes a bunch of endpoints, so doing the math convinced us it wouldn’t work.

many ✕ a bunch = ouch my brain hurts

It was a problem not being able to reason about concepts like sign a note which requires access to note show, patient show, note sign and many other actions. We decided this type of concept was important, and as with many abstractions, once we identified and named it things got much easier. We decided to call these things capabilities. A capability is a set of actions that defines business functionality we want to assign a group of users — for example, view a practice schedule, view a patient, update a patient or sign a note. Each capability combines access to several API endpoints, potentially across different services; access can be granted to different groups such as patients, practice staff, licensed providers, even providers in Colorado. There were few enough capabilities that once we started talking about them we were able to reason about Chirp as a whole and gain new insights into how our system should work. We’re still using Pundit for scoping and we’ll talk about that when we get to the next section.

Let’s look at how we use capabilities to check authorization.

First we enhanced our OAuth provider to lookup and return the current user’s capabilities as part of its authentication response. We are able to make these changes because we built our own OAuth provider called Snowflake, since every person is as unique as a snowflake. Snowflake now manages the mapping between our capabilities and users. For example Dr. Dog has the sign a note capability while Alex does not. Right now it does this through a few database tables but eventually we hope to integrate this with our IT and HR systems and the existing groups maintained there. Now the authentication response includes the user’s capabilities and looks something like this:

{
  user: {
    uid: "1234567890",
    first_name: "doctor",
    last_name: "dog",
    email: "doctor_dog@iorahealth.com",
    capabilities: ["sign-a-note", "view-patient"]
  }
}

Once we know the current user’s capabilities we enhanced our before_action by adding an authorize step. This step can now check whether the current action is included in one of the user’s capabilities. We’re storing the list of capabilities granted to each service in code because that list typically only changes when we make a code change and we wanted these rules to be governed by version control and code review. The configuration looks like this:

config.capability_rules = {
  view_a_patient: [
    { controller: "api/v1/patient", action: "show" },
    ...
  ]
  sign_a_note: [
    { controller: "api/v1/patient", action: "show" },
    { controller: "api/v1/notes", action: "show" },
    { controller: "api/v1/notes", action: "sign" },
    ...
  ],
...
}

Distributing this responsibility to each of our services is working out well for us so far.

Scoping

The last step in securing our APIs is to make sure that users can only interact with their patients. We went back to using Pundit for this since Pundit scopes do exactly that: Instead of querying against everything in our database, we are limited to the subset the current user is allowed to see. It essentially puts a where onto your query in addition to whatever other ActiveRecord goodness you’re doing. For example, in our NotesController show action we want to ensure that the note with the requested id belongs to a patient you’re allowed to see — so we write:

def show
  my_notes = policy_scope(Note)
  note = my_notes.find(params[:id])
  ...
end

This has to be written in each action, and because it’s so important not to forget any, Pundit provides a nice after_action called verify_policy_scoped, which verifies that at least one policy_scope was called. We’ve added that to our base ApplicationController like so:

class ApplicationController < ActionController::Base
  after_action :verify_policy_scoped
end

Most of the Pundit documentation suggests this is useful for read actions like index and show but we’ve found it useful to use everywhere including creates where there is no id parameter. In these cases we verify the foreign keys on the request to make sure the note we create belongs to one of the user’s patients. We write our create action something like:

def create
  my_patients = policy_scope(Patient)
  patient = my_patients.find(params[:patient_id])
  ...
  Note.create(patient: patient, ...)
end

As we started writing scopes we realized almost all of them scoped in the same way:

  1. Practice staff members are scoped within their practice
  2. Patients are scoped to themselves only
  3. Applications and jobs can operate across all our data

Pundit made it pretty simple to DRY this up by building a base policy that defined a scope encoding those three rules.

class IoraPolicy
  class Scope
    attr_reader :user, :scope
    def initialize(user, scope)
      @user = user
      @scope = scope
    end
    def resolve
      if policy_user.practice_user?
        scope.by_practice_id(policy_user.practice_id)
      elsif policy_user.patient?
        scope.by_patient_id(policy_user.patient_id)
      elsif policy_user.iora_application_user?
        scope
      else
        scope.none
      end
    end
  end
end

When applying a scope, Pundit uses a naming convention to find a policy. For example, when asking for policy_scope(Note) Pundit will look for the policy NotePolicy. This means we’ve needed to create a bunch of empty policies that inherit from IoraPolicy.

class NotePolicy < IoraPolicy
end

It is a bit tedious to create these nearly empty files but the advantage is that in the rare cases where we need to scope differently than the standard rules we have a convenient place to define our custom rules. For instance, if we want to allow admin practice users to view all practices but leave the default rules in effect for everyone else, we could define a PracticePolicy like this:

class PracticePolicy < IoraPolicy
  class Scope < Scope
    def resolve
      if policy_user.practice_user? && admin?
        scope # unscoped to everything
      else
        super
      end
    end
  end
end

You may have noticed our IoraPolicy calling scope.by_practice_id and scope.by_patient_id. scope is usually the ActiveRecord model we’re limiting (i.e., Note) which means we need to add self.by_practice_id and self.by_patient_id methods to any model we’re scoping (which turns out to be most our models). Most of our models have practice_id or patient_id as foreign keys so these are easy to write as single line where method. When that doesn’t work we can write more complex joins or logic to achieve the scoping since these are just methods.

class Note < ActiveRecord::Base
  def self.by_patient_id(practice_id)
    where(patient_id: patient_id)
  end
  def self.by_practice_id(practice_id)
    joins(:patient).
      merge(Patient.where(practice_id: practice_id))
  end
end

Summary

Every request in Chirp authenticates with our OAuth provider, authorizes with our capabilities, and scopes data access using Pundit scopes, thus ensuring that only the right people can do the right things to the right data.

Portions of this architecture have been in place for a long time but until recently we also had ad-hoc implementations scattered throughout. All of the engineers on the Chirp team have contributed to transitioning to this more-consistent capabilities system over the course of nine months.

We’re really happy to have this uniform solution in place. Now that we’ve named capabilities we’ve begun talking about securing different aspects of Chirp in ways we never did before. We’re building out a new capability to allow our compliance team to correct signed notes when incorrect information was added by mistake. We’re also trying to figure out how to think about the prescribe a drug capability, which is trickier than it seems, because a provider may be licensed in one state but not another, so the capability changes depending on which patient the provider is seeing.

We’re excited to see where this leads.