Kaynağa Gözat

add deployment target scope to Porter API (#4192)

d-g-town 2 yıl önce
ebeveyn
işleme
259dc1a0fe

+ 79 - 0
api/server/authz/deployment_target.go

@@ -0,0 +1,79 @@
+package authz
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+// DeploymentTargetScopedFactory is a factory for generating deployment target middleware
+type DeploymentTargetScopedFactory struct {
+	config *config.Config
+}
+
+// NewDeploymentTargetScopedFactory returns a new DeploymentTargetScopedFactory
+func NewDeploymentTargetScopedFactory(
+	config *config.Config,
+) *DeploymentTargetScopedFactory {
+	return &DeploymentTargetScopedFactory{config}
+}
+
+// Middleware checks that the request is scoped to a deployment target
+func (p *DeploymentTargetScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &DeploymentTargetScopedMiddleware{next, p.config}
+}
+
+// DeploymentTargetScopedMiddleware checks that the request is scoped to a deployment target
+type DeploymentTargetScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+// ServeHTTP checks that the request is scoped to a deployment target
+func (p *DeploymentTargetScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project to check scopes
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// get the deployment target identifier from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	deploymentTargetIdentifier := reqScopes[types.DeploymentTargetScope].Resource.Name
+
+	deploymentTargetDB, err := p.config.Repo.DeploymentTarget().DeploymentTarget(proj.ID, deploymentTargetIdentifier)
+	if err != nil {
+		if !errors.Is(err, gorm.ErrRecordNotFound) {
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError), true)
+			return
+		}
+		err := fmt.Errorf("deployment target with identifier %s not found in project %d", deploymentTargetIdentifier, proj.ID)
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden), true)
+		return
+	}
+
+	deploymentTarget := types.DeploymentTarget{
+		ID:           deploymentTargetDB.ID,
+		ProjectID:    uint(deploymentTargetDB.ProjectID),
+		ClusterID:    uint(deploymentTargetDB.ClusterID),
+		Name:         deploymentTargetDB.VanityName,
+		Namespace:    deploymentTargetDB.Selector,
+		IsPreview:    deploymentTargetDB.Preview,
+		IsDefault:    deploymentTargetDB.IsDefault,
+		CreatedAtUTC: deploymentTargetDB.CreatedAt.UTC(),
+		UpdatedAtUTC: deploymentTargetDB.UpdatedAt.UTC(),
+	}
+
+	ctx := NewDeploymentTargetContext(r.Context(), deploymentTarget)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+// NewDeploymentTargetContext returns a new context with the deployment target
+func NewDeploymentTargetContext(ctx context.Context, deploymentTarget types.DeploymentTarget) context.Context {
+	return context.WithValue(ctx, types.DeploymentTargetScope, deploymentTarget)
+}

+ 2 - 0
api/server/authz/policy.go

@@ -122,6 +122,8 @@ func getRequestActionForEndpoint(
 			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamProjectID)
 		case types.ClusterScope:
 			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamClusterID)
+		case types.DeploymentTargetScope:
+			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamDeploymentTargetIdentifier)
 		case types.RegistryScope:
 			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamRegistryID)
 		case types.HelmRepoScope:

+ 51 - 46
api/server/handlers/deployment_target/get.go

@@ -49,6 +49,7 @@ func (c *GetDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 
 	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	deploymentTarget, deploymentTargetOK := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget)
 
 	if !project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) {
 		err := telemetry.Error(ctx, span, nil, "project does not have validate apply v2 enabled")
@@ -56,55 +57,59 @@ func (c *GetDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		return
 	}
 
-	deploymentTargetID, reqErr := requestutils.GetURLParamString(r, types.URLParamDeploymentTargetID)
-	if reqErr != nil {
-		err := telemetry.Error(ctx, span, reqErr, "error parsing deployment target id")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	if deploymentTargetID == "" {
-		err := telemetry.Error(ctx, span, nil, "deployment target id cannot be empty")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
-		ProjectID:          int64(project.ID),
-		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: deploymentTargetID,
-		CCPClient:          c.Config().ClusterControlPlaneClient,
-	})
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting deployment target details")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	id, err := uuid.Parse(deploymentTarget.ID)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error parsing deployment target id")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	if id == uuid.Nil {
-		err := telemetry.Error(ctx, span, err, "deployment target id is nil")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
+	if !deploymentTargetOK {
+		deploymentTargetID, reqErr := requestutils.GetURLParamString(r, types.URLParamDeploymentTargetID)
+		if reqErr != nil {
+			err := telemetry.Error(ctx, span, reqErr, "error parsing deployment target id")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+		if deploymentTargetID == "" {
+			err := telemetry.Error(ctx, span, nil, "deployment target id cannot be empty")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+
+		deploymentTargetDetails, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
+			ProjectID:          int64(project.ID),
+			ClusterID:          int64(cluster.ID),
+			DeploymentTargetID: deploymentTargetID,
+			CCPClient:          c.Config().ClusterControlPlaneClient,
+		})
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting deployment target details")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		id, err := uuid.Parse(deploymentTargetDetails.ID)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error parsing deployment target id")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		if id == uuid.Nil {
+			err := telemetry.Error(ctx, span, err, "deployment target id is nil")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		deploymentTarget = types.DeploymentTarget{
+			ID:           id,
+			ProjectID:    project.ID,
+			ClusterID:    cluster.ID,
+			Name:         deploymentTargetDetails.Name,
+			Namespace:    deploymentTargetDetails.Namespace,
+			IsPreview:    deploymentTargetDetails.IsPreview,
+			IsDefault:    deploymentTargetDetails.IsDefault,
+			CreatedAtUTC: time.Time{}, // not provided by deployment target details response
+			UpdatedAtUTC: time.Time{}, // not provided by deployment target details response
+		}
 	}
 
 	res := &GetDeploymentTargetResponse{
-		DeploymentTarget: types.DeploymentTarget{
-			ID:        id,
-			ProjectID: project.ID,
-			ClusterID: cluster.ID,
-			Name:      deploymentTarget.Name,
-			Namespace: deploymentTarget.Namespace,
-			IsPreview: deploymentTarget.IsPreview,
-			IsDefault: deploymentTarget.IsDefault,
-			CreatedAt: time.Time{}, // not provided by deployment target details response
-			UpdatedAt: time.Time{}, // not provided by deployment target details response
-		},
+		DeploymentTarget: deploymentTarget,
 	}
 
 	c.WriteResult(w, r, res)

+ 9 - 9
api/server/handlers/porter_app/default_deployment_target.go

@@ -130,15 +130,15 @@ func defaultDeploymentTarget(ctx context.Context, input defaultDeploymentTargetI
 	}
 
 	defaultDeploymentTarget = types.DeploymentTarget{
-		ID:        id,
-		ProjectID: uint(deploymentTargetProto.ProjectId),
-		ClusterID: uint(deploymentTargetProto.ClusterId),
-		Name:      deploymentTargetProto.Name,
-		Namespace: deploymentTargetProto.Namespace,
-		IsPreview: deploymentTargetProto.IsPreview,
-		IsDefault: deploymentTargetProto.IsDefault,
-		CreatedAt: time.Time{}, // not provided by default deployment target response
-		UpdatedAt: time.Time{}, // not provided by default deployment target response
+		ID:           id,
+		ProjectID:    uint(deploymentTargetProto.ProjectId),
+		ClusterID:    uint(deploymentTargetProto.ClusterId),
+		Name:         deploymentTargetProto.Name,
+		Namespace:    deploymentTargetProto.Namespace,
+		IsPreview:    deploymentTargetProto.IsPreview,
+		IsDefault:    deploymentTargetProto.IsDefault,
+		CreatedAtUTC: time.Time{}, // not provided by default deployment target response
+		UpdatedAtUTC: time.Time{}, // not provided by default deployment target response
 	}
 
 	return defaultDeploymentTarget, nil

+ 6 - 95
api/server/router/deployment_target.go

@@ -1,8 +1,6 @@
 package router
 
 import (
-	"fmt"
-
 	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -11,7 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
-// NewDeploymentTargetScopedRegisterer applies /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets routes to the gin Router
+// NewDeploymentTargetScopedRegisterer applies /api/projects/{project_id}/targets routes to the gin Router
 func NewDeploymentTargetScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 		GetRoutes: GetDeploymentTargetScopedRoutes,
@@ -42,14 +40,14 @@ func GetDeploymentTargetScopedRoutes(
 	return routes
 }
 
-// getDeploymentTargetRoutes gets the routes specific to deployment targets
+// getDeploymentTargetRoutes gets the routes that use deployment targets as a first class object instead of scoped to clusters
 func getDeploymentTargetRoutes(
 	r chi.Router,
 	config *config.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
 ) ([]*router.Route, *types.Path) {
-	relPath := "/deployment-targets"
+	relPath := "/targets/{deployment_target_identifier}"
 
 	newPath := &types.Path{
 		Parent:       basePath,
@@ -58,106 +56,19 @@ func getDeploymentTargetRoutes(
 
 	var routes []*router.Route
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets -> deployment_target.CreateDeploymentTargetHandler
-	createDeploymentTargetEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath,
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	createDeploymentTargetHandler := deployment_target.NewCreateDeploymentTargetHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: createDeploymentTargetEndpoint,
-		Handler:  createDeploymentTargetHandler,
-		Router:   r,
-	})
-
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets -> deployment_target.ListDeploymentTargetsHandler
-	listDeploymentTargetsEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbList,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath,
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	listDeploymentTargetsHandler := deployment_target.NewListDeploymentTargetsHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: listDeploymentTargetsEndpoint,
-		Handler:  listDeploymentTargetsHandler,
-		Router:   r,
-	})
-
-	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets/{deployment_target_id} -> deployment_target.DeleteDeploymentTargetHandler
-	deleteDeploymentTargetEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbDelete,
-			Method: types.HTTPVerbDelete,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamDeploymentTargetID),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	deleteDeploymentTargetHandler := deployment_target.NewDeleteDeploymentTargetHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: deleteDeploymentTargetEndpoint,
-		Handler:  deleteDeploymentTargetHandler,
-		Router:   r,
-	})
-
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets/{deployment_target_id} -> deployment_target.GetDeploymentTargetHandler
+	// GET /api/projects/{project_id}/targets/{deployment_target_identifier} -> deployment_target.GetDeploymentTargetHandler
 	getDeploymentTargetEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamDeploymentTargetID),
+				RelativePath: relPath,
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
 				types.ProjectScope,
-				types.ClusterScope,
+				types.DeploymentTargetScope,
 			},
 		},
 	)

+ 181 - 0
api/server/router/deployment_target_legacy.go

@@ -0,0 +1,181 @@
+package router
+
+import (
+	"fmt"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+// NewLegacyDeploymentTargetScopedRegisterer applies /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets routes to the gin Router
+// Deprecated: use NewDeploymentTargetScopedRegisterer instead
+func NewLegacyDeploymentTargetScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetLegacyDeploymentTargetScopedRoutes,
+		Children:  children,
+	}
+}
+
+// GetLegacyDeploymentTargetScopedRoutes returns the router handlers specific to deployment targets
+// Deprecated: use GetDeploymentTargetScopedRoutes instead, which are not scoped by cluster
+func GetLegacyDeploymentTargetScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getLegacyDeploymentTargetRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+// getLegacyDeploymentTargetRoutes gets the routes specific to deployment targets
+// Deprecated: use getDeploymentTargetRoutes instead, which are not scoped by cluster
+func getLegacyDeploymentTargetRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/deployment-targets"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets -> deployment_target.CreateDeploymentTargetHandler
+	createDeploymentTargetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	createDeploymentTargetHandler := deployment_target.NewCreateDeploymentTargetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createDeploymentTargetEndpoint,
+		Handler:  createDeploymentTargetHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets -> deployment_target.ListDeploymentTargetsHandler
+	listDeploymentTargetsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listDeploymentTargetsHandler := deployment_target.NewListDeploymentTargetsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listDeploymentTargetsEndpoint,
+		Handler:  listDeploymentTargetsHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets/{deployment_target_id} -> deployment_target.DeleteDeploymentTargetHandler
+	deleteDeploymentTargetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamDeploymentTargetID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	deleteDeploymentTargetHandler := deployment_target.NewDeleteDeploymentTargetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteDeploymentTargetEndpoint,
+		Handler:  deleteDeploymentTargetHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets/{deployment_target_id} -> deployment_target.GetDeploymentTargetHandler
+	getDeploymentTargetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamDeploymentTargetID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getDeploymentTargetHandler := deployment_target.NewGetDeploymentTargetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getDeploymentTargetEndpoint,
+		Handler:  getDeploymentTargetHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 9 - 1
api/server/router/router.go

@@ -36,7 +36,8 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	stackRegisterer := NewPorterAppScopedRegisterer()
 	addonRegisterer := NewAddonScopedRegisterer()
 	deploymentTargetRegisterer := NewDeploymentTargetScopedRegisterer()
-	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer, stackRegisterer, deploymentTargetRegisterer, addonRegisterer)
+	legacyDeploymentTargetRegisterer := NewLegacyDeploymentTargetScopedRegisterer()
+	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer, stackRegisterer, legacyDeploymentTargetRegisterer, addonRegisterer)
 	infraRegisterer := NewInfraScopedRegisterer()
 	gitInstallationRegisterer := NewGitInstallationScopedRegisterer()
 	registryRegisterer := NewRegistryScopedRegisterer()
@@ -56,6 +57,7 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 		projectIntegrationRegisterer,
 		projectOAuthRegisterer,
 		slackIntegrationRegisterer,
+		deploymentTargetRegisterer,
 	)
 	statusRegisterer := NewStatusScopedRegisterer()
 
@@ -201,6 +203,10 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 	// after authorization. Each subsequent http.Handler can lookup the cluster in context.
 	clusterFactory := authz.NewClusterScopedFactory(config)
 
+	// Create a new "deployment-target-scoped" factory which will create a new deployment-target-scoped request
+	// after authorization. Each subsequent http.Handler can lookup the deployment target in context.
+	deploymentTargetFactory := authz.NewDeploymentTargetScopedFactory(config)
+
 	// Create a new "namespace-scoped" factory which will create a new namespace-scoped request
 	// after authorization. Each subsequent http.Handler can lookup the namespace in context.
 	namespaceFactory := authz.NewNamespaceScopedFactory(config)
@@ -273,6 +279,8 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 				atomicGroup.Use(projFactory.Middleware)
 			case types.ClusterScope:
 				atomicGroup.Use(clusterFactory.Middleware)
+			case types.DeploymentTargetScope:
+				atomicGroup.Use(deploymentTargetFactory.Middleware)
 			case types.NamespaceScope:
 				atomicGroup.Use(namespaceFactory.Middleware)
 			case types.HelmRepoScope:

+ 6 - 6
api/types/deployment_target.go

@@ -12,10 +12,10 @@ type DeploymentTarget struct {
 	ProjectID uint      `json:"project_id"`
 	ClusterID uint      `json:"cluster_id"`
 
-	Name      string    `json:"name"`
-	Namespace string    `json:"namespace"`
-	IsPreview bool      `json:"is_preview"`
-	IsDefault bool      `json:"is_default"`
-	CreatedAt time.Time `json:"created_at"`
-	UpdatedAt time.Time `json:"updated_at"`
+	Name         string    `json:"name"`
+	Namespace    string    `json:"namespace"`
+	IsPreview    bool      `json:"is_preview"`
+	IsDefault    bool      `json:"is_default"`
+	CreatedAtUTC time.Time `json:"created_at"`
+	UpdatedAtUTC time.Time `json:"updated_at"`
 }

+ 1 - 0
api/types/policy.go

@@ -8,6 +8,7 @@ const (
 	UserScope                PermissionScope = "user"
 	ProjectScope             PermissionScope = "project"
 	ClusterScope             PermissionScope = "cluster"
+	DeploymentTargetScope    PermissionScope = "target"
 	RegistryScope            PermissionScope = "registry"
 	InviteScope              PermissionScope = "invite"
 	HelmRepoScope            PermissionScope = "helm_repo"

+ 3 - 1
api/types/request.go

@@ -58,7 +58,9 @@ const (
 	URLParamCloudProviderType     URLParam = "cloud_provider_type"
 	URLParamCloudProviderID       URLParam = "cloud_provider_id"
 	URLParamDeploymentTargetID    URLParam = "deployment_target_id"
-	URLParamWebhookID             URLParam = "webhook_id"
+	// URLParamDeploymentTargetIdentifier can be either the deployment target id or deployment target name
+	URLParamDeploymentTargetIdentifier URLParam = "deployment_target_identifier"
+	URLParamWebhookID                  URLParam = "webhook_id"
 )
 
 type Path struct {

+ 9 - 9
internal/models/deployment_target.go

@@ -49,14 +49,14 @@ type DeploymentTarget struct {
 // ToDeploymentTargetType generates an external types.PorterApp to be shared over REST
 func (d *DeploymentTarget) ToDeploymentTargetType() *types.DeploymentTarget {
 	return &types.DeploymentTarget{
-		ID:        d.ID,
-		ProjectID: uint(d.ProjectID),
-		ClusterID: uint(d.ClusterID),
-		Namespace: d.Selector,
-		IsPreview: d.Preview,
-		IsDefault: d.IsDefault,
-		Name:      d.VanityName,
-		CreatedAt: d.CreatedAt,
-		UpdatedAt: d.UpdatedAt,
+		ID:           d.ID,
+		ProjectID:    uint(d.ProjectID),
+		ClusterID:    uint(d.ClusterID),
+		Namespace:    d.Selector,
+		IsPreview:    d.Preview,
+		IsDefault:    d.IsDefault,
+		Name:         d.VanityName,
+		CreatedAtUTC: d.CreatedAt,
+		UpdatedAtUTC: d.UpdatedAt,
 	}
 }

+ 2 - 0
internal/repository/deployment_target.go

@@ -12,4 +12,6 @@ type DeploymentTargetRepository interface {
 	List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error)
 	// CreateDeploymentTarget creates a new deployment target
 	CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error)
+	// DeploymentTarget retrieves a deployment target by its id if a uuid is provided or by name
+	DeploymentTarget(projectID uint, deploymentTargetIdentifier string) (*models.DeploymentTarget, error)
 }

+ 21 - 0
internal/repository/gorm/deployment_target.go

@@ -42,6 +42,27 @@ func (repo *DeploymentTargetRepository) List(projectID uint, clusterID uint, pre
 	return deploymentTargets, nil
 }
 
+// DeploymentTarget finds all deployment targets for a given project
+func (repo *DeploymentTargetRepository) DeploymentTarget(projectID uint, deploymentTargetIdentifier string) (*models.DeploymentTarget, error) {
+	if deploymentTargetIdentifier == "" {
+		return nil, errors.New("deployment target identifier is empty")
+	}
+
+	whereArg := "project_id = ? AND id = ?"
+
+	_, err := uuid.Parse(deploymentTargetIdentifier)
+	if err != nil {
+		whereArg = "project_id = ? AND vanity_name = ?"
+	}
+
+	deploymentTarget := &models.DeploymentTarget{}
+	if err := repo.db.Where(whereArg, projectID, deploymentTargetIdentifier).Find(deploymentTarget).Error; err != nil {
+		return nil, err
+	}
+
+	return deploymentTarget, nil
+}
+
 // CreateDeploymentTarget creates a new deployment target
 func (repo *DeploymentTargetRepository) CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error) {
 	if deploymentTarget == nil {

+ 5 - 0
internal/repository/test/deployment_target.go

@@ -31,3 +31,8 @@ func (repo *DeploymentTargetRepository) List(projectID uint, clusterID uint, pre
 func (repo *DeploymentTargetRepository) CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error) {
 	return nil, errors.New("cannot write database")
 }
+
+// DeploymentTarget finds a deployment target by its id if a uuid is provided or by name
+func (repo *DeploymentTargetRepository) DeploymentTarget(projectID uint, deploymentTargetIdentifier string) (*models.DeploymentTarget, error) {
+	return nil, errors.New("cannot read database")
+}