An “easy button” for API Authorization

Jul 8th, 2024

Omri Gazitt avatar

Omri Gazitt

API Authorization  |  

ReBAC  |  

Topaz  |  

Authorization  |  

Security

api authorization

A new mandate comes down from on high: we need to apply the principle of least privilege to our API estate. Users should only be able to invoke APIs if they have a valid business reason for it. Hey Platform Services team, go make it happen!

As a platform services engineer, you have a nasty problem on your hands. On the one hand, you may have hundreds (or thousands) of APIs to worry about - so the scale of the problem is pretty massive. On the other hand, you may not even have the source code for these apps, and even when you do, the cost (and risk) of changing the application code may be too great to justify.

Not to worry, API gateways provide a natural control point for applying fine-grained authorization in a uniform way across applications and APIs. And you know that the Open Policy Agent is the most popular technology for applying authorization policy at the gateway level.

API authorization challenges

But with OPA, a number of challenges are left as an exercise to the reader.

Dynamic entitlement

How do you make it easy to entitle a user to invoke an API (or a set of APIs?)

With an OPA policy, you’ll either have to manage the entitlements of users to APIs outside of the policy, or bake a lookup table in your policy that determines which users have access to which APIs.  The former is an “exercise left to the reader”, while the latter is a cumbersome, tightly coupled architecture that requires building a new policy bundle every time you want to add (or remove) an entitlement.

Governance

In most organizations, you need to be able to answer questions like “what APIs does this user have access to?” and “what users have access to this API?”

With an OPA policy, these are hard questions to answer even if you’re baking the entitlements inside your policy, and impossible if you’re managing the entitlements of users to APIs outside of the policy.

Scaling across services

When you have dozens or hundreds of services, how do you scale the process of building an OPA policy for each of these services? Are all of these policies owned by the Platform Services team, or do you have each API team own their policy? The latter is obviously more scalable, but how do you teach the API teams to write policies correctly and consistently?

ReBAC: a new game in town

OPA is a good starting point for API authorization, but has some drawbacks. Specifically, OPA is good at policies, but isn’t great at handling state.

What we need is a good state management solution for API entitlements, and fortunately, there is a good answer: the relationship-based access control (ReBAC) model popularized by Google’s Zanzibar paper.

ReBAC provides a natural solution for problems like this. With ReBAC, we can re-cast our authorization problem as a set of relationships between subjects (users or groups) and APIs (services or specific endpoints), which we can manage outside the authorization policy, and add or remove with a simple API call.

Topaz: a scalable API authorization service

Enter Topaz: an OPA-compatible authorization engine that natively supports the ReBAC model, providing the best of both worlds. Topaz provides a scalable alternative to writing custom policies for every API, and makes it trivial to add or remove entitlements to APIs. Let’s see it in action!

Defining the model

Every ReBAC-centric policy in Topaz starts with a domain model. The model contains the “nouns” (service, endpoint), the verbs (can_invoke), and the relationships between subjects (user/groups) and endpoints (invoker) or between subjects and services (reader, writer, creator, deleter).

Here’s a Topaz manifest that defines this model.

types:
  # service represents a set of endpoints
  service:
    relations:
      owner: user
      deleter: user | group#member
      creator: user | group#member
      writer: user | group#member
      reader: user | group#member

    permissions:
      can_get: reader | can_put
      can_put: writer | can_post
      can_patch: writer | can_post
      can_post: creator | can_delete
      can_delete: deleter | owner

  # endpoint represents a specific API endpoint
  endpoint:
    relations:
      # each endpoint picks the reader/writer/creator/deleter relation to the service
      # based on the method (GET -> reader, PUT/PATCH -> writer, etc)
      service-reader: service
      service-writer: service
      service-creator: service
      service-deleter: service
      # invoker allows a user or group to get access to invoke this specific endpoint
      invoker: user | group#member
    permissions:
      can_invoke: invoker | service-reader->can_get | service-writer->can_put |
        service-creator->can_post | service-deleter->can_delete

Each endpoint has a relationship to its enclosing service, depending on the HTTP method of that endpoint. For example, the endpoint GET /api/todos has a service-reader relationship to the Todo service, and the endpoint POST /api/todos has a service-creator relationship to the Todo service. These heuristics can of course be customized to your API structure.

So how does a user gain access to an API? A user is granted the can_invoke permission on an endpoint in one of two ways:

  • Directly assigning that user as an invoker of that endpoint.
  • Assigning the user as a reader, writer, creator, or deleter of that service, where a reader can invoke all GET endpoints; a writer has the reader permissions, plus the ability to invoke all PUT/PATCH endpoints; a creator has all writer permissions, plus the ability to invoke all POST endpoints; and a deleter can invoke all endpoints on the service.

Importing a service using its OpenAPI definition

In order to scalably manage API entitlements, it would be great to be able to automatically import a machine-readable description of the API, create the corresponding service and endpoint objects, and the relationships between them.

OpenAPI is a popular way to describe REST APIs, and this example will utilize OpenAPI descriptions for three popular services - a Todo API, the Rick and Morty API, and the Petstore API.

Topaz works hand-in-hand with the ds-load toolchain, which makes it easy to import data from various sources (e.g. users and groups from identity providers), and transform them into Topaz objects and relations. ds-load has an openapi plug-in that makes it trivial to import services and endpoints from an openapi.json file.

Let’s illustrate this with a real example.

Clone the example repository:

git clone https://github.com/aserto-demo/api-auth
cd api-auth

Install the topaz and ds-load CLIs:

brew tap aserto-dev/tap
brew install topaz
brew install ds-load

Create a new Topaz configuration which uses the api-auth template:

topaz templates install api-auth https://raw.githubusercontent.com/aserto-demo/api-auth/main/templates.json

This will create a Topaz configuration called api-auth with the model we went over, and also import a set of users and groups (themed after the Rick and Morty cartoon).

Next, import the openapi specs for three services - Todo, Rick and Morty, and Petstore:

ds-load openapi -d ./openapi

This will create 4 groups per service:

  • <service-name>-readers: can invoke GET endpoints
  • <service-name>-writers: can invoke GET, PUT, PATCH endpoints
  • <service-name>-creators: can invoke GET, PUT, PATCH, POST endpoints
  • <service-name>-deleters: can invoke GET, PUT, PATCH, POST, DELETE endpoints

It will also create 4 global groups, which have these entitlements across all services:

  • global-readers
  • global-writers
  • global-creators
  • global-deleters

Entitling a user to an endpoint

Now that we have everything set up, let’s entitle our users on some endpoints.

Currently, no user has permissions to invoke any API. Let’s test that by checking whether Morty can invoke the Todo_List_API:GET:/v1/todos endpoint.

topaz ds check '{
  "object_type": "endpoint",
  "object_id": "Todo_List_API:GET:/v1/todos",
  "relation": "can_invoke",
  "subject_type": "user",
  "subject_id": "morty@the-citadel.com"
}'

This should return false.

To make Morty a global reader (meaning, he can invoke any GET endpoint), all we need to do is make Morty a member of the global-readers group. We're going to do this through the topaz CLI, but we can just as easily do this via a REST, gRPC, or graphQL API call, or via the graphical UI that comes with Topaz (more on this later).

topaz ds set relation '
{
  "relation": {
    "object_type": "group",
    "object_id": "global-readers",
    "relation": "member",
    "subject_type": "user",
    "subject_id": "morty@the-citadel.com"
  }
}'

Now, invoking the check call should return true.

topaz ds check '{
  "object_type": "endpoint",
  "object_id": "Todo_List_API:GET:/v1/todos",
  "relation": "can_invoke",
  "subject_type": "user",
  "subject_id": "morty@the-citadel.com"
}'

To allow Rick to invoke (only) this endpoint, we can make him an invoker:

topaz ds set relation '
{
  "relation": {
    "object_type": "endpoint",
    "object_id": "Todo_List_API:GET:/v1/todos",
    "relation": "invoker",
    "subject_type": "user",
    "subject_id": "rick@the-citadel.com"
  }
}'

Now Rick should be able to invoke the API:

topaz ds check '{
  "object_type": "endpoint",
  "object_id": "Todo_List_API:GET:/v1/todos",
  "relation": "can_invoke",
  "subject_type": "user",
  "subject_id": "rick@the-citadel.com"
}'

This should evaluate as true.

Answering governance questions

With a ReBAC model in place, we can now ask some interesting questions.

1. Which endpoints can Morty invoke?

topaz ds search '{
  "object_type": "endpoint",
  "relation": "can_invoke",
  "subject_type": "user",
  "subject_id": "morty@the-citadel.com"
}'

This should return all the GET endpoints across all three services.

2. Which users can invoke the Todo_List_API:GET:/v1/todos endpoint?

topaz ds search '{
  "object_type": "endpoint",
  "object_id": "Todo_List_API:GET:/v1/todos",
  "relation": "can_invoke",
  "subject_type": "user"
}'

This should return two users - Rick and Morty - since both have been entitled to invoke this API:

{
  "results":  [
    {
      "object_type":  "user",
      "object_id":  "rick@the-citadel.com"
    },
    {
      "object_type":  "user",
      "object_id":  "morty@the-citadel.com"
    }
  ],
  "explanation":  null,
  "trace":  []
}

Visualizing relationships and invoking queries

To view all of this visually, bring up the Topaz console:

topaz console

This will allow you to visually navigate the relationships between users, groups, services, and endpoints.

endpoints

It also makes it convenient to execute check and search queries from the Evaluator:

endpoint search

Conclusion

It can be tricky to implement fine-grained API authorization at scale. Topaz brings together the strengths of OPA and the Zanzibar / ReBAC authorization model to make it a snap.

This post is long enough, so we didn’t cover how to actually integrate a call to Topaz from your API gateway before dispatching the call. We’ll cover that in a future post… stay tuned! In the meantime, check out the language SDKs, REST APIs, and gRPC APIs to see how easy it is to integrate Topaz directory and authorization calls.

If you have any questions or suggestions, don’t hesitate to contact us, join our community slack, or set up a call with one of our engineers to discuss how we can help you with your API Authorization project.

Happy hacking!

Omri Gazitt avatar

Omri Gazitt

CEO, Aserto