Skip to main content

Role-Based Access Control (RBAC) in Rego

Based on our best practice model, we will now implement RBAC in Rego.

tip

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

Common Model

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

{
"subject": "alice",
"action": "create",
"resource": "documents"
}

as described in the introduction.

Recap: input and common.rego
{
"subject": "alice",
"action": "create",
"resource": "documents"
}
package common

import rego.v1

subject := input.subject

action := input.action

resource := input.resource

Mapping Role to Resource and Action

Static

The simplest approach to mapping roles to resources and actions is to do so statically. In Rego terms, this would look like this:

package rbac

import rego.v1

action := data.common.action

resource := data.common.resource

default allow := false

allow if "admin" in roles # for "roles", see below

allow if {
"viewer" in roles
action in {"view", "list"}
resource == "documents"
}

allow if {
"editor" in roles
action in {"view", "list", "create", "delete"}
resource == "documents"
}

Explicit Mapping

Refactoring this a bit, we can centralize the mapping into an object rule:

package rbac

import rego.v1

action := data.common.action

resource := data.common.resource

default allow := false

allow if "admin" in roles

allow if {
some role in roles
action in role_mappings[role].actions
resource in role_mappings[role].resources
}

role_mappings["viewer"] := {
"actions": {"view", "list"},
"resources": {"documents"},
}

role_mappings["editor"] := {
"actions": {"view", "list", "create", "delete"},
"resources": {"documents"},
}

Dynamic Mapping (Data)

With the role mapping put into a structure like this, OPA's data in-memory store can be used. The mapping can then be controlled elsewhere, and changed independently of the policies.

For OPA, this can be done

  1. using the Data API: feeding it updates from another process
  2. using bundles created/served by another process.

Enterprise OPA also lets you use Data plugins that mirror the state of an external resource (from Kafka, LDAP, MongoDB, and others) into the in-memory store.

This JSON document corresponds to the previous static mapping:

{
"role_mappings": {
"viewer": {
"actions": [
"view",
"list"
],
"resources": [
"documents"
]
},
"editor": {
"actions": [
"view",
"list",
"create",
"delete"
],
"resources": [
"documents"
]
}
}
}

Note that JSON does not support sets, so we have to use arrays.

The policy using this instead of the mapping defined in Rego changes slightly:

package rbac

import rego.v1

subject := data.common.subject

action := data.common.action

resource := data.common.resource

role_mappings := data.role_mappings

default allow := false

allow if "admin" in roles

allow if {
some role in roles
action in role_mappings[role].actions
resource in role_mappings[role].resources
}

Now "admin" still plays a special role here, and it would be more consistent if that role was also defined in data.role_mappings alone. To do so, we'll introduce a special action "*", to encode that "admin" can do anything:

{
"role_mappings": {
"admin": {
"actions": "*",
"resources": "*"
},
"viewer": {
"actions": [
"view",
"list"
],
"resources": [
"documents"
]
},
"editor": {
"actions": [
"view",
"list",
"create",
"delete"
],
"resources": [
"documents"
]
}
}
}
package rbac

import rego.v1

subject := data.common.subject

action := data.common.action

resource := data.common.resource

role_mappings := data.role_mappings

default allow := false

allow if {
some role in roles
action_matches(action, role_mappings[role].actions)
resource_matches(resource, role_mappings[role].resources)
}

action_matches(_, "*") # implements that wildcard always matches

action_matches(a, actions) if a in actions

resource_matches(_, "*")

resource_matches(r, resources) if r in resources

Just-in-time Mapping (at query-time)

Any external data source that can be used as a source for this mapping:

The will not go into details here, as the options are varied. See below for an example of dynamic subject-role assignments and role-permission definitions.

Mapping Subjects to Roles

For mapping a subject, like "Alice" to a role, we are facing the same options:

  1. static in Rego
  2. dynamic via Data
  3. just-in-time via querying

Assuming that the assignment of roles to subjects changes more frequently than the "meaning" of a role as embodied by its actions and resources, we'll skip option (1.).

Dynamic Mapping

With a data store content at like this JSON:

{
"subject_mappings": {
"alice": [
"admin"
],
"bob": [
"viewer"
],
"catherine": [
"editor"
]
}
}

we can resolve the mapping between subjects and roles like this:

package rbac

import rego.v1

subject := data.common.subject

roles contains role if some role in data.subject_mappings[subject]

Just-in-time Mapping (at query-time)

There are again many ways to fetch a subjects roles from an external source at query-time.

Assuming an HTTP API that returns this JSON when querying a subjects roles via GET /roles/{subject},

{
"roles": [
"viewer"
]
}

the policy code would look like this:

package rbac

import rego.v1

subject := data.common.subject

roles := http.send({"method": "GET", "url": sprintf("roles/%s", [subject])}).body.roles

See below for an example that spells it all out: dynamic subject-role assignments and role-permission definitions.

Example of Dynamic Roles and Assignments

In what follows, we'll use a simple database that can be queried via HTTP. It'll serve as both Roles Assignment and Management Service, the building blocks of RBAC with User-Editable Roles.

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 users (user_id INTEGER NOT NULL PRIMARY KEY, name TEXT)",
"CREATE TABLE roles (role_id INTEGER NOT NULL PRIMARY KEY, name TEXT)",
"CREATE TABLE actions (action_id INTEGER NOT NULL PRIMARY KEY, name TEXT)",
"CREATE TABLE resources (resource_id INTEGER NOT NULL PRIMARY KEY, name TEXT)",
"CREATE TABLE permissions (role_id INTEGER NOT NULL, action_id INTEGER NOT NULL, resource_id INTEGER NOT NULL, FOREIGN KEY(role_id) REFERENCES roles(role_id), FOREIGN KEY(action_id) REFERENCES actions(action_id), FOREIGN KEY(resource_id) REFERENCES resources(resource_id))",
"CREATE TABLE role_assignments (role_id INTEGER NOT NULL, user_id INTEGER NOT NULL, FOREIGN KEY(role_id) REFERENCES roles(role_id), FOREIGN KEY(user_id) REFERENCES users(user_id))",
"INSERT INTO users(name) VALUES (\"alice\"), (\"bob\")",
"INSERT INTO roles(name) VALUES (\"viewer\"), (\"admin\")",
"INSERT INTO role_assignments(role_id, user_id) VALUES (1, 1), (2, 2)",
"INSERT INTO actions(name) VALUES (\"read\"), (\"delete\")",
"INSERT INTO resources(name) VALUES (\"documents\")",
"INSERT INTO permissions(role_id, action_id, resource_id) VALUES (1, 1, 1), (2, 1, 1), (2, 2, 1)"
]'
roles.rego
package roles

import rego.v1

subject := data.common.subject

user_roles := rs if {
query_fmt := `SELECT r.name
FROM role_assignments ra
LEFT JOIN users u USING(user_id)
LEFT JOIN roles r USING(role_id)
WHERE u.name = "%s"`
encoded := urlquery.encode_object({"q": sprintf(query_fmt, [subject])})
url := sprintf("http://127.0.0.1:4001/db/query?%s", [encoded])
resp := http.send({"url": url, "method": "GET"})
rs := resp.body.results[0].values[0]
}

role_permissions := perms if {
query := `
SELECT r.name, json_group_array(json_object('action', act.name, 'resource', res.name))
FROM permissions ps
LEFT JOIN actions act USING(action_id)
LEFT JOIN resources res USING(resource_id)
LEFT JOIN roles r USING (role_id)
GROUP BY r.name`
encoded := urlquery.encode_object({"q": query})
url := sprintf("http://127.0.0.1:4001/db/query?%s", [encoded])
resp := http.send({"url": url, "method": "GET"})
perms := {role: json.unmarshal(ps) |
some [role, ps] in resp.body.results[0].values
}
}

The allow rule combining this with our common model:

authz.rego
package rbac

import rego.v1

action := data.common.action

resource := data.common.resource

role_permissions := data.roles.role_permissions

user_roles := data.roles.user_roles

default allow := false

allow if {
some role in user_roles # the subject's roles
{"action": action, "resource": resource} in role_permissions[role]
}

To bring everything into right place for our example evaluations, also copy common.rego:

common.rego
package common

import rego.v1

subject := input.subject

action := input.action

resource := input.resource

With these in place, and the database set up, we can use opa eval to exercise our dynamic role mappings:

echo '{"subject":"alice","action":"read","resource":"documents"}' | opa eval --format=pretty --stdin-input --data authz.rego --data common.rego --data roles.rego data.rbac.allow
true
echo '{"subject":"bob","action":"read","resource":"documents"}' | opa eval --format=pretty --stdin-input --data authz.rego --data common.rego --data roles.rego data.rbac.allow
true
echo '{"subject":"bob","action":"delete","resource":"documents"}' | opa eval --format=pretty --stdin-input --data authz.rego --data common.rego --data roles.rego data.rbac.allow
false
echo '{"subject":"alice","action":"delete","resource":"documents"}' | opa eval --format=pretty --stdin-input --data authz.rego --data common.rego --data roles.rego data.rbac.allow
true

Now, we can add action "edit" and resource "documents" to the "viewer" role in our role management service:

curl -XPOST 'localhost:4001/db/execute?pretty' -H "Content-Type: application/json" \
-d '[
"INSERT INTO actions(name) VALUES (\"edit\")",
"INSERT INTO permissions(role_id, action_id, resource_id) VALUES (1, 3, 1)"
]'

We now find that Bob can edit documents, too:

echo '{"subject":"bob","action":"edit","resource":"documents"}' | opa eval --format=pretty --stdin-input --data authz.rego --data common.rego --data roles.rego data.rbac.allow
true
info

To inspect what the users' roles are according to the API-querying policy, use a query like this:

echo '{"subject":"bob"}' | opa eval --format=pretty --stdin-input --data common.rego --data roles.rego data.roles.user_roles
[
"viewer"
]

To list the dynamically-retrieved role-permission mapping:

opa eval --format=pretty --data common.rego --data roles.rego data.roles.role_permissions
{
"admin": [
{
"action": "read",
"resource": "documents"
},
{
"action": "delete",
"resource": "documents"
}
],
"viewer": [
{
"action": "read",
"resource": "documents"
},
{
"action": "edit",
"resource": "documents"
}
]
}