8000 chore: improve rbac and add benchmark tooling (#18584) · coder/coder@3cb9b20 · GitHub
[go: up one dir, main page]

Skip to content

Commit 3cb9b20

Browse files
authored
chore: improve rbac and add benchmark tooling (#18584)
## Description This PR improves the RBAC package by refactoring the policy, enhancing documentation, and adding utility scripts. ## Changes * Refactored `policy.rego` for clarity and readability * Updated README with OPA section * Added `benchmark_authz.sh` script for authz performance testing and comparison * Added `gen_input.go` to generate input for `opa eval` testing
1 parent a5bfb20 commit 3cb9b20

File tree

5 files changed

+369
-56
lines changed

5 files changed

+369
-56
lines changed

coderd/rbac/README.md

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,106 @@ Example of a scope for a workspace agent token, using an `allow_list` containing
102102
}
103103
```
104104

105+
## OPA (Open Policy Agent)
106+
107+
Open Policy Agent (OPA) is an open source tool used to define and enforce policies.
108+
Policies are written in a high-level, declarative language called Rego.
109+
Coder’s RBAC rules are defined in the [`policy.rego`](policy.rego) file under the `authz` package.
110+
111+
When OPA evaluates policies, it binds input data to a global variable called `input`.
112+
In the `rbac` package, this structured data is defined as JSON and contains the action, object and subject (see `regoInputValue` in [astvalue.go](astvalue.go)).
113+
OPA evaluates whether the subject is allowed to perform the action on the object across three levels: `site`, `org`, and `user`.
114+
This is determined by the final rule `allow`, which aggregates the results of multiple rules to decide if the user has the necessary permissions.
115+
Similarly to the input, OPA produces structured output data, which includes the `allow` variable as part of the evaluation result.
116+
Authorization succeeds only if `allow` explicitly evaluates to `true`. If no `allow` is returned, it is considered unauthorized.
117+
To learn more about OPA and Rego, see https://www.openpolicyagent.org/docs.
118+
119+
### Application and Database Integration
120+
121+
- [`rbac/authz.go`](authz.go) – Application layer integration: provides the core authorization logic that integrates with Rego for policy evaluation.
122+
- [`database/dbauthz/dbauthz.go`](../database/dbauthz/dbauthz.go) – Database layer integration: wraps the database layer with authorization checks to enforce access control.
123+
124+
There are two types of evaluation in OPA:
125+
126+
- **Full evaluation**: Produces a decision that can be enforced.
127+
This is the default evaluation mode, where OPA evaluates the policy using `input` data that contains all known values and returns output data with the `allow` variable.
128+
- **Partial evaluation**: Produces a new policy that can be evaluated later when the _unknowns_ become _known_.
129+
This is an optimization in OPA where it evaluates as much of the policy as possible without resolving expressions that depend on _unknown_ values from the `input`.
130+
To learn more about partial evaluation, see this [OPA blog post](https://blog.openpolicyagent.org/partial-evaluation-162750eaf422).
131+
132+
Application of Full and Partial evaluation in `rbac` package:
133+
134+
- **Full Evaluation** is handled by the `RegoAuthorizer.Authorize()` method in [`authz.go`](authz.go).
135+
This method determines whether a subject (user) can perform a specific action on an object.
136+
It performs a full evaluation of the Rego policy, which returns the `allow` variable to decide whether access is granted (`true`) or denied (`false` or undefined).
137+
- **Partial Evaluation** is handled by the `RegoAuthorizer.Prepare()` method in [`authz.go`](authz.go).
138+
This method compiles OPA’s partial evaluation queries into `SQL WHERE` clauses.
139+
These clauses are then used to enforce authorization directly in database queries, rather than in application code.
140+
141+
Authorization Patterns:
142+
143+
- Fetch-then-authorize: an object is first retrieved from the database, and a single authorization check is performed using full evaluation via `Authorize()`.
144+
- Authorize-while-fetching: Partial evaluation via `Prepare()` is used to inject SQL filters directly into queries, allowing efficient authorization of many objects of the same type.
145+
`dbauthz` methods that enforce authorization directly in the SQL query are prefixed with `Authorized`, for example, `GetAuthorizedWorkspaces`.
146+
105147
## Testing
106148

107-
You can test outside of golang by using the `opa` cli.
149+
- OPA Playground: https://play.openpolicyagent.org/
150+
- OPA CLI (`opa eval`): useful for experimenting with different inputs and understanding how the policy behaves under various conditions.
151+
`opa eval` returns the constraints that must be satisfied for a rule to evaluate to `true`.
152+
- `opa eval` requires an `input.json` file containing the input data to run the policy against.
153+
You can generate this file using the [gen_input.go](../../scripts/rbac-authz/gen_input.go) script.
154+
Note: the script currently produces a fixed input. You may need to tweak it for your specific use case.
108155

109-
**Evaluation**
156+
### Full Evaluation
110157

111158
```bash
112159
opa eval --format=pretty "data.authz.allow" -d policy.rego -i input.json
113160
```
114161

115-
**Partial Evaluation**
162+
This command fully evaluates the policy in the `policy.rego` file using the input data from `input.json`, and returns the result of the `allow` variable:
163+
164+
- `data.authz.allow` accesses the `allow` rule within the `authz` package.
165+
- `data.authz` on its own would return the entire output object of the package.
166+
167+
This command answers the question: “Is the user allowed?”
168+
169+
### Partial Evaluation
116170

117171
```bash
118172
opa eval --partial --format=pretty 'data.authz.allow' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_user_list --unknowns input.object.acl_group_list -i input.json
119173
```
174+
175+
This command performs a partial evaluation of the policy, specifying a set of unknown input parameters.
176+
The result is a set of partial queries that can be converted into `SQL WHERE` clauses and injected into SQL queries.
177+
178+
This command answers the question: “What conditions must be met for the user to be allowed?”
179+
180+
### Benchmarking
181+
182+
Benchmark tests to evaluate the performance of full and partial evaluation can be found in `authz_test.go`.
183+
You can run these tests with the `-bench` flag, for example:
184+
185+
```bash
186+
go test -bench=BenchmarkRBACFilter -run=^$
187+
```
188+
189+
To capture memory and CPU profiles, use the following flags:
190+
191+
- `-memprofile memprofile.out`
192+
- `-cpuprofile cpuprofile.out`
193+
194+
The script [`benchmark_authz.sh`](../../scripts/rbac-authz/benchmark_authz.sh) runs the `authz` benchmark tests on the current Git branch or compares benchmark results between two branches using [`benchstat`](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat).
195+
`benchstat` compares the performance of a baseline benchmark against a new benchmark result and highlights any statistically significant differences.
196+
197+
- To run benchmark on the current branch:
198+
199+
```bash
200+
benchmark_authz.sh --single
201+
```
202+
203+
- To compare benchmarks between 2 branches:
204+
205+
```bash
206+
benchmark_authz.sh --compare main prebuild_policy
207+
```

coderd/rbac/authz_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U
148148

149149
// BenchmarkRBACAuthorize benchmarks the rbac.Authorize method.
150150
//
151-
// go test -run=^$ -bench BenchmarkRBACAuthorize -benchmem -memprofile memprofile.out -cpuprofile profile.out
151+
// go test -run=^$ -bench '^BenchmarkRBACAuthorize$' -benchmem -memprofile memprofile.out -cpuprofile profile.out
152152
func BenchmarkRBACAuthorize(b *testing.B) {
153153
benchCases, user, orgs := benchmarkUserCases()
154154
users := append([]uuid.UUID{},
@@ -178,7 +178,7 @@ func BenchmarkRBACAuthorize(b *testing.B) {
178178
// BenchmarkRBACAuthorizeGroups benchmarks the rbac.Authorize method and leverages
179179
// groups for authorizing rather than the permissions/roles.
180180
//
181-
// go test -bench BenchmarkRBACAuthorizeGroups -benchmem -memprofile memprofile.out -cpuprofile profile.out
181+
// go test -bench '^BenchmarkRBACAuthorizeGroups$' -benchmem -memprofile memprofile.out -cpuprofile profile.out
182182
func BenchmarkRBACAuthorizeGroups(b *testing.B) {
183183
benchCases, user, orgs := benchmarkUserCases()
184184
users := append([]uuid.UUID{},
@@ -229,7 +229,7 @@ func BenchmarkRBACAuthorizeGroups(b *testing.B) {
229229

230230
// BenchmarkRBACFilter benchmarks the rbac.Filter method.
231231
//
232-
// go test -bench BenchmarkRBACFilter -benchmem -memprofile memprofile.out -cpuprofile profile.out
232+
// go test -bench '^BenchmarkRBACFilter$' -benchmem -memprofile memprofile.out -cpuprofile profile.out
233233
func BenchmarkRBACFilter(b *testing.B) {
234234
benchCases, user, orgs := benchmarkUserCases()
235235
users := append([]uuid.UUID{},

coderd/rbac/policy.rego

Lines changed: 90 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -29,76 +29,93 @@ import rego.v1
2929
# different code branches based on the org_owner. 'num's value does, but
3030
# that is the whole point of partial evaluation.
3131

32-
# bool_flip lets you assign a value to an inverted bool.
32+
# bool_flip(b) returns the logical negation of a boolean value 'b'.
3333
# You cannot do 'x := !false', but you can do 'x := bool_flip(false)'
34-
bool_flip(b) := flipped if {
34+
bool_flip(b) := false if {
3535
b
36-
flipped = false
3736
}
3837

39-
bool_flip(b) := flipped if {
38+
bool_flip(b) := true if {
4039
not b
41-
flipped = true
4240
}
4341

44-
# number is a quick way to get a set of {true, false} and convert it to
45-
# -1: {false, true} or {false}
46-
# 0: {}
47-
# 1: {true}
48-
number(set) := c if {
49-
count(set) == 0
50-
c := 0
51-
}
42+
# number(set) maps a set of boolean values to one of the following numbers:
43+
# -1: deny (if 'false' value is in the set) => set is {true, false} or {false}
44+
# 0: no decision (if the set is empty) => set is {}
45+
# 1: allow (if only 'true' values are in the se F987 t) => set is {true}
5246

53-
number(set) := c if {
47+
# Return -1 if the set contains any 'false' value (i.e., an explicit deny)
48+
number(set) := -1 if {
5449
false in set
55-
c := -1
5650
}
5751

58-
number(set) := c if {
52+
# Return 0 if the set is empty (no matching permissions)
53+
number(set) := 0 if {
54+
count(set) == 0
55+
}
56+
57+
# Return 1 if the set is non-empty and contains no 'false' values (i.e., only allows)
58+
number(set) := 1 if {
5959
not false in set
6060
set[_]
61-
c := 1
6261
}
6362

64-
# site, org, and user rules are all similar. Each rule should return a number
65-
# from [-1, 1]. The number corresponds to "negative", "abstain", and "positive"
66-
# for the given level. See the 'allow' rules for how these numbers are used.
67-
default site := 0
63+
# Permission evaluation is structured into three levels: site, org, and user.
64+
# For each level, two variables are computed:
65+
# - <level>: the decision based on the subject's full set of roles for that level
66+
# - scope_<level>: the decision based on the subject's scoped roles for that level
67+
#
68+
# Each of these variables is assigned one of three values:
69+
# -1 => negative (deny)
70+
# 0 => abstain (no matching permission)
71+
# 1 => positive (allow)
72+
#
73+
# These values are computed by calling the corresponding <level>_allow functions.
74+
# The final decision is derived from combining these values (see 'allow' rule).
75+
76+
# -------------------
77+
# Site Level Rules
78+
# -------------------
6879

80+
default site := 0
6981
site := site_allow(input.subject.roles)
7082

7183
default scope_site := 0
72-
7384
scope_site := site_allow([input.subject.scope])
7485

86+
# site_allow receives a list of roles and returns a single number:
87+
# -1 if any matching permission denies access
88+
# 1 if there's at least one allow and no denies
89+
# 0 if there are no matching permissions
7590
site_allow(roles) := num if {
76-
# allow is a set of boolean values without duplicates.
77-
allow := {x |
91+
# allow is a set of boolean values (sets don't contain duplicates)
92+
allow := {is_allowed |
7893
# Iterate over all site permissions in all roles
7994
perm := roles[_].site[_]
8095
perm.action in [input.action, "*"]
8196
perm.resource_type in [input.object.type, "*"]
8297

83-
# x is either 'true' or 'false' if a matching permission exists.
84-
x := bool_flip(perm.negate)
98+
# is_allowed is either 'true' or 'false' if a matching permission exists.
99+
is_allowed := bool_flip(perm.negate)
85100
}
86101
num := number(allow)
87102
}
88103

104+
# -------------------
105+
# Org Level Rules
106+
# -------------------
107+
89108
# org_members is the list of organizations the actor is apart of.
90109
org_members := {orgID |
91110
input.subject.roles[_].org[orgID]
92111
}
93112

94-
# org is the same as 'site' except we need to iterate over each organization
113+
# 'org' is the same as 'site' except we need to iterate over each organization
95114
# that the actor is a member of.
96115
default org := 0
97-
98116
org := org_allow(input.subject.roles)
99117

100118
default scope_org := 0
101-
102119
scope_org := org_allow([input.scope])
103120

104121
# org_allow_set is a helper function that iterates over all orgs that the actor
@@ -114,11 +131,14 @@ scope_org := org_allow([input.scope])
114131
org_allow_set(roles) := allow_set if {
115132
allow_set := {id: num |
116133
id := org_members[_]
117-
set := {x |
134+
set := {is_allowed |
135+
# Iterate over all org permissions in all roles
118136
perm := roles[_].org[id][_]
119137
perm.action in [input.action, "*"]
120138
perm.resource_type in [input.object.type, "*"]
121-
x := bool_flip(perm.negate)
139+
140+
# is_allowed is either 'true' or 'false' if a matching permission exists.
141+
is_allowed := bool_flip(perm.negate)
122142
}
123143
num := number(set)
124144
}
@@ -191,24 +211,30 @@ org_ok if {
191211
not input.object.any_org
192212
}
193213

194-
# User is the same as the site, except it only applies if the user owns the object and
214+
# -------------------
215+
# User Level Rules
216+
# -------------------
217+
218+
# 'user' is the same as 'site', except it only applies if the user owns the object and
195219
# the user is apart of the org (if the object has an org).
196220
default user := 0
197-
198221
user := user_allow(input.subject.roles)
199222

200-
default user_scope := 0
201-
223+
default scope_user := 0
202224
scope_user := user_allow([input.scope])
203225

204226
user_allow(roles) := num if {
205227
input.object.owner != ""
206228
input.subject.id = input.object.owner
207-
allow := {x |
229+
230+
allow := {is_allowed |
231+
# Iterate over all user permissions in all roles
208232
perm := roles[_].user[_]
209233
perm.action in [input.action, "*"]
210234
perm.resource_type in [input.object.type, "*"]
211-
x := bool_flip(perm.negate)
235+
236+
# is_allowed is either 'true' or 'false' if a matching permission exists.
237+
is_allowed := bool_flip(perm.negate)
212238
}
213239
num := number(allow)
214240
}
@@ -227,17 +253,9 @@ scope_allow_list if {
227253
input.object.id in input.subject.scope.allow_list
228254
}
229255

230-
# The allow block is quite simple. Any set with `-1` cascades down in levels.
231-
# Authorization looks for any `allow` statement that is true. Multiple can be true!
232-
# Note that the absence of `allow` means "unauthorized".
233-
# An explicit `"allow": true` is required.
234-
#
235-
# Scope is also applied. The default scope is "wildcard:wildcard" allowing
236-
# all actions. If the scope is not "1", then the action is not authorized.
237-
#
238-
#
239-
# Allow query:
240-
# data.authz.role_allow = true data.authz.scope_allow = true
256+
# -------------------
257+
# Role-Specific Rules
258+
# -------------------
241259

242260
role_allow if {
243261
site = 1
@@ -258,6 +276,10 @@ role_allow if {
258276
user = 1
259277
}
260278

279+
# -------------------
280+
# Scope-Specific Rules
281+
# -------------------
282+
261283
scope_allow if {
262284
scope_allow_list
263285
scope_site = 1
@@ -280,6 +302,11 @@ scope_allow if {
280302
scope_user = 1
281303
}
282304

305+
# -------------------
306+
# ACL-Specific Rules
307+
# Access Control List
308+
# -------------------
309+
283310
# ACL for users
284311
acl_allow if {
285312
# Should you have to be a member of the org too?
@@ -308,11 +335,24 @@ acl_allow if {
308335
[input.action, "*"][_] in perms
309336
}
310337

311-
###############
338+
# -------------------
312339
# Final Allow
340+
#
341+
# The 'allow' block is quite simple. Any set with `-1` cascades down in levels.
342+
# Authorization looks for any `allow` statement that is true. Multiple can be true!
343+
# Note that the absence of `allow` means "unauthorized".
344+
# An explicit `"allow": true` is required.
345+
#
346+
# Scope is also applied. The default scope is "wildcard:wildcard" allowing
347+
# all actions. If the scope is not "1", then the action is not authorized.
348+
#
349+
# Allow query:
350+
# data.authz.role_allow = true
351+
# data.authz.scope_allow = true
352+
# -------------------
353+
313354
# The role or the ACL must allow the action. Scopes can be used to limit,
314355
# so scope_allow must always be true.
315-
316356
allow if {
317357
role_allow
318358
scope_allow

0 commit comments

Comments
 (0)
0