Role-Based Access Control (RBAC) in Rego
Based on our best practice model, we will now implement RBAC in Rego.
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
- using the Data API: feeding it updates from another process
- 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:
- an HTTP API queried via
http.send
, - pulled in via an Enterprise OPA's query functions (for SQL, MongoDB, Neo4J, DynamoDB, and others)
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:
- static in Rego
- dynamic via Data
- 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.
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)"
]'
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:
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
:
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
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"
}
]
}