andinfinity

Authz via SpiceDb

At digital office we are building a new authorization system. We’re using Spring Security ACL at the moment, but we’re seeing a number of issues with it. Namely:

  • the documentation is outdated at best
  • they’re contemplating deprecating it with Spring Security 7
  • no migration documentation from Spring Security 5 to 6, which blocks us from upgrading to Spring Boot 3 as well
  • it’s finnicky to use, and we’re seeing a number of issues with it
  • we regularly have trouble with caching and performance

On top of that, we’re seeing a number of issues with the way we’re using it. I always disliked how bad the testability is. How do you have a meaningful check for an annotation like this?

@PreAuthorize("hasPermission(#id, 'com.example.domain.MyEntity', 'read')")
fun doSomething(...) {
    // ...
}

@PostFilter("hasPermission(filterObject, 'com.example.domain.MyEntity', 'read')")
fun doSomethingElse(...) {
    // ...
}

How do you check that this is properly tested? How do you get coverage for that? How do you make sure this DSL doesn’t have any typos? I want to know if the DSL is incorrect before starting the app. I want compiler safety for that. In short, this is a mess and I dislike everything about it.

Enter Zanzibar. A system developed by Google for planet-scale (™) ACL-based authorization.

Zanzibar is an authorization system with a bunch of open source implementations like Ory Keto, Permify, and SpiceDB. I tried keto in the beginning, ditching it quite soon because I had some troubles getting someone to help me (even for money) and the project feels like abandonware in favor of their commercial version “Ory Permissions”. The Github repo doesn’t see much activity and some crucial aspects of Zanzibar like Zookies are missing.

SpiceDB promised more activity and a more complete implementation of Zanzibar. I also really liked the communication on Discord or in calls with them. The DX is superb, especially the documentation and the playground.

Now what I didn’t like was the documentation around actually implementing it. The auto-generated code is not very helpful and the documentation is lacking in that regard. But nothing a little asking on discord can’t fix. To put out more information about this, I’m writing this blog post.

What is Zanzibar?

Zanzibar basically manages ACLs or so called relations like:

  • user U has relation R to object O
  • set of users S has relation R to object O

You can relate relations to one another and so groups can be part of other groups. This is a very powerful concept and allows for very flexible authorization schemes. Groups could be roles so you get RBAC. SpiceDB also got so called caveats for ABACs contributed by Netflix.

Gotchas with Zanzibar

In our use-case we have locally unique ids which are not global. We are true multi-tenant and have a backend and database per customer. This means that we can’t just rely on the check for document abc but need to consider the parent organization for example. Building a hierarchy is not possible with Zanzibar, but there’s a workaround.

The biggest caveat with Zanzibar is that you have to manage relations yourself. This means that you need to keep track of all relations and their changes. That also means that you need to make sure to keep Zanzibar in sync with the database.

Hierarchy

Out of the box you can’t check for workspace:1 -> org:acme -> document:123 if neither the org nor the document id are unique across all workspaces. If the user happens to have access to any org with id acme in any workspace or any document with id 123, they will have access to all org:acme and document:123 everywhere.

Gotchas with SpiceDB

I ran into a number of minor problems and nitpicks that are not directly clear from the documentation.

No nested relations

At the moment we can’t bubble up relations like document->organization->workspace. You can’t chain arrows just yet. The issue mentions a workaround which we use as well. For every child (or even child of child) you add a permission (relation) to the parent. This is a bit cumbersome but works well enough for us.

definition user {}

definition workspace {
    relation owner: user
}

definition organization {
    relation parent_workspace: workspace

    // this is the workaround
    // instead of chaining arrows, we add a permission to the child
    permission owner = parent_workspace->owner
}

definition document {
    relation parent_organization: organization

    // this is the workaround again and here would be the second arrow
    // permission owner = parent_organization->parent_workspace->owner
    permission owner = parent_organization->owner
}

No optional relations

We have documents which can be assigned to bank accounts. If there’s no bank account assigned to the document just yet, we don’t want to consider it for the permission check. SpiceDB evaluates absent relations as “no permission” though.

The workaround is to add a second permission check and the caller needs to decide on the permission to be used prior to calling check with SpiceDB. (The example extends from the one above).

definition organization {
    relation parent_workspace: workspace
    relation viewer: user

    permission owner = parent_workspace->owner
    // a user can view the org if they are either a viewer or owner
    permission view = owner + viewer
}

definition bank_account {
    relation parent_workspace: workspace
    relation viewer: user

    permission owner = parent_workspace->owner
    // a user can view the bank account if they are either a viewer or owner
    permission view = owner + viewer
}

definition document {
    relation parent_organization: organization
    relation bank_account: bank_account

    // not taking the bank account into account, the user can view the document if they can view the organization
    permission view_no_bank_account = parent_organization->owner + parent_organization->view
    // the user should be allowed to view the document and the bank account
    permission view_via_bank_account = view_no_bank_account & bank_account->view
}

Consider the example scenario where alice is a workspace owner, eve has access to the org and bank account, and bob only to the org.

We can define this in SpiceDB like this:

workspace:1#owner@user:alice
organization:acme#parent_workspace@workspace:1
organization:acme#viewer@user:eve
organization:acme#viewer@user:bob
bank_account:gs#parent_workspace@workspace:1
bank_account:gs#viewer@user:eve
document:123-ABC#parent_organization@organization:acme
document:456-DEF#parent_organization@organization:acme
document:456-DEF#bank_account@bank_account:gs

You read this relation notation from the back, i.e. user alice is an owner of workspace 1.

Along with tests to verify our assumptions:

assertTrue:
    - organization:acme#view@user:alice
    - bank_account:gs#view@user:alice
    - document:123-ABC#view_no_bank_account@user:alice
    - document:123-ABC#view_no_bank_account@user:eve
    - document:123-ABC#view_no_bank_account@user:bob
    - document:456-DEF#view_via_bank_account@user:alice
    - document:456-DEF#view_via_bank_account@user:eve
assertFalse:
    - document:456-DEF#view_via_bank_account@user:bob

You can view the full example here. The example not only includes “nested arrows”, but also optional relations as well as intersections.

Hierarchy

As mentioned above, Zanzibar cannot inherently model hierarchies. What I mean by that is that you can’t check for workspace:1 -> org:acme -> document:123 if neither the org nor the document id are unique across all workspaces.

The solution is actually quite straightforward. You include the hierarchy in the object id. So instead of workspace:1 -> org:acme -> document:123 you use org:w-1-acme and document:w-1-acme-123. This way you can check for workspace:1 -> org:w-1-acme -> document:w-1-acme-123 by just checking for document:w-1-acme-123.

In reality you wouldn’t use int ids but uuids, but the principle is the same. The folks at SpiceDB told me that the ids can be up to 1024 characters long, so you should be fine with uuids up to a certain length. If you need more, you can always use a hash of the hierarchy instead. We might actually do that just to retain some readability.

Pagination and Zookies

Zookies are tokens that indicate freshness of the authz system. They are used to invalidate caches and are returned with the results of a query. So they should be passed along with the next query to make sure the results are consistent. Alternatively, you can use the Consistency.newBuilder().setFullyConsistent(true).build() parameter to make sure the results are consistent as well.

Note that Zookies are called ZedTokens in SpiceDB.

As the interest in this might be limited, I’ll just give a full example in Kotlin.

/**
 * Get all subjects that have a permission for a given object in a paginated manner.
 *
 * Note that if there are multiple relations granting the permission you may get duplicate ids.
 *
 * Example:
 * ```java
 * getSubjectsForRelation("book", "user", "123", "view")
 * ```
*/
fun getSubjectsForRelation(
    objType: String,
    subject: String,
    subjectId: String,
    permission: String,
    pagination: SpiceDbPagination? = null
): PaginatedResult<String> {
    val request = LookupResourcesRequest.newBuilder()
        .setSubject(buildSubjectRef(subject, subjectId))
        .setPermission(permission)
        .setResourceObjectType(objType)
        .setConsistency(
            Consistency.newBuilder()
                .setMinimizeLatency(true)
                .setAtLeastAsFresh(zedToken)
                .build()
        )
        .apply {
            if (pagination != null) {
                // the first page has a null cursor
                if (pagination.cursor != null) {
                    optionalCursor = Cursor.newBuilder().setToken(pagination.cursor).build()
                }
                optionalLimit = pagination.limit
            }
        }
        .build()

    val response = permissionsService.lookupResources(request)
    val results = mutableListOf<String>()
    var cursor: Cursor? = null

    while (response.hasNext()) {
        val next = response.next()
        cursor = next.afterResultCursor
        results.add(next.resourceObjectId)
        // also update zedToken
        zedToken = next.lookedUpAt
    }

    return PaginatedResult(
        results,
        SpiceDbPagination(cursor?.token, pagination?.limit ?: results.size)
    )
}

Remember to save the ZedToken on any interactions with the PermissionService. Either as next.lookedUpAt (as above) or permissionsService.checkPermission(request).checkedAt or permissionsService.writeRelationships(request).writtenAt.

Overall I quite like the experience and externalizing the authz system to another service keeps our code cleaner, testable, and more maintainable. There sure will be some more gotchas, but I’ll make sure to update you here on this blog.