RBAC and ABAC for Multi-Tenant Products: Custom Roles and Tested Rego Policies
When roles are enough, when to add attribute rules, how customers can define roles, and a lab that parses and tests a tenant-aware OPA policy in Rego v1.
Most products launch with three roles: owner, member, viewer. Then enterprise customers arrive with requests that roles alone cannot express: compliance staff who may read audit logs but not invoices, regional managers who may edit only their region's accounts, contractors who may work only from the corporate network. Adding a role for each combination quickly produces dozens of near-duplicates that nobody can audit.
This guide explains when to stay with roles, when to add attributes, how to let customers define their own roles, and how to run attribute rules in Open Policy Agent (OPA) with tests. The lab parses a policy as it was commonly published (it fails in both current and legacy Rego syntax) and runs seven unit tests against a corrected policy. One of those tests catches a quieter bug: a batch rule that allowed documents the single-document rule denied.
Three models, and when each fits
| Model | How access is decided | Fits well | Gets hard when |
|---|---|---|---|
| Role-based (RBAC) | User has roles; roles have permissions | Coarse duties such as admin, billing, member | Rules depend on the object or the request context |
| Relationship-based (ReBAC) | Access follows relationships, such as "editor of the folder that contains this document" | Sharing, nested folders and teams | You need fast "list everything I can see" queries at scale |
| Attribute-based (ABAC) | Rules over attributes of user, resource, action, and environment | Department, region, data classification, network, MFA state | Attributes are missing, stale, or come from the client |
NIST SP 800-162 defines ABAC and its components. Google's Zanzibar paper describes the relationship model behind several open-source authorization services.
Most business products end up with a hybrid: roles grant permissions, and a small number of attribute rules narrow them. "Editors may update documents" is a role rule. "...but only documents in their own department, and only with MFA" is an attribute rule layered on top.
Letting customers define roles
Enterprise customers often want to build their own roles from a list of permissions. Keep permissions as a fixed catalog defined by you (documents:read, billing:view, audit:export), and let each tenant create roles that bundle them:
CREATE TABLE permissions (
id text PRIMARY KEY, -- 'documents:update'
description text NOT NULL
);
CREATE TABLE roles (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenants(id),
name text NOT NULL,
UNIQUE (tenant_id, name)
);
CREATE TABLE role_permissions (
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id text NOT NULL REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (tenant_id, user_id, role_id)
);
This schema is illustrative and was not executed for this guide. One rule matters more than the rest: a role may only be assigned to users of the same tenant that owns it. Enforce that in the assignment code and test it; otherwise one tenant's administrator could grant a role to a user in another tenant.
Attribute rules in OPA
Open Policy Agent, a graduated CNCF project, evaluates policies written in Rego. Your service sends it a JSON input describing the subject, resource, action, and environment, and gets a decision back. Keeping the rules in Rego gives you one place to review them and a test runner for them.
Since OPA 1.0, the default is Rego v1 syntax, which requires the if keyword before rule bodies and contains for partial set rules. OPA's upgrade guide lists the changes. Many policies written before then, and many still published, mix the two syntaxes, which fails to parse in either.
Lab: parse and test a tenant-aware policy
The program uses the OPA Go library (v1.22.0). It first parses a policy in the form previously published in this guide, once as Rego v1 and once as Rego v0. It then runs seven unit tests against a corrected Rego v1 policy, including a test that the batch rule allowed_document_ids returns exactly the documents the single-decision allow rule would allow.
In an empty directory, run go mod init lab, save the program as main.go, then go mod tidy && go run .. The OPA module and its dependencies take a minute to download the first time.
// Lab: parse and test a tenant-aware Rego policy with the OPA Go library.
//
// 1. Parse the policy as it was previously published, as Rego v1 (the
// default since OPA 1.0) and as Rego v0.
// 2. Run unit tests against a corrected Rego v1 policy, including a check that
// the batch rule never allows a document the single-decision rule denies.
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/open-policy-agent/opa/v1/ast"
"github.com/open-policy-agent/opa/v1/tester"
"github.com/open-policy-agent/opa/v1/version"
)
const published = `package platform.authz
default allow = false
tenant_boundary_valid {
input.subject.tenant_id == input.resource.tenant_id
}
allow {
tenant_boundary_valid
"workspace_owner" in input.subject.roles
}
allowed_document_ids contains doc.id if {
some doc in input.documents
doc.tenant_id == input.subject.tenant_id
doc.department == input.subject.department
}
`
const policy = `package platform.authz
default allow := false
same_tenant if input.subject.tenant_id == input.resource.tenant_id
allow if {
same_tenant
"workspace_owner" in input.subject.roles
}
allow if {
same_tenant
"document_editor" in input.subject.roles
input.action == "documents:update"
input.subject.department == input.resource.department
input.subject.mfa_authenticated == true
net.cidr_contains(input.env.corporate_cidr, input.env.client_ip)
}
# Batch decisions reuse the single-decision rule, so the two cannot drift apart.
allowed_document_ids contains doc.id if {
some doc in input.documents
allow with input.resource as doc
}
`
const tests = `package platform.authz_test
import data.platform.authz
editor := {"tenant_id": "a", "roles": ["document_editor"], "department": "eng", "mfa_authenticated": true}
office := {"client_ip": "10.0.1.5", "corporate_cidr": "10.0.0.0/16"}
doc := {"id": "d1", "tenant_id": "a", "department": "eng"}
test_owner_cannot_cross_tenants if {
not authz.allow with input as {"subject": {"tenant_id": "a", "roles": ["workspace_owner"]}, "resource": {"tenant_id": "b"}, "action": "documents:update"}
}
test_editor_same_department_allowed if {
authz.allow with input as {"subject": editor, "resource": doc, "action": "documents:update", "env": office}
}
test_editor_without_mfa_denied if {
not authz.allow with input as {"subject": object.union(editor, {"mfa_authenticated": false}), "resource": doc, "action": "documents:update", "env": office}
}
test_editor_outside_network_denied if {
not authz.allow with input as {"subject": editor, "resource": doc, "action": "documents:update", "env": {"client_ip": "203.0.113.9", "corporate_cidr": "10.0.0.0/16"}}
}
test_missing_env_denied if {
not authz.allow with input as {"subject": editor, "resource": doc, "action": "documents:update"}
}
test_viewer_gets_no_batch_permissions if {
ids := authz.allowed_document_ids with input as {
"subject": {"tenant_id": "a", "roles": ["viewer"], "department": "eng"},
"action": "documents:update", "env": office, "documents": [doc]}
count(ids) == 0
}
test_batch_matches_single_decision if {
ids := authz.allowed_document_ids with input as {
"subject": editor, "action": "documents:update", "env": office,
"documents": [doc, {"id": "d2", "tenant_id": "b", "department": "eng"}, {"id": "d3", "tenant_id": "a", "department": "sales"}]}
ids == {"d1"}
}
`
func main() {
fmt.Println("OPA library", version.Version)
fmt.Println("\n1) Previously published policy")
for _, v := range []ast.RegoVersion{ast.RegoV1, ast.RegoV0} {
_, err := ast.ParseModuleWithOpts("published.rego", published, ast.ParserOptions{RegoVersion: v})
msg := "parses"
if err != nil {
var lines []string
for _, l := range strings.Split(err.Error(), "\n") {
if strings.Contains(l, "rego_") {
lines = append(lines, strings.TrimSpace(l))
}
}
msg = "\n " + strings.Join(lines, "\n ")
}
fmt.Printf(" as %s: %s\n", v, msg)
}
dir, _ := os.MkdirTemp("", "regolab")
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "authz.rego"), []byte(policy), 0o644)
os.WriteFile(filepath.Join(dir, "authz_test.rego"), []byte(tests), 0o644)
results, err := tester.Run(context.Background(), dir)
if err != nil {
fmt.Println("test run failed:", err)
return
}
fmt.Println("\n2) Corrected Rego v1 policy, unit tests")
sort.Slice(results, func(i, j int) bool { return results[i].Name < results[j].Name })
pass := 0
for _, r := range results {
status := "PASS"
if !r.Pass() {
status = "FAIL"
} else {
pass++
}
fmt.Printf(" %-4s %s\n", status, r.Name)
}
fmt.Printf(" %d/%d passed\n", pass, len(results))
}
Output recorded on November 3, 2026 (Go 1.26.5, windows/amd64, OPA library 1.22.0):
OPA library 1.22.0
1) Previously published policy
as v1:
published.rego:5: rego_parse_error: `if` keyword is required before rule body
published.rego:9: rego_parse_error: `if` keyword is required before rule body
as v0:
1 error occurred: published.rego:11: rego_parse_error: unexpected identifier token: expected \n or ; or } (hint: `import future.keywords.in` for `x in xs` expressions)
2) Corrected Rego v1 policy, unit tests
PASS test_batch_matches_single_decision
PASS test_editor_outside_network_denied
PASS test_editor_same_department_allowed
PASS test_editor_without_mfa_denied
PASS test_missing_env_denied
PASS test_owner_cannot_cross_tenants
PASS test_viewer_gets_no_batch_permissions
7/7 passed
What the output shows
The old policy parses in neither syntax. As Rego v1, the rules without if are errors. As Rego v0, the in keyword is unknown without an import. A policy that does not compile cannot protect anything, and depending on how the service handles the error, it may deny everything or, worse, skip the check.
The tests cover the cases that matter for a multi-tenant product: an owner of tenant A cannot act on tenant B; an editor needs the same department, MFA, and an address in the corporate range; missing environment data denies rather than errors.
The batch rule now follows the single rule. The old batch rule returned every document in the user's tenant and department, without checking role, action, MFA, or network. A viewer would have been told they could edit everything in their department. The corrected rule calls allow for each document with with input.resource as doc, so the two cannot drift. test_viewer_gets_no_batch_permissions and test_batch_matches_single_decision would both fail against the old rule.
Run tests like these with opa test in CI on every policy change; OPA's policy testing documentation covers coverage reports and test data.
Calling OPA from a Go service
You can run OPA as a separate service and call it over HTTP, or embed the library and evaluate in-process. Embedding avoids a network hop; a separate service lets non-Go services share one policy engine and gives you OPA's bundle and decision-log features out of the box. Measure both with your real policy and input sizes before choosing.
The package below compiles the policy once and evaluates it per request. It builds with Go 1.26 against OPA v1.22.0 and uses the github.com/open-policy-agent/opa/v1/rego import path; the older github.com/open-policy-agent/opa/rego path is the v0-compatible API.
// Package authz evaluates a Rego policy in-process with the OPA Go library.
package authz
import (
"context"
"fmt"
"github.com/open-policy-agent/opa/v1/rego"
)
type Authorizer struct {
allow rego.PreparedEvalQuery
}
// New compiles the policy once. Rego v1 syntax is the default in OPA 1.x.
func New(ctx context.Context, module string) (*Authorizer, error) {
q, err := rego.New(
rego.Query("data.platform.authz.allow"),
rego.Module("authz.rego", module),
).PrepareForEval(ctx)
if err != nil {
return nil, fmt.Errorf("compile policy: %w", err)
}
return &Authorizer{allow: q}, nil
}
// Input is what every decision sees. Build it on the server from
// authenticated data; never accept subject attributes from the client.
type Input struct {
Subject map[string]any `json:"subject"`
Resource map[string]any `json:"resource"`
Action string `json:"action"`
Env map[string]any `json:"env"`
}
// Allowed returns false unless the policy explicitly returns true.
func (a *Authorizer) Allowed(ctx context.Context, in Input) (bool, error) {
rs, err := a.allow.Eval(ctx, rego.EvalInput(in))
if err != nil {
return false, err
}
if len(rs) != 1 || len(rs[0].Expressions) != 1 {
return false, nil
}
allowed, ok := rs[0].Expressions[0].Value.(bool)
return ok && allowed, nil
}
Build the input on the server from authenticated data: tenant and roles from the session, department from your directory, MFA state from the authentication record, and resource attributes from the database. An attribute that the client can set is not an authorization input.
Service accounts and API keys
Do not create fake users for integrations. Give each API key its own principal with its own roles, usually narrower than any human's, and include its type in the policy input so rules can treat it differently. An attribute rule such as "this key may be used only from these address ranges" is easy to express once the key is a subject in its own right.
Logging decisions
Log each denied decision, and each allowed decision on sensitive actions, with the subject, action, resource, policy version, and result. That record answers "why could this person do that?" during an incident and supports access reviews. The audit log guide covers storing such records so they cannot be quietly edited.
Where to start
List your roles and count how many differ only by a condition such as department or region. Those conditions are your attribute rules. Move them into one Rego policy, write a test for each rule including the cross-tenant case, and add a test that every batch or list endpoint agrees with the single-object check. The cross-tenant leak guide explains why that object-level check is where most multi-tenant authorization bugs hide.
Frequently Asked Questions
Do I need a policy engine for a handful of rules?
Not necessarily. A small, well-tested authorization package in your own language is fine. A policy engine pays off when several services need the same rules, when rules change often, or when security reviewers want to read and test rules separately from application code.
Should OPA run as a sidecar or as a library?
A library avoids a network call and is simple for a single Go service. A sidecar or central service lets services in other languages share policies and provides bundle distribution and decision logs. Benchmark with your real policy before choosing.
How do I answer 'which documents can this user see?' efficiently?
Evaluating a rule per document does not scale to large lists. Push the tenant and simple attribute filters into the database query, then check the remaining candidates with the policy, or use a relationship-based system designed for listing.
What happens if the policy fails to load?
Deny. Treat compile and evaluation errors as denials, alert on them, and keep the last known good policy bundle so a bad deploy does not lock everyone out.
How do I migrate v0 Rego policies?
Add if before rule bodies and use contains for partial set rules; keywords such as in no longer need an import. OPA's upgrade guide lists every change, and opa check reports what still fails. Then run your existing tests against the converted policy.
Sources
Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.
-
SP 800-162: Guide to Attribute Based Access Control (ABAC) Definition and Considerations
(National Institute of Standards and Technology)
• Link checked 2026-11-03
Definitions and components of ABAC.
-
Open Policy Agent documentation
(Open Policy Agent project (CNCF graduated))
• Link checked 2026-11-03
Rego policies and OPA deployment options.
-
Upgrading to v1.0
(Open Policy Agent project)
• Link checked 2026-11-03
Rego v1 syntax changes, including required if and contains keywords.
-
Policy Testing
(Open Policy Agent project)
• Link checked 2026-11-03
Writing and running Rego unit tests with opa test.
-
Zanzibar: Google's Consistent, Global Authorization System
(Google Research (USENIX ATC 2019))
• Link checked 2026-11-03
The relationship-based authorization model.
-
API1:2023 Broken Object Level Authorization
(OWASP API Security Project)
• Link checked 2026-11-03
Why each object access needs an authorization check.
Revision history
- — Added a reproducible OPA lab. Replaced the previous Rego policy, which mixed v0 and v1 syntax and did not parse in either, and its batch rule, which ignored role, MFA, and network checks. Updated the Go embedding example to the OPA v1 import path and removed the SHA-256 and IP-allow-list table that described credential storage inaccurately.
- — Removed invented OPA timings.