-
Notifications
You must be signed in to change notification settings - Fork 881
feat: Rbac more coderd endpoints, unit test to confirm #1437
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
62ec87e
452c72d
307f6c0
32af1e6
28a099f
ff7bd81
d123b9f
186eb5f
5d32d9d
301d42a
a989224
ed9be78
1047391
7ad069e
e68fdbf
1a4e7e1
f250fbe
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 |
---|---|---|
@@ -0,0 +1,43 @@ | ||
package coderd | ||
|
||
import ( | ||
"net/http" | ||
|
||
"golang.org/x/xerrors" | ||
|
||
"cdr.dev/slog" | ||
|
||
"github.com/coder/coder/coderd/httpapi" | ||
"github.com/coder/coder/coderd/httpmw" | ||
"github.com/coder/coder/coderd/rbac" | ||
) | ||
|
||
func (api *api) Authorize(rw http.ResponseWriter, r *http.Request, action rbac.Action, object rbac.Object) bool { | ||
roles := httpmw.UserRoles(r) | ||
err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, action, object) | ||
if err != nil { | ||
httpapi.Write(rw, http.StatusForbidden, httpapi.Response{ | ||
Message: err.Error(), | ||
}) | ||
|
||
// Log the errors for debugging | ||
internalError := new(rbac.UnauthorizedError) | ||
logger := api.Logger | ||
if xerrors.As(err, internalError) { | ||
logger = api.Logger.With(slog.F("internal", internalError.Internal())) | ||
} | ||
// Log information for debugging. This will be very helpful | ||
// in the early days | ||
logger.Warn(r.Context(), "unauthorized", | ||
slog.F("roles", roles.Roles), | ||
slog.F("user_id", roles.ID), | ||
slog.F("username", roles.Username), | ||
slog.F("route", r.URL.Path), | ||
slog.F("action", action), | ||
slog.F("object", object), | ||
) | ||
|
||
return false | ||
} | ||
return true | ||
} |
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,197 @@ 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) { | ||
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") | ||
|
||
// Setup some data in the database. | ||
coderdtest.NewProvisionerDaemon(t, client) | ||
version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) | ||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID) | ||
template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) | ||
workspace := coderdtest.CreateWorkspace(t, client, admin.OrganizationID, template.ID) | ||
|
||
// 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}, | ||
"GET:/api/v2/organizations/{organization}/workspaces/{user}": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace}, | ||
"GET:/api/v2/organizations/{organization}/workspaces/{user}/{workspace}": { | ||
AssertObject: rbac.ResourceWorkspace.InOrg(organization.ID).WithID(workspace.ID.String()).WithOwner(workspace.OwnerID.String()), | ||
}, | ||
"GET:/api/v2/organizations/{organization}/workspaces": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace}, | ||
|
||
// These endpoints need payloads to get to the auth part. | ||
"PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, | ||
} | ||
|
||
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.StatusForbidden | ||
} | ||
|
||
// 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) | ||
route = strings.ReplaceAll(route, "{workspace}", workspace.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 authorizer.Called != nil { | ||
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 | ||
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. you only capture the most recent authorization call, but many endpoints call this multiple times with different objects. This should be captured and asserted in testing. 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. Yes this is true. Most cases though the first call returns an error and the request terminates. As I add more endpoints and see more cases, I should increase the assertions. I intend to follow this PR up with more testing and endpoints. I just want to be on the right path first. |
||
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.
minor: should we just define a single
var rbac.UnauthorizedError
and use that instead in the check below?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.
Not sure what you mean?