Ver Fonte

list projects and clusters'

Alexander Belanger há 5 anos atrás
pai
commit
6a55f00a20

+ 32 - 0
cli/cmd/api/project.go

@@ -40,6 +40,38 @@ func (c *Client) GetProject(ctx context.Context, projectID uint) (*GetProjectRes
 	return bodyResp, nil
 }
 
+// ListProjectClustersResponse lists the linked clusters for a project
+type ListProjectClustersResponse []models.ClusterExternal
+
+// ListProjectClusters creates a list of clusters for a given project
+func (c *Client) ListProjectClusters(
+	ctx context.Context,
+	projectID uint,
+) (ListProjectClustersResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/clusters", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := ListProjectClustersResponse{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
 // CreateProjectRequest represents the accepted fields for creating a project
 type CreateProjectRequest struct {
 	Name string `json:"name" form:"required"`

+ 66 - 0
cli/cmd/api/project_test.go

@@ -46,6 +46,33 @@ func initProjectCandidate(
 	return resp[0]
 }
 
+func initProjectSA(
+	projectID uint,
+	candidateID uint,
+	client *api.Client,
+	t *testing.T,
+) *api.CreateProjectServiceAccountResponse {
+	t.Helper()
+
+	resp, err := client.CreateProjectServiceAccount(
+		context.Background(),
+		projectID,
+		candidateID,
+		api.CreateProjectServiceAccountRequest{
+			&models.ServiceAccountAllActions{
+				Name:             models.OIDCIssuerDataAction,
+				OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
+			},
+		},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	return resp
+}
+
 func TestCreateProject(t *testing.T) {
 	email := "create_project_test@example.com"
 	client := api.NewClient(baseURL, "cookie_create_project_test.json")
@@ -284,6 +311,45 @@ func TestCreateProjectServiceAccount(t *testing.T) {
 	}
 }
 
+func TestListProjectClusters(t *testing.T) {
+	email := "list_project_clusters_test@example.com"
+	client := api.NewClient(baseURL, "cookie_list_project_clusters_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+	saCandidate := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+	sa := initProjectSA(project.ID, saCandidate.ID, client, t)
+
+	resp, err := client.ListProjectClusters(
+		context.Background(),
+		project.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// verify clusters
+	if len(resp) != 1 {
+		t.Fatalf("length of clusters is not 1")
+	}
+
+	if resp[0].ServiceAccountID != sa.ID {
+		t.Errorf("cluster's sa id is incorrect: expected %d, got %d\n", sa.ID, resp[0].ServiceAccountID)
+	}
+
+	if resp[0].Name != "cluster-test" {
+		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp[0].Name)
+	}
+
+	if resp[0].Server != "https://localhost" {
+		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://localhost", resp[0].Server)
+	}
+}
+
 const OIDCAuthWithoutData string = `
 apiVersion: v1
 clusters:

+ 30 - 0
cli/cmd/api/user.go

@@ -184,6 +184,36 @@ func (c *Client) GetUser(ctx context.Context, userID uint) (*GetUserResponse, er
 	return bodyResp, nil
 }
 
+// ListUserProjectsResponse is the list of projects returned
+type ListUserProjectsResponse []*models.ProjectExternal
+
+// ListUserProjects returns a list of projects associated with a user
+func (c *Client) ListUserProjects(ctx context.Context, userID uint) (ListUserProjectsResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/users/%d/projects", c.BaseURL, userID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	bodyResp := ListUserProjectsResponse{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
 // DeleteUserRequest is the password needed to verify a user should be deleted
 type DeleteUserRequest struct {
 	Password string `json:"password"`

+ 41 - 0
cli/cmd/api/user_test.go

@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/internal/models"
 )
 
 func initUser(email string, client *api.Client, t *testing.T) *api.CreateUserResponse {
@@ -102,6 +103,46 @@ func TestGetUser(t *testing.T) {
 	}
 }
 
+func TestListUserProjects(t *testing.T) {
+	email := "list_user_projects@example.com"
+	client := api.NewClient(baseURL, "cookie_list_user_projects.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+
+	projects, err := client.ListUserProjects(context.Background(), user.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(projects) != 1 {
+		t.Fatalf("length of projects is not 1")
+	}
+
+	resp := projects[0]
+
+	// make sure user is admin and project name is correct
+	if resp.Name != project.Name {
+		t.Errorf("project name incorrect: expected %s, got %s\n", project.Name, resp.Name)
+	}
+
+	if len(resp.Roles) != 1 {
+		t.Fatalf("project role length is not 1")
+	}
+
+	if resp.Roles[0].Kind != models.RoleAdmin {
+		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
+	}
+
+	if resp.Roles[0].UserID != user.ID {
+		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
+	}
+}
+
 func TestDeleteUser(t *testing.T) {
 	email := "delete_user_test@example.com"
 	client := api.NewClient(baseURL, "cookie_delete_user_test.json")

+ 36 - 1
cli/cmd/project.go

@@ -30,6 +30,19 @@ var createProjectCmd = &cobra.Command{
 	},
 }
 
+var listProjectCmd = &cobra.Command{
+	Use:   "list",
+	Short: "Lists the projects for the logged in user",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := listProjects(getHost())
+
+		if err != nil {
+			fmt.Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
 func init() {
 	rootCmd.AddCommand(projectCmd)
 
@@ -42,7 +55,7 @@ func init() {
 		"host url of Porter instance",
 	)
 
-	projectCmd.AddCommand(setProjectCmd)
+	projectCmd.AddCommand(listProjectCmd)
 }
 
 func createProject(host string, name string) error {
@@ -60,3 +73,25 @@ func createProject(host string, name string) error {
 
 	return setProject(resp.ID)
 }
+
+func listProjects(host string) error {
+	client := api.NewClient(host+"/api", "cookie.json")
+
+	user, err := client.AuthCheck(context.Background())
+
+	if err != nil {
+		return err
+	}
+
+	projects, err := client.ListUserProjects(context.Background(), user.ID)
+
+	if err != nil {
+		return err
+	}
+
+	for _, project := range projects {
+		fmt.Println(project.Name, project.ID)
+	}
+
+	return nil
+}

+ 2 - 2
internal/models/project.go

@@ -10,7 +10,7 @@ type Project struct {
 
 	Name        string       `json:"name"`
 	Roles       []Role       `json:"roles"`
-	RepoClients []RepoClient `json:"repo_clients"`
+	RepoClients []RepoClient `json:"repo_clients,omitempty"`
 
 	ServiceAccountCandidates []ServiceAccountCandidate `json:"sa_candidates"`
 	ServiceAccounts          []ServiceAccount          `json:"serviceaccounts"`
@@ -21,7 +21,7 @@ type ProjectExternal struct {
 	ID          uint                 `json:"id"`
 	Name        string               `json:"name"`
 	Roles       []RoleExternal       `json:"roles"`
-	RepoClients []RepoClientExternal `json:"repo_clients"`
+	RepoClients []RepoClientExternal `json:"repo_clients,omitempty"`
 }
 
 // Externalize generates an external Project to be shared over REST

+ 16 - 0
internal/repository/gorm/helpers_test.go

@@ -102,6 +102,22 @@ func initProject(tester *tester, t *testing.T) {
 	tester.initProjects = append(tester.initProjects, proj)
 }
 
+func initProjectRole(tester *tester, t *testing.T) {
+	t.Helper()
+
+	role := &models.Role{
+		Kind:      models.RoleAdmin,
+		UserID:    tester.initUsers[0].Model.ID,
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[0], role)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+}
+
 func initServiceAccountCandidate(tester *tester, t *testing.T) {
 	t.Helper()
 

+ 13 - 0
internal/repository/gorm/project.go

@@ -51,3 +51,16 @@ func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 
 	return project, nil
 }
+
+// ListProjectsByUserID lists projects where a user has an associated role
+func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
+	projects := make([]*models.Project, 0)
+
+	subQuery := repo.db.Model(&models.Role{}).Where("user_id = ?", userID).Select("project_id")
+
+	if err := repo.db.Preload("Roles").Model(&models.Project{}).Where("id IN (?)", subQuery).Find(&projects).Error; err != nil {
+		return nil, err
+	}
+
+	return projects, nil
+}

+ 62 - 0
internal/repository/gorm/project_test.go

@@ -106,3 +106,65 @@ func TestCreateProjectRole(t *testing.T) {
 		t.Error(diff)
 	}
 }
+
+func TestListProjectsByUserID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./list_projects_user_id.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	// create two projects, same name
+	initProject(tester, t)
+	initProjectRole(tester, t)
+	initProject(tester, t)
+
+	role := &models.Role{
+		Kind:   models.RoleAdmin,
+		UserID: 1,
+	}
+
+	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[1], role)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	defer cleanup(tester, t)
+
+	projects, err := tester.repo.Project.ListProjectsByUserID(tester.initUsers[0].Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(projects) != 2 {
+		t.Fatalf("projects length was not 2\n")
+	}
+
+	for i, project := range projects {
+		// make sure data is correct
+		expProj := &models.Project{
+			Name: "project-test",
+			Roles: []models.Role{
+				models.Role{
+					Kind:      models.RoleAdmin,
+					UserID:    tester.initUsers[0].Model.ID,
+					ProjectID: uint(i + 1),
+				},
+			},
+		}
+
+		copyProj := project
+
+		// reset fields for reflect.DeepEqual
+		copyProj.Model = orm.Model{}
+		copyProj.Roles[0].Model = orm.Model{}
+
+		if diff := deep.Equal(copyProj, expProj); diff != nil {
+			t.Errorf("incorrect project")
+			t.Error(diff)
+		}
+	}
+
+}

+ 1 - 0
internal/repository/project.go

@@ -12,4 +12,5 @@ type ProjectRepository interface {
 	CreateProject(project *models.Project) (*models.Project, error)
 	CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error)
 	ReadProject(id uint) (*models.Project, error)
+	ListProjectsByUserID(userID uint) ([]*models.Project, error)
 }

+ 20 - 0
internal/repository/test/project.go

@@ -64,3 +64,23 @@ func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 	index := int(id - 1)
 	return repo.projects[index], nil
 }
+
+// ListProjectsByUserID lists projects where a user has an associated role
+func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	resp := make([]*models.Project, 0)
+
+	// find all roles matching
+	for _, project := range repo.projects {
+		for _, role := range project.Roles {
+			if role.UserID == userID {
+				resp = append(resp, project)
+			}
+		}
+	}
+
+	return resp, nil
+}

+ 47 - 0
server/api/project_handler.go

@@ -110,6 +110,53 @@ func (app *App) HandleReadProject(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleListProjectClusters returns a list of clusters that have linked ServiceAccounts.
+// If multiple service accounts exist for a cluster, the service account created later
+// will take precedence. This may be changed in a future release to return multiple
+// service accounts.
+func (app *App) HandleListProjectClusters(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	sas, err := app.repo.ServiceAccount.ListServiceAccountsByProjectID(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	clusters := make([]*models.ClusterExternal, 0)
+
+	// clusterMapIndex used for checking if cluster has already been added
+	// maps from the cluster's endpoint to the index in the cluster array
+	clusterMapIndex := make(map[string]int)
+
+	for _, sa := range sas {
+		for _, cluster := range sa.Clusters {
+			if currIndex, ok := clusterMapIndex[cluster.Server]; ok {
+				if clusters[currIndex].ServiceAccountID <= cluster.ServiceAccountID {
+					clusters[currIndex] = cluster.Externalize()
+					continue
+				}
+			}
+
+			clusterMapIndex[cluster.Server] = len(clusters)
+			clusters = append(clusters, cluster.Externalize())
+		}
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(clusters); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 // HandleCreateProjectSACandidates handles the creation of ServiceAccountCandidates
 // using a kubeconfig and a project id
 func (app *App) HandleCreateProjectSACandidates(w http.ResponseWriter, r *http.Request) {

+ 40 - 2
server/api/project_handler_test.go

@@ -7,6 +7,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/go-test/deep"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -81,7 +82,7 @@ var createProjectTests = []*projTest{
 			"name": "project-test"
 		}`,
 		expStatus: http.StatusCreated,
-		expBody:   `{"id":1,"name":"project-test","roles":[{"id":0,"kind":"admin","user_id":1,"project_id":1}],"repo_clients":[]}`,
+		expBody:   `{"id":1,"name":"project-test","roles":[{"id":0,"kind":"admin","user_id":1,"project_id":1}]}`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
 			projectModelBodyValidator,
@@ -104,7 +105,7 @@ var readProjectTests = []*projTest{
 		endpoint:  "/api/projects/1",
 		body:      ``,
 		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"name":"project-test","roles":[{"id":0,"kind":"admin","user_id":1,"project_id":1}],"repo_clients":[]}`,
+		expBody:   `{"id":1,"name":"project-test","roles":[{"id":0,"kind":"admin","user_id":1,"project_id":1}]}`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
 			projectModelBodyValidator,
@@ -116,6 +117,30 @@ func TestHandleReadProject(t *testing.T) {
 	testProjRequests(t, readProjectTests, true)
 }
 
+var listProjectClustersTest = []*projTest{
+	&projTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initProjectSADefault,
+		},
+		msg:       "List project clusters",
+		method:    "GET",
+		endpoint:  "/api/projects/1/clusters",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://localhost"}]`,
+		useCookie: true,
+		validators: []func(c *projTest, tester *tester, t *testing.T){
+			projectClustersValidator,
+		},
+	},
+}
+
+func TestHandleListProjectClusters(t *testing.T) {
+	testProjRequests(t, listProjectClustersTest, true)
+}
+
 var createProjectSACandidatesTests = []*projTest{
 	&projTest{
 		initializers: []func(t *tester){
@@ -345,6 +370,19 @@ func projectSABodyValidator(c *projTest, tester *tester, t *testing.T) {
 	}
 }
 
+func projectClustersValidator(c *projTest, tester *tester, t *testing.T) {
+	gotBody := make([]*models.ClusterExternal, 0)
+	expBody := make([]*models.ClusterExternal, 0)
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}
+
 const OIDCAuthWithDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://localhost\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://localhost\n        idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n      name: oidc`
 
 const OIDCAuthWithoutDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://localhost\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://localhost\n        idp-certificate-authority: /fake/path/to/ca.pem\n      name: oidc`

+ 29 - 0
server/api/user_handler.go

@@ -165,6 +165,35 @@ func (app *App) HandleReadUser(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
 }
 
+// HandleListUserProjects lists all projects belonging to a given user
+func (app *App) HandleListUserProjects(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	projects, err := app.repo.Project.ListProjectsByUserID(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrUserDataRead, w)
+	}
+
+	projectsExt := make([]*models.ProjectExternal, 0)
+
+	for _, project := range projects {
+		projectsExt = append(projectsExt, project.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(projectsExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+}
+
 // HandleDeleteUser removes a user after checking that the sent password is correct
 func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
 	id, err := strconv.ParseUint(chi.URLParam(r, "user_id"), 0, 64)

+ 51 - 0
server/api/user_handler_test.go

@@ -8,6 +8,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/go-test/deep"
 	"github.com/porter-dev/porter/internal/models"
 )
 
@@ -360,6 +361,43 @@ func TestHandleReadUser(t *testing.T) {
 	testUserRequests(t, readUserTests, true)
 }
 
+var listUserProjectsTests = []*userTest{
+	&userTest{
+		initializers: []func(tester *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "List user projects successful",
+		method:    "GET",
+		endpoint:  "/api/users/1/projects",
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   `[{"id":1,"name":"project-test","roles":[{"id":0,"kind":"admin","user_id":1,"project_id":1}]}]`,
+		useCookie: true,
+		validators: []func(c *userTest, tester *tester, t *testing.T){
+			userProjectsListValidator,
+		},
+	},
+	&userTest{
+		initializers: []func(tester *tester){
+			initUserDefault,
+		},
+		msg:       "List user projects unauthorized",
+		method:    "GET",
+		endpoint:  "/api/users/2/projects",
+		body:      "",
+		expStatus: http.StatusForbidden,
+		expBody:   http.StatusText(http.StatusForbidden) + "\n",
+		validators: []func(c *userTest, tester *tester, t *testing.T){
+			userBasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleListUserProjects(t *testing.T) {
+	testUserRequests(t, listUserProjectsTests, true)
+}
+
 var deleteUserTests = []*userTest{
 	&userTest{
 		initializers: []func(tester *tester){
@@ -481,3 +519,16 @@ func userContextBodyValidator(c *userTest, tester *tester, t *testing.T) {
 			c.msg, gotBody, expBody)
 	}
 }
+
+func userProjectsListValidator(c *userTest, tester *tester, t *testing.T) {
+	gotBody := make([]*models.ProjectExternal, 0)
+	expBody := make([]*models.ProjectExternal, 0)
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}

+ 11 - 0
server/router/router.go

@@ -33,6 +33,7 @@ func New(
 
 		// /api/users routes
 		r.Method("GET", "/users/{user_id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUser, l), mw.URLParam))
+		r.Method("GET", "/users/{user_id}/projects", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleListUserProjects, l), mw.URLParam))
 		r.Method("POST", "/users", requestlog.NewHandler(a.HandleCreateUser, l))
 		r.Method("DELETE", "/users/{user_id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l), mw.URLParam))
 		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
@@ -67,6 +68,16 @@ func New(
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/clusters",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListProjectClusters, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method("POST", "/projects", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleCreateProject, l)))
 
 		r.Method(