-
Notifications
You must be signed in to change notification settings - Fork 883
test: Add unit test for rbac Authorize()
function
#853
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 our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ab61328
03e4d0f
9981291
3ab32da
e1d5893
84a90f3
1fac0d9
1e3aac0
e977e84
00a7c3f
1f04c01
fbf4db1
4946897
7e6cc66
a0017e5
4b110b3
65ef4e3
d294786
c1f8945
01f3d40
de7de6e
4c86e44
30c6568
a419a65
bbd1c4c
def010f
c4ee590
84e3ab9
c2eec18
5a2834a
913d141
2804b92
5698938
75ed8ef
b2db661
26ef1e6
19aba30
ceee9cd
ee8bf04
44c02a1
dfb9ad1
e482d2c
a4e038f
9918c16
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
# Authz | ||
|
||
Package `authz` implements AuthoriZation for Coder. | ||
|
||
## Overview | ||
|
||
Authorization defines what **permission** an **subject** has to perform **actions** to **objects**: | ||
- **Permission** is binary: *yes* (allowed) or *no* (denied). | ||
- **Subject** in this case is anything that implements interface `authz.Subject`. | ||
- **Action** here is an enumerated list of actions, but we stick to `Create`, `Read`, `Update`, and `Delete` here. | ||
- **Object** here is anything that implements `authz.Object`. | ||
|
||
## Permission Structure | ||
|
||
A **permission** is a rule that grants or denies access for a **subject** to perform an **action** on a **object**. | ||
A **permission** is always applied at a given **level**: | ||
|
||
- **site** level applies to all objects in a given Coder deployment. | ||
- **org** level applies to all objects that have an organization owner (`org_owner`) | ||
- **user** level applies to all objects that have an owner with the same ID as the subject. | ||
|
||
**Permissions** at a higher **level** always override permissions at a **lower** level. | ||
|
||
The effect of a **permission** can be: | ||
- **positive** (allows) | ||
- **negative** (denies) | ||
- **abstain** (neither allows or denies, not applicable) | ||
|
||
**Negative** permissions **always** override **positive** permissions at the same level. | ||
Both **negative** and **positive** permissions override **abstain** at the same level. | ||
|
||
This can be represented by the following truth table, where Y represents *positive*, N represents *negative*, and _ represents *abstain*: | ||
|
||
| Action | Positive | Negative | Result | | ||
|--------|----------|----------|--------| | ||
| read | Y | _ | Y | | ||
| read | Y | N | N | | ||
| read | _ | _ | _ | | ||
| read | _ | N | Y | | ||
|
||
|
||
## Permission Representation | ||
|
||
**Permissions** are represented in string format as `<sign>?<level>.<object>.<id>.<action>`, where: | ||
|
||
- `sign` can be either `+` or `-`. If it is omitted, sign is assumed to be `+`. | ||
- `level` is either `*`, `site`, `org`, or `user`. | ||
- `object` is any valid resource type. | ||
- `id` is any valid UUID v4. | ||
- `action` is `create`, `read`, `modify`, or `delete`. | ||
|
||
## Example Permissions | ||
|
||
- `+site.*.*.read`: allowed to perform the `read` action against all objects of type `devurl` in a given Coder deployment. | ||
- `-user.workspace.*.create`: user is not allowed to create workspaces. | ||
|
||
## Roles | ||
|
||
A *role* is a set of permissions. When evaluating a role's permission to form an action, all the relevant permissions for the role are combined at each level. Permissions at a higher level override permissions at a lower level. | ||
|
||
The following table shows the per-level role evaluation. | ||
Y indicates that the role provides positive permissions, N indicates the role provides negative permissions, and _ indicates the role does not provide positive or negative permissions. YN_ indicates that the value in the cell does not matter for the access result. | ||
|
||
| Role (example) | Site | Org | User | Result | | ||
|-----------------|------|-----|------|--------| | ||
| site-admin | Y | YN_ | YN_ | Y | | ||
| no-permission | N | YN_ | YN_ | N | | ||
| org-admin | _ | Y | YN_ | Y | | ||
| non-org-member | _ | N | YN_ | N | | ||
| user | _ | _ | Y | Y | | ||
| | _ | _ | N | N | | ||
| unauthenticated | _ | _ | _ | N | | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
package authz | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could this be named There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When authn comes in, I think it'll be like this.
Maybe There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason to not call them There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's only common in this particular domain. Cloudflare for example uses the terms fairly prominently. (EDIT: Steven already linked it 😆) |
||
|
||
// Action represents the allowed actions to be done on an object. | ||
type Action string | ||
|
||
const ( | ||
ActionCreate = "create" | ||
ActionRead = "read" | ||
ActionUpdate = "update" | ||
ActionDelete = "delete" | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
package authz | ||
|
||
// TODO: Implement Authorize | ||
func Authorize(subj Subject, obj Resource, action Action) error { | ||
// TODO: Expand subject roles into their permissions as appropriate. Apply scopes. | ||
return AuthorizePermissions(subj.ID(), []Permission{}, obj, action) | ||
} | ||
|
||
// AuthorizePermissions runs the authorize function with the raw permissions in a single list. | ||
func AuthorizePermissions(_ string, _ []Permission, _ Resource, _ Action) error { | ||
return nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,258 @@ | ||
package authz_test | ||
|
||
import ( | ||
"math/bits" | ||
"strings" | ||
"testing" | ||
|
||
"github.com/coder/coder/coderd/authz" | ||
"github.com/coder/coder/coderd/authz/authztest" | ||
) | ||
|
||
var nilSet = authztest.Set{nil} | ||
|
||
func TestExhaustiveAuthorize(t *testing.T) { | ||
t.Parallel() | ||
|
||
all := authztest.GroupedPermissions(authztest.AllPermissions()) | ||
roleVariants := permissionVariants(all) | ||
res := authz.ResourceType(authztest.PermObjectType).AsID(authztest.PermObjectID) | ||
|
||
testCases := []struct { | ||
Name string | ||
Objs []authz.Resource | ||
// Action is constant | ||
// Subject comes from roleVariants | ||
Result func(pv string) bool | ||
}{ | ||
{ | ||
Name: "User:Org", | ||
Objs: []authz.Resource{ | ||
res.Owner(authztest.PermMe).Org(authztest.PermOrgID), | ||
}, | ||
Result: func(pv string) bool { | ||
return strings.Contains(pv, "+") | ||
}, | ||
}, | ||
{ | ||
// All U+/- tests should fail | ||
Name: "NotUser:Org", | ||
Objs: []authz.Resource{ | ||
res.Owner("other").Org(authztest.PermOrgID), | ||
res.Owner("").Org(authztest.PermOrgID), | ||
}, | ||
Result: func(pv string) bool { | ||
if strings.Contains(pv, "U") { | ||
return false | ||
} | ||
return strings.Contains(pv, "+") | ||
}, | ||
}, | ||
{ | ||
// All O+/- and U+/- tests should fail | ||
Name: "NotUser:NotOrg", | ||
Objs: []authz.Resource{ | ||
res.Owner(authztest.PermMe).Org("non-mem"), | ||
res.Owner("other").Org("non-mem"), | ||
res.Owner("other").Org(""), | ||
res.Owner("").Org("non-mem"), | ||
res.Owner("").Org(""), | ||
}, | ||
|
||
Result: func(pv string) bool { | ||
if strings.Contains(pv, "U") { | ||
return false | ||
} | ||
if strings.Contains(pv, "O") { | ||
return false | ||
} | ||
return strings.Contains(pv, "+") | ||
}, | ||
}, | ||
// TODO: @emyrk for this one, we should probably pass a custom roles variant | ||
//{ | ||
// // O+, O- no longer pass judgement. Defer to user level judgement (only somewhat tricky case) | ||
// Name: "User:NotOrg", | ||
// Objs: authztest.Objects( | ||
// []string{authztest.PermMe, ""}, | ||
// ), | ||
// Result: func(pv string) bool { | ||
// return strings.Contains(pv, "+") | ||
// }, | ||
// }, | ||
} | ||
|
||
failedTests := make(map[string]int) | ||
//nolint:paralleltest | ||
for _, c := range testCases { | ||
t.Run(c.Name, func(t *testing.T) { | ||
for _, o := range c.Objs { | ||
for name, v := range roleVariants { | ||
v.Each(func(set authztest.Set) { | ||
// TODO: Authz.Permissions does allocations at the moment. We should fix that. | ||
err := authz.AuthorizePermissions( | ||
authztest.PermMe, | ||
set.Permissions(), | ||
o, | ||
authztest.PermAction) | ||
if c.Result(name) && err != nil { | ||
failedTests[name]++ | ||
} else if !c.Result(name) && err == nil { | ||
failedTests[name]++ | ||
} | ||
}) | ||
v.Reset() | ||
} | ||
} | ||
}) | ||
} | ||
// TODO: @emyrk when we implement the correct authorize, we can enable this check. | ||
// for testName, numFailed := range failedTests { | ||
// require.Zero(t, failedTests[testName], fmt.Sprintf("%s: %d tests failed", testName, numFailed)) | ||
// } | ||
} | ||
|
||
func permissionVariants(all authztest.SetGroup) map[string]*authztest.Role { | ||
// an is any noise above the impactful set | ||
an := noiseAbstain | ||
// ln is any noise below the impactful set | ||
ln := noisePositive | noiseNegative | noiseAbstain | ||
|
||
// Cases are X+/- where X indicates the level where the impactful set is. | ||
// The impactful set determines the result. | ||
return map[string]*authztest.Role{ | ||
// Wild | ||
"W+": authztest.NewRole( | ||
pos(all.Wildcard()), | ||
noise(ln, all.Site(), all.Org(), all.User()), | ||
), | ||
"W-": authztest.NewRole( | ||
neg(all.Wildcard()), | ||
noise(ln, all.Site(), all.Org(), all.User()), | ||
), | ||
// Site | ||
"S+": authztest.NewRole( | ||
noise(an, all.Wildcard()), | ||
pos(all.Site()), | ||
noise(ln, all.Org(), all.User()), | ||
), | ||
"S-": authztest.NewRole( | ||
noise(an, all.Wildcard()), | ||
neg(all.Site()), | ||
noise(ln, all.Org(), all.User()), | ||
), | ||
// Org:* -- Added org:mem noise | ||
"O+": authztest.NewRole( | ||
noise(an, all.Wildcard(), all.Site(), all.OrgMem()), | ||
pos(all.Org()), | ||
noise(ln, all.User()), | ||
), | ||
"O-": authztest.NewRole( | ||
noise(an, all.Wildcard(), all.Site(), all.OrgMem()), | ||
neg(all.Org()), | ||
noise(ln, all.User()), | ||
), | ||
// Org:Mem -- Added org:* noise | ||
"M+": authztest.NewRole( | ||
noise(an, all.Wildcard(), all.Site(), all.Org()), | ||
pos(all.OrgMem()), | ||
noise(ln, all.User()), | ||
), | ||
"M-": authztest.NewRole( | ||
noise(an, all.Wildcard(), all.Site(), all.Org()), | ||
neg(all.OrgMem()), | ||
noise(ln, all.User()), | ||
), | ||
// User | ||
"U+": authztest.NewRole( | ||
noise(an, all.Wildcard(), all.Site(), all.Org()), | ||
pos(all.User()), | ||
), | ||
"U-": authztest.NewRole( | ||
noise(an, all.Wildcard(), all.Site(), all.Org()), | ||
neg(all.User()), | ||
), | ||
// Abstain | ||
"A+": authztest.NewRole( | ||
authztest.Union( | ||
all.Wildcard().Abstain(), | ||
all.Site().Abstain(), | ||
all.Org().Abstain(), | ||
all.OrgMem().Abstain(), | ||
all.User().Abstain(), | ||
), | ||
all.User().Positive()[:1], | ||
), | ||
"A-": authztest.NewRole( | ||
authztest.Union( | ||
all.Wildcard().Abstain(), | ||
all.Site().Abstain(), | ||
all.Org().Abstain(), | ||
all.OrgMem().Abstain(), | ||
all.User().Abstain(), | ||
), | ||
), | ||
} | ||
} | ||
|
||
// pos returns the positive impactful variant for a given level. It does not | ||
// include noise at any other level but the one given. | ||
func pos(lvl authztest.LevelGroup) *authztest.Role { | ||
return authztest.NewRole( | ||
lvl.Positive(), | ||
authztest.Union(lvl.Abstain()[:1], nilSet), | ||
) | ||
} | ||
|
||
func neg(lvl authztest.LevelGroup) *authztest.Role { | ||
return authztest.NewRole( | ||
lvl.Negative(), | ||
authztest.Union(lvl.Positive()[:1], nilSet), | ||
authztest.Union(lvl.Abstain()[:1], nilSet), | ||
) | ||
} | ||
|
||
type noiseBits uint8 | ||
|
||
const ( | ||
_ noiseBits = 1 << iota | ||
noisePositive | ||
noiseNegative | ||
noiseAbstain | ||
) | ||
|
||
func flagMatch(flag, in noiseBits) bool { | ||
return flag&in != 0 | ||
} | ||
|
||
// noise returns the noise permission permutations for a given level. You can | ||
// use this helper function when this level is not impactful. | ||
// The returned role is the permutations including at least one example of | ||
// positive, negative, and neutral permissions. It also includes the set of | ||
// no additional permissions. | ||
func noise(f noiseBits, lvls ...authztest.LevelGroup) *authztest.Role { | ||
rs := make([]authztest.Iterable, 0, len(lvls)) | ||
for _, lvl := range lvls { | ||
sets := make([]authztest.Iterable, 0, bits.OnesCount8(uint8(f))) | ||
|
||
if flagMatch(noisePositive, f) { | ||
sets = append(sets, authztest.Union(lvl.Positive()[:1], nilSet)) | ||
} | ||
if flagMatch(noiseNegative, f) { | ||
sets = append(sets, authztest.Union(lvl.Negative()[:1], nilSet)) | ||
} | ||
if flagMatch(noiseAbstain, f) { | ||
sets = append(sets, authztest.Union(lvl.Abstain()[:1], nilSet)) | ||
} | ||
|
||
rs = append(rs, authztest.NewRole( | ||
sets..., | ||
)) | ||
} | ||
|
||
if len(rs) == 1 { | ||
role, _ := rs[0].(*authztest.Role) | ||
return role | ||
} | ||
return authztest.NewRole(rs...) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we default to allows in the absence of denies?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that's a good idea.
Default deny is the de-facto behaviour of operation for the other authorization implementations I reviewed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just so I understand,
workspaces.*.create
would be interpreted as a deny by default in those implementations?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, for example here: https://sourcegraph.com/github.com/fleetdm/fleet/-/blob/server/authz/policy_test.go?L69
A
nil
user is not allowed to either read or writesession
. Obviously what you want to deny-by-default depends on what you're building.