-
Notifications
You must be signed in to change notification settings - Fork 881
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
Conversation
Just testing out some ideas. The code is far from finished, and very sloppy. Committing to share it to start conversations
Playing around with helper functions to make life easy
- Cleanup some code too
- Add some docs
Authorize()
function
Codecov Report
@@ Coverage Diff @@
## main #853 +/- ##
==========================================
+ Coverage 65.74% 66.10% +0.35%
==========================================
Files 202 226 +24
Lines 13209 14048 +839
Branches 87 103 +16
==========================================
+ Hits 8684 9286 +602
- Misses 3633 3840 +207
- Partials 892 922 +30
Continue to review full report at Codecov.
|
coderd/authz/role.go
Outdated
type Role struct { | ||
Level permLevel | ||
Permissions []Permission | ||
} |
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.
This is very TBD. Just a placeholder
Example added: coder/coderd/authz/example_test.go Lines 35 to 65 in 75ed8ef
This PR is not the RBAC implementation, but only the stubbed functions and unit tests to test the rbac opa/rego. @johnstcn and I decided to generate the tests instead of enumerating cases manually. This enumeration is way more comprehensive than any manual tabled test. All files in Once thus is merged, other devs can use rbac and for now the stub always returns no error |
coderd/authz/example_test.go
Outdated
|
||
// Test_Example gives some examples on how to use the authz library. | ||
// This serves to test syntax more than functionality. | ||
func Test_Example(t *testing.T) { |
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.
👍
Please don't merge until I review! I'll get to it before the afternoon. |
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.
Mostly questions here. Steven and I hopped in Discord which helped me understand the objective quite a bit.
Minor nit: in other places we haven't used _
in test names. I'm impartial to either way, but we should be consistent.
@@ -0,0 +1,11 @@ | |||
package authz |
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.
Could this be named rbac
instead? I think it might more accurately scope what we're trying to do.
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.
When authn comes in, I think it'll be like this. authz
is not my term, https://www.cloudflare.com/learning/access-management/authn-vs-authz/. It's more clear than rbac
, as it's the authorize
function. TBD on where the roles go.
.
├── auth
│ ├── authz/
│ └── authn/
├── subject.go
├── object.go
├── action.go
├── roles.go
└── authorize.go
Maybe auth/rbac
can have roles? Still in the air, just needed to stub until Dean was ready.
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.
Is there a reason to not call them authorization
and authentication
? I've rarely seen the terms authn and authz used in the wild, so it's confusing to me at a glance.
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 think it's only common in this particular domain. Cloudflare for example uses the terms fairly prominently. (EDIT: Steven already linked it 😆)
coderd/authz/object.go
Outdated
func (z zObject) ResourceType() ResourceType { | ||
return z.ObjectType | ||
} | ||
|
||
func (z zObject) OwnerID() string { | ||
return z.OwnedBy | ||
} | ||
|
||
func (z zObject) OrgOwnerID() string { | ||
return z.OwnedByOrg | ||
} | ||
|
||
// Org adds an org OwnerID to the resource | ||
//nolint:revive | ||
func (z zObject) Org(orgID string) zObject { | ||
z.OwnedByOrg = orgID | ||
return z | ||
} | ||
|
||
// Owner adds an OwnerID to the resource | ||
//nolint:revive | ||
func (z zObject) Owner(id string) zObject { | ||
z.OwnedBy = id | ||
return z | ||
} | ||
|
||
//nolint:revive | ||
func (z zObject) AsID(id string) zObject { | ||
z.ObjectID = id | ||
return z | ||
} |
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.
Why have all these helper functions when you can just access the struct directly?
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 want to expose this object. I believe the api is cleaner when you have to stem from a resource type. The accessors above are to fulfill an interface.
obj := authz.ResourceWorkspace.Org("default")
// VS
obj := authz.ZObject{
OrgOwner: "default",
ResourceType: authz.ResourceWorkspace,
}
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'm in favor of the more verbose option. Especially with something like RBAC, I think we should put extreme emphasis on simplicity in what we expose to the callers. Wrapping these feels unnecessary since they're just setting a struct parameter anyways.
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.
authz.Authorize(user, authz.ResourceWorkspace.Org("default").Owner(user.UserID).AsID("1234"), authz.ActionRead)
authz.Authorize(user, authz.ResourceWorkspace, authz.ActionRead)
To
authz.Authorize(user, authz.ActionRead, authz.ZObject{
Owner: user.UserID,
OrgOwner: "default",
ID: "1234"
ResourceType: authz.ResourceWorkspace,
})
authz.Authorize(user, authz.ActionRead, authz.ZObject{
ResourceType: authz.ResourceWorkspace,
})
LevelID string | ||
|
||
ResourceType ResourceType | ||
ResourceID string |
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.
All of our resource identifiers are UUIDs, can this be one too?
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 thought some id
s from v1 are still strings. I would like uuid yes, I can change if this is true
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.
This is true! All v1 IDs have been converted to UUIDs.
coderd/authz/permission.go
Outdated
|
||
// String returns the <level>.<resource_type>.<id>.<action> string formatted permission. | ||
func (p Permission) String() string { | ||
sign := "-" |
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.
In the absence of a -
, would a +
be implicit?
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.
It is yes, I thought it'd be fine to just be explicit. I can roll either way on it
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 think I'd prefer implicit, as -
nodes should rarely be used from my perspective.
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.
Sure 👍
func ParsePermissions(perms string) ([]Permission, error) { | ||
permList := strings.Split(perms, ",") | ||
parsed := make([]Permission, 0, len(permList)) | ||
for _, permStr := range permList { | ||
p, err := ParsePermission(strings.TrimSpace(permStr)) | ||
if err != nil { | ||
return nil, xerrors.Errorf("perm '%s': %w", permStr, err) | ||
} | ||
parsed = append(parsed, p) | ||
} | ||
return parsed, nil | ||
} |
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.
Are we planning on storing permissions as a comma-separated array? Or is this a helper function?
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 a helper function. It's easier to make permissions this way. We will probably store as json
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.
Could we store it as a string array? Exposing a helper function to the API that's just used for tests may confuse a caller.
) | ||
|
||
const ( | ||
OtherOption = "other" |
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.
What is this?
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.
For testing purposes, we want to ensure a non-relevant permission is in fact not relevant.
So I use this for fields. Like other
action should never be relevant
Make negate bool default to positive permission
coderd/authz/README.md
Outdated
The effect of a **permission** can be: | ||
- **positive** (allows) | ||
- **negative** (denies) | ||
- **abstain** (neither allows or denies, but interpreted as deny by default) |
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.
Why do we need abstain?
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.
It's part of the permission model.
Some permissions are not applicable to a given request.
They will return an abstain
result in this case.
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.
Is this so we could display to customers which permission node is truly having an impact on the success/failure of an auth request?
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.
It is not. Abstain is just naming the case where a permission node is irrelevant. So it's just naming this case so our language is complete in that we can describe all permissions.
Example: If the input action is read
, then the perm *.*.*.update
has no effect and passes no judgement. We named this abstain
.
By default, if the only effect from a set of permissions is abstain
, then we default to deny. Since they have no perm to access the object.
**Permissions** at a higher **level** always override permissions at a **lower** level. | ||
|
||
The effect of a **permission** can be: | ||
- **positive** (allows) |
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 write session
. Obviously what you want to deny-by-default depends on what you're building.
coderd/authz/README.md
Outdated
|
||
## Example Permissions | ||
|
||
- `+site.devurl.*.read`: allowed to perform the `read` action against all resources of type `devurl` in a given Coder deployment. |
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.
Could you add docs on why site
is required here? This seems like it'd be attached to a workspace.
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.
Eh, it's probably a bad example. +site:*.*.read
might be better here.
// An example is if you want to list all workspaces, you can create a zObject | ||
// that represents the set of workspaces you are trying to get access too. | ||
// Do not export this type, as it can be created from a resource type constant. | ||
type zObject struct { |
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.
Does this need to be prefixed with z
? The package already ends with z
, so I'd assume not.
type UserResource interface { | ||
Resource | ||
OwnerID() string | ||
} | ||
|
||
type OrgResource interface { | ||
Resource | ||
OrgOwnerID() string | ||
} |
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 can't find anywhere that OwnerID
or OrgOwnerID
is used. Can we remove these interfaces?
type Resource interface { | ||
ID() string | ||
ResourceType() ResourceType | ||
} | ||
|
||
type UserResource interface { | ||
Resource | ||
OwnerID() string | ||
} | ||
|
||
type OrgResource interface { | ||
Resource | ||
OrgOwnerID() string | ||
} |
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 see in the code how these interfaces are used. Can we remove them?
coderd/authz/object.go
Outdated
func (z zObject) OrgOwnerID() string { | ||
return z.OwnedByOrg |
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 think it's generally bad practice to expose a struct parameter under a different name.
OwnerByOrg
and OrgOwnerID
are the same thing, but in different words. What's the harm in exposing OwnedByOrg
? I feel like these wrapper functions are misdirecting.
|
||
// Org adds an org OwnerID to the resource | ||
//nolint:revive | ||
func (z zObject) Org(orgID string) zObject { |
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.
In idiomatic Go, these would be SetOrgOwnerID
https://go.dev/doc/effective_go#Getters
TODO | ||
Each row in the above table corresponds to a set of role permutations. | ||
There are 12 possible groups of role permutations: | ||
- Case 1 (W+): |
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.
Why did we need to create our own mini-DSL to test these cases? Since there's only 12, could we just write 12 test cases?
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.
This isn't a DSL, it's just labeling categories for communication. It allows understanding a failure in O+ means there is a bug in positive org permissions.
Note that these 12 cases are applied to object permutations too. Using the labels makes this very easy to identify which sets change based on the object input.
So they are just names.
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.
Could we reference these directly instead of abstracting them into abbreviated labels?
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.
Closing this PR, so it doesn't matter, but giving them string names in a map or var names seems identical to me. It's a matter of documenting what the notation means, which I believe I have done in Notion. Would have had to be ported to Git if we continued this route.
unexport fields from zobject
We will not be generating the search space to test authz. |
What this does
The main objective of this PR is to write the unit test for the
Authorize()
function before it is implemented. As consequence the function is also stubbed for use before authz is ready.Unit test coverage
The only lines not covered will be covered or removed with the Authz implementation. The lines exist as stubs for "using" the library before it is ready. 100% will be reached as the implementation is introduced
Issues
Implement tests for permissions: #715
Implement stub function to authenticate: #718
Build the struct for permission format for RBAC: #720