Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

chore: improve rbac and add benchmark tooling#18584

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Draft
ssncferreira wants to merge4 commits intomain
base:main
Choose a base branch
Loading
fromssncferreira/chore-rbac-improvements
Draft
Show file tree
Hide file tree
Changes fromall commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 87 additions & 3 deletionscoderd/rbac/README.md
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,18 +102,102 @@ Example of a scope for a workspace agent token, using an `allow_list` containing
}
```

## OPA (Open Policy Agent)

Open Policy Agent (OPA) is an open source tool used to define and enforce policies.
Policies are written in a high-level, declarative language called Rego. Coder’s RBAC rules are defined in the [`policy.rego`](policy.rego) file.

When OPA evaluates policies, it binds input data to a global variable called `input`.
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)).
OPA evaluates whether the subject is allowed to perform the action on the object across three levels: site, org, and user.
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.
Similarly to the input, OPA produces structured output data, which includes the `allow` variable as part of the evaluation result.
Authorization succeeds only if `allow` explicitly evaluates to `true`. If no `allow` is returned, it is considered unauthorized.
To learn more about OPA and Rego, see https://www.openpolicyagent.org/docs.

### Application and Database Integration

- [`rbac/authz.go`](authz.go) – Application layer integration: provides the core authorization logic that integrates with Rego for policy evaluation.
- [`database/dbauthz/dbauthz.go`](../database/dbauthz/dbauthz.go) – Database layer integration: wraps the database layer with authorization checks to enforce access control.

There are two types of evaluation in OPA:

- **Full evaluation**: Produces a decision that can be enforced.
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.
- **Partial evaluation**: Produces a new policy that can be evaluated later when the _unknowns_ become _known_.
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`.
To learn more about partial evaluation, see this [OPA blog post](https://blog.openpolicyagent.org/partial-evaluation-162750eaf422).

Application of Full and Partial evaluation in `rbac` package:

- **Full Evaluation** is handled by the `RegoAuthorizer.Authorize()` method in `authz.go`.
This method determines whether a subject (user) can perform a specific action on an object.
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).
- **Partial Evaluation** is handled by the `RegoAuthorizer.Prepare()` method in `authz.go`.
This method compiles Rego’s partial evaluation queries into `SQL WHERE` clauses.
These clauses are then used to enforce authorization directly in database queries, rather than in application code.

Authorization Patterns:

- Fetch-then-authorize: an object is first retrieved from the database, and a single authorization check is performed using full evaluation via `Authorize()`.
- 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.
`dbauthz` methods that enforce authorization directly in the SQL query are prefixed with `Authorized`, for example, `GetAuthorizedWorkspaces`.

## Testing

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

**Evaluation**
### FullEvaluation

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

**Partial Evaluation**
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:

- `data.authz.allow` accesses the `allow` rule within the `authz` package.
- `data.authz` on its own would return the entire output object of the package.

This command answers the question: “Is the user allowed?”

### Partial Evaluation

```bash
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
```

This command performs a partial evaluation of the policy, specifying a set of unknown input parameters.
The result is a set of partial queries that can be converted into `SQL WHERE` clauses and injected into SQL queries.

This command answers the question: “What conditions must be met for the user to be allowed?”

### Benchmarking

Benchmark tests to evaluate the performance of full and partial evaluation can be found in `authz_test.go`.
You can run these tests with the `-bench` flag, for example:

```bash
go test -bench=BenchmarkRBACFilter -run=^$
```

To capture memory and CPU profiles, use the following flags:

- `-memprofile memprofile.out`
- `-cpuprofile cpuprofile.out`

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).
`benchstat` compares the performance of a baseline benchmark against a new benchmark result and highlights any statistically significant differences.

- To run benchmark on the current branch:

```bash
benchmark_authz.sh --single
```

- To compare benchmarks between 2 branches:

```bash
benchmark_authz.sh --compare main prebuild_policy
```
6 changes: 3 additions & 3 deletionscoderd/rbac/authz_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,7 +148,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U

// BenchmarkRBACAuthorize benchmarks the rbac.Authorize method.
//
//go test -run=^$ -bench BenchmarkRBACAuthorize -benchmem -memprofile memprofile.out -cpuprofile profile.out
//go test -run=^$ -bench'^BenchmarkRBACAuthorize$' -benchmem -memprofile memprofile.out -cpuprofile profile.out
func BenchmarkRBACAuthorize(b *testing.B) {
benchCases, user, orgs := benchmarkUserCases()
users := append([]uuid.UUID{},
Expand DownExpand Up@@ -178,7 +178,7 @@ func BenchmarkRBACAuthorize(b *testing.B) {
// BenchmarkRBACAuthorizeGroups benchmarks the rbac.Authorize method and leverages
// groups for authorizing rather than the permissions/roles.
//
//go test -bench BenchmarkRBACAuthorizeGroups -benchmem -memprofile memprofile.out -cpuprofile profile.out
//go test -bench'^BenchmarkRBACAuthorizeGroups$' -benchmem -memprofile memprofile.out -cpuprofile profile.out
func BenchmarkRBACAuthorizeGroups(b *testing.B) {
benchCases, user, orgs := benchmarkUserCases()
users := append([]uuid.UUID{},
Expand DownExpand Up@@ -229,7 +229,7 @@ func BenchmarkRBACAuthorizeGroups(b *testing.B) {

// BenchmarkRBACFilter benchmarks the rbac.Filter method.
//
//go test -bench BenchmarkRBACFilter -benchmem -memprofile memprofile.out -cpuprofile profile.out
//go test -bench'^BenchmarkRBACFilter$' -benchmem -memprofile memprofile.out -cpuprofile profile.out
func BenchmarkRBACFilter(b *testing.B) {
benchCases, user, orgs := benchmarkUserCases()
users := append([]uuid.UUID{},
Expand Down
141 changes: 91 additions & 50 deletionscoderd/rbac/policy.rego
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,76 +29,93 @@ import rego.v1
# different code branches based on the org_owner. 'num's value does, but
# that is the whole point of partial evaluation.

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

bool_flip(b) :=flipped if {
bool_flip(b) :=true if {
not b
flipped = true
}

# number is a quick way to get a set of {true, false} and convert it to
# -1: {false, true} or {false}
# 0: {}
# 1: {true}
number(set) := c if {
count(set) == 0
c := 0
}
# number(set) maps a set of boolean values to one of the following numbers:
# -1: deny (if 'false' value is in the set) => set is {true, false} or {false}
# 0: no decision (if the set is empty) => set is {}
# 1: allow (if only 'true' values are in the set)=> set is {true}

number(set) := c if {
# Return -1 if the set contains any 'false' value (i.e., an explicit deny)
number(set) := -1 if {
false in set
c := -1
}

number(set) := c if {
# Return 0 if the set is empty (no matching permissions)
number(set) := 0 if {
count(set) == 0
}

# Return 1 if the set is non-empty and contains no 'false' values (i.e., only allows)
number(set) := 1 if {
not false in set
set[_]
c := 1
}

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

# -------------------
# Site Level Rules
# -------------------

default site := 0
site := site_allow(input.subject.roles)

default scope_site := 0

scope_site := site_allow([input.subject.scope])

# site_allow receives a list of roles and returns a single number:
# -1 if any matching permission denies access
# 1 if there's at least one allow and no denies
# 0 if there are no matching permissions
site_allow(roles) := num if {
# allow is a set of boolean valueswithoutduplicates.
allow := {x |
# allow is a set of boolean values(sets don't containduplicates)
allow := {is_allowed |
# Iterate over all site permissions in all roles
perm := roles[_].site[_]
perm.action in [input.action, "*"]
perm.resource_type in [input.object.type, "*"]

#x is either 'true' or 'false' if a matching permission exists.
x := bool_flip(perm.negate)
#is_allowed is either 'true' or 'false' if a matching permission exists.
is_allowed := bool_flip(perm.negate)
}
num := number(allow)
}

# -------------------
# Org Level Rules
# -------------------

# org_members is the list of organizations the actor is apart of.
org_members := {orgID |
input.subject.roles[_].org[orgID]
}

# org is the same as 'site' except we need to iterate over each organization
#'org' is the same as 'site' except we need to iterate over each organization
# that the actor is a member of.
default org := 0

org := org_allow(input.subject.roles)

default scope_org := 0

scope_org := org_allow([input.scope])

# org_allow_set is a helper function that iterates over all orgs that the actor
Expand All@@ -114,11 +131,14 @@ scope_org := org_allow([input.scope])
org_allow_set(roles) := allow_set if {
allow_set := {id: num |
id := org_members[_]
set := {x |
set := {is_allowed |
# Iterate over all org permissions in all roles
perm := roles[_].org[id][_]
perm.action in [input.action, "*"]
perm.resource_type in [input.object.type, "*"]
x := bool_flip(perm.negate)

# is_allowed is either 'true' or 'false' if a matching permission exists.
is_allowed := bool_flip(perm.negate)
}
num := number(set)
}
Expand DownExpand Up@@ -191,24 +211,30 @@ org_ok if {
not input.object.any_org
}

# User is the same as the site, except it only applies if the user owns the object and
# -------------------
# User Level Rules
# -------------------

# 'user' is the same as 'site', except it only applies if the user owns the object and
# the user is apart of the org (if the object has an org).
default user := 0

user := user_allow(input.subject.roles)

default user_scope := 0

default scope_user := 0
scope_user := user_allow([input.scope])

user_allow(roles) := num if {
input.object.owner != ""
input.subject.id = input.object.owner
allow := {x |

allow := {is_allowed |
# Iterate over all user permissions in all roles
perm := roles[_].user[_]
perm.action in [input.action, "*"]
perm.resource_type in [input.object.type, "*"]
x := bool_flip(perm.negate)

# is_allowed is either 'true' or 'false' if a matching permission exists.
is_allowed := bool_flip(perm.negate)
}
num := number(allow)
}
Expand All@@ -227,17 +253,9 @@ scope_allow_list if {
input.object.id in input.subject.scope.allow_list
}

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

role_allow if {
site = 1
Expand All@@ -258,6 +276,10 @@ role_allow if {
user = 1
}

# -------------------
# Scope-Specific Rules
# -------------------

scope_allow if {
scope_allow_list
scope_site = 1
Expand All@@ -280,6 +302,11 @@ scope_allow if {
scope_user = 1
}

# -------------------
# ACL-Specific Rules
# Access Control List
# -------------------

# ACL for users
acl_allow if {
# Should you have to be a member of the org too?
Expand DownExpand Up@@ -308,11 +335,25 @@ acl_allow if {
[input.action, "*"][_] in perms
}

###############
# -------------------
# Final Allow
#
# The 'allow' block is quite simple. Any set with `-1` cascades down in levels.
# Authorization looks for any `allow` statement that is true. Multiple can be true!
# Note that the absence of `allow` means "unauthorized".
# An explicit `"allow": true` is required.
#
# Scope is also applied. The default scope is "wildcard:wildcard" allowing
# all actions. If the scope is not "1", then the action is not authorized.
#
#
# Allow query:
# data.authz.role_allow = true
# data.authz.scope_allow = true
# -------------------

# The role or the ACL must allow the action. Scopes can be used to limit,
# so scope_allow must always be true.

allow if {
role_allow
scope_allow
Expand Down
Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp