فهرست منبع

image registry ecr list

Alexander Belanger 5 سال پیش
والد
کامیت
fbaa9b1f19

+ 58 - 0
cli/cmd/api/integration.go

@@ -0,0 +1,58 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// CreateAWSIntegrationRequest represents the accepted fields for creating
+// an aws integration
+type CreateAWSIntegrationRequest struct {
+	AWSRegion          string `json:"aws_region"`
+	AWSAccessKeyID     string `json:"aws_access_key_id"`
+	AWSSecretAccessKey string `json:"aws_secret_access_key"`
+}
+
+// CreateAWSIntegrationResponse is the resulting integration after creation
+type CreateAWSIntegrationResponse ints.AWSIntegrationExternal
+
+// CreateAWSIntegration creates an AWS integration with the given request options
+func (c *Client) CreateAWSIntegration(
+	ctx context.Context,
+	projectID uint,
+	createAWS *CreateAWSIntegrationRequest,
+) (*CreateAWSIntegrationResponse, error) {
+	data, err := json.Marshal(createAWS)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/integrations/aws", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateAWSIntegrationResponse{}
+
+	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
+}

+ 92 - 0
cli/cmd/api/registry.go

@@ -0,0 +1,92 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/registry"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateECRRequest represents the accepted fields for creating
+// an ECR registry
+type CreateECRRequest struct {
+	Name             string `json:"name"`
+	AWSIntegrationID uint   `json:"aws_integration_id"`
+}
+
+// CreateECRResponse is the resulting registry after creation
+type CreateECRResponse models.RegistryExternal
+
+// CreateECR creates an Elastic Container Registry integration
+func (c *Client) CreateECR(
+	ctx context.Context,
+	projectID uint,
+	createECR *CreateECRRequest,
+) (*CreateECRResponse, error) {
+	data, err := json.Marshal(createECR)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/registries", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateECRResponse{}
+
+	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
+}
+
+// ListRegistryRepositoryResponse is the list of repositories in a registry
+type ListRegistryRepositoryResponse []registry.Repository
+
+// ListRegistryRepositories lists the repositories in a registry
+func (c *Client) ListRegistryRepositories(
+	ctx context.Context,
+	projectID uint,
+	registryID uint,
+) (ListRegistryRepositoryResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/registries/%d/repositories", c.BaseURL, projectID, registryID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &ListRegistryRepositoryResponse{}
+
+	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
+}

+ 23 - 2
cli/cmd/connect.go

@@ -23,7 +23,19 @@ var connectKubeconfigCmd = &cobra.Command{
 	Use:   "kubeconfig",
 	Short: "Uses the local kubeconfig to connect to a cluster",
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, runConnect)
+		err := checkLoginAndRun(args, runConnectKubeconfig)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var connectECRCmd = &cobra.Command{
+	Use:   "ecr",
+	Short: "Connects an ECR instance to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectECR)
 
 		if err != nil {
 			os.Exit(1)
@@ -63,9 +75,11 @@ func init() {
 		nil,
 		"the list of contexts to connect (defaults to the current context)",
 	)
+
+	connectCmd.AddCommand(connectECRCmd)
 }
 
-func runConnect(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+func runConnectKubeconfig(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	isLocal := false
 
 	if getDriver() == "local" {
@@ -80,3 +94,10 @@ func runConnect(_ *api.AuthCheckResponse, client *api.Client, _ []string) error
 		isLocal,
 	)
 }
+
+func runConnectECR(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	return connect.ECR(
+		client,
+		getProjectID(),
+	)
+}

+ 99 - 0
cli/cmd/connect/ecr.go

@@ -0,0 +1,99 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+// ECR creates an ECR integration
+func ECR(
+	client *api.Client,
+	projectID uint,
+) error {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// query for the access key id
+	accessKeyID, err := utils.PromptPlaintext(fmt.Sprintf(`AWS Access Key ID: `))
+
+	if err != nil {
+		return err
+	}
+
+	// query for the secret access key
+	secretKey, err := utils.PromptPlaintext(fmt.Sprintf(`AWS Secret Access Key: `))
+
+	if err != nil {
+		return err
+	}
+
+	// query for the region
+	region, err := utils.PromptPlaintext(fmt.Sprintf(`AWS Region: `))
+
+	if err != nil {
+		return err
+	}
+
+	// create the aws integration
+	integration, err := client.CreateAWSIntegration(
+		context.Background(),
+		projectID,
+		&api.CreateAWSIntegrationRequest{
+			AWSAccessKeyID:     accessKeyID,
+			AWSSecretAccessKey: secretKey,
+			AWSRegion:          region,
+		},
+	)
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Printf("created aws integration with id %d\n", integration.ID)
+
+	// create the registry
+	// query for registry name
+	regName, err := utils.PromptPlaintext(fmt.Sprintf(`Give this registry a name: `))
+
+	if err != nil {
+		return err
+	}
+
+	reg, err := client.CreateECR(
+		context.Background(),
+		projectID,
+		&api.CreateECRRequest{
+			Name:             regName,
+			AWSIntegrationID: integration.ID,
+		},
+	)
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Printf("created registry with id %d and name %s\n", reg.ID, reg.Name)
+
+	// test by listing repositories
+	repos, err := client.ListRegistryRepositories(
+		context.Background(),
+		projectID,
+		reg.ID,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	for _, repo := range repos {
+		fmt.Println("REPO IS", repo.Name)
+	}
+
+	return nil
+}

+ 1 - 0
cmd/app/main.go

@@ -39,6 +39,7 @@ func main() {
 		&models.User{},
 		&models.Session{},
 		&models.GitRepo{},
+		&models.Registry{},
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},

+ 1 - 0
cmd/migrate/main.go

@@ -30,6 +30,7 @@ func main() {
 		&models.User{},
 		&models.Session{},
 		&models.GitRepo{},
+		&models.Registry{},
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},

+ 45 - 0
internal/forms/integration.go

@@ -0,0 +1,45 @@
+package forms
+
+import (
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// CreateGCPIntegrationForm represents the accepted values for creating a
+// GCP Integration
+type CreateGCPIntegrationForm struct {
+	UserID     uint   `json:"user_id" form:"required"`
+	ProjectID  uint   `json:"project_id" form:"required"`
+	GCPKeyData string `json:"gcp_key_data" form:"required"`
+}
+
+// ToGCPIntegration converts the project to a gorm project model
+func (cgf *CreateGCPIntegrationForm) ToGCPIntegration() (*ints.GCPIntegration, error) {
+	return &ints.GCPIntegration{
+		UserID:     cgf.UserID,
+		ProjectID:  cgf.ProjectID,
+		GCPKeyData: []byte(cgf.GCPKeyData),
+	}, nil
+}
+
+// CreateAWSIntegrationForm represents the accepted values for creating an
+// AWS Integration
+type CreateAWSIntegrationForm struct {
+	UserID             uint   `json:"user_id" form:"required"`
+	ProjectID          uint   `json:"project_id" form:"required"`
+	AWSRegion          string `json:"aws_region"`
+	AWSClusterID       string `json:"aws_cluster_id"`
+	AWSAccessKeyID     string `json:"aws_access_key_id"`
+	AWSSecretAccessKey string `json:"aws_secret_access_key"`
+}
+
+// ToAWSIntegration converts the project to a gorm project model
+func (caf *CreateAWSIntegrationForm) ToAWSIntegration() (*ints.AWSIntegration, error) {
+	return &ints.AWSIntegration{
+		UserID:             caf.UserID,
+		ProjectID:          caf.ProjectID,
+		AWSRegion:          caf.AWSRegion,
+		AWSClusterID:       []byte(caf.AWSClusterID),
+		AWSAccessKeyID:     []byte(caf.AWSAccessKeyID),
+		AWSSecretAccessKey: []byte(caf.AWSSecretAccessKey),
+	}, nil
+}

+ 24 - 0
internal/forms/registry.go

@@ -0,0 +1,24 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateRegistry represents the accepted values for creating a
+// registry
+type CreateRegistry struct {
+	Name             string `json:"name" form:"required"`
+	ProjectID        uint   `json:"project_id" form:"required"`
+	GCPIntegrationID uint   `json:"gcp_integration_id"`
+	AWSIntegrationID uint   `json:"aws_integration_id"`
+}
+
+// ToRegistry converts the form to a gorm registry model
+func (cr *CreateRegistry) ToRegistry() (*models.Registry, error) {
+	return &models.Registry{
+		Name:             cr.Name,
+		ProjectID:        cr.ProjectID,
+		GCPIntegrationID: cr.GCPIntegrationID,
+		AWSIntegrationID: cr.AWSIntegrationID,
+	}, nil
+}

+ 25 - 10
internal/models/integrations/aws.go

@@ -27,6 +27,9 @@ type AWSIntegration struct {
 	// The AWS caller identity (ARN) which linked this service
 	AWSCallerID string `json:"aws-caller-id"`
 
+	// The optional AWS region (required by some session configurations)
+	AWSRegion string `json:"aws_region"`
+
 	// ------------------------------------------------------------------
 	// All fields encrypted before storage.
 	// ------------------------------------------------------------------
@@ -87,6 +90,27 @@ func (a *AWSIntegration) ToProjectIntegration(
 	}
 }
 
+// GetSession retrieves an AWS session to use based on the access key and secret
+// access key
+func (a *AWSIntegration) GetSession() (*session.Session, error) {
+	awsConf := &aws.Config{
+		Credentials: credentials.NewStaticCredentials(
+			string(a.AWSAccessKeyID),
+			string(a.AWSSecretAccessKey),
+			string(a.AWSSessionToken),
+		),
+	}
+
+	if a.AWSRegion != "" {
+		awsConf.Region = &a.AWSRegion
+	}
+
+	return session.NewSessionWithOptions(session.Options{
+		SharedConfigState: session.SharedConfigEnable,
+		Config:            *awsConf,
+	})
+}
+
 // GetBearerToken retrieves a bearer token for an AWS account
 func (a *AWSIntegration) GetBearerToken(
 	getTokenCache GetTokenCacheFunc,
@@ -105,16 +129,7 @@ func (a *AWSIntegration) GetBearerToken(
 		return "", err
 	}
 
-	sess, err := session.NewSessionWithOptions(session.Options{
-		SharedConfigState: session.SharedConfigEnable,
-		Config: aws.Config{
-			Credentials: credentials.NewStaticCredentials(
-				string(a.AWSAccessKeyID),
-				string(a.AWSSecretAccessKey),
-				string(a.AWSSessionToken),
-			),
-		},
-	})
+	sess, err := a.GetSession()
 
 	if err != nil {
 		return "", err

+ 3 - 0
internal/models/project.go

@@ -16,6 +16,9 @@ type Project struct {
 	// linked repos
 	GitRepos []GitRepo `json:"git_repos,omitempty"`
 
+	// linked registries
+	Registries []Registry `json:"registries,omitempty"`
+
 	// linked clusters
 	Clusters          []Cluster          `json:"clusters"`
 	ClusterCandidates []ClusterCandidate `json:"cluster_candidates"`

+ 44 - 0
internal/models/registry.go

@@ -0,0 +1,44 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+// Registry is an integration that can connect to a Docker image registry via
+// a specific auth mechanism
+type Registry struct {
+	gorm.Model
+
+	// Name of the registry
+	Name string `json:"name"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// ------------------------------------------------------------------
+	// All fields below this line are encrypted before storage
+	// ------------------------------------------------------------------
+
+	GCPIntegrationID uint
+	AWSIntegrationID uint
+}
+
+// RegistryExternal is an external Registry to be shared over REST
+type RegistryExternal struct {
+	ID uint `json:"id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// Name of the registry
+	Name string `json:"name"`
+}
+
+// Externalize generates an external Registry to be shared over REST
+func (r *Registry) Externalize() *RegistryExternal {
+	return &RegistryExternal{
+		ID:        r.ID,
+		ProjectID: r.ProjectID,
+		Name:      r.Name,
+	}
+}

+ 90 - 0
internal/registry/registry.go

@@ -0,0 +1,90 @@
+package registry
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/aws/aws-sdk-go/service/ecr"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// Registry wraps the gorm Registry model
+type Registry models.Registry
+
+// Repository is a collection of images
+type Repository struct {
+	// Name of the repository
+	Name string `json:"name"`
+
+	// When the repository was created
+	CreatedAt time.Time `json:"created_at,omitempty"`
+}
+
+// Image is a Docker image type
+type Image struct {
+	// The sha256 digest of the image manifest.
+	Digest string `json:"digest"`
+
+	// The tag used for the image.
+	Tag string `json:"tag"`
+
+	// The image manifest associated with the image.
+	Manifest string `json:"manifest"`
+
+	// The name of the repository associated with the image.
+	RepositoryName string `json:"repository_name"`
+}
+
+// ListRepositories lists the repositories for a registry
+func (r *Registry) ListRepositories(repo repository.Repository) ([]*Repository, error) {
+	// switch on the auth mechanism to get a token
+	if r.AWSIntegrationID != 0 {
+		return r.listECRRepositories(repo.AWSIntegration)
+	}
+
+	return nil, fmt.Errorf("error listing repositories")
+}
+
+// ListImages lists the images for an image repository
+func (r *Registry) ListImages(
+	repo repository.Repository,
+	regName string,
+) ([]*Image, error) {
+	return nil, nil
+}
+
+func (r *Registry) listECRRepositories(repo repository.AWSIntegrationRepository) ([]*Repository, error) {
+	aws, err := repo.ReadAWSIntegration(
+		r.AWSIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	sess, err := aws.GetSession()
+
+	if err != nil {
+		return nil, err
+	}
+
+	svc := ecr.New(sess)
+
+	resp, err := svc.DescribeRepositories(&ecr.DescribeRepositoriesInput{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	res := make([]*Repository, 0)
+
+	for _, repo := range resp.Repositories {
+		res = append(res, &Repository{
+			Name:      *repo.RepositoryName,
+			CreatedAt: *repo.CreatedAt,
+		})
+	}
+
+	return res, nil
+}

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

@@ -19,6 +19,7 @@ type tester struct {
 	initUsers    []*models.User
 	initProjects []*models.Project
 	initGRs      []*models.GitRepo
+	initRegs     []*models.Registry
 	initClusters []*models.Cluster
 	initCCs      []*models.ClusterCandidate
 	initKIs      []*ints.KubeIntegration
@@ -47,6 +48,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.User{},
 		&models.Session{},
 		&models.GitRepo{},
+		&models.Registry{},
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
@@ -356,3 +358,24 @@ func initGitRepo(tester *tester, t *testing.T) {
 
 	tester.initGRs = append(tester.initGRs, gr)
 }
+
+func initRegistry(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	reg := &models.Registry{
+		ProjectID: tester.initProjects[0].ID,
+		Name:      "registry-test",
+	}
+
+	reg, err := tester.repo.Registry.CreateRegistry(reg)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initRegs = append(tester.initRegs, reg)
+}

+ 64 - 0
internal/repository/gorm/registry.go

@@ -0,0 +1,64 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// RegistryRepository uses gorm.DB for querying the database
+type RegistryRepository struct {
+	db *gorm.DB
+}
+
+// NewRegistryRepository returns a RegistryRepository which uses
+// gorm.DB for querying the database
+func NewRegistryRepository(db *gorm.DB) repository.RegistryRepository {
+	return &RegistryRepository{db}
+}
+
+// CreateRegistry creates a new registry
+func (repo *RegistryRepository) CreateRegistry(reg *models.Registry) (*models.Registry, error) {
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", reg.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("Registries")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(reg); err != nil {
+		return nil, err
+	}
+
+	return reg, nil
+}
+
+// ReadRegistry gets a registry specified by a unique id
+func (repo *RegistryRepository) ReadRegistry(id uint) (*models.Registry, error) {
+	reg := &models.Registry{}
+
+	if err := repo.db.Where("id = ?", id).First(&reg).Error; err != nil {
+		return nil, err
+	}
+
+	return reg, nil
+}
+
+// ListRegistriesByProjectID finds all registries
+// for a given project id
+func (repo *RegistryRepository) ListRegistriesByProjectID(
+	projectID uint,
+) ([]*models.Registry, error) {
+	regs := []*models.Registry{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&regs).Error; err != nil {
+		return nil, err
+	}
+
+	return regs, nil
+}

+ 84 - 0
internal/repository/gorm/registry_test.go

@@ -0,0 +1,84 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+func TestCreateRegistry(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_reg.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	reg := &models.Registry{
+		Name:      "registry-test",
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	reg, err := tester.repo.Registry.CreateRegistry(reg)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	reg, err = tester.repo.Registry.ReadRegistry(reg.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1 and name is "registry-test"
+	if reg.Model.ID != 1 {
+		t.Errorf("incorrect registry ID: expected %d, got %d\n", 1, reg.Model.ID)
+	}
+
+	if reg.Name != "registry-test" {
+		t.Errorf("incorrect project name: expected %s, got %s\n", "registry-test", reg.Name)
+	}
+}
+
+func TestListRegistriesByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_regs.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initRegistry(tester, t)
+	defer cleanup(tester, t)
+
+	regs, err := tester.repo.Registry.ListRegistriesByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(regs) != 1 {
+		t.Fatalf("length of registries incorrect: expected %d, got %d\n", 1, len(regs))
+	}
+
+	// make sure data is correct
+	expRegistry := models.Registry{
+		ProjectID: tester.initProjects[0].ID,
+		Name:      "registry-test",
+	}
+
+	reg := regs[0]
+
+	// reset fields for reflect.DeepEqual
+	reg.Model = gorm.Model{}
+
+	if diff := deep.Equal(expRegistry, *reg); diff != nil {
+		t.Errorf("incorrect registry")
+		t.Error(diff)
+	}
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -14,6 +14,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		Project:          NewProjectRepository(db),
 		GitRepo:          NewGitRepoRepository(db, key),
 		Cluster:          NewClusterRepository(db, key),
+		Registry:         NewRegistryRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),
 		OAuthIntegration: NewOAuthIntegrationRepository(db, key),

+ 10 - 0
internal/repository/registry.go

@@ -0,0 +1,10 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// RegistryRepository represents the set of queries on the Registry model
+type RegistryRepository interface {
+	CreateRegistry(reg *models.Registry) (*models.Registry, error)
+	ReadRegistry(id uint) (*models.Registry, error)
+	ListRegistriesByProjectID(projectID uint) ([]*models.Registry, error)
+}

+ 1 - 0
internal/repository/repository.go

@@ -7,6 +7,7 @@ type Repository struct {
 	Session          SessionRepository
 	GitRepo          GitRepoRepository
 	Cluster          ClusterRepository
+	Registry         RegistryRepository
 	KubeIntegration  KubeIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository
 	OAuthIntegration OAuthIntegrationRepository

+ 73 - 0
internal/repository/test/registry.go

@@ -0,0 +1,73 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// RegistryRepository implements repository.RegistryRepository
+type RegistryRepository struct {
+	canQuery   bool
+	registries []*models.Registry
+}
+
+// NewRegistryRepository will return errors if canQuery is false
+func NewRegistryRepository(canQuery bool) repository.RegistryRepository {
+	return &RegistryRepository{
+		canQuery,
+		[]*models.Registry{},
+	}
+}
+
+// CreateRegistry creates a new registry
+func (repo *RegistryRepository) CreateRegistry(
+	reg *models.Registry,
+) (*models.Registry, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.registries = append(repo.registries, reg)
+	reg.ID = uint(len(repo.registries))
+
+	return reg, nil
+}
+
+// ReadRegistry finds a registry by id
+func (repo *RegistryRepository) ReadRegistry(
+	id uint,
+) (*models.Registry, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.registries) || repo.registries[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.registries[index], nil
+}
+
+// ListRegistriesByProjectID finds all registries
+// for a given project id
+func (repo *RegistryRepository) ListRegistriesByProjectID(
+	projectID uint,
+) ([]*models.Registry, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.Registry, 0)
+
+	for _, reg := range repo.registries {
+		if reg.ProjectID == projectID {
+			res = append(res, reg)
+		}
+	}
+
+	return res, nil
+}

+ 1 - 0
internal/repository/test/repository.go

@@ -12,6 +12,7 @@ func NewRepository(canQuery bool) *repository.Repository {
 		Session:          NewSessionRepository(canQuery),
 		Project:          NewProjectRepository(canQuery),
 		Cluster:          NewClusterRepository(canQuery),
+		Registry:         NewRegistryRepository(canQuery),
 		GitRepo:          NewGitRepoRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),

+ 130 - 0
server/api/integration_handler.go

@@ -3,6 +3,10 @@ package api
 import (
 	"encoding/json"
 	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
 
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
@@ -45,3 +49,129 @@ func (app *App) HandleListRepoIntegrations(w http.ResponseWriter, r *http.Reques
 		return
 	}
 }
+
+// HandleCreateGCPIntegration creates a new GCP integration in the DB
+func (app *App) HandleCreateGCPIntegration(w http.ResponseWriter, r *http.Request) {
+	session, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateGCPIntegrationForm{
+		UserID:    userID,
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to a gcp integration
+	gcp, err := form.ToGCPIntegration()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	gcp, err = app.repo.GCPIntegration.CreateGCPIntegration(gcp)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.logger.Info().Msgf("New gcp integration created: %d", gcp.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	gcpExt := gcp.Externalize()
+
+	if err := json.NewEncoder(w).Encode(gcpExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleCreateAWSIntegration creates a new AWS integration in the DB
+func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Request) {
+	session, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateAWSIntegrationForm{
+		UserID:    userID,
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to a aws integration
+	aws, err := form.ToAWSIntegration()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	aws, err = app.repo.AWSIntegration.CreateAWSIntegration(aws)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.logger.Info().Msgf("New aws integration created: %d", aws.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	awsExt := aws.Externalize()
+
+	if err := json.NewEncoder(w).Encode(awsExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 78 - 0
server/api/integration_handler_test.go

@@ -134,6 +134,58 @@ func TestHandleListRepoIntegrations(t *testing.T) {
 	testPublicIntegrationRequests(t, listRepoIntegrationsTests, true)
 }
 
+var createGCPIntegrationTests = []*publicIntTest{
+	&publicIntTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:      "Create GCP Integration",
+		method:   "POST",
+		endpoint: "/api/projects/1/integrations/gcp",
+		body: `{
+			"gcp_key_data": "yoooo"
+		}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"user_id":1,"project_id":1,"gcp-project-id":"","gcp-user-email":""}`,
+		useCookie: true,
+		validators: []func(c *publicIntTest, tester *tester, t *testing.T){
+			gcpIntBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateGCPIntegration(t *testing.T) {
+	testPublicIntegrationRequests(t, createGCPIntegrationTests, true)
+}
+
+var createAWSIntegrationTests = []*publicIntTest{
+	&publicIntTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:      "Create AWS Integration",
+		method:   "POST",
+		endpoint: "/api/projects/1/integrations/aws",
+		body: `{
+			"aws_cluster_id": "cluster-id-0",
+			"aws_access_key_id": "accesskey",
+			"aws_secret_access_key": "secretkey"
+		}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"user_id":1,"project_id":1,"aws-entity-id":"","aws-caller-id":""}`,
+		useCookie: true,
+		validators: []func(c *publicIntTest, tester *tester, t *testing.T){
+			awsIntBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateAWSIntegration(t *testing.T) {
+	testPublicIntegrationRequests(t, createGCPIntegrationTests, true)
+}
+
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 
 func publicIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
@@ -148,3 +200,29 @@ func publicIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
 		t.Error(diff)
 	}
 }
+
+func gcpIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
+	gotBody := &ints.GCPIntegration{}
+	expBody := &ints.GCPIntegration{}
+
+	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)
+	}
+}
+
+func awsIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
+	gotBody := &ints.AWSIntegration{}
+	expBody := &ints.AWSIntegration{}
+
+	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)
+	}
+}

+ 131 - 0
server/api/registry_handler.go

@@ -0,0 +1,131 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/porter-dev/porter/internal/registry"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// HandleCreateRegistry creates a new registry
+func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateRegistry{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to a registry
+	registry, err := form.ToRegistry()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	registry, err = app.repo.Registry.CreateRegistry(registry)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.logger.Info().Msgf("New registry created: %d", registry.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	regExt := registry.Externalize()
+
+	if err := json.NewEncoder(w).Encode(regExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListProjectRegistries returns a list of registries for a project
+func (app *App) HandleListProjectRegistries(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	regs, err := app.repo.Registry.ListRegistriesByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extRegs := make([]*models.RegistryExternal, 0)
+
+	for _, reg := range regs {
+		extRegs = append(extRegs, reg.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extRegs); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListRepositories returns a list of repositories for a given registry
+func (app *App) HandleListRepositories(w http.ResponseWriter, r *http.Request) {
+	regID, err := strconv.ParseUint(chi.URLParam(r, "registry_id"), 0, 64)
+
+	if err != nil || regID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	reg, err := app.repo.Registry.ReadRegistry(uint(regID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	// cast to a registry from registry package
+	_reg := registry.Registry(*reg)
+	regAPI := &_reg
+
+	repos, err := regAPI.ListRepositories(*app.repo)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(repos); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 156 - 0
server/api/registry_handler_test.go

@@ -0,0 +1,156 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type regTest struct {
+	initializers []func(t *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *regTest, tester *tester, t *testing.T)
+}
+
+func testRegistryRequests(t *testing.T, tests []*regTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		tester := newTester(canQuery)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// first, check that the status matches
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		// if there's a validator, call it
+		for _, validate := range c.validators {
+			validate(c, tester, t)
+		}
+	}
+}
+
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
+var createRegistryTests = []*regTest{
+	&regTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "Create registry",
+		method:    "POST",
+		endpoint:  "/api/projects/1/registries",
+		body:      `{"name":"registry-test","aws_integration_id":1}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"name":"registry-test","project_id":1}`,
+		useCookie: true,
+		validators: []func(c *regTest, tester *tester, t *testing.T){
+			regBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateRegistry(t *testing.T) {
+	testRegistryRequests(t, createRegistryTests, true)
+}
+
+var listRegistryTests = []*regTest{
+	&regTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initRegistry,
+		},
+		msg:       "List registries",
+		method:    "GET",
+		endpoint:  "/api/projects/1/registries",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"id":1,"name":"registry-test","project_id":1}]`,
+		useCookie: true,
+		validators: []func(c *regTest, tester *tester, t *testing.T){
+			regsBodyValidator,
+		},
+	},
+}
+
+func TestHandleListRegistries(t *testing.T) {
+	testRegistryRequests(t, listRegistryTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initRegistry(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	reg := &models.Registry{
+		Name:             "registry-test",
+		ProjectID:        proj.Model.ID,
+		AWSIntegrationID: 1,
+	}
+
+	tester.repo.Registry.CreateRegistry(reg)
+}
+
+func regBodyValidator(c *regTest, tester *tester, t *testing.T) {
+	gotBody := &models.Registry{}
+	expBody := &models.Registry{}
+
+	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)
+	}
+}
+
+func regsBodyValidator(c *regTest, tester *tester, t *testing.T) {
+	gotBody := make([]*models.Registry, 0)
+	expBody := make([]*models.Registry, 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)
+	}
+}

+ 99 - 0
server/router/middleware/auth.go

@@ -70,6 +70,10 @@ type bodyClusterID struct {
 	ClusterID uint64 `json:"cluster_id"`
 }
 
+type bodyRegistryID struct {
+	RegistryID uint64 `json:"registry_id"`
+}
+
 // DoesUserIDMatch checks the id URL parameter and verifies that it matches
 // the one stored in the session
 func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
@@ -206,6 +210,56 @@ func (auth *Auth) DoesUserHaveClusterAccess(
 	})
 }
 
+// DoesUserHaveRegistryAccess looks for a project_id parameter and a
+// registry_id parameter, and verifies that the registry belongs
+// to the project
+func (auth *Auth) DoesUserHaveRegistryAccess(
+	next http.Handler,
+	projLoc IDLocation,
+	registryLoc IDLocation,
+) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		regID, err := findRegistryIDInRequest(r, registryLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		projID, err := findProjIDInRequest(r, projLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		// get the service accounts belonging to the project
+		regs, err := auth.repo.Registry.ListRegistriesByProjectID(uint(projID))
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+
+		doesExist := false
+
+		for _, reg := range regs {
+			if reg.ID == uint(regID) {
+				doesExist = true
+				break
+			}
+		}
+
+		if doesExist {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	})
+}
+
 // Helpers
 func (auth *Auth) doesSessionMatchID(r *http.Request, id uint) bool {
 	session, _ := auth.store.Get(r, auth.cookieName)
@@ -367,3 +421,48 @@ func findClusterIDInRequest(r *http.Request, clusterLoc IDLocation) (uint64, err
 
 	return clusterID, nil
 }
+
+func findRegistryIDInRequest(r *http.Request, registryLoc IDLocation) (uint64, error) {
+	var regID uint64
+	var err error
+
+	if registryLoc == URLParam {
+		regID, err = strconv.ParseUint(chi.URLParam(r, "registry_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
+	} else if registryLoc == BodyParam {
+		form := &bodyRegistryID{}
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
+
+		regID = form.RegistryID
+
+		// need to create a new stream for the body
+		r.Body = ioutil.NopCloser(bytes.NewReader(body))
+	} else {
+		vals, err := url.ParseQuery(r.URL.RawQuery)
+
+		if err != nil {
+			return 0, err
+		}
+
+		if regStrArr, ok := vals["registry_id"]; ok && len(regStrArr) == 1 {
+			regID, err = strconv.ParseUint(regStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("registry id not found")
+		}
+	}
+
+	return regID, nil
+}

+ 56 - 0
server/router/router.go

@@ -159,6 +159,62 @@ func New(
 			),
 		)
 
+		// /api/projects/{project_id}/integrations routes
+		r.Method(
+			"POST",
+			"/projects/{project_id}/integrations/gcp",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleCreateGCPIntegration, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/integrations/aws",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleCreateAWSIntegration, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		// /api/projects/{project_id}/registries routes
+		r.Method(
+			"POST",
+			"/projects/{project_id}/registries",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleCreateRegistry, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/registries",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListProjectRegistries, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/registries/{registry_id}/repositories",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveRegistryAccess(
+					requestlog.NewHandler(a.HandleListRepositories, l),
+					mw.URLParam,
+					mw.URLParam,
+				),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/releases routes
 		r.Method(
 			"GET",