Skip to main content

Relationship-Based Access Control (ReBAC) in Rego

tip

This how-to assumes basic knowledge of the ReBAC model of access control. See the explanation page for a refresher

Common Model

As outlined in the introduction, the actual policy model (ReBAC) described here is independent of how the subject/action/resource tuple is determined. We will thus assume that data.common resolves to

{
"subject": "user:alice",
"action": "view",
"resource": "doc:readme"
}

as described in the introduction.

Recap: common.rego
package common

import rego.v1

subject := input.subject

action := input.action

resource := input.resource

Relationship Data in JSON

Since OPA's in-memory store's data model is basically JSON (with sets), it is amendable to encode hierarchies. Here, we will illustrate the modelling of a system with groups, documents, and user information. First, we describe it in high-level terms, before going into the nitty-gritty of a possible JSON representation.

  • Alice (user:alice) has created a "readme" document (doc:readme) with path top/middle/readme.
  • Alice and Bob (user:bob) belong to group "engineering" (group:eng).
  • Engineers have been given the direct permission to view the document.
  • Catherine (user:catherine) is the workspace administrator and owns all documents anywhere in folder top/ (folder:top).
{
"users": [
{
"name": "user:alice"
},
{
"name": "user:catherine"
},
{
"name": "user:bob"
}
],
"owners": [
{
"entity": "doc:readme",
"owner": "user:alice"
}
],
"members": {
"group:eng": [
"user:alice",
"user:bob"
]
},
"viewers": [
{
"entity": "doc:readme",
"userset_relation": "MEMBER",
"userset_object": "group:eng"
},
{
"entity": "folder:middle",
"userset_object": "user:catherine"
}
],
"parents": {
"folder:middle": "folder:top",
"doc:readme": "folder:middle"
}
}

Modelling relational logic

We start off with a top-down approach: A query returns {"allow": true} if

  1. the subject is the owner of the resource (regardless of the action),
  2. the subject is a viewer of the resource or one of its parents.

For illustration purposes, we will not spell out all possible relations: only the viewer relation in this Rego code will respect parent-child relationships.

package rebac

import rego.v1

action := data.common.action

resource := data.common.resource

subject := data.common.subject

default allow := false

allow if is_owner(subject, resource)

allow if {
action == "view"
is_viewer(subject, resource)
}

# direct ownership
is_owner(subject, resource) if {
some rel in data.owners
rel.entity == resource
rel.owner == subject
}

# direct relation
is_viewer(subject, resource) if {
some rel in data.viewers
rel.entity == resource
rel.userset_object == subject
}

# viewer via group membership
is_viewer(subject, resource) if {
some rel in data.viewers
rel.entity == resource
rel.userset_relation == "MEMBER"
some rel.userset_object, members in data.members
subject in members
}

# viewer via parent of resource
is_viewer(subject, resource) if {
some rel in data.viewers
other_resource := rel.entity
rel.userset_object == subject
is_parent(other_resource, resource)
}

# direct parent
is_parent(a, b) if b, a in data.parents

# one layer deep
is_parent(a, b) if {
c := data.parents[b]
c, a in data.parents
}

# two layers deep
is_parent(a, b) if {
c := data.parents[b]
d := data.parents[c]
d, a in data.parents
}

Test Evaluations

With this Rego policy set up, we can test different scenarios:

package tests

import rego.v1

test_direct_owner_of_doc if {
data.rebac.allow with input.subject as "user:alice"
with input.action as "view"
with input.resource as "doc:readme"
}
test_viewer_via_parent_folder if {
data.rebac.allow with input.subject as "user:catherine"
with input.action as "view"
with input.resource as "doc:readme"
}

Recursion

Since Rego does not allow recursion, we've been unrolling a bounded number of recursive relations in the Rego code above. This limitation be approached in other ways:

  1. Encoding the data such that it's amendable to be used with the graph.reachable built-in, see below.
  2. Use SQL and sql.send to deal with recursion in relations with a complex SQL query.
  3. Query a graph database.

In what follows, we will provide some details on (1.) and (3.):

graph.reachable for parent-child relations

The JSON modelling we've used for data.parents above is based on pointer from the child to its parent -- in our folder model, each child (file or directory) has a single parent (directory). To make use of OPA's built-in graph traversal functions, like graph.reachable, we'll need to turn this structure around:

{
"files": {
"folder:top": [
"folder:middle"
],
"folder:middle": [
"doc:readme"
],
"doc:readme": []
}
}

Queries for "Is a a parent folder of b?" then become:

package tests

import rego.v1

is_parent(a, b) if b in graph.reachable(data.files, {a})

test_folder_top_folder_middle if is_parent("folder:top", "folder:middle")
test_folder_middle_doc if is_parent("folder:middle", "doc:readme")
test_folder_top_doc if is_parent("folder:top", "doc:readme")

Note that this modelling has no limitations on nesting depth.

danger

Please be aware that for large relationship data, it's not the best choice to use graph.reachable: Due to the way Rego is evaluated, the arguments to a function are fully evaluated (pass-by-value), and that can lead to bad policy evaluation performance.

tip

See Playground examples: Access Control for a similar example of hierarchical roles in RBAC.

Use SQL for Recursion

In what follows, we'll use a simple database that can be queried via SQL over HTTP. It'll allow us to demonstrate how to use SQL to offload the graph traversal to a different service.

note

The service backing the documentation's Evaluate button doesn't run extra services, so you need to run the steps on your machine to follow along.

Setup of example API service

Start rqlite as the database service in the background:

docker run -d -p 4001:4001 rqlite/rqlite

Create tables and fill them:

curl -XGET 'localhost:4001/db/execute?pretty' --data-urlencode 'q=PRAGMA foreign_keys'
curl -XPOST 'localhost:4001/db/execute?pretty' -H "Content-Type: application/json" \
-d '[
"CREATE TABLE files (id INTEGER NOT NULL PRIMARY KEY, name TEXT, parent_id INTEGER)",
"INSERT INTO files(id, name, parent_id) VALUES (1, \"folder:top\", NULL), (2, \"folder:middle\", 1), (3, \"doc:readme\", 2)"
]'
reachable.rego
package rebac

import rego.v1

subject := data.common.subject

is_parent(a, b) if {
body := [[
`WITH RECURSIVE reachable(id, n) AS
(SELECT id, name FROM files WHERE name=?
UNION
SELECT f.id, name FROM files f, reachable
WHERE f.parent_id=reachable.id)
SELECT true FROM reachable WHERE n=?`,
a, b,
]]
url := "http://127.0.0.1:4001/db/query"
resp := http.send({"url": url, "method": "POST", "body": body})
rs := resp.body.results[0].values[0] == 1 # true
}

Let's use opa eval to run a few queries:

opa eval --format=pretty --data reachable.rego 'data.rebac.is_parent("folder:top", "folder:middle")'
true
opa eval --format=pretty --data reachable.rego 'data.rebac.is_parent("folder:top", "doc:readme")'
true
opa eval --format=pretty --data reachable.rego 'data.rebac.is_parent("folder:middle", "folder:top")'
undefined
tip

Using rqlite for demonstration purposes is convenient, but if you really need to query a SQL database, it may not come with a HTTP interface. For this, Enterprise OPA features query functions for various databases, such as PostgreSQL, MySQL, MS SQL Server, DynamoDB.

Query a Graph Database: SpiceDB

Using the OPA extension for SpiceDB, we can offload graph traversal to SpiceDB.

note

Our supporting services don't provide a SpiceDB instance, so the following examples are not interactive. Please check the extension repository for instructions on how to run this locally.

Schema and data definition for SpiceDB
schema-and-data.yaml
schema: |-
definition user {}

definition folder {
relation parent: folder
relation viewer: user

permission view = viewer + parent->view
}

definition doc {
relation parent_folder: folder
relation viewer: user

permission view = viewer + parent_folder->view
}

relationships: |-
doc:readme#parent_folder@folder:middle
folder:middle#parent@folder:top
folder:middle#viewer@user:alice
folder:top#viewer@user:catherine

assertions:
assertTrue:
- folder:middle#view@user:alice
- folder:top#view@user:catherine
- folder:middle#view@user:catherine
assertFalse: []
validation: {}

With the OPA extension, we can have SpiceDB resolve graph queries via special builtins. One of those is spicedb.lookup_subjects() which returns an object like this for our example data:

spicedb.lookup_subjects("doc", "readme", "view", "user")
{
"lookedUpAt": "GhUKEzE3Mjg1NTIyODUwMDAwMDAwMDA=",
"permission": "view",
"resourceId": "doc",
"resourceType": "readme",
"result": true,
"subjectIds": [
"alice",
"catherine"
],
"subjectType": "user"
}

A policy that uses a query sent to SpiceDB for evaluating the relations looks like this:

package rebac

import rego.v1

action := data.common.action

resource := data.common.resource

subject := data.common.subject

default allow := false

allow if {
[resource_type, resource_id] := split(resource, ":")
[subject_type, subject_id] := split(subject, ":")
res := spicedb.lookup_subjects(resource_type, resource_id, action, subject_type)
subject_id in res.subjectIds
}