Skip to content

feat: Rbac more coderd endpoints, unit test to confirm #1406

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 13 commits into from
81 changes: 55 additions & 26 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ type Options struct {
SecureAuthCookie bool
SSHKeygenAlgorithm gitsshkey.Algorithm
TURNServer *turnconn.Server
Authorizer *rbac.RegoAuthorizer
Authorizer rbac.Authorizer
}

// New constructs the Coder API into an HTTP handler.
Expand Down Expand Up @@ -83,8 +83,8 @@ func New(options *Options) (http.Handler, func()) {
// TODO: @emyrk we should just move this into 'ExtractAPIKey'.
authRolesMiddleware := httpmw.ExtractUserRoles(options.Database)

authorize := func(f http.HandlerFunc, actions rbac.Action) http.HandlerFunc {
return httpmw.Authorize(api.Logger, api.Authorizer, actions)(f).ServeHTTP
authorize := func(f http.HandlerFunc, actions ...rbac.Action) http.HandlerFunc {
return httpmw.Authorize(api.Logger, api.Authorizer, actions...)(f).ServeHTTP
}

r := chi.NewRouter()
Expand Down Expand Up @@ -127,20 +127,25 @@ func New(options *Options) (http.Handler, func()) {
r.Route("/files", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
authRolesMiddleware,
// This number is arbitrary, but reading/writing
// file content is expensive so it should be small.
httpmw.RateLimitPerMinute(12),
// TODO: @emyrk (rbac) Currently files are owned by the site?
// Should files be org scoped? User scoped?
httpmw.WithRBACObject(rbac.ResourceFile),
Comment on lines +134 to +136
Copy link
Member Author

Choose a reason for hiding this comment

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

Leaving some TODOs to come back to as I add authorize to more endpoints. There are some routes I might want to move around or scope differently.

)
r.Get("/{hash}", api.fileByHash)
r.Post("/", api.postFile)
})
r.Route("/organizations/{organization}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractOrganizationParam(options.Database),
authRolesMiddleware,
httpmw.ExtractOrganizationParam(options.Database),
)
r.Get("/", api.organization)
r.With(httpmw.WithRBACObject(rbac.ResourceOrganization)).
Get("/", authorize(api.organization, rbac.ActionRead))
Comment on lines +147 to +148
Copy link
Contributor

Choose a reason for hiding this comment

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

This indent makes this harder to read. Can we not push this middleware into the r.Use() block?

Copy link
Member Author

@Emyrk Emyrk May 12, 2022

Choose a reason for hiding this comment

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

It's just for 1 endpoint. I can put it inside a group block by itself with use?

Copy link
Member Author

Choose a reason for hiding this comment

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

r.With(httpmw.WithRBACObject(rbac.ResourceOrganization)).
    Get("/", authorize(api.organization, rbac.ActionRead))

// ------------------
// Or
// ------------------

r.Group(func(r chi.Router) {
    r.Use(httpmw.WithRBACObject(rbac.ResourceOrganization))
    r.Get("/", authorize(api.organization, rbac.ActionRead))
})

r.Get("/provisionerdaemons", api.provisionerDaemonsByOrganization)
r.Post("/templateversions", api.postTemplateVersionsByOrganization)
r.Route("/templates", func(r chi.Router) {
Expand All @@ -149,12 +154,17 @@ func New(options *Options) (http.Handler, func()) {
r.Get("/{templatename}", api.templateByOrganizationAndName)
})
r.Route("/workspaces", func(r chi.Router) {
r.Use(httpmw.WithRBACObject(rbac.ResourceWorkspace))
// Posting a workspace is inherently owned by the api key creating it.
r.With(httpmw.WithAPIKeyAsOwner()).
Post("/", authorize(api.postWorkspacesByOrganization, rbac.ActionCreate))
r.Get("/", authorize(api.workspacesByOrganization, rbac.ActionRead))
r.Post("/", api.postWorkspacesByOrganization)
r.Get("/", api.workspacesByOrganization)
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/{workspace}", api.workspaceByOwnerAndName)
r.Get("/", api.workspacesByOwner)
// TODO: @emyrk add the resource id to this authorize.
r.Get("/{workspace}", authorize(api.workspaceByOwnerAndName, rbac.ActionRead))
r.Get("/", authorize(api.workspacesByOwner, rbac.ActionRead))
})
})
r.Route("/members", func(r chi.Router) {
Expand Down Expand Up @@ -228,39 +238,58 @@ func New(options *Options) (http.Handler, func()) {
apiKeyMiddleware,
authRolesMiddleware,
)
r.Post("/", api.postUser)
r.Get("/", api.users)
r.Group(func(r chi.Router) {
// Site wide, all users
r.Use(httpmw.WithRBACObject(rbac.ResourceUser))
r.Post("/", authorize(api.postUser, rbac.ActionCreate))
r.Get("/", authorize(api.users, rbac.ActionRead))
})
// These routes query information about site wide roles.
r.Route("/roles", func(r chi.Router) {
r.Use(httpmw.WithRBACObject(rbac.ResourceUserRole))
r.Get("/", authorize(api.assignableSiteRoles, rbac.ActionRead))
})
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/", api.userByName)
r.Put("/profile", api.putUserProfile)
r.Put("/suspend", api.putUserSuspend)
r.Route("/password", func(r chi.Router) {
r.Use(httpmw.WithRBACObject(rbac.ResourceUserPasswordRole))
r.Put("/", authorize(api.putUserPassword, rbac.ActionUpdate))
r.Group(func(r chi.Router) {
r.Use(httpmw.WithRBACObject(rbac.ResourceUser))
Copy link
Member Author

Choose a reason for hiding this comment

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

Might add more resources for granular control of user settings, but for now, it's all clumped under update for the user resource.

r.Get("/", authorize(api.userByName, rbac.ActionRead))
r.Put("/profile", authorize(api.putUserProfile, rbac.ActionUpdate))
// suspension is deleting for a user
r.Put("/suspend", authorize(api.putUserSuspend, rbac.ActionDelete))
r.Route("/password", func(r chi.Router) {
r.Put("/", authorize(api.putUserPassword, rbac.ActionUpdate))
})
// This route technically also fetches the organization member struct, but only
// returns the roles.
r.Get("/roles", authorize(api.userRoles, rbac.ActionRead))

// This has 2 authorize calls. The second is explicitly called
// in the handler.
Comment on lines +267 to +268
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we make it a middleware? I feel like calling authorize in the handler is a sign of a failure in what we originally want.

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 would be a middleware made for just this endpoint. The authorize check has to be called on each role assigned/deleted. So it depends on the payload of the request body.

Some endpoints will likely have this because it's really difficult to determine the authorize input at the middleware level, as it might be dependent on the request body.


One good thing to note is that my unit test still ensures if we don't use the middleware, an authorize check is still called. We still need to add tests for the endpoint to test auth though and make sure a user can't give themselves admin for example.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, is this the only endpoint where we require multiple RBAC checks for a specific action? Or, do we need to make things more general?

r.Put("/roles", authorize(api.putUserRoles, rbac.ActionUpdate))

// For now, just use the "user" role for their ssh keys.
// We can always split this out to it's own resource if we need to.
r.Get("/gitsshkey", authorize(api.gitSSHKey, rbac.ActionRead))
r.Put("/gitsshkey", authorize(api.regenerateGitSSHKey, rbac.ActionUpdate))

r.Post("/authorization", authorize(api.checkPermissions, rbac.ActionRead))
})
r.Get("/organizations", api.organizationsByUser)
r.Post("/organizations", api.postOrganizationsByUser)
// These roles apply to the site wide permissions.
r.Put("/roles", api.putUserRoles)
r.Get("/roles", api.userRoles)

r.Post("/authorization", api.checkPermissions)
r.With(httpmw.WithRBACObject(rbac.ResourceAPIKey)).Post("/keys", authorize(api.postAPIKey, rbac.ActionCreate))
r.Get("/workspaces", api.workspacesByUser)

r.Post("/keys", api.postAPIKey)
r.Route("/organizations", func(r chi.Router) {
// TODO: @emyrk This creates an organization, so why is it nested under {user}?
// Shouldn't this be outside the {user} param subpath? Maybe in the organizations/
// path?
r.Post("/", api.postOrganizationsByUser)

r.Get("/", api.organizationsByUser)

// TODO: @emyrk why is this nested under {user} when the user param is not used?
r.Get("/{organizationname}", api.organizationByUserAndName)
})
r.Get("/gitsshkey", api.gitSSHKey)
r.Put("/gitsshkey", api.regenerateGitSSHKey)
r.Get("/workspaces", api.workspacesByUser)
})
})
})
Expand Down
185 changes: 183 additions & 2 deletions coderd/coderd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@ package coderd_test

import (
"context"
"net/http"
"strings"
"testing"

"go.uber.org/goleak"

"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"golang.org/x/xerrors"

"github.com/coder/coder/buildinfo"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/rbac"
)

func TestMain(m *testing.M) {
Expand All @@ -24,3 +29,179 @@ func TestBuildInfo(t *testing.T) {
require.Equal(t, buildinfo.ExternalURL(), buildInfo.ExternalURL, "external URL")
require.Equal(t, buildinfo.Version(), buildInfo.Version, "version")
}

// TestAuthorizeAllEndpoints will check `authorize` is called on every endpoint registered.
func TestAuthorizeAllEndpoints(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.

praise: This is a really useful check to have. Nice one.

Copy link
Contributor

Choose a reason for hiding this comment

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

💯

t.Parallel()

authorizer := &fakeAuthorizer{}
srv, client := coderdtest.NewMemoryCoderd(t, &coderdtest.Options{
Authorizer: authorizer,
})
admin := coderdtest.CreateFirstUser(t, client)
organization, err := client.Organization(context.Background(), admin.OrganizationID)
require.NoError(t, err, "fetch org")

// Always fail auth from this point forward
authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil)

// skipRoutes allows skipping routes from being checked.
type routeCheck struct {
NoAuthorize bool
AssertObject rbac.Object
StatusCode int
}
assertRoute := map[string]routeCheck{
// These endpoints do not require auth
"GET:/api/v2": {NoAuthorize: true},
"GET:/api/v2/buildinfo": {NoAuthorize: true},
"GET:/api/v2/users/first": {NoAuthorize: true},
"POST:/api/v2/users/first": {NoAuthorize: true},
"POST:/api/v2/users/login": {NoAuthorize: true},
"POST:/api/v2/users/logout": {NoAuthorize: true},
"GET:/api/v2/users/authmethods": {NoAuthorize: true},

// All workspaceagents endpoints do not use rbac
"POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true},
"POST:/api/v2/workspaceagents/azure-instance-identity": {NoAuthorize: true},
"POST:/api/v2/workspaceagents/google-instance-identity": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/gitsshkey": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/iceservers": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/listen": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/metadata": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/dial": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/pty": {NoAuthorize: true},
"GET:/api/v2/workspaceagents/{workspaceagent}/turn": {NoAuthorize: true},

// TODO: @emyrk these need to be fixed by adding authorize calls
"GET:/api/v2/workspaceresources/{workspaceresource}": {NoAuthorize: true},
"GET:/api/v2/workspacebuilds/{workspacebuild}": {NoAuthorize: true},
"GET:/api/v2/workspacebuilds/{workspacebuild}/logs": {NoAuthorize: true},
"GET:/api/v2/workspacebuilds/{workspacebuild}/resources": {NoAuthorize: true},
"GET:/api/v2/workspacebuilds/{workspacebuild}/state": {NoAuthorize: true},
"PATCH:/api/v2/workspacebuilds/{workspacebuild}/cancel": {NoAuthorize: true},
"GET:/api/v2/workspaces/{workspace}/builds/{workspacebuildname}": {NoAuthorize: true},

"GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true},

"POST:/api/v2/users/{user}/organizations/": {NoAuthorize: true},
"PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true},
"GET:/api/v2/organizations/{organization}/provisionerdaemons": {NoAuthorize: true},
"POST:/api/v2/organizations/{organization}/templates": {NoAuthorize: true},
"GET:/api/v2/organizations/{organization}/templates": {NoAuthorize: true},
"GET:/api/v2/organizations/{organization}/templates/{templatename}": {NoAuthorize: true},
"POST:/api/v2/organizations/{organization}/templateversions": {NoAuthorize: true},
"POST:/api/v2/organizations/{organization}/workspaces": {NoAuthorize: true},

"POST:/api/v2/parameters/{scope}/{id}": {NoAuthorize: true},
"GET:/api/v2/parameters/{scope}/{id}": {NoAuthorize: true},
"DELETE:/api/v2/parameters/{scope}/{id}/{name}": {NoAuthorize: true},

"GET:/api/v2/provisionerdaemons/me/listen": {NoAuthorize: true},

"DELETE:/api/v2/templates/{template}": {NoAuthorize: true},
"GET:/api/v2/templates/{template}": {NoAuthorize: true},
"GET:/api/v2/templates/{template}/versions": {NoAuthorize: true},
"PATCH:/api/v2/templates/{template}/versions": {NoAuthorize: true},
"GET:/api/v2/templates/{template}/versions/{templateversionname}": {NoAuthorize: true},

"GET:/api/v2/templateversions/{templateversion}": {NoAuthorize: true},
"PATCH:/api/v2/templateversions/{templateversion}/cancel": {NoAuthorize: true},
"GET:/api/v2/templateversions/{templateversion}/logs": {NoAuthorize: true},
"GET:/api/v2/templateversions/{templateversion}/parameters": {NoAuthorize: true},
"GET:/api/v2/templateversions/{templateversion}/resources": {NoAuthorize: true},
"GET:/api/v2/templateversions/{templateversion}/schema": {NoAuthorize: true},

"POST:/api/v2/users/{user}/organizations": {NoAuthorize: true},

"GET:/api/v2/workspaces/{workspace}": {NoAuthorize: true},
"PUT:/api/v2/workspaces/{workspace}/autostart": {NoAuthorize: true},
"PUT:/api/v2/workspaces/{workspace}/autostop": {NoAuthorize: true},
"GET:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true},
"POST:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true},

"POST:/api/v2/files": {NoAuthorize: true},
"GET:/api/v2/files/{hash}": {NoAuthorize: true},

// These endpoints have more assertions. This is good, add more endpoints to assert if you can!
"GET:/api/v2/organizations/{organization}": {AssertObject: rbac.ResourceOrganization.InOrg(admin.OrganizationID)},
"GET:/api/v2/users/{user}/organizations": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceOrganization},
"GET:/api/v2/users/{user}/workspaces": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace},
}

c, _ := srv.Config.Handler.(*chi.Mux)
err = chi.Walk(c, func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
name := method + ":" + route
t.Run(name, func(t *testing.T) {
authorizer.reset()
routeAssertions, ok := assertRoute[strings.TrimRight(name, "/")]
if !ok {
// By default, all omitted routes check for just "authorize" called
routeAssertions = routeCheck{}
}
if routeAssertions.StatusCode == 0 {
routeAssertions.StatusCode = http.StatusUnauthorized
}

// Replace all url params with known values
route = strings.ReplaceAll(route, "{organization}", admin.OrganizationID.String())
route = strings.ReplaceAll(route, "{user}", admin.UserID.String())
route = strings.ReplaceAll(route, "{organizationname}", organization.Name)

resp, err := client.Request(context.Background(), method, route, nil)
require.NoError(t, err, "do req")
_ = resp.Body.Close()

if !routeAssertions.NoAuthorize {
assert.NotNil(t, authorizer.Called, "authorizer expected")
assert.Equal(t, routeAssertions.StatusCode, resp.StatusCode, "expect unauthorized")
if routeAssertions.AssertObject.Type != "" {
assert.Equal(t, routeAssertions.AssertObject.Type, authorizer.Called.Object.Type, "resource type")
}
if routeAssertions.AssertObject.Owner != "" {
assert.Equal(t, routeAssertions.AssertObject.Owner, authorizer.Called.Object.Owner, "resource owner")
}
if routeAssertions.AssertObject.OrgID != "" {
assert.Equal(t, routeAssertions.AssertObject.OrgID, authorizer.Called.Object.OrgID, "resource org")
}
if routeAssertions.AssertObject.ResourceID != "" {
assert.Equal(t, routeAssertions.AssertObject.ResourceID, authorizer.Called.Object.ResourceID, "resource ID")
}
} else {
assert.Nil(t, authorizer.Called, "authorize not expected")
}
})
return nil
})
require.NoError(t, err)
}

type authCall struct {
SubjectID string
Roles []string
Action rbac.Action
Object rbac.Object
}

type fakeAuthorizer struct {
Called *authCall
AlwaysReturn error
}

func (f *fakeAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, action rbac.Action, object rbac.Object) error {
f.Called = &authCall{
SubjectID: subjectID,
Roles: roleNames,
Action: action,
Object: object,
}
return f.AlwaysReturn
}

func (f *fakeAuthorizer) reset() {
f.Called = nil
}
Loading