Zanzibar-style ACLs with OPA Rego

what does it take to convert Zanzibar ACLs to OPA Rego?
thumbnail

In the previous article on OPA[1], I asked this question:

why would Ory Keto drop OPA from its implementation?

What’s Ory Keto? After Ory Keto documentation[2]:

Ory Keto is the first and only open source implementation of “Zanzibar: Google’s Consistent, Global Authorization System”.

The question bothered me to the point that I sat down and reread the Zanzibar white paper[3] in search for clues on OPA applicability to this problem. It’s been about a year since I last looked into Zanzibar so I’m coming back to this with a fresh mind.

Google Zanzibar white paper describes a consistent globally distributed authorization system. The paper discusses three facets:

  • The notation: defines a method to describe ACLs and their hierarchy.
  • The API: defines an ACL read, write, check, and expand API.
  • The architecture and design: describes how Google implemented Zanzibar.

The ACL notation format:

namespace:object#relation@namespace:user

Rules should be read from right to left. That rule means: a user is in a relation relation to an object. Namespaces offer a logical grouping of object types.

The user part of the ACL can be substituted with a userset:

allows ACLs to refer to groups and thus supports representing nested group membership

Essentially, it could be a result of a different rule evaluation. A userset is always an object#relation.

The white paper gives two prominent examples. The first one (page 2, table 1):

Members of group:eng are viewers of doc:readme.

doc:readme#viewer@group:eng#member

The second one (page 4, figure 1) is written in pseudo code:

Simple namespace configuration with concentric relations on documents. All owners are editors, and all editors are viewers. Further, viewers of the parent folder are also viewers of the document.

name: "doc"

relation { name: "owner" }

relation {
  name: "editor"
  userset_rewrite {
    union {
      child { _this {} }
      child { computed_userset { relation: "owner" } }
} } }

relation {
  name: "viewer"
  userset_rewrite {
    union {
      child { _this {} }
      child { computed_userset { relation: "editor" } }
      child { tuple_to_userset {
        tupleset { relation: "parent" }
        computed_userset {
          object: $TUPLE_USERSET_OBJECT # parent folder
          relation: "viewer"
   } } }
} } }

This one is somewhat troublesome because we aren’t offered a notation. Let’s try reverse-engineering it. Each line builds on the previous one to arrive at the final notation:

# document has owners;
# per the white paper: the namespace and the relation are predefined in client configuration;
# clearly noted here:
doc#owner
# document owners are document editors:
doc#editor@doc#owner
# document editors are document viewers:
doc#viewer@doc#editor@doc#owner
# the complete ACL notation for the first part, with namespaces for completeness:
doc:some-doc#viewer@doc:some-doc#editor@doc:some-doc#owner

This isn’t complete yet because the latter statement is still missing:

Further, viewers of the parent folder are also viewers of the document.

There doesn’t seem to be a way to encode this information continuously within the first rule. The decision tree is:

is a viewer
       ├< if is an editor; is editor -< if is an owner
       └< if is a viewer of the parent folder

The Zanzibar notation doesn’t have a union notion. So there have to be two rules defined like this:

doc:some-doc#viewer@doc:some-doc#editor@doc:some-doc#owner
doc:some-doc#viewer@folder:some-doc-parent-folder#viewer

A few interesting things can be inferred from this notation:

  • Checks are in the form of narrow set ⊂ wider set ⊂ wider set ....
  • The white paper isn’t explicit about it but checks can be seemingly chained together as long as we’re chaining on the same object and narrowing down within a set of the same the user/userset. I actually like how Keto refers to this s subject. Not sure how ergonomic this really is but interesting to keep in mind.
  • Order doesn’t matter, reversing individual ACLs produces the same end result.
doc:some-doc#viewer@doc:some-doc#editor@doc:some-doc#owner
doc:some-doc#viewer@folder:some-doc-parent-folder#viewer

produces the same union as:

doc:some-doc#viewer@folder:some-doc-parent-folder#viewer
doc:some-doc#viewer@doc:some-doc#editor@doc:some-doc#owner
  • To find all viewers of the document doc:some-doc, one needs to find (and evaluate) all ACLs starting with doc:some-doc#viewer.
  • To find out what relations can be used in combination with doc:some-doc, one needs to list all ACLs starting with doc:some-doc#.
  • Whatever the decision tree is, it’s possible to find all ACLs contributing to a single union by finding all rules with the required object and relation prefix, then evaluating them. As the Zanzibar white paper suggests, individual rules could be evaluated in parallel.
  • It’s handy to have the ergonomy of using strings as user identifiers as Ory Keto does it.

§how easy is it to translate zanzibar notation to rego

OPA policies are written using Rego:

… a declarative language … purpose-built for expressing policies over complex hierarchical data structures.

A handy way to work with policies is to write them in rego files. A policy file starts with a package declaration and includes one or more rules. Policy documents may define variables, import data from data documents, and include other policies so rules can be combined and reused. There’s a great introduction to Rego on the OPA website[4].

It should be possible to rewrite a Zanzibar ACL as a Rego policy.

§a member of the engineering group is a document viewer

The first example from the white paper would be:

Feeding it with:

1
2
3
4
{
    "doc": "readme",
    "groups": ["employee", "eng"]
}

produces:

1
2
3
{
    "doc_viewer": true
}

An example of an invalid input:

1
2
3
4
{
    "doc": "readme",
    "groups": ["hr"]
}

returns:

1
2
3
{
    "doc_viewer": false
}

This policy can be tested with OPA Playground.

§owner is an editor and a viewer, a viewer of the parent folder is also a document viewer

The second example could be translated to the following Rego representation:

For this input:

1
2
3
4
{
    "userid": "owner",
    "parent_viewers": ["viewer"]
}

The output is:

1
2
3
4
5
6
{
    "editor": false,
    "owner": false,
    "parent_viewer": true,
    "viewer": true
}

Similarly, for:

1
2
3
4
{
    "userid": "owner",
    "viewers": ["viewer"]
}

we get:

1
2
3
4
5
{
    "editor": false,
    "owner": false,
    "viewer": false
}

This policy can be also tested with the Playground.

§let’s take this up a notch

I’m going to build on the second policy. For the next step, I’m going to put the policy and some data on the OPA server. The server is running in a container with in-memory storage. Data will be lost on the restart but it doesn’t matter right now.

1
2
3
docker run --rm \
  -ti openpolicyagent/opa:0.40.0 \
  run --server --log-format=json-pretty --set=decision_logs.console=true

I’ve made a couple of significant changes:

  • When working with the OPA server, policies make decisions based on structured data loaded onto the server. Those data documents provide facts about the external world. Instead of passing facts to the policies via input (like I did with input.viewers, input.editors, and input.owners in the original implementation), I’m using this data storage mechanism. OPA cannot answer queries about data not it doesn’t know about. Storing data on the server also allows importing it into Rego documents. This is an ergonomic way of working with OPA. The previous iteration of this document was written for the playground.
  • There’s more structure in the data model. I’m storing user identifiers as relationship object keys. Here, user-id and another-user-id have a viewer relationship. It’s a key/value storage.
1
2
3
4
5
6
{
  "viewers": {
    "user-id": true,
    "another-user-id": true
  }
}
  • With this, I can use OPA patch support to add, modify, and remove object-to-subject relationships.

First, the modified policy document:

with relevant test data:

  • documents:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
    "readme": {
        "properties": {
            "directory": "parent-folder-id"
        },
        "permissions": {
            "editors": {},
            "owners": {},
            "viewers": {}
        }
    }
}
  • folders:
1
2
3
4
5
6
7
{
    "parent-folder-id": {
        "permissions": {
            "viewers": {}
        }
    }
}

In real-world those document and folder IDs would be something more cryptic, for example, an UUID. Here, I’m using literal values for better readability. This data model isn’t optimal, it could be improved. But it is good enough to visualize what’s happening so it’s good enough for this article.

Assuming that the Docker container is already running, I can load the data from the terminal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# load the policy:
curl --silent -X PUT --data-binary @doc-policy.rego \
  localhost:8181/v1/policies/doc-policy
# load the data:
curl --silent -X PUT \
    -H 'Content-Type: application/json' \
    --data-binary @data-docs.json \
    localhost:8181/v1/data/docs
curl --silent -X PUT \
    -H 'Content-Type: application/json' \
    --data-binary @data-folders.json \
    localhost:8181/v1/data/folders

All default data is loaded into OPA. What’s worth paying the attention to:

  • The path the JSON data is loaded into reflects the policy import:
    • /v1/data/docs maps directly to import data.docs,
    • /v1/data/folders maps directly to import data.folders.

First, a query to check if a user is a viewer of the readme document:

1
2
3
curl --silent -X POST localhost:8181/v1/data/doc/viewer \
    -H 'Content-Type: application/json' \
    -d '{"input": {"docid": "readme", "userid": "user-viewer"}}' | jq '.'
1
2
3
4
{
  "decision_id": "b5f14892-d92c-4804-8a02-150e3d437da5",
  "result": false
}

It’s false, as expected. There are no permissions defined in the data document. I will now give the user-viewer permission to view the document:

1
2
3
4
5
6
7
8
9
# the path is a combination of the JSON placement and the path
# within the JSON data document:
curl --silent -X PATCH localhost:8181/v1/data/docs/readme/permissions/viewers \
    -H 'Content-Type: application/json-patch+json' \
    -d '[{
        "op": "add",
        "path": "user-viewer",
        "value": true
    }]'

Can the user view the readme document now?

1
2
3
curl --silent -X POST localhost:8181/v1/data/doc/viewer \
    -H 'Content-Type: application/json' \
    -d '{"input": {"docid": "readme", "userid": "user-viewer"}}' | jq '.'
1
2
3
4
{
  "decision_id": "4c73a96a-1f5f-4c08-9339-816ef97b7328",
  "result": true
}

They do. This works with a direct viewer permission assignment. One of the ACL statements is that a viewer of a parent folder is a viewer of the document. I can test that by removing the direct permission and giving the user-viewer a viewer permission on the folder:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# remove the direct permission:
curl --silent -X PATCH localhost:8181/v1/data/docs/readme/permissions/viewers \
    -H 'Content-Type: application/json-patch+json' \
    -d '[{
        "op": "remove",
        "path": "user-viewer"
    }]'
# document contains a property with an ID of its parent folder:
export parent_folder_id=$(curl --silent localhost:8181/v1/data/docs/readme/properties/directory | jq '.result' -r)
# assign the `viewer` permission to the `user-viewer` on the `parent folder`:
curl --silent -X PATCH localhost:8181/v1/data/folders/${parent_folder_id}/permissions/viewers \
    -H 'Content-Type: application/json-patch+json' \
    -d '[{
        "op": "add",
        "path": "user-viewer",
        "value": true
    }]'

And verify:

1
2
3
curl --silent -X POST localhost:8181/v1/data/doc/viewer \
    -H 'Content-Type: application/json' \
    -d '{"input": {"docid": "readme", "userid": "user-viewer"}}' | jq '.'
1
2
3
4
{
  "decision_id": "a9ed829a-c66e-4435-8e97-85a644ac879b",
  "result": true
}

It indeed works. To be completely sure, let’s remove the permission from the parent folder and repeat the query:

1
2
3
4
5
6
7
8
9
curl --silent -X PATCH localhost:8181/v1/data/folders/${parent_folder_id}/permissions/viewers \
    -H 'Content-Type: application/json-patch+json' \
    -d '[{
        "op": "remove",
        "path": "user-viewer"
    }]'
curl --silent -X POST localhost:8181/v1/data/doc/viewer \
    -H 'Content-Type: application/json' \
    -d '{"input": {"docid": "readme", "userid": "user-viewer"}}' | jq '.'
1
2
3
4
{
  "decision_id": "83e2b6e0-ef0f-4d6d-9e2a-5e9cb740c50f",
  "result": false
}

Let’s test that the first rule of the policy holds ⤳ an owner:

1
2
3
4
5
6
7
curl --silent -X PATCH localhost:8181/v1/data/docs/readme/permissions/owners \
    -H 'Content-Type: application/json-patch+json' \
    -d '[{
        "op": "add",
        "path": "user-owner",
        "value": true
    }]'
  • is an editor:
1
2
3
curl --silent -X POST localhost:8181/v1/data/doc/editor \
    -H 'Content-Type: application/json' \
    -d '{"input": {"docid": "readme", "userid": "user-owner"}}' | jq '.'
1
2
3
4
{
  "decision_id": "9e95e5dd-1622-496b-8ccd-50bb2ac7fa7b",
  "result": true
}
  • and a viewer:
1
2
3
curl --silent -X POST localhost:8181/v1/data/doc/viewer \
    -H 'Content-Type: application/json' \
    -d '{"input": {"docid": "readme", "userid": "user-owner"}}' | jq '.'
1
2
3
4
{
  "decision_id": "b960f9c9-3d2a-4cb1-afbd-53df618a36f0",
  "result": true
}

This works surprisingly well.

§observations

A few observations after researching this solution:

  • The Zanzibar namespace translates very nicely to the object type: for example, the doc namespace maps very well to a Rego doc policy. A Rego doc policy is what would be attached to any document.
  • Zanzibar relationship maps to a Rego rule: onwer, editor, viewer, those are Zanzibar relationships and Rego rules.
  • Object identifier and user identifier are variables: doc:document-id#relation@user:user-id says that a user with a user-id is in a relationship with a document with an id of document-id. This ACL will hold for any document and any user. Hence document-id and user-id are variable.

§caveats

I haven’t answered the question from the beginning of this write-up. It’s a little bit too early for that. I am able to translate Zanzibar ACLs to Rego but a Zanzibar-like ACL solution does much more than an ACL evaluation. There are the following aspects of Zanzibar still to evaluate:

  • API to read relationships for an object.
  • API to read all users for an object and relationship.
  • API to read all users for an object regardless of the relationship.
  • Expand API.
  • A viable, somewhat scalable architecture for storing individual ACLs.
  • A method to reference them in a robust way when querying usersets.

Nevertheless, it’s a good start.