From 49be7c509ff08447ab6344ddbe6a6bee1f2859af Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Tue, 10 May 2022 00:48:36 +0000 Subject: [PATCH] feat: Add endpoint to get all workspaces a user can access This iterates through user organizations to get permitted workspaces. This will allow admins to manage user workspaces! --- cmd/templater/main.go | 279 ------------------- coderd/coderd.go | 1 + coderd/database/databasefake/databasefake.go | 22 ++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 43 +++ coderd/database/queries/workspaces.sql | 3 + coderd/users.go | 45 +++ coderd/users_test.go | 45 +++ codersdk/users.go | 16 ++ go.mod | 2 +- 10 files changed, 177 insertions(+), 280 deletions(-) delete mode 100644 cmd/templater/main.go diff --git a/cmd/templater/main.go b/cmd/templater/main.go deleted file mode 100644 index 33388628665f9..0000000000000 --- a/cmd/templater/main.go +++ /dev/null @@ -1,279 +0,0 @@ -package main - -import ( - "context" - "fmt" - "io" - "net" - "net/http/httptest" - "net/url" - "os" - "strings" - "time" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - "google.golang.org/api/idtoken" - - "cdr.dev/slog" - "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/coderd" - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/databasefake" - "github.com/coder/coder/coderd/devtunnel" - "github.com/coder/coder/codersdk" - "github.com/coder/coder/provisioner/terraform" - "github.com/coder/coder/provisionerd" - "github.com/coder/coder/provisionersdk" - "github.com/coder/coder/provisionersdk/proto" -) - -func main() { - var rawParameters []string - cmd := &cobra.Command{ - Use: "templater", - RunE: func(cmd *cobra.Command, args []string) error { - parameters := make([]codersdk.CreateParameterRequest, 0) - for _, parameter := range rawParameters { - parts := strings.SplitN(parameter, "=", 2) - parameters = append(parameters, codersdk.CreateParameterRequest{ - Name: parts[0], - SourceValue: parts[1], - SourceScheme: database.ParameterSourceSchemeData, - DestinationScheme: database.ParameterDestinationSchemeProvisionerVariable, - }) - } - return parse(cmd, parameters) - }, - } - cmd.Flags().StringArrayVarP(&rawParameters, "parameter", "p", []string{}, "Specify parameters to pass in a template.") - err := cmd.Execute() - if err != nil { - panic(err) - } -} - -func parse(cmd *cobra.Command, parameters []codersdk.CreateParameterRequest) error { - srv := httptest.NewUnstartedServer(nil) - srv.Config.BaseContext = func(_ net.Listener) context.Context { - return cmd.Context() - } - srv.Start() - serverURL, err := url.Parse(srv.URL) - if err != nil { - return err - } - accessURL, errCh, err := devtunnel.New(cmd.Context(), serverURL) - if err != nil { - return err - } - go func() { - err := <-errCh - if err != nil { - panic(err) - } - }() - accessURLParsed, err := url.Parse(accessURL) - if err != nil { - return err - } - var closeWait func() - validator, err := idtoken.NewValidator(cmd.Context()) - if err != nil { - return err - } - logger := slog.Make(sloghuman.Sink(cmd.OutOrStdout())) - srv.Config.Handler, closeWait = coderd.New(&coderd.Options{ - AccessURL: accessURLParsed, - Logger: logger, - Database: databasefake.New(), - Pubsub: database.NewPubsubInMemory(), - GoogleTokenValidator: validator, - }) - - client := codersdk.New(serverURL) - daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger) - if err != nil { - return err - } - defer daemonClose.Close() - - created, err := client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{ - Email: "templater@coder.com", - Username: "templater", - OrganizationName: "templater", - Password: "insecure", - }) - if err != nil { - return err - } - auth, err := client.LoginWithPassword(cmd.Context(), codersdk.LoginWithPasswordRequest{ - Email: "templater@coder.com", - Password: "insecure", - }) - if err != nil { - return err - } - client.SessionToken = auth.SessionToken - - dir, err := os.Getwd() - if err != nil { - return err - } - content, err := provisionersdk.Tar(dir, provisionersdk.TemplateArchiveLimit) - if err != nil { - return err - } - resp, err := client.Upload(cmd.Context(), codersdk.ContentTypeTar, content) - if err != nil { - return err - } - - before := time.Now() - version, err := client.CreateTemplateVersion(cmd.Context(), created.OrganizationID, codersdk.CreateTemplateVersionRequest{ - StorageMethod: database.ProvisionerStorageMethodFile, - StorageSource: resp.Hash, - Provisioner: database.ProvisionerTypeTerraform, - ParameterValues: parameters, - }) - if err != nil { - return err - } - logs, err := client.TemplateVersionLogsAfter(cmd.Context(), version.ID, before) - if err != nil { - return err - } - for { - log, ok := <-logs - if !ok { - break - } - _, _ = fmt.Printf("terraform (%s): %s\n", log.Level, log.Output) - } - version, err = client.TemplateVersion(cmd.Context(), version.ID) - if err != nil { - return err - } - if version.Job.Status != codersdk.ProvisionerJobSucceeded { - return xerrors.Errorf("Job wasn't successful, it was %q. Check the logs!", version.Job.Status) - } - - _, err = client.TemplateVersionResources(cmd.Context(), version.ID) - if err != nil { - return err - } - - template, err := client.CreateTemplate(cmd.Context(), created.OrganizationID, codersdk.CreateTemplateRequest{ - Name: "test", - VersionID: version.ID, - }) - if err != nil { - return err - } - - workspace, err := client.CreateWorkspace(cmd.Context(), created.OrganizationID, codersdk.CreateWorkspaceRequest{ - TemplateID: template.ID, - Name: "example", - }) - if err != nil { - return err - } - logs, err = client.WorkspaceBuildLogsAfter(cmd.Context(), workspace.LatestBuild.ID, before) - if err != nil { - return err - } - for { - log, ok := <-logs - if !ok { - break - } - _, _ = fmt.Printf("terraform (%s): %s\n", log.Level, log.Output) - } - - resources, err := client.WorkspaceResourcesByBuild(cmd.Context(), workspace.LatestBuild.ID) - if err != nil { - return err - } - for _, resource := range resources { - for _, agent := range resource.Agents { - err = awaitAgent(cmd.Context(), client, agent) - if err != nil { - return err - } - } - } - - build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: version.ID, - Transition: database.WorkspaceTransitionDelete, - }) - if err != nil { - return err - } - logs, err = client.WorkspaceBuildLogsAfter(cmd.Context(), build.ID, before) - if err != nil { - return err - } - for { - log, ok := <-logs - if !ok { - break - } - _, _ = fmt.Printf("terraform (%s): %s\n", log.Level, log.Output) - } - - _ = daemonClose.Close() - srv.Close() - closeWait() - return nil -} - -func awaitAgent(ctx context.Context, client *codersdk.Client, agent codersdk.WorkspaceAgent) error { - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-ticker.C: - agent, err := client.WorkspaceAgent(ctx, agent.ID) - if err != nil { - return err - } - if agent.FirstConnectedAt == nil { - continue - } - return nil - } - } -} - -func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger) (io.Closer, error) { - terraformClient, terraformServer := provisionersdk.TransportPipe() - go func() { - err := terraform.Serve(ctx, &terraform.ServeOptions{ - ServeOptions: &provisionersdk.ServeOptions{ - Listener: terraformServer, - }, - Logger: logger, - }) - if err != nil { - panic(err) - } - }() - - tempDir, err := os.MkdirTemp("", "provisionerd") - if err != nil { - return nil, xerrors.Errorf("mkdir temp: %w", err) - } - - return provisionerd.New(client.ListenProvisionerDaemon, &provisionerd.Options{ - Logger: logger, - PollInterval: 50 * time.Millisecond, - UpdateInterval: 500 * time.Millisecond, - Provisioners: provisionerd.Provisioners{ - string(database.ProvisionerTypeTerraform): proto.NewDRPCProvisionerClient(provisionersdk.Conn(terraformClient)), - }, - WorkDirectory: tempDir, - }), nil -} diff --git a/coderd/coderd.go b/coderd/coderd.go index aa5cdbb21b86c..9113ede221d05 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -258,6 +258,7 @@ func New(options *Options) (http.Handler, func()) { }) r.Get("/gitsshkey", api.gitSSHKey) r.Put("/gitsshkey", api.regenerateGitSSHKey) + r.Get("/workspaces", api.workspacesByOwner) }) }) }) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 295062a0a7096..825d287b6cbc9 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -478,6 +478,28 @@ func (q *fakeQuerier) GetWorkspacesByOrganizationID(_ context.Context, req datab return workspaces, nil } +func (q *fakeQuerier) GetWorkspacesByOrganizationIDs(_ context.Context, req database.GetWorkspacesByOrganizationIDsParams) ([]database.Workspace, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaces := make([]database.Workspace, 0) + for _, workspace := range q.workspaces { + for _, id := range req.Ids { + if workspace.ID != id { + continue + } + if workspace.Deleted != req.Deleted { + continue + } + workspaces = append(workspaces, workspace) + } + } + if len(workspaces) == 0 { + return nil, sql.ErrNoRows + } + return workspaces, nil +} + func (q *fakeQuerier) GetWorkspacesByOwnerID(_ context.Context, req database.GetWorkspacesByOwnerIDParams) ([]database.Workspace, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index dbf6b5cfed8cd..216db6dcd93fd 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -70,6 +70,7 @@ type querier interface { GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error) GetWorkspacesByOrganizationID(ctx context.Context, arg GetWorkspacesByOrganizationIDParams) ([]Workspace, error) + GetWorkspacesByOrganizationIDs(ctx context.Context, arg GetWorkspacesByOrganizationIDsParams) ([]Workspace, error) GetWorkspacesByOwnerID(ctx context.Context, arg GetWorkspacesByOwnerIDParams) ([]Workspace, error) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c79f9690e7f3a..8392ea278318a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3210,6 +3210,49 @@ func (q *sqlQuerier) GetWorkspacesByOrganizationID(ctx context.Context, arg GetW return items, nil } +const getWorkspacesByOrganizationIDs = `-- name: GetWorkspacesByOrganizationIDs :many +SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule FROM workspaces WHERE organization_id = ANY($1 :: uuid [ ]) AND deleted = $2 +` + +type GetWorkspacesByOrganizationIDsParams struct { + Ids []uuid.UUID `db:"ids" json:"ids"` + Deleted bool `db:"deleted" json:"deleted"` +} + +func (q *sqlQuerier) GetWorkspacesByOrganizationIDs(ctx context.Context, arg GetWorkspacesByOrganizationIDsParams) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesByOrganizationIDs, pq.Array(arg.Ids), arg.Deleted) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Workspace + for rows.Next() { + var i Workspace + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.AutostopSchedule, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspacesByOwnerID = `-- name: GetWorkspacesByOwnerID :many SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, autostop_schedule diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index dec81778373fc..d271f7ddf7847 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -11,6 +11,9 @@ LIMIT -- name: GetWorkspacesByOrganizationID :many SELECT * FROM workspaces WHERE organization_id = $1 AND deleted = $2; +-- name: GetWorkspacesByOrganizationIDs :many +SELECT * FROM workspaces WHERE organization_id = ANY(@ids :: uuid [ ]) AND deleted = @deleted; + -- name: GetWorkspacesByTemplateID :many SELECT * diff --git a/coderd/users.go b/coderd/users.go index b2e3ab5deb2ab..d9e2a9a9107df 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -806,6 +806,51 @@ func (api *api) createUser(ctx context.Context, req codersdk.CreateUserRequest) }) } +func (api *api) workspacesByUser(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) + roles := httpmw.UserRoles(r) + + organizations, err := api.Database.GetOrganizationsByUserID(r.Context(), user.ID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get organizations: %s", err), + }) + return + } + organizationIDs := make([]uuid.UUID, 0) + for _, organization := range organizations { + err = api.Authorizer.AuthorizeByRoleName(r.Context(), user.ID.String(), roles.Roles, rbac.ActionRead, rbac.ResourceWorkspace.All().InOrg(organization.ID)) + if errors.Is(err, &rbac.UnauthorizedError{}) { + continue + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("authorize: %s", err), + }) + return + } + organizationIDs = append(organizationIDs, organization.ID) + } + + workspaces, err := api.Database.GetWorkspacesByOrganizationIDs(r.Context(), database.GetWorkspacesByOrganizationIDsParams{ + Ids: organizationIDs, + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspaces for organizations: %s", err), + }) + return + } + apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, workspaces) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert workspaces: %s", err), + }) + return + } + httpapi.Write(rw, http.StatusOK, apiWorkspaces) +} + func convertUser(user database.User, organizationIDs []uuid.UUID) codersdk.User { convertedUser := codersdk.User{ ID: user.ID, diff --git a/coderd/users_test.go b/coderd/users_test.go index aaf5737ce6b61..f075e670c4052 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -662,6 +662,51 @@ func TestPostAPIKey(t *testing.T) { }) } +func TestWorkspacesByUser(t *testing.T) { + t.Parallel() + t.Run("Empty", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + workspaces, err := client.WorkspacesByUser(context.Background(), codersdk.Me) + require.NoError(t, err) + require.Len(t, workspaces, 0) + }) + t.Run("Access", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + newUser, err := client.CreateUser(context.Background(), codersdk.CreateUserRequest{ + Email: "test@coder.com", + Username: "someone", + Password: "password", + OrganizationID: user.OrganizationID, + }) + require.NoError(t, err) + auth, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ + Email: newUser.Email, + Password: "password", + }) + require.NoError(t, err) + + newUserClient := codersdk.New(client.URL) + newUserClient.SessionToken = auth.SessionToken + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + + workspaces, err := newUserClient.WorkspacesByUser(context.Background(), codersdk.Me) + require.NoError(t, err) + require.Len(t, workspaces, 0) + + workspaces, err = client.WorkspacesByUser(context.Background(), codersdk.Me) + require.NoError(t, err) + require.Len(t, workspaces, 1) + }) +} + // TestPaginatedUsers creates a list of users, then tries to paginate through // them using different page sizes. func TestPaginatedUsers(t *testing.T) { diff --git a/codersdk/users.go b/codersdk/users.go index 79667a38e7739..1747a300cf947 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -402,6 +402,22 @@ func (c *Client) AuthMethods(ctx context.Context) (AuthMethods, error) { return userAuth, json.NewDecoder(res.Body).Decode(&userAuth) } +// WorkspacesByUser returns all workspaces a user has access to. +func (c *Client) WorkspacesByUser(ctx context.Context, userID uuid.UUID) ([]Workspace, error) { + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspaces", uuidOrMe(userID)), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + + var workspaces []Workspace + return workspaces, json.NewDecoder(res.Body).Decode(&workspaces) +} + // uuidOrMe returns the provided uuid as a string if it's valid, ortherwise // `me`. func uuidOrMe(id uuid.UUID) string { diff --git a/go.mod b/go.mod index e1cdf7b043eef..51c35f72fe452 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( github.com/fatedier/frp v0.42.0 github.com/fatedier/golib v0.1.1-0.20220321042308-c306138b83ac github.com/fatih/color v1.13.0 + github.com/fatih/structs v1.1.0 github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa github.com/gliderlabs/ssh v0.3.3 github.com/go-chi/chi/v5 v5.0.7 @@ -155,7 +156,6 @@ require ( github.com/dustin/go-humanize v1.0.0 // indirect github.com/fatedier/beego v0.0.0-20171024143340-6c6a4f5bd5eb // indirect github.com/fatedier/kcp-go v2.0.4-0.20190803094908-fe8645b0a904+incompatible // indirect - github.com/fatih/structs v1.1.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect