feat: Add RBAC to /workspace endpoints (#1566)

* feat: Add RBAC to /workspace endpoints
This commit is contained in:
Steven Masley 2022-05-18 18:15:19 -05:00 committed by GitHub
parent f3fe2a08ce
commit c034e8389e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 215 additions and 35 deletions

View File

@ -149,7 +149,7 @@ func New(options *Options) (http.Handler, func()) {
r.Get("/", api.workspacesByOrganization)
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/{workspace}", api.workspaceByOwnerAndName)
r.Get("/{workspacename}", api.workspaceByOwnerAndName)
r.Get("/", api.workspacesByOwner)
})
})
@ -237,8 +237,6 @@ func New(options *Options) (http.Handler, func()) {
r.Route("/password", func(r chi.Router) {
r.Put("/", api.putUserPassword)
})
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)
@ -316,6 +314,7 @@ func New(options *Options) (http.Handler, func()) {
r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
authRolesMiddleware,
httpmw.ExtractWorkspaceBuildParam(options.Database),
httpmw.ExtractWorkspaceParam(options.Database),
)

View File

@ -2,6 +2,7 @@ package coderd_test
import (
"context"
"io"
"net/http"
"strings"
"testing"
@ -48,13 +49,18 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, admin.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
// Always fail auth from this point forward
authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil)
// Some quick reused objects
workspaceRBACObj := rbac.ResourceWorkspace.InOrg(organization.ID).WithID(workspace.ID.String()).WithOwner(workspace.OwnerID.String())
// skipRoutes allows skipping routes from being checked.
type routeCheck struct {
NoAuthorize bool
AssertAction rbac.Action
AssertObject rbac.Object
StatusCode int
}
@ -84,13 +90,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
"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/workspaceresources/{workspaceresource}": {NoAuthorize: true},
"GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true},
@ -123,15 +123,9 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
"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},
"GET:/api/v2/workspaces/{workspace}/watch": {NoAuthorize: true},
"POST:/api/v2/files": {NoAuthorize: true},
"GET:/api/v2/files/{hash}": {NoAuthorize: true},
"POST:/api/v2/files": {NoAuthorize: true},
"GET:/api/v2/files/{hash}": {NoAuthorize: true},
"GET:/api/v2/workspaces/{workspace}/watch": {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)},
@ -141,11 +135,60 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
"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/workspaces/{workspace}/builds/{workspacebuildname}": {
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,
},
"GET:/api/v2/organizations/{organization}/workspaces/{user}/{workspacename}": {
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,
},
"GET:/api/v2/organizations/{organization}/workspaces": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace},
"GET:/api/v2/workspaces": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceWorkspace},
"GET:/api/v2/workspacebuilds/{workspacebuild}": {
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,
},
"GET:/api/v2/workspacebuilds/{workspacebuild}/logs": {
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,
},
"GET:/api/v2/workspaces/{workspace}/builds": {
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,
},
"GET:/api/v2/workspaces/{workspace}": {
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,
},
"PUT:/api/v2/workspaces/{workspace}/autostart": {
AssertAction: rbac.ActionUpdate,
AssertObject: workspaceRBACObj,
},
"PUT:/api/v2/workspaces/{workspace}/autostop": {
AssertAction: rbac.ActionUpdate,
AssertObject: workspaceRBACObj,
},
"PATCH:/api/v2/workspacebuilds/{workspacebuild}/cancel": {
AssertAction: rbac.ActionUpdate,
AssertObject: workspaceRBACObj,
},
"GET:/api/v2/workspacebuilds/{workspacebuild}/resources": {
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,
},
"GET:/api/v2/workspacebuilds/{workspacebuild}/state": {
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,
},
"GET:/api/v2/workspaces/": {
StatusCode: http.StatusOK,
AssertAction: rbac.ActionRead,
AssertObject: workspaceRBACObj,
},
// These endpoints need payloads to get to the auth part.
"PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
// These endpoints need payloads to get to the auth part. Payloads will be required
"PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
"POST:/api/v2/workspaces/{workspace}/builds": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
}
for k, v := range assertRoute {
@ -175,16 +218,24 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
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)
route = strings.ReplaceAll(route, "{workspace}", workspace.ID.String())
route = strings.ReplaceAll(route, "{workspacebuild}", workspace.LatestBuild.ID.String())
route = strings.ReplaceAll(route, "{workspacename}", workspace.Name)
route = strings.ReplaceAll(route, "{workspacebuildname}", workspace.LatestBuild.Name)
resp, err := client.Request(context.Background(), method, route, nil)
require.NoError(t, err, "do req")
body, _ := io.ReadAll(resp.Body)
t.Logf("Response Body: %q", string(body))
_ = 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.AssertAction != "" {
assert.Equal(t, routeAssertions.AssertAction, authorizer.Called.Action, "resource action")
}
if routeAssertions.AssertObject.Type != "" {
assert.Equal(t, routeAssertions.AssertObject.Type, authorizer.Called.Object.Type, "resource type")
}

View File

@ -568,6 +568,14 @@ func (api *api) postOrganizationsByUser(rw http.ResponseWriter, r *http.Request)
if !httpapi.Read(rw, r, &req) {
return
}
// Create organization uses the organization resource without an OrgID.
// This means you need the site wide permission to make a new organization.
if !api.Authorize(rw, r, rbac.ActionCreate,
rbac.ResourceOrganization) {
return
}
_, err := api.Database.GetOrganizationByName(r.Context(), req.Name)
if err == nil {
httpapi.Write(rw, http.StatusConflict, httpapi.Response{

View File

@ -173,7 +173,7 @@ func TestPostUsers(t *testing.T) {
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
notInOrg := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
other := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
other := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleAdmin(), rbac.RoleMember())
org, err := other.CreateOrganization(context.Background(), codersdk.Me, codersdk.CreateOrganizationRequest{
Name: "another",
})

View File

@ -15,11 +15,25 @@ import (
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
func (api *api) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspaceBuild.WorkspaceID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: "no workspace exists for this job",
})
return
}
if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
@ -34,6 +48,11 @@ func (api *api) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
func (api *api) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
paginationParams, ok := parsePagination(rw, r)
if !ok {
return
@ -90,6 +109,11 @@ func (api *api) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
func (api *api) workspaceBuildByName(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
workspaceBuildName := chi.URLParam(r, "workspacebuildname")
workspaceBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDAndName(r.Context(), database.GetWorkspaceBuildByWorkspaceIDAndNameParams{
WorkspaceID: workspace.ID,
@ -125,6 +149,25 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
if !httpapi.Read(rw, r, &createBuild) {
return
}
// Rbac action depends on the transition
var action rbac.Action
switch createBuild.Transition {
case database.WorkspaceTransitionDelete:
action = rbac.ActionDelete
case database.WorkspaceTransitionStart, database.WorkspaceTransitionStop:
action = rbac.ActionUpdate
default:
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("transition not supported: %q", createBuild.Transition),
})
return
}
if !api.Authorize(rw, r, action, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
if createBuild.TemplateVersionID == uuid.Nil {
latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
if err != nil {
@ -269,6 +312,19 @@ func (api *api) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
func (api *api) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Request) {
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspaceBuild.WorkspaceID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: "no workspace exists for this job",
})
return
}
if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
@ -308,6 +364,19 @@ func (api *api) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
func (api *api) workspaceBuildResources(rw http.ResponseWriter, r *http.Request) {
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspaceBuild.WorkspaceID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: "no workspace exists for this job",
})
return
}
if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
@ -320,6 +389,19 @@ func (api *api) workspaceBuildResources(rw http.ResponseWriter, r *http.Request)
func (api *api) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) {
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspaceBuild.WorkspaceID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: "no workspace exists for this job",
})
return
}
if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
@ -330,8 +412,20 @@ func (api *api) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) {
api.provisionerJobLogs(rw, r, job)
}
func (*api) workspaceBuildState(rw http.ResponseWriter, r *http.Request) {
func (api *api) workspaceBuildState(rw http.ResponseWriter, r *http.Request) {
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspaceBuild.WorkspaceID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: "no workspace exists for this job",
})
return
}
if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)

View File

@ -29,6 +29,10 @@ import (
func (api *api) workspace(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
if !api.Authorize(rw, r, rbac.ActionRead,
rbac.ResourceWorkspace.InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
if err != nil {
@ -63,11 +67,6 @@ func (api *api) workspace(rw http.ResponseWriter, r *http.Request) {
return
}
if !api.Authorize(rw, r, rbac.ActionRead,
rbac.ResourceWorkspace.InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
httpapi.Write(rw, http.StatusOK,
convertWorkspace(workspace, convertWorkspaceBuild(build, convertProvisionerJob(job)), template, owner))
}
@ -219,7 +218,7 @@ func (api *api) workspacesByOwner(rw http.ResponseWriter, r *http.Request) {
func (api *api) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) {
owner := httpmw.UserParam(r)
organization := httpmw.OrganizationParam(r)
workspaceName := chi.URLParam(r, "workspace")
workspaceName := chi.URLParam(r, "workspacename")
workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(r.Context(), database.GetWorkspaceByOwnerIDAndNameParams{
OwnerID: owner.ID,
@ -477,6 +476,12 @@ func (api *api) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
}
func (api *api) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
var req codersdk.UpdateWorkspaceAutostartRequest
if !httpapi.Read(rw, r, &req) {
return
@ -495,7 +500,6 @@ func (api *api) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
dbSched.Valid = true
}
workspace := httpmw.WorkspaceParam(r)
err := api.Database.UpdateWorkspaceAutostart(r.Context(), database.UpdateWorkspaceAutostartParams{
ID: workspace.ID,
AutostartSchedule: dbSched,
@ -509,6 +513,12 @@ func (api *api) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
}
func (api *api) putWorkspaceAutostop(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceWorkspace.
InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) {
return
}
var req codersdk.UpdateWorkspaceAutostopRequest
if !httpapi.Read(rw, r, &req) {
return
@ -527,7 +537,6 @@ func (api *api) putWorkspaceAutostop(rw http.ResponseWriter, r *http.Request) {
dbSched.Valid = true
}
workspace := httpmw.WorkspaceParam(r)
err := api.Database.UpdateWorkspaceAutostop(r.Context(), database.UpdateWorkspaceAutostopParams{
ID: workspace.ID,
AutostopSchedule: dbSched,

View File

@ -7,6 +7,8 @@ import (
"testing"
"time"
"github.com/coder/coder/coderd/rbac"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
@ -18,7 +20,7 @@ import (
"github.com/coder/coder/provisionersdk/proto"
)
func TestWorkspace(t *testing.T) {
func TestAdminViewAllWorkspaces(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
@ -27,8 +29,25 @@ func TestWorkspace(t *testing.T) {
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
_, err := client.Workspace(context.Background(), workspace.ID)
require.NoError(t, err)
otherOrg, err := client.CreateOrganization(context.Background(), codersdk.Me, codersdk.CreateOrganizationRequest{
Name: "default-test",
})
require.NoError(t, err, "create other org")
// This other user is not in the first user's org. Since other is an admin, they can
// still see the "first" user's workspace.
other := coderdtest.CreateAnotherUser(t, client, otherOrg.ID, rbac.RoleAdmin(), rbac.RoleMember())
otherWorkspaces, err := other.Workspaces(context.Background(), codersdk.WorkspaceFilter{})
require.NoError(t, err, "(other) fetch workspaces")
firstWorkspaces, err := other.Workspaces(context.Background(), codersdk.WorkspaceFilter{})
require.NoError(t, err, "(first) fetch workspaces")
require.ElementsMatch(t, otherWorkspaces, firstWorkspaces)
}
func TestPostWorkspacesByOrganization(t *testing.T) {
@ -52,7 +71,7 @@ func TestPostWorkspacesByOrganization(t *testing.T) {
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
other := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)
other := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleMember(), rbac.RoleAdmin())
org, err := other.CreateOrganization(context.Background(), codersdk.Me, codersdk.CreateOrganizationRequest{
Name: "another",
})