Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

neon integration #4600

Merged
merged 4 commits into from
May 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions api/server/handlers/oauth_callback/neon.go
Original file line number Diff line number Diff line change
@@ -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 changes: 51 additions & 0 deletions api/server/handlers/project_oauth/neon.go
Original file line number Diff line number Diff line change
@@ -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 changes: 24 additions & 0 deletions api/server/router/oauth_callback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
28 changes: 28 additions & 0 deletions api/server/router/project_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
3 changes: 3 additions & 0 deletions api/server/shared/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions api/server/shared/config/env/envconfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
13 changes: 12 additions & 1 deletion api/server/shared/config/loader/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions internal/models/integrations/neon.go
Original file line number Diff line number Diff line change
@@ -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 changes: 14 additions & 0 deletions internal/oauth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions internal/repository/gorm/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
Loading
Loading