Skip to content

fix: Fix properly selecting workspace apps by agent #3684

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

Merged
merged 12 commits into from
Aug 29, 2022
Merged
8 changes: 5 additions & 3 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,19 @@ func New(options *Options) *API {
apps := func(r chi.Router) {
r.Use(
httpmw.RateLimitPerMinute(options.APIRateLimit),
tracing.HTTPMW(api.TracerProvider, "coderd.http"),
httpmw.ExtractAPIKey(options.Database, oauthConfigs, true),
httpmw.ExtractUserParam(api.Database),
tracing.HTTPMW(api.TracerProvider, "coderd.http"),
// Extracts the <workspace.agent> from the url
httpmw.ExtractWorkspaceAndAgentParam(api.Database),
)
r.HandleFunc("/*", api.workspaceAppsProxyPath)
}
// %40 is the encoded character of the @ symbol. VS Code Web does
// not handle character encoding properly, so it's safe to assume
// other applications might not as well.
r.Route("/%40{user}/{workspacename}/apps/{workspaceapp}", apps)
r.Route("/@{user}/{workspacename}/apps/{workspaceapp}", apps)
r.Route("/%40{user}/{workspace_and_agent}/apps/{workspaceapp}", apps)
r.Route("/@{user}/{workspace_and_agent}/apps/{workspaceapp}", apps)

r.Route("/api/v2", func(r chi.Router) {
r.NotFound(func(rw http.ResponseWriter, r *http.Request) {
Expand Down
67 changes: 42 additions & 25 deletions coderd/coderdtest/authtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ func NewAuthTester(ctx context.Context, t *testing.T, options *Options) *AuthTes
Name: "some",
Type: "example",
Agents: []*proto.Agent{{
Name: "agent",
Id: "something",
Auth: &proto.Agent_Token{},
Apps: []*proto.App{{
Expand Down Expand Up @@ -119,22 +120,23 @@ func NewAuthTester(ctx context.Context, t *testing.T, options *Options) *AuthTes
require.NoError(t, err, "create template param")

urlParameters := map[string]string{
"{organization}": admin.OrganizationID.String(),
"{user}": admin.UserID.String(),
"{organizationname}": organization.Name,
"{workspace}": workspace.ID.String(),
"{workspacebuild}": workspace.LatestBuild.ID.String(),
"{workspacename}": workspace.Name,
"{workspacebuildname}": workspace.LatestBuild.Name,
"{workspaceagent}": workspaceResources[0].Agents[0].ID.String(),
"{buildnumber}": strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10),
"{template}": template.ID.String(),
"{hash}": file.Hash,
"{workspaceresource}": workspaceResources[0].ID.String(),
"{workspaceapp}": workspaceResources[0].Agents[0].Apps[0].Name,
"{templateversion}": version.ID.String(),
"{jobID}": templateVersionDryRun.ID.String(),
"{templatename}": template.Name,
"{organization}": admin.OrganizationID.String(),
"{user}": admin.UserID.String(),
"{organizationname}": organization.Name,
"{workspace}": workspace.ID.String(),
"{workspacebuild}": workspace.LatestBuild.ID.String(),
"{workspacename}": workspace.Name,
"{workspacebuildname}": workspace.LatestBuild.Name,
"{workspaceagent}": workspaceResources[0].Agents[0].ID.String(),
"{buildnumber}": strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10),
"{template}": template.ID.String(),
"{hash}": file.Hash,
"{workspaceresource}": workspaceResources[0].ID.String(),
"{workspaceapp}": workspaceResources[0].Agents[0].Apps[0].Name,
"{templateversion}": version.ID.String(),
"{jobID}": templateVersionDryRun.ID.String(),
"{templatename}": template.Name,
"{workspace_and_agent}": workspace.Name + "." + workspaceResources[0].Agents[0].Name,
// Only checking template scoped params here
"parameters/{scope}/{id}": fmt.Sprintf("parameters/%s/%s",
string(templateParam.Scope), templateParam.ScopeID.String()),
Expand Down Expand Up @@ -178,15 +180,6 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
"POST:/api/v2/csp/reports": {NoAuthorize: true},
"GET:/api/v2/entitlements": {NoAuthorize: true},

"GET:/%40{user}/{workspacename}/apps/{workspaceapp}/*": {
AssertAction: rbac.ActionCreate,
AssertObject: workspaceExecObj,
},
"GET:/@{user}/{workspacename}/apps/{workspaceapp}/*": {
AssertAction: rbac.ActionCreate,
AssertObject: workspaceExecObj,
},

// Has it's own auth
"GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true},
"GET:/api/v2/users/oidc/callback": {NoAuthorize: true},
Expand Down Expand Up @@ -399,6 +392,29 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
"POST:/api/v2/workspaces/{workspace}/builds": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
"POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
}

// Routes like proxy routes support all HTTP methods. A helper func to expand
// 1 url to all http methods.
assertAllHTTPMethods := func(url string, check RouteCheck) {
methods := []string{http.MethodGet, http.MethodHead, http.MethodPost,
http.MethodPut, http.MethodPatch, http.MethodDelete,
http.MethodConnect, http.MethodOptions, http.MethodTrace}

for _, method := range methods {
route := method + ":" + url
assertRoute[route] = check
}
}

assertAllHTTPMethods("/%40{user}/{workspace_and_agent}/apps/{workspaceapp}/*", RouteCheck{
AssertAction: rbac.ActionCreate,
AssertObject: workspaceExecObj,
})
assertAllHTTPMethods("/@{user}/{workspace_and_agent}/apps/{workspaceapp}/*", RouteCheck{
AssertAction: rbac.ActionCreate,
AssertObject: workspaceExecObj,
})

return skipRoutes, assertRoute
}

Expand Down Expand Up @@ -446,6 +462,7 @@ func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck
a.t.Run(name, func(t *testing.T) {
a.authorizer.reset()
routeKey := strings.TrimRight(name, "/")

routeAssertions, ok := assertRoute[routeKey]
if !ok {
// By default, all omitted routes check for just "authorize" called
Expand Down
10 changes: 2 additions & 8 deletions coderd/database/databasefake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -592,14 +592,14 @@ func (q *fakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(_ context.Context, wo
defer q.mutex.RUnlock()

var row database.WorkspaceBuild
var buildNum int32
var buildNum int32 = -1
for _, workspaceBuild := range q.workspaceBuilds {
if workspaceBuild.WorkspaceID.String() == workspaceID.String() && workspaceBuild.BuildNumber > buildNum {
row = workspaceBuild
buildNum = workspaceBuild.BuildNumber
}
}
if buildNum == 0 {
if buildNum == -1 {
return database.WorkspaceBuild{}, sql.ErrNoRows
}
return row, nil
Expand Down Expand Up @@ -1263,9 +1263,6 @@ func (q *fakeQuerier) GetWorkspaceAgentsByResourceIDs(_ context.Context, resourc
workspaceAgents = append(workspaceAgents, agent)
}
}
if len(workspaceAgents) == 0 {
return nil, sql.ErrNoRows
}
return workspaceAgents, nil
}

Expand Down Expand Up @@ -1347,9 +1344,6 @@ func (q *fakeQuerier) GetWorkspaceResourcesByJobID(_ context.Context, jobID uuid
}
resources = append(resources, resource)
}
if len(resources) == 0 {
return nil, sql.ErrNoRows
}
return resources, nil
}

Expand Down
110 changes: 110 additions & 0 deletions coderd/httpmw/workspaceparam.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"strings"

"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)

type workspaceParamContextKey struct{}
Expand Down Expand Up @@ -48,3 +52,109 @@ func ExtractWorkspaceParam(db database.Store) func(http.Handler) http.Handler {
})
}
}

// ExtractWorkspaceAndAgentParam grabs a workspace and an agent from the
// "workspace_and_agent" URL parameter. `ExtractUserParam` must be called
// before this.
// This can be in the form of:
// - "<workspace-name>.[workspace-agent]" : If multiple agents exist
// - "<workspace-name>" : If one agent exists
func ExtractWorkspaceAndAgentParam(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
user := UserParam(r)
workspaceWithAgent := chi.URLParam(r, "workspace_and_agent")
workspaceParts := strings.Split(workspaceWithAgent, ".")

workspace, err := db.GetWorkspaceByOwnerIDAndName(r.Context(), database.GetWorkspaceByOwnerIDAndNameParams{
OwnerID: user.ID,
Name: workspaceParts[0],
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace.",
Detail: err.Error(),
})
return
}

build, err := db.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace build.",
Detail: err.Error(),
})
return
}

resources, err := db.GetWorkspaceResourcesByJobID(r.Context(), build.JobID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace resources.",
Detail: err.Error(),
})
return
}
resourceIDs := make([]uuid.UUID, 0)
for _, resource := range resources {
resourceIDs = append(resourceIDs, resource.ID)
}

agents, err := db.GetWorkspaceAgentsByResourceIDs(r.Context(), resourceIDs)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace agents.",
Detail: err.Error(),
})
return
}

if len(agents) == 0 {
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
Message: "No agents exist for this workspace",
})
return
}

// If we have more than 1 workspace agent, we need to specify which one to use.
if len(agents) > 1 && len(workspaceParts) <= 1 {
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
Message: "More than one agent exists, but no agent specified.",
})
return
}

var agent database.WorkspaceAgent
var found bool
// If we have more than 1 workspace agent, we need to specify which one to use.
// If the user specified an agent, we need to make sure that agent
// actually exists.
if len(workspaceParts) > 1 || len(agents) > 1 {
for _, otherAgent := range agents {
if otherAgent.Name == workspaceParts[1] {
agent = otherAgent
found = true
break
}
}
if !found {
httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("No agent exists with the name %q", workspaceParts[1]),
})
return
}
} else {
agent = agents[0]
}

ctx := r.Context()
ctx = context.WithValue(ctx, workspaceParamContextKey{}, workspace)
ctx = context.WithValue(ctx, workspaceAgentParamContextKey{}, agent)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}
Loading