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

Skip to content

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

Closed
wants to merge 44 commits into from

Conversation

Emyrk
Copy link
Member

@Emyrk Emyrk commented Apr 4, 2022

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

@Emyrk Emyrk marked this pull request as draft April 4, 2022 14:48
@Emyrk Emyrk changed the title test: Add unit test for rbac ` test: Add unit test for rbac Authorize() function Apr 4, 2022
@codecov
Copy link

codecov bot commented Apr 4, 2022

Codecov Report

Merging #853 (9918c16) into main (4dd3c57) will increase coverage by 0.35%.
The diff coverage is 86.34%.

@@            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     
Flag Coverage Δ
unittest-go- 65.90% <86.34%> (+0.73%) ⬆️
unittest-go-macos-latest 53.73% <86.34%> (+1.50%) ⬆️
unittest-go-ubuntu-latest 55.99% <86.34%> (+0.93%) ⬆️
unittest-go-windows-2022 52.94% <86.34%> (+1.23%) ⬆️
unittest-js 58.83% <ø> (-3.81%) ⬇️
Impacted Files Coverage Δ
coderd/authz/subject.go 7.69% <7.69%> (ø)
coderd/authz/resources.go 52.63% <52.63%> (ø)
coderd/authz/object.go 52.94% <52.94%> (ø)
coderd/authz/permission.go 96.77% <96.77%> (ø)
coderd/authz/authz.go 100.00% <100.00%> (ø)
coderd/authz/authztest/iterator.go 100.00% <100.00%> (ø)
coderd/authz/authztest/level.go 100.00% <100.00%> (ø)
coderd/authz/authztest/permissions.go 100.00% <100.00%> (ø)
coderd/authz/authztest/role.go 100.00% <100.00%> (ø)
coderd/authz/authztest/set.go 100.00% <100.00%> (ø)
... and 51 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 4dd3c57...9918c16. Read the comment docs.

Comment on lines 3 to 6
type Role struct {
Level permLevel
Permissions []Permission
}
Copy link
Member Author

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

@Emyrk Emyrk marked this pull request as ready for review April 5, 2022 12:58
@Emyrk
Copy link
Member Author

Emyrk commented Apr 5, 2022

Could an example be added? I'm a bit confused how this package would be consumed.

Example added:

t.Run("ReadAllWorkspaces", func(t *testing.T) {
// To read all workspaces on the site
err := authz.Authorize(user, authz.ResourceWorkspace, authz.ActionRead)
var _ = err
// require.Error(t, err, "this user cannot read all workspaces")
})
//nolint:paralleltest
t.Run("ReadOrgWorkspaces", func(t *testing.T) {
// To read all workspaces on the org 'default'
err := authz.Authorize(user, authz.ResourceWorkspace.Org("default"), authz.ActionRead)
require.NoError(t, err, "this user can read all org workspaces in 'default'")
})
//nolint:paralleltest
t.Run("ReadMyWorkspace", func(t *testing.T) {
// Note 'database.Workspace' could fulfill the object interface and be passed in directly
err := authz.Authorize(user, authz.ResourceWorkspace.Org("default").Owner(user.UserID), authz.ActionRead)
require.NoError(t, err, "this user can their workspace")
err = authz.Authorize(user, authz.ResourceWorkspace.Org("default").Owner(user.UserID).AsID("1234"), authz.ActionRead)
require.NoError(t, err, "this user can read workspace '1234'")
})
//nolint:paralleltest
t.Run("CreateNewSiteUser", func(t *testing.T) {
err := authz.Authorize(user, authz.ResourceUser, authz.ActionCreate)
var _ = err
// require.Error(t, err, "this user cannot create new users")
})
}

I'm nervous that the size and scope of functionality here are not aligned with the market status quo. Did we take precedent from another Enterprise product to determine feature scope?

I understand this is WIP, so if things are likely to change we can punt this discussion until it's more final.

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 authztest are purely for testing. So only <300 LoC defines the surface area of this package so far.

Once thus is merged, other devs can use rbac and for now the stub always returns no error

@Emyrk Emyrk requested review from kylecarbs and johnstcn April 5, 2022 13:03

// 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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@kylecarbs
Copy link
Member

Please don't merge until I review! I'll get to it before the afternoon.

Copy link
Member

@kylecarbs kylecarbs left a 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
Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member

@johnstcn johnstcn Apr 5, 2022

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 😆)

Comment on lines 41 to 71
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
}
Copy link
Member

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?

Copy link
Member Author

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,
}

Copy link
Member

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.

Copy link
Member Author

@Emyrk Emyrk Apr 5, 2022

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
Copy link
Member

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?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought some ids from v1 are still strings. I would like uuid yes, I can change if this is true

Copy link
Member

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.


// String returns the <level>.<resource_type>.<id>.<action> string formatted permission.
func (p Permission) String() string {
sign := "-"
Copy link
Member

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?

Copy link
Member Author

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

Copy link
Member

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure 👍

Comment on lines +50 to +61
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
}
Copy link
Member

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?

Copy link
Member Author

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

Copy link
Member

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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this?

Copy link
Member Author

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

The effect of a **permission** can be:
- **positive** (allows)
- **negative** (denies)
- **abstain** (neither allows or denies, but interpreted as deny by default)
Copy link
Member

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?

Copy link
Member

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.

Copy link
Member

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?

Copy link
Member Author

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)
Copy link
Member

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?

Copy link
Member

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.

Copy link
Member

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?

Copy link
Member

@johnstcn johnstcn Apr 6, 2022

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.


## Example Permissions

- `+site.devurl.*.read`: allowed to perform the `read` action against all resources of type `devurl` in a given Coder deployment.
Copy link
Member

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.

Copy link
Member

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 {
Copy link
Member

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.

Comment on lines +8 to +16
type UserResource interface {
Resource
OwnerID() string
}

type OrgResource interface {
Resource
OrgOwnerID() string
}
Copy link
Member

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?

Comment on lines +3 to +16
type Resource interface {
ID() string
ResourceType() ResourceType
}

type UserResource interface {
Resource
OwnerID() string
}

type OrgResource interface {
Resource
OrgOwnerID() string
}
Copy link
Member

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?

Comment on lines 49 to 50
func (z zObject) OrgOwnerID() string {
return z.OwnedByOrg
Copy link
Member

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 {
Copy link
Member

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+):
Copy link
Member

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?

Copy link
Member Author

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.

Screenshot from 2022-04-06 09-42-33

So they are just names.

Copy link
Member

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?

Copy link
Member Author

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.

@Emyrk
Copy link
Member Author

Emyrk commented Apr 8, 2022

We will not be generating the search space to test authz.

@Emyrk Emyrk closed this Apr 8, 2022
@misskniss misskniss added this to the V2 Beta milestone May 15, 2022
@ammario ammario deleted the stevenmasley/rbac branch August 3, 2022 00:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants