An “easy button” for 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.
It also makes it convenient to execute check and search queries from the Evaluator:
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!
Related Content
Gateway-enforced API Authorization
Learn how platform engineering teams can enforce service, method, and endpoint-level API access in a scalable way, without changing application code.
Jul 20th, 2024
API Authorization using Aserto and Zuplo
Platform Engineering teams that want to authorize access to their APIs now have a turnkey solution from Aserto and Zuplo.
Sep 25th, 2024