Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit d56df14

Browse files
committed
refactor: update policy.rego and expand RBAC readme
1 parent 4954edb commit d56df14

File tree

3 files changed

+170
-55
lines changed

3 files changed

+170
-55
lines changed

coderd/rbac/README.md

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,92 @@ 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. Coder’s RBAC rules are defined in the [`policy.rego`](policy.rego) file.
109+
110+
When OPA evaluates policies, it binds input data to a global variable called `input`.
111+
In the `rbac` package, this structured data is defined as JSON and contains the subject, action, and object (see `regoInputValue` in [astvalue.go](astvalue.go)).
112+
OPA evaluates whether the subject is allowed to perform the action on the object across three levels: site, org, and user.
113+
This is determined by the final rule `allow`, defined in [`policy.rego`](policy.rego), which aggregates the results of multiple rules to decide if the user has the necessary permissions.
114+
Similarly to the input, OPA produces structured output data, which includes the `allow` variable as part of the evaluation result.
115+
Authorization succeeds only if `allow` explicitly evaluates to `true`. If no `allow` is returned, it is considered unauthorized.
116+
To learn more about OPA and Rego, see https://www.openpolicyagent.org/docs.
117+
118+
### Application and Database Integration
119+
120+
* [`rbac/authz.go`](authz.go) – Application layer integration: provides the core authorization logic that integrates with Rego for policy evaluation.
121+
* [`database/dbauthz/dbauthz.go`](../database/dbauthz/dbauthz.go) – Database layer integration: wraps the database layer with authorization checks to enforce access control.
122+
123+
There are two types of evaluation in OPA:
124+
* **Full evaluation**: Produces a decision that can be enforced.
125+
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.
126+
* **Partial evaluation**: Produces a new policy that can be evaluated later when the _unknowns_ become _known_.
127+
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`.
128+
To learn more about partial evaluation, see this [OPA blog post](https://blog.openpolicyagent.org/partial-evaluation-162750eaf422).
129+
130+
Application of Full and Partial evaluation in `rbac` package:
131+
* **Full Evaluation** is handled by the `RegoAuthorizer.Authorize()` method in `authz.go`.
132+
This method determines whether a subject (user) can perform a specific action on an object.
133+
It performs a full evaluation of the Rego policy, which returns the `allow` variable to decide whether access is granted or denied (`true` or `false`, respectively).
134+
* **Partial Evaluation** is handled by the `RegoAuthorizer.Prepare()` method in `authz.go`.
135+
This method compiles Rego’s partial evaluation queries into `SQL WHERE` clauses.
136+
These clauses are then used to enforce authorization directly in database queries, rather than in application code.
137+
138+
Authorization Patterns:
139+
* Fetch-then-authorize: an object is first retrieved from the database, and a single authorization check is performed using full evaluation via `Authorize()`.
140+
* 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.
141+
`dbauthz` methods that enforce authorization directly in the SQL query are prefixed with `Authorized`, for example, `GetAuthorizedWorkspaces`.
142+
105143
## Testing
106144

107-
You can test outside of golang by using the `opa` cli.
145+
* OPA Playground: https://play.openpolicyagent.org/
146+
* OPA CLI (`opa eval`): useful for experimenting with different inputs and understanding how the policy behaves under various conditions.
147+
`opa eval` returns the constraints that must be satisfied for a rule to evaluate to true.
108148

109-
**Evaluation**
149+
**Full Evaluation**
110150

111151
```bash
112152
opa eval --format=pretty "data.authz.allow" -d policy.rego -i input.json
113153
```
114154

155+
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:
156+
* `data.authz.allow` accesses the `allow` rule within the `authz` package.
157+
* `data.authz` on its own would return the entire output object of the package.
158+
159+
This command answers the question: “Is the user allowed?”
160+
115161
**Partial Evaluation**
116162

117163
```bash
118164
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
119165
```
166+
167+
This command performs a partial evaluation of the policy, specifying a set of unknown input parameters.
168+
The result is a set of partial queries that can be converted into `SQL WHERE` clauses and injected into SQL queries.
169+
170+
This command answers the question: “What conditions must be met for the user to be allowed?”
171+
172+
### Benchmarking
173+
174+
Benchmark tests to evaluate the performance of full and partial evaluation can be found in `authz_test.go`.
175+
You can run these tests with the `-bench` flag, for example:
176+
```bash
177+
go test -bench=BenchmarkRBACFilter -run=^$
178+
```
179+
180+
To capture memory and CPU profiles, use the following flags:
181+
* `-memprofile memprofile.out`
182+
* `-cpuprofile cpuprofile.out`
183+
184+
The script [`benchmark_authz.sh`](./scripts/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).
185+
`benchstat` compares the performance of a baseline benchmark against a new benchmark result and highlights any statistically significant differences.
186+
* To run benchmark on the current branch:
187+
```bash
188+
benchmark_authz.sh --single
189+
```
190+
* To compare benchmarks between 2 branches:
191+
```bash
192+
benchmark_authz.sh --compare main prebuild_policy
193+
```

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: 91 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 set) => 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,25 @@ 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+
#
350+
# Allow query:
351+
# data.authz.role_allow = true
352+
# data.authz.scope_allow = true
353+
# -------------------
354+
313355
# The role or the ACL must allow the action. Scopes can be used to limit,
314356
# so scope_allow must always be true.
315-
316357
allow if {
317358
role_allow
318359
scope_allow

0 commit comments

Comments
 (0)