Protecting a List Endpoint
The Problem Statement
In a normal monolithic application, the authorization for a list endpoint is typically rolled into the database calls that the application makes to fetch the data in question. Whether a user can access a resource is phrased in terms of a join on the tables that represent the authorization logic.
In a system protected by SpiceDB, the authorization and data concerns are separated. This means that enforcing authorization on a list endpoint requires making a call to both the database and SpiceDB and combining the queries into a response.
Broadly speaking, there are three ways to do this:
- filtering with LookupResources (opens in a new tab)
- checking with CheckBulkPermissions (opens in a new tab)
- using Authzed Materialize (opens in a new tab) to create a denormalized local view of a user's permissions.
Which one you choose will depend on whether the set of accessible resources is much larger than a page of results returned by your API, in addition to the overall size of your data.
Filtering with LookupResources
If the number of resources that a user has access to is sufficiently small, you can use LookupResources
to get the full
list of resources for which a user has a particular permission, and then use that as a filtering clause in your database
query.
In python pseudocode:
accessible_resource_ids = call_lookup_resources(user_id=some_user, permission=permission, resource_type=resource_type)
resources_for_response = fetch_resources_from_db(accessible_ids=accessible_resource_ids)
where fetch_resources_from_db
would produce SQL that includes a clause equivalent to:
WHERE id = ANY(ARRAY[<resource_ids>])
This is the simplest approach from a bookkeeping perspective and is a good place to start.
It should be noted that LookupResources
can get heavy quickly - with a sufficiently large relation dataset,
a sufficiently complex schema, or a sufficiently large set of accessible results, you'll need to take a different approach.
Checking with CheckBulkPermissions
If the number of resources that a user has access to is sufficiently large and LookupResources
can't satisfy the use case
anymore, another approach is to fetch a page of results and then call CheckBulkPermissions
to determine which of the
resources are accessible to the user.
In python pseudocode:
PAGE_SIZE = 20
results = []
while (len(results) < PAGE_SIZE):
candidate_results = fetch_resources_from_db(params=params, db_cursor=cursor)
accessible_ids = call_check_bulk_permissions(
ids=[result.id for result in candidate_results],
permission=permission,
user_id=user_id
)
update_cursor(cursor)
results += [result for result in candidate_results if result.id in accessible_ids]
# Return a page of results with size PAGE_SIZE
return results[:PAGE_SIZE]
Note that because we don't know how many results are going to be accessible beforehand, we need to iterate until we have a full page of accessible results. The performance of this approach will depend in part on choosing the size of the page of candidate results, and that in turn will depend on the shape of your particular data.
Note too that this approach works better with an API backed by cursor-based pagination than limit-offset pagination, since the database doesn't know the offset associated with the last accessible result.
This approach is handy for search interfaces since the filters on a search can reduce the set of candidate results to the point where checking them via bulk check is relatively easy.
It's recommended to run the various CheckBulkPermissions API calls at the same revision to get a consistent view of the permissions. (e.g. take the ZedToken from the first call, and use it in all subsequent calls)
Using Materialize
Materialize is currently in Early Access. Additional documentation and product information will be coming soon. In the meantime, if you're interested, schedule a call! (opens in a new tab)
Authzed Materialize (opens in a new tab) is Authzed's version of the Leopard cache (opens in a new tab) referenced in the Zanzibar paper, which provides a denormalized view of user permissions to a consuming service. This allows a service (e.g. a search service) to store a local copy of which users have permission to which resources, which then means that ACL-aware filtering again becomes a simple JOIN against the local copy.
In broad terms:
- Authzed Materialize watches changes in your SpiceDB cluster and emits events as users gain and lose particular permissions to particular resources.
- Your service listens to that change stream and stores a local copy of the materialized view of user permissions
- Your API endpoints join with the tables where the local copy is stored to determine which resources are accessible
This approach provides the greatest scalability of the three options, so if your data and/or traffic are prohibitive under the above two approaches, we recommend giving Authzed Materialize a try.
Other Considerations
Decide on a semantic for an empty list
There's a difference between "data exists but the user isn't allowed to see it" and "there is no data to be seen." In a coarse-grained authorization system, it may make sense to return a 403 as a response from a list endpoint to indicate that the user cannot access anything. In a fine-grained authorization system backed by SpiceDB, it often makes sense to treat "there is no data" and "there is nothing you're authorized to see" as the same, by returning a successful response with an empty result set.