NOTE: After writing this article, the team at AuthZed reached out to me. We had a great discussion and I’ve updated the article to reflect that.
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 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. As a caveat, delegation can fulfill a lot of the needs there.
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.
The documentation doesn’t have everything that I’d like to see, but after pointing out of the shortcomings, the AuthZed team was happy to add more detailed explanations, which I greatly appreciate. Example use cases do feel lacking when it comes 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. Update: After speaking with the AuthZed team, they discussed how delegation can solve the overall problem. I discuss this more in a new section on delegation.
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 16c9c53ec7870dad0268e98d42fb102c57ca379b
.
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:
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 snapshot-3.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,
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.
Delegation
After meeting with the AuthZed team, we discussed the concerns I had with attenuation and why I was so interested in it. The main concern was how to enable users to allow a service account to perform actions on their behalf. My goals for this were:
- The service account should not be trusted on its own to do actions on someone’s behalf, the original requester must somehow provide their authorization.
- The actions the service app can do should be limited, the service app cannot completely impersonate the user.
- This should be time limited, so the service app will not have this permission indefinitely.
In other words, I want to make a statement along the lines of “user er4hn authorized service app release-signer to sign the release with id 1234, and this permission expires 48 hours after granted.” I had been reaching for an attenuation, via a ticket or session, as the means of doing this. The AuthZed team pointed out that delegation would support that just as well.
Delegation is a concept where one principal allows another to take actions on it’s behalf. In other words principal Alpha will grant principal Bravo the ability to carry out an action that principal Alpha would do. er4hn would allow the release-signer app to sign a release, and without the delegation the release-signer app could not do so. This is not attenuation as I’d envisioned it because there is no means of passing er4hn’s identity to the release-signer app to use, however it does allow limiting grants to a per-release basis. This isn’t impersonation of any kind because the release-signer app is using it’s release-signer identity, not that of er4hn.
Applying this to the problem above, delegation from a human to a service for signing releases, you end up with the following schema:
use expiration
caveat release_attenuation(release_requested int, release_permitted int) {
release_requested == release_permitted
}
definition user {
relation grant: grant with release_attenuation and expiration
permission can_sign_w_grant = grant->can_sign
permission can_delete_w_grant = grant->can_delete
}
definition role {
relation member: user
permission can_sign = member + member->can_sign_w_grant
permission can_delete = member + member->can_delete_w_grant
}
definition grant {
relation sign_perm: serviceapp
relation delete_perm: serviceapp
permission can_sign = sign_perm
permission can_delete = delete_perm
}
definition serviceapp {}
definition release {
relation signer: role
relation deleter: role
permission can_sign = signer->can_sign
permission can_delete = deleter->can_delete
permission can_complex_sign = can_sign & can_delete
}
Schema showing how to do delegation. This is available at snapshot-4.yaml.
The above schema has a few different elements to understand:
- serviceapp: The humble serviceapp, which exists as a target for the grant.
- grant: The grant which permits delegation of signing and deletion permissions to a serviceapp.
- user: A user is a human principal who is potentially able to sign code releases. They are also able to issue a grant to serviceapps.
- The grant in the user schema has a couple of caveats:
- Expiration of the relationship, as a first class feature.
- Attenuation for the specific release the grant applies to. Without this the grant would apply to all releases.
- The grant in the user schema has a couple of caveats:
- role: A role contains users and permissions. Without a role, a user is unable to do anything. Roles also store the logic for a user issuing grants.
- release: The release itself, which in spicedb has permissions and relations for who is able to sign it.
- The “can_complex_sign” permission is in there to show how an and clause for two other permissions can work. Why is it needed? Maybe because the serviceapp wants to delete unsigned images after 🤷
role:release-leads#member@user:er4hn
// uncomment alpha being in release-leads and note how alpha's grant works after
//role:release-leads#member@user:alpha
release:1234#signer@role:release-leads
release:1234#deleter@role:release-leads
release:5678#signer@role:release-leads
grant:release-grant-er4hn-1234#sign_perm@serviceapp:release-signer
grant:release-grant-er4hn-1234#delete_perm@serviceapp:release-signer
grant:release-grant-alpha-5678#sign_perm@serviceapp:release-signer
user:er4hn#grant@grant:release-grant-er4hn-1234[release_attenuation:{"release_permitted":1234}][expiration:2035-03-31T12:00:00Z]
user:alpha#grant@grant:release-grant-alpha-5678[release_attenuation:{"release_permitted":5678}][expiration:2035-03-31T12:00:00Z]
Test relationships for the delegation schema. This is available at snapshot-4.yaml.
With the schema defined it is now possible to setup some test relationships to show the delegation. user:er4hn
is made a member of role:release-leads
, and release:1234
gives members of release-leads the ability to sign and delete the release by establishing signer and deleter relationships. A second release, release:5678
gives members of release-leads signing permission.
Next come the grants. Two grants are created:
- release-grant-er4hn-1234: This grant gives signing and deletion abilities to
serviceapp:release-signer
. - release-grant-alpha-5678: This grant gives signing abilities to
serviceapp:release-signer
. Neither grant is tied to a particular release, that is done when the user establishes a caveat context. This is done next when er4hn and alpha define a relationship to the grant, applying per-release caveat contexts to attenuate the release the grant applies to.
As an aside the grant is set to expire in 2035 so it’s easy to play with for anyone looking at this in the next few years. In practice it would be shortened to whatever is reasonable.
assertTrue:
- "release:1234#can_sign@user:er4hn"
- "release:5678#can_sign@user:er4hn"
- "release:1234#can_complex_sign@user:er4hn"
- 'release:1234#can_sign@serviceapp:release-signer with {"release_requested": 1234}'
- 'release:1234#can_complex_sign@serviceapp:release-signer with {"release_requested": 1234}'
assertFalse:
- "release:5432#can_sign@user:er4hn"
- "release:1234#can_sign@user:alpha"
- 'release:5678#can_complex_sign@serviceapp:release-signer with {"release_requested": 1234}'
- 'release:5678#can_sign@serviceapp:release-signer with {"release_requested": 5678}'
- 'release:1234#can_sign@serviceapp:release-signer with {"release_requested": 5}'
- 'release:1234#can_sign@serviceapp:release-signer with {"release_requested": 5, "release_permitted": 5}'
Assertions for the delegation example. This is available at snapshot-4.yaml.
At this point it’s time to test that everything functions as expected. user:er4hn
is able to sign and complex sign, as expected. Furthermore the release-signer app is able to do both as well. By using the “Check Watches” feature in the playground this can be confirmed to be happening via the grant.
What’s worth noting is that user:alpha
, not being a member of role:release-leads
is unable to sign a release, nor does their grant allow the serviceapp to sign on their behalf. If alpha is made a member of the role, both of these actions become permitted.
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. Delegation however solves that use case, which means that there is no need to pass around user tokens, however attenuated. There is a new requirement that the required relationship be present, but that’s easy enough to check for.
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.
- Thank you to the AuthZed team for taking the time to meet with me and discuss how to solve my use cases with their product.