-
Notifications
You must be signed in to change notification settings - Fork 881
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
Changes from all commits
bed0f8f
af6dc5f
01b2c94
be5b0b3
970e345
945e9fa
fdfef88
89a3678
29da9aa
63727e0
96a5727
4b6c9b0
cd2fda7
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 |
---|---|---|
|
@@ -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. | ||
|
@@ -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() | ||
|
@@ -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), | ||
) | ||
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
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. This indent makes this harder to read. Can we not push this middleware into the r.Use() block? 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. It's just for 1 endpoint. I can put it inside a group block by itself with use? 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. 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) { | ||
|
@@ -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) { | ||
|
@@ -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)) | ||
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. Might add more resources for granular control of |
||
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
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. 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. 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. 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. 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. 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) | ||
}) | ||
}) | ||
}) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) { | ||
|
@@ -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) { | ||
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. praise: This is a really useful check to have. Nice one. 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. 💯 |
||
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 | ||
} |
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.
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.