Parcourir la source

neon integration (#4600)

Feroze Mohideen il y a 2 ans
Parent
commit
74757577fa

+ 118 - 0
api/server/handlers/oauth_callback/neon.go

@@ -0,0 +1,118 @@
+package oauth_callback
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// OAuthCallbackNeonHandler is the handler responding to the neon oauth callback
+type OAuthCallbackNeonHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewOAuthCallbackNeonHandler generates a new OAuthCallbackNeonHandler
+func NewOAuthCallbackNeonHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *OAuthCallbackNeonHandler {
+	return &OAuthCallbackNeonHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ServeHTTP gets the neon oauth token from the callback code then creates a new neon integration
+func (p *OAuthCallbackNeonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-oauth-callback-neon")
+	defer span.End()
+
+	r = r.Clone(ctx)
+
+	session, err := p.Config().Store.Get(r, p.Config().ServerConf.CookieName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "session could not be retrieved")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		err = telemetry.Error(ctx, span, nil, "state not found in session")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		err = telemetry.Error(ctx, span, nil, "state does not match")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	projID, ok := session.Values["project_id"].(uint)
+	if !ok {
+		err = telemetry.Error(ctx, span, nil, "project id not found in session")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: projID},
+	)
+
+	if projID == 0 {
+		err = telemetry.Error(ctx, span, nil, "project id not found in session")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	code := r.URL.Query().Get("code")
+	if code == "" {
+		err = telemetry.Error(ctx, span, nil, "code not found in query params")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
+		return
+	}
+
+	token, err := p.Config().NeonConf.Exchange(ctx, code)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "exchange failed")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
+		return
+	}
+
+	if !token.Valid() {
+		err = telemetry.Error(ctx, span, nil, "invalid token")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
+		return
+	}
+
+	oauthInt := integrations.NeonIntegration{
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(token.AccessToken),
+			RefreshToken: []byte(token.RefreshToken),
+			Expiry:       token.Expiry,
+		},
+		ProjectID: projID,
+	}
+
+	_, err = p.Repo().NeonIntegration().Insert(ctx, oauthInt)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating oauth integration")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	redirect := "/dashboard"
+	if redirectStr, ok := session.Values["redirect_uri"].(string); ok && redirectStr != "" {
+		redirectURI, err := url.Parse(redirectStr)
+		if err == nil {
+			redirect = fmt.Sprintf("%s?%s", redirectURI.Path, redirectURI.RawQuery)
+		}
+	}
+	http.Redirect(w, r, redirect, http.StatusFound)
+}

+ 51 - 0
api/server/handlers/project_oauth/neon.go

@@ -0,0 +1,51 @@
+package project_oauth
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"golang.org/x/oauth2"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/internal/oauth"
+)
+
+// ProjectOAuthNeonHandler is the handler which redirects to the neon oauth flow
+type ProjectOAuthNeonHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewProjectOAuthNeonHandler generates a new ProjectOAuthNeonHandler
+func NewProjectOAuthNeonHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ProjectOAuthNeonHandler {
+	return &ProjectOAuthNeonHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ServeHTTP populates the oauth session with state and project id then redirects the user to the neon oauth flow
+func (p *ProjectOAuthNeonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-project-oauth-neon")
+	defer span.End()
+
+	r = r.Clone(ctx)
+
+	state := oauth.CreateRandomState()
+
+	if err := p.PopulateOAuthSession(ctx, w, r, state, true, false, "", 0); err != nil {
+		err = telemetry.Error(ctx, span, err, "population oauth session failed")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	url := p.Config().NeonConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, http.StatusFound)
+}

+ 24 - 0
api/server/router/oauth_callback.go

@@ -75,6 +75,30 @@ func GetOAuthCallbackRoutes(
 		Router:   r,
 	})
 
+	// GET /api/oauth/neon/callback -> oauth_callback.NewOAuthCallbackNeonHandler
+	neonEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/neon/callback",
+			},
+		},
+	)
+
+	neonHandler := oauth_callback.NewOAuthCallbackNeonHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: neonEndpoint,
+		Handler:  neonHandler,
+		Router:   r,
+	})
+
 	// GET /api/oauth/digitalocean/callback -> oauth_callback.NewOAuthCallbackDOHandler
 	doEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 28 - 0
api/server/router/project_oauth.go

@@ -110,6 +110,34 @@ func getProjectOAuthRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/oauth/neon -> project_integration.NewProjectOAuthNeonHandler
+	neonEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/neon",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	neonHandler := project_oauth.NewProjectOAuthNeonHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: neonEndpoint,
+		Handler:  neonHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/oauth/digitalocean -> project_integration.NewProjectOAuthDOHandler
 	doEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 3 - 0
api/server/shared/config/config.go

@@ -79,6 +79,9 @@ type Config struct {
 	// UpstashConf is the configuration for an Upstash OAuth client
 	UpstashConf oauth2.Config
 
+	// NeonConf is the configuration for a Neon OAuth client
+	NeonConf oauth2.Config
+
 	// WSUpgrader upgrades HTTP connections to websocket connections
 	WSUpgrader *websocket.Upgrader
 

+ 4 - 0
api/server/shared/config/env/envconfs.go

@@ -85,6 +85,10 @@ type ServerConf struct {
 	UpstashEnabled  bool   `env:"UPSTASH_ENABLED,default=false"`
 	UpstashClientID string `env:"UPSTASH_CLIENT_ID"`
 
+	NeonEnabled      bool   `env:"NEON_ENABLED,default=false"`
+	NeonClientID     string `env:"NEON_CLIENT_ID"`
+	NeonClientSecret string `env:"NEON_CLIENT_SECRET"`
+
 	BillingPrivateKey       string `env:"BILLING_PRIVATE_KEY"`
 	BillingPrivateServerURL string `env:"BILLING_PRIVATE_URL"`
 	BillingPublicServerURL  string `env:"BILLING_PUBLIC_URL"`

+ 12 - 1
api/server/shared/config/loader/loader.go

@@ -265,13 +265,24 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		res.Logger.Info().Msg("Creating Upstash client")
 		res.UpstashConf = oauth.NewUpstashClient(&oauth.Config{
 			ClientID:     sc.UpstashClientID,
-			ClientSecret: "",
+			ClientSecret: "", // Upstash doesn't require a secret
 			Scopes:       []string{"offline_access"},
 			BaseURL:      sc.ServerURL,
 		})
 		res.Logger.Info().Msg("Created Upstash client")
 	}
 
+	if sc.NeonEnabled && sc.NeonClientID != "" && sc.NeonClientSecret != "" {
+		res.Logger.Info().Msg("Creating Neon client")
+		res.NeonConf = oauth.NewNeonClient(&oauth.Config{
+			ClientID:     sc.NeonClientID,
+			ClientSecret: sc.NeonClientSecret,
+			Scopes:       []string{"urn:neoncloud:projects:create", "urn:neoncloud:projects:read", "urn:neoncloud:projects:update", "urn:neoncloud:projects:delete", "offline", "offline_access"},
+			BaseURL:      sc.ServerURL,
+		})
+		res.Logger.Info().Msg("Created Neon client")
+	}
+
 	res.WSUpgrader = &websocket.Upgrader{
 		WSUpgrader: &gorillaws.Upgrader{
 			ReadBufferSize:  1024,

+ 12 - 0
internal/models/integrations/neon.go

@@ -0,0 +1,12 @@
+package integrations
+
+import "gorm.io/gorm"
+
+// NeonIntegration is an integration for the Neon service
+type NeonIntegration struct {
+	gorm.Model
+
+	ProjectID uint `json:"project_id"`
+
+	SharedOAuthModel
+}

+ 14 - 0
internal/oauth/config.go

@@ -125,6 +125,20 @@ func NewUpstashClient(cfg *Config) oauth2.Config {
 	}
 }
 
+// NewNeonClient creates a new oauth2.Config for Neon
+func NewNeonClient(cfg *Config) oauth2.Config {
+	return oauth2.Config{
+		ClientID:     cfg.ClientID,
+		ClientSecret: cfg.ClientSecret,
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  "https://oauth2.neon.tech/oauth2/auth",
+			TokenURL: "https://oauth2.neon.tech/oauth2/token",
+		},
+		RedirectURL: cfg.BaseURL + "/api/oauth/neon/callback",
+		Scopes:      cfg.Scopes,
+	}
+}
+
 func CreateRandomState() string {
 	b := make([]byte, 16)
 	rand.Read(b)

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

@@ -86,6 +86,7 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&ints.GithubAppOAuthIntegration{},
 		&ints.SlackIntegration{},
 		&ints.UpstashIntegration{},
+		&ints.NeonIntegration{},
 		&models.Ipam{},
 		&models.AppEventWebhooks{},
 		&models.ClusterHealthReport{},

+ 81 - 0
internal/repository/gorm/neon.go

@@ -0,0 +1,81 @@
+package gorm
+
+import (
+	"context"
+
+	"github.com/porter-dev/porter/internal/encryption"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"gorm.io/gorm"
+)
+
+// NeonIntegrationRepository is a repository that manages neon integrations
+type NeonIntegrationRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewNeonIntegrationRepository returns a NeonIntegrationRepository
+func NewNeonIntegrationRepository(db *gorm.DB, key *[32]byte) repository.NeonIntegrationRepository {
+	return &NeonIntegrationRepository{db, key}
+}
+
+// Insert creates a new neon integration
+func (repo *NeonIntegrationRepository) Insert(
+	ctx context.Context, neonInt ints.NeonIntegration,
+) (ints.NeonIntegration, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-create-neon-integration")
+	defer span.End()
+
+	var created ints.NeonIntegration
+
+	encrypted, err := repo.EncryptNeonIntegration(neonInt, repo.key)
+	if err != nil {
+		return created, telemetry.Error(ctx, span, err, "failed to encrypt")
+	}
+
+	if err := repo.db.Create(&encrypted).Error; err != nil {
+		return created, telemetry.Error(ctx, span, err, "failed to create neon integration")
+	}
+
+	return created, nil
+}
+
+// EncryptNeonIntegration will encrypt the neon integration data before
+// writing to the DB
+func (repo *NeonIntegrationRepository) EncryptNeonIntegration(
+	neonInt ints.NeonIntegration,
+	key *[32]byte,
+) (ints.NeonIntegration, error) {
+	encrypted := neonInt
+
+	if len(encrypted.ClientID) > 0 {
+		cipherData, err := encryption.Encrypt(encrypted.ClientID, key)
+		if err != nil {
+			return encrypted, err
+		}
+
+		encrypted.ClientID = cipherData
+	}
+
+	if len(encrypted.AccessToken) > 0 {
+		cipherData, err := encryption.Encrypt(encrypted.AccessToken, key)
+		if err != nil {
+			return encrypted, err
+		}
+
+		encrypted.AccessToken = cipherData
+	}
+
+	if len(encrypted.RefreshToken) > 0 {
+		cipherData, err := encryption.Encrypt(encrypted.RefreshToken, key)
+		if err != nil {
+			return encrypted, err
+		}
+
+		encrypted.RefreshToken = cipherData
+	}
+
+	return encrypted, nil
+}

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

@@ -34,6 +34,7 @@ type GormRepository struct {
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
 	upstashIntegration        repository.UpstashIntegrationRepository
+	neonIntegration           repository.NeonIntegrationRepository
 	appEventWebhook           repository.AppEventWebhookRepository
 	gitlabIntegration         repository.GitlabIntegrationRepository
 	gitlabAppOAuthIntegration repository.GitlabAppOAuthIntegrationRepository
@@ -175,6 +176,11 @@ func (t *GormRepository) UpstashIntegration() repository.UpstashIntegrationRepos
 	return t.upstashIntegration
 }
 
+// NeonIntegration returns the NeonIntegrationRepository interface implemented by gorm
+func (t *GormRepository) NeonIntegration() repository.NeonIntegrationRepository {
+	return t.neonIntegration
+}
+
 // AppEventWebhook returns the AppEventWebhookRepository interface implemented by gorm
 func (t *GormRepository) AppEventWebhook() repository.AppEventWebhookRepository {
 	return t.appEventWebhook
@@ -338,6 +344,7 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		gitlabIntegration:         NewGitlabIntegrationRepository(db, key, storageBackend),
 		gitlabAppOAuthIntegration: NewGitlabAppOAuthIntegrationRepository(db, key, storageBackend),
 		upstashIntegration:        NewUpstashIntegrationRepository(db, key),
+		neonIntegration:           NewNeonIntegrationRepository(db, key),
 		notificationConfig:        NewNotificationConfigRepository(db),
 		jobNotificationConfig:     NewJobNotificationConfigRepository(db),
 		buildEvent:                NewBuildEventRepository(db),

+ 13 - 0
internal/repository/neon.go

@@ -0,0 +1,13 @@
+package repository
+
+import (
+	"context"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// NeonIntegrationRepository represents the set of queries on an Neon integration
+type NeonIntegrationRepository interface {
+	// Insert creates a new neon integration
+	Insert(ctx context.Context, neonInt ints.NeonIntegration) (ints.NeonIntegration, error)
+}

+ 1 - 0
internal/repository/repository.go

@@ -28,6 +28,7 @@ type Repository interface {
 	GithubAppOAuthIntegration() GithubAppOAuthIntegrationRepository
 	SlackIntegration() SlackIntegrationRepository
 	UpstashIntegration() UpstashIntegrationRepository
+	NeonIntegration() NeonIntegrationRepository
 	AppEventWebhook() AppEventWebhookRepository
 	GitlabIntegration() GitlabIntegrationRepository
 	GitlabAppOAuthIntegration() GitlabAppOAuthIntegrationRepository

+ 18 - 0
internal/repository/test/neon.go

@@ -0,0 +1,18 @@
+package test
+
+import (
+	"context"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type NeonIntegrationRepository struct{}
+
+func NewNeonIntegrationRepository(canQuery bool) repository.NeonIntegrationRepository {
+	return &NeonIntegrationRepository{}
+}
+
+func (s *NeonIntegrationRepository) Insert(ctx context.Context, neonInt ints.NeonIntegration) (ints.NeonIntegration, error) {
+	panic("not implemented") // TODO: Implement
+}

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

@@ -33,6 +33,7 @@ type TestRepository struct {
 	gitlabAppOAuthIntegration repository.GitlabAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
 	upstashIntegration        repository.UpstashIntegrationRepository
+	neonIntegration           repository.NeonIntegrationRepository
 	appEventWebhook           repository.AppEventWebhookRepository
 	notificationConfig        repository.NotificationConfigRepository
 	jobNotificationConfig     repository.JobNotificationConfigRepository
@@ -175,6 +176,10 @@ func (t *TestRepository) UpstashIntegration() repository.UpstashIntegrationRepos
 	return t.upstashIntegration
 }
 
+func (t *TestRepository) NeonIntegration() repository.NeonIntegrationRepository {
+	return t.neonIntegration
+}
+
 func (t *TestRepository) AppEventWebhook() repository.AppEventWebhookRepository {
 	return t.appEventWebhook
 }
@@ -326,6 +331,7 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		gitlabAppOAuthIntegration: NewGitlabAppOAuthIntegrationRepository(canQuery),
 		slackIntegration:          NewSlackIntegrationRepository(canQuery),
 		upstashIntegration:        NewUpstashIntegrationRepository(canQuery),
+		neonIntegration:           NewNeonIntegrationRepository(canQuery),
 		appEventWebhook:           NewAppEventWebhookRepository(canQuery),
 		notificationConfig:        NewNotificationConfigRepository(canQuery),
 		jobNotificationConfig:     NewJobNotificationConfigRepository(canQuery),

+ 8 - 1
zarf/helm/.serverenv

@@ -85,4 +85,11 @@ PORTER_STANDARD_PLAN_ID=
 # UPSTASH_ENABLED is used to enable the Upstash integration
 UPSTASH_ENABLED=false
 # UPSTASH_CLIENT_ID is used to integrate with Upstash
-UPSTASH_CLIENT_ID=
+UPSTASH_CLIENT_ID=
+
+# NEON_ENABLED is used to enable the Neon integration
+NEON_ENABLED=false
+# NEON_CLIENT_ID is used to integrate with Neon
+NEON_CLIENT_ID=
+# NEON_CLIENT_SECRET is used to integrate with Neon
+NEON_CLIENT_SECRET=