test: Add unit test for rbac Authorize() function #853
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.
|
| type Role struct { | ||
| Level permLevel | ||
| Permissions []Permission | ||
| } |
There was a problem hiding this comment.
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) { |
|
Please don't merge until I review! I'll get to it before the afternoon. |
kylecarbs
left a comment
There was a problem hiding this comment.
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.
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.
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.
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.
I think it's only common in this particular domain. Cloudflare for example uses the terms fairly prominently. (EDIT: Steven already linked it 😆)
| 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.
Why have all these helper functions when you can just access the struct directly?
There was a problem hiding this comment.
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.
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.
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.
All of our resource identifiers are UUIDs, can this be one too?
There was a problem hiding this comment.
I thought some ids from v1 are still strings. I would like uuid yes, I can change if this is true
There was a problem hiding this comment.
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.
In the absence of a -, would a + be implicit?
There was a problem hiding this comment.
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.
I think I'd prefer implicit, as - nodes should rarely be used from my perspective.
| 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.
Are we planning on storing permissions as a comma-separated array? Or is this a helper function?
There was a problem hiding this comment.
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.
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.
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.
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.
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.
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.
Can we default to allows in the absence of denies?
There was a problem hiding this comment.
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.
Just so I understand, workspaces.*.create would be interpreted as a deny by default in those implementations?
There was a problem hiding this comment.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Could we reference these directly instead of abstracting them into abbreviated labels?
There was a problem hiding this comment.
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