SpiceDB is a ReBAC authorization system. It’s an open source reimplementation of Google’s Zanzibar system and attempts to provide paid enterprise features to make money. This post focuses solely on the OSS featureset.

A worm and human review paperwork in an open air sand filled office

A worm and a human review paperwork in an open air sand filled office. This was intended to be the sandworm from Dune, but the AI refused to draw that. [sandworm]

Being a ReBAC system, SpiceDB focuses on relationships. These take the form of of:

  • If an object has a relationship with another object.
  • If an object has permission to perform an action against another object.

By combining these two concepts, and adding a bit of walking along the resulting graph, it’s possible to build out very complex authorization systems.

The two primary items stored in SpiceDB are schema’s and relationships. Schemas describe the graph to be queried. Object types, relationship types, and permissions are defined in the schema. Relationships are where objects, and the relationships between them, are entered.

Being based on a Google product, SpiceDB is designed to scale up quite a bit. Scaling up in turn involves both vertical and horizontal directions. Vertically, different types of caches can be implemented on a single instance. Horizontally, multiple instances can be created and queries dispatched across them to spread load. In order to avoid consistency issues that arise in distributed systems, relationship updates return “ZedTokens” that represent a point in time the last update was accepted. Each application querying SpiceDB can choose different levels of caching to use, ranging from “give me a fast response - even if it is stale” to “At least as new as the point in time of this ZedToken” where the ZedToken may represent the last update the application made, to “give me the most up to date (fully consistent) results - even if it’s slow”. ZedTokens are designed to be the preferred choice that each client application provides when doing a query. “maybe stale” can introduce issues when an object that was supposed to have permission removed, fails to have that removal be returned. Fully consistent in turn may mean that queries in a busy system will become too slow to answer in a reasonable timeframe.

SpiceDB Review

To test out SpiceDB I tried to implement the same git forge I implemented in my post on biscuits. The BLUF was that I like SpiceDB but that it lacks a few features I was hoping for:

  • The Good: It’s very easy to write and test schemas. The built in tooling and playground are very easy to work with.
  • The Bad: The documentation feels a little lacking, especially around using some of the testing tools, and common design patterns.
  • The Sad: No real support for attenuation nor sessions.

Expanding on these parts more and starting with the good bits: SpiceDB has everything needed to make it easy to go from an empty screen to a full schema. Setting up the tools is fairly easy. The playground lets one experiment with writing schemas and tests without even needing to setup a local dev environment. Most of the work I did on my example use case was in fact done in the playground.

On the downside the documentation feels lacking once you get beyond the basic concepts. Explanations for the playground tools (in particular “expected relationships”) felt lacking and I had to reverse engineer examples to figure out what to do. Example use cases also felt lacking when it came to design patterns one could base their own work on.

Where I found SpiceDB completely lacking was support for attenuation and sessions. SpiceDB is very oriented around checks for a subject, and the subject is also implied to have their full set of relationships and permissions. There is no easy way to state something along the lines of “Subject erahn@ has read and write access to resource Alpha. In session Foo, subject erahn@ only has read access to resource Alpha.” Given the historical tie to things like Zanizibar, and how that’s accomplished on the Google client side via things like OAuth2, it makes sense, but it’s unfortunate. I discuss this more near the end of the Example Use Case.

Example Use Case

The code, of which there is not so much this time, is available at https://github.com/er4hn/spicedb-play. This blog post was written at commit a6f0d71711807c8c5a7490d55dc34a1951e38153.

As a recap of what I built in the biscuit’s blog post I made the authorization system for a Git forge. It had users, repos, permissions associated with what a user could do with each repo, user groups (that could be nested), and repo groups (which could not be nested).

Getting Started (woes)

As is tradition I started off by using AI (This time Anthropic Claude, whatever was the free tier as of 2024-12) to generate the nix flake to hold my code. Getting started proved to be a bit difficult since it turns out there are multiple binaries named “zed” that nix knows of. After a bit of arguing with the AI and some searches myself I got the right “zed” client for interacting with spicedb.

The next minor hurdle to setting up a test schema was figuring out how to start a test instance of the spicedb binary and connect a client to it. This was one of the places where the docs could have been a lot more clear. The command line flags were kind of explained, but still required me to read the server logs and adjust the client commands to successfully connect. The instructions to do this are in “Getting Started” in README.md. Once this was up and running everything worked fine and I had a (non-persistent) instance of SpiceDB running for testing.

Once I had something running in a dev environment I played around with ZedTokens to get a feel for that concept. Afterwards I found the playground so easy to develop with that I did the rest of my work out of that.

Git Forge

In order to break the features into different deliverables and look for the simplest things to deliver I wanted to start with users, repos, and permissions. From there I could learn about testing and see how to improve on it. The playground made this easy to write out by offering syntax highlighting and some basic static analysis in the schema. After a little playing around I ended up with this schema, which is saved in snapshot-1.yaml. Any of the saved snapshots can be loaded into the playground to use directly.

definition user {}

definition repo {
	relation owner: user
	relation writer: user
	relation reader: user

	// Permissions are defined in terms of a base membership group
	// and higher level permissions.
	permission change_membership = owner
	permission write = writer + change_membership
	permission read = reader + write + change_membership
}

Schema from snapshot-1.yaml

As I typed references to resources that didn’t exist (ex: “usr”) would be flagged. Adding testing was likewise simple to do as well. Using either a spreadsheet-esque entry with helpful suggestions, or a manual text entry mode, I could come up with an example using the schema. Assertions let me specify tuples of (resource Alpha, permission on resource Alpha, check if resource Bravo has that permission) and group them into if they should be true (Bravo has permission to perform the action on Alpha) or false (Bravo does not have the permission). This ended up looking like the following:

repo:charlie#owner@user:olivia
repo:charlie#reader@user:noah

Test relationships from snapshot-1.yaml

assertTrue: [
    repo:charlie#read@user:olivia,
    repo:charlie#write@user:olivia,
    repo:charlie#owner@user:olivia,
    repo:charlie#change_membership@user:olivia,
    repo:charlie#read@user:noah,
]
assertFalse: [
    repo:charlie#write@user:noah,
]

Assertions from snapshot-1.yaml

As an aside, one thing that stood out to me when working on test examples is that SpiceDB is very oriented around representing how resources relate to one another. Since this is ReBAC and resources are nodes, where relationships are edges, SpiceDB only wants to store edges. You can’t really represent something like “There is a user named ‘Charles’ who has no access to any repos” because that looks like a lack of edges for Charles. Charles simply doesn’t exist in SpiceDBs representation of the world.

One final testing feature that I thought was very useful was “expected relationships”. It’s not the best documented, but it lets you specify a resource and either a permission or relation on the resource. It will then fill in all the resources that have that permission or relationship. This can be used to do testing, which is probably better put into assertions, but what I found it was more useful for was being able to eyeball who can perform what actions and identify if my schema seemed correct.

As an example:

repo:charlie#change_membership:
repo:charlie#read:
repo:charlie#reader:

Expected Relationships before hitting “Re-Generate”

repo:charlie#change_membership:
  - "[user:olivia] is <repo:charlie#owner>"
repo:charlie#read:
  - "[user:noah] is <repo:charlie#reader>"
  - "[user:olivia] is <repo:charlie#owner>"
repo:charlie#reader:
  - "[user:noah] is <repo:charlie#reader>"

Expected Relationships re-generating them. This is what is found in snapshot-1.yaml.

User Groups

User groups make this more interesting, because these groups are nested. This means that to check membership one now needs to walk through groups. Adding nested groups is straightforward, the documentation even has an example of doing so. In a great example of frustrating documentation a later section describes potential recursion issues when doing this, and suggests a pattern for avoiding it, while not showing a code example implementing that. Regardless, user groups end up being pretty easy to add as part of ${LOREM IPSUM snapshot 2}:

definition user {}

definition user_group {
	relation member: user | user_group#member
}

definition repo {
	relation owner: user | user_group#member
	relation writer: user | user_group#member
	relation reader: user | user_group#member

	// Permissions are defined in terms of a base membership group
	// and higher level permissions.
	permission change_membership = owner
	permission write = writer + change_membership
	permission read = reader + write + change_membership
}

Schema with user groups added. Full playground is found in snapshot-2.yaml.

Something that I found interesting was understanding the “subject relation” parameter. In the below test relationship for snapshot-2 this is the portion after the hashtag ("#"):

user_group:FooOps#member@user:liam
user_group:FooOps#member@user_group:BarOps#member

Test relationship for snapshot-2 that shows the subject relation #member.

The first line is straightforward: The user “liam” is a member of the user_group “FooOps”. The second line is slightly different: All of the members of the user_group “BarOps” are members of the user_group “FooOps”. The important part is “all of the members”, not the user_group itself, but the members of the group are members of FooOps. This means that relationships can be assigned from one group to another, but permissions checks can be applied to the members of the groups. This is what makes intuitive sense and is an important distinction to make.

Repo Groups (and schema issues!)

Next I needed to add repo groups. This is where things fell apart. Repo groups are not nested and they capture read and write permissions that apply to all the repos and all the users in that repo.

When trying to just copy over the biscuit example I hit a few issues. The first was that I defined the repo group relationships to be writer and reader which tied to the write and read permissions. Unlike biscuits there’s no concept of just being a member in a group, it needs to have a tie to what permissions this entails. This is a more precise definition, which is a good thing. My final schema ended up being the following:

definition user {}

definition user_group {
	relation member: user | user_group#member
}

definition repo_group {
	relation writer: user | user_group#member
	relation reader: user | user_group#member

	permission write = writer
	permission read = reader + writer
}

definition repo {
	relation repo_group: repo_group
	relation owner: user | user_group#member
	relation writer: user | user_group#member
	relation reader: user | user_group#member 

	// Permissions are defined in terms of a base membership group
	// and higher level permissions.
	permission change_membership = owner
	permission write = writer + change_membership + repo_group->write
	permission read = reader + write + change_membership + repo_group->read
}

Schema with repo_group’s added in. The full example is in snapshot-3.yaml.

The problems arose during testing when I realized that tony, in BazOps unexpectedly had write permission. I had intended that FooOps would inherit BazOps permissions, and that is how it worked in the biscuits example, but it didn’t work here. The test details are below:

repo:charlie#owner@user:olivia
repo:charlie#reader@user:noah
user_group:FooOps#member@user:emma
user_group:FooOps#member@user:liam
user_group:FooOps#member@user_group:BarOps#member
user_group:BarOps#member@user_group:BazOps#member
user_group:BazOps#member@user:tony
repo_group:Foo#writer@user_group:FooOps#member
repo:charlie#repo_group@repo_group:Foo
repo:bravo#repo_group@repo_group:Foo

Test relationships for ${lorem ipsum snapshot 3}

assertTrue: [
    repo:charlie#read@user:olivia,
    repo:charlie#write@user:olivia,
    repo:charlie#owner@user:olivia,
    repo:charlie#change_membership@user:olivia,
    repo:charlie#read@user:noah,
    repo:bravo#read@user:emma,
    repo:bravo#write@user:emma,
    repo:charlie#write@user:tony,
]
assertFalse: [
    repo:charlie#change_membership@user:tony,
    repo:charlie#write@user:noah,
    repo:bravo#change_membership@user:emma,
]

Assertions for snapshot-3.yaml.

repo:charlie#read:
  - "[user:emma] is <user_group:FooOps#member>"
  - "[user:liam] is <user_group:FooOps#member>"
  - "[user:noah] is <repo:charlie#reader>"
  - "[user:olivia] is <repo:charlie#owner>"
  - "[user:tony] is <user_group:BazOps#member>"
  - "[user_group:BarOps#member] is <user_group:FooOps#member>"
  - "[user_group:BazOps#member] is <user_group:BarOps#member>"
  - "[user_group:FooOps#member] is <repo_group:Foo#writer>"

Expected Relationships for snapshot-3.yaml.

My big takeaway was that it is great how easy SpiceDB makes catching errors like this. I ended up running out of time and never fixed the issues with the schema definition, which I’m also fine with. This project fulfilled my goal of seeing what features SpiceDB offers and understanding the strengths and limitations there.

Attenuation

Attenuation, limiting what permissions are possible for a session, was not something that I could figure out a good way to do in SpiceDB. With biscuits I could perform all sorts of operations like:

  • Time based expiration
  • Action Based Restrictions (can only read repos)
  • Specific Restrictions (can only write to a specific repo)

The documentation suggested that caveats were the way to do this and even showed some examples at https://authzed.com/blog/top-three-caveat-use-cases and https://github.com/authzed/examples/blob/main/schemas/caveats/schema-and-data.yaml which captures things like time based restrictions. I wasn’t a big fan of these. For each attribute you want to have be a caveat you need to add explicit support for it in advance. While you still need some concept of how to communicate what to restrict with biscuits it feels much easier to write out with biscuits. You can also easily tie restrictions to the biscuit token, unlike with SpiceDB which seems to require it to be at the relationship level.

I’m sure it is possible (in the “it’s not impossible” sense) to do attenuation, maybe with a session relationship that is a subset of the full relationship, and use an and operator to ensure that both the main relationship and subset do overlap… but it seems lame. You need to write a lot of code over each permission to make sure all the possible cases and caveats are covered. Gradual coherence also means you’ll inevitably need to be careful about the use of ZedTokens so that you don’t hit coherence issues with adding the new session relationship and using it right away. These drawbacks do not seem worthwhile.

A more sensible solution may be to use a biscuit that can limit actions, have SpiceDB be used for the long lived relationship check, and then have the client app combine the biscuit and SpiceDB check to decide if an action should be authorized.

It was also pointed out to me that OAuth2 can have fine grained authorization because you can define arbitrary scopes and come up with a language to define what each scope means. For example don’t just have documents.write, define a custom scope documents.write:/folderA/folderB/docC to only allow writing docC at that path. I’m not a big fan of this solution since it breaks from conventional usage and feels like a potential source of footguns and complicated customizations.

Conclusion

I like SpiceDB, a lot. The killer feature for me was the playground tooling. It makes developing, and testing, schemas very easy. There are some rough edges to SpiceDB but the ease of use and versatility in writing out examples outweighs those issues.

The biggest bummer for me is a lack of attenuation. It’s understandable that this is an issue since Zanzibar doesn’t have that concept as well.

If you’re considering SpiceDB, just give it a try. It’s super easy to setup and get started.

References

  • [sandworm] - My prompt was “The sandworm from Dune, dressed as a security guard and reviewing documents from someone seeking to enter a building.” Taken from Bing Image Creator on 2025-01-28. I suspect it may refuse to draw copyrighted figures? All of the sample generated images also had a face on the sandworm, which was an interesting flaw.

Acknowledgements

  • Special thanks to Ilia Lebedev for pointing out all the exciting and cursed things you can do with custom OAuth2 scopes.