diff --git a/api/server/handlers/oauth_callback/neon.go b/api/server/handlers/oauth_callback/neon.go new file mode 100644 index 0000000000..042b3dfb0f --- /dev/null +++ b/api/server/handlers/oauth_callback/neon.go @@ -0,0 +1,122 @@ +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 +} + +// NeonApiKeyEndpoint is the endpoint to fetch the neon developer api key +// nolint:gosec // Not a security key +const NeonApiKeyEndpoint = "https://api.neon.com/apikey" + +// 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, uses it to create a developer api token, 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) +} diff --git a/api/server/handlers/project_oauth/neon.go b/api/server/handlers/project_oauth/neon.go new file mode 100644 index 0000000000..89a47a1cf8 --- /dev/null +++ b/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) +} diff --git a/api/server/router/oauth_callback.go b/api/server/router/oauth_callback.go index a9cbb2d7e8..4001f86511 100644 --- a/api/server/router/oauth_callback.go +++ b/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{ diff --git a/api/server/router/project_oauth.go b/api/server/router/project_oauth.go index 0d44d356fe..a7248c7767 100644 --- a/api/server/router/project_oauth.go +++ b/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{ diff --git a/api/server/shared/config/config.go b/api/server/shared/config/config.go index 3aba0866bc..5772cb16a8 100644 --- a/api/server/shared/config/config.go +++ b/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 diff --git a/api/server/shared/config/env/envconfs.go b/api/server/shared/config/env/envconfs.go index cb45cc22d5..1069be392f 100644 --- a/api/server/shared/config/env/envconfs.go +++ b/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"` diff --git a/api/server/shared/config/loader/loader.go b/api/server/shared/config/loader/loader.go index 3f3f540061..749e6dac22 100644 --- a/api/server/shared/config/loader/loader.go +++ b/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, diff --git a/internal/models/integrations/neon.go b/internal/models/integrations/neon.go new file mode 100644 index 0000000000..c887010b5b --- /dev/null +++ b/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 +} diff --git a/internal/oauth/config.go b/internal/oauth/config.go index 76f03db46d..88fe10835d 100644 --- a/internal/oauth/config.go +++ b/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) diff --git a/internal/repository/gorm/migrate.go b/internal/repository/gorm/migrate.go index 16e435d2e9..c028a7c38e 100644 --- a/internal/repository/gorm/migrate.go +++ b/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{}, diff --git a/internal/repository/gorm/neon.go b/internal/repository/gorm/neon.go new file mode 100644 index 0000000000..59250f12e9 --- /dev/null +++ b/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 +} diff --git a/internal/repository/gorm/repository.go b/internal/repository/gorm/repository.go index 2fb06a3899..e129a92e35 100644 --- a/internal/repository/gorm/repository.go +++ b/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), diff --git a/internal/repository/neon.go b/internal/repository/neon.go new file mode 100644 index 0000000000..1b89e18d4a --- /dev/null +++ b/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) +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index a3f6b8960a..89f2ad548e 100644 --- a/internal/repository/repository.go +++ b/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 diff --git a/internal/repository/test/neon.go b/internal/repository/test/neon.go new file mode 100644 index 0000000000..3d67691594 --- /dev/null +++ b/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 +} diff --git a/internal/repository/test/repository.go b/internal/repository/test/repository.go index 1725039af6..aff5215101 100644 --- a/internal/repository/test/repository.go +++ b/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), diff --git a/zarf/helm/.serverenv b/zarf/helm/.serverenv index 0e8ca91ea7..c07596a81d 100644 --- a/zarf/helm/.serverenv +++ b/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= \ No newline at end of file +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= \ No newline at end of file