From 599f017cb402c3bb32ff61d86e53788f08b4dc93 Mon Sep 17 00:00:00 2001 From: dgtown Date: Thu, 9 May 2024 11:56:56 -0400 Subject: [PATCH 1/6] add ory creation webhook --- api/server/handlers/user/create_ory.go | 126 ++++++++++++++++++++++ api/server/router/base.go | 24 +++++ api/server/shared/config/config.go | 1 + api/server/shared/config/env/envconfs.go | 2 + api/server/shared/config/loader/loader.go | 1 + 5 files changed, 154 insertions(+) create mode 100644 api/server/handlers/user/create_ory.go diff --git a/api/server/handlers/user/create_ory.go b/api/server/handlers/user/create_ory.go new file mode 100644 index 0000000000..381cf31055 --- /dev/null +++ b/api/server/handlers/user/create_ory.go @@ -0,0 +1,126 @@ +package user + +import ( + "errors" + "fmt" + "net/http" + + "github.com/porter-dev/porter/internal/telemetry" + + "gorm.io/gorm" + + "github.com/porter-dev/porter/api/server/shared/apierrors" + "github.com/porter-dev/porter/internal/analytics" + "github.com/porter-dev/porter/internal/models" + + "github.com/porter-dev/porter/api/server/handlers" + "github.com/porter-dev/porter/api/server/shared" + "github.com/porter-dev/porter/api/server/shared/config" +) + +type OryUserCreateHandler struct { + handlers.PorterHandlerReadWriter +} + +func NewOryUserCreateHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *OryUserCreateHandler { + return &OryUserCreateHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + } +} + +type CreateOryUserRequest struct { + UserId string `json:"user_id"` + Email string `json:"email"` + Referral string `json:"referral"` +} + +func (u *OryUserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-create-ory-user") + defer span.End() + + // this endpoint is not authenticated through middleware; instead, we check + // for the presence of an ory action cookie that matches env + oryActionCookie, err := r.Cookie("ory_action") + if err != nil { + err = telemetry.Error(ctx, span, err, "invalid ory action cookie") + reqErr := apierrors.NewErrForbidden(err) + apierrors.HandleAPIError(u.Config().Logger, u.Config().Alerter, w, r, reqErr, true) + return + } + + if oryActionCookie.Value != u.Config().OryActionKey { + err = telemetry.Error(ctx, span, nil, "cookie does not match") + reqErr := apierrors.NewErrForbidden(err) + apierrors.HandleAPIError(u.Config().Logger, u.Config().Alerter, w, r, reqErr, true) + return + } + + request := &CreateOryUserRequest{} + ok := u.DecodeAndValidate(w, r, request) + if !ok { + err = telemetry.Error(ctx, span, nil, "invalid request") + u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + if request.Email == "" { + err = telemetry.Error(ctx, span, nil, "email is required") + u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + if request.UserId == "" { + err = telemetry.Error(ctx, span, nil, "user_id is required") + u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + + user := &models.User{ + Model: gorm.Model{}, + Email: request.Email, + EmailVerified: false, + AuthProvider: models.AuthProvider_Ory, + ExternalId: request.UserId, + } + + existingUser, err := u.Repo().User().ReadUserByEmail(user.Email) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + err = telemetry.Error(ctx, span, err, "error reading user by email") + u.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + + if existingUser == nil || existingUser.ID == 0 { + user, err = u.Repo().User().CreateUser(user) + if err != nil { + err = telemetry.Error(ctx, span, err, "error creating user") + u.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + } else { + existingUser.AuthProvider = models.AuthProvider_Ory + existingUser.ExternalId = request.UserId + _, err = u.Repo().User().UpdateUser(existingUser) + if err != nil { + err = telemetry.Error(ctx, span, err, "error updating user") + u.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + } + + u.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user)) + + u.Config().AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{ + UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID), + Email: user.Email, + FirstName: user.FirstName, + LastName: user.LastName, + CompanyName: user.CompanyName, + ReferralMethod: request.Referral, + })) + + fmt.Println("triggered by ory") +} diff --git a/api/server/router/base.go b/api/server/router/base.go index f89d236719..f3ce2ca9ce 100644 --- a/api/server/router/base.go +++ b/api/server/router/base.go @@ -197,6 +197,30 @@ func GetBaseRoutes( Router: r, }) + // POST /api/users/ory -> user.NewOryUserCreateHandler + createOryUserEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: "/users/ory", + }, + }, + ) + + createOryUserHandler := user.NewOryUserCreateHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + + routes = append(routes, &router.Route{ + Endpoint: createOryUserEndpoint, + Handler: createOryUserHandler, + Router: r, + }) + // POST /api/login -> user.NewUserLoginHandler loginUserEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ diff --git a/api/server/shared/config/config.go b/api/server/shared/config/config.go index 03ff5a237d..53c298a099 100644 --- a/api/server/shared/config/config.go +++ b/api/server/shared/config/config.go @@ -126,6 +126,7 @@ type Config struct { Ory ory.APIClient OryApiKeyContextWrapper func(ctx context.Context) context.Context + OryActionKey string } type ConfigLoader interface { diff --git a/api/server/shared/config/env/envconfs.go b/api/server/shared/config/env/envconfs.go index b388d22614..f22b2c7b66 100644 --- a/api/server/shared/config/env/envconfs.go +++ b/api/server/shared/config/env/envconfs.go @@ -177,6 +177,8 @@ type ServerConf struct { OryEnabled bool `env:"ORY_ENABLED,default=false"` OryUrl string `env:"ORY_URL,default=http://localhost:4000"` OryApiKey string `env:"ORY_API_KEY"` + // OryActionKey is the key used to authenticate api requests from Ory Actions to the Porter API + OryActionKey string `env:"ORY_ACTION_KEY"` } // DBConf is the database configuration: if generated from environment variables, diff --git a/api/server/shared/config/loader/loader.go b/api/server/shared/config/loader/loader.go index be9cf93cf0..71923784f4 100644 --- a/api/server/shared/config/loader/loader.go +++ b/api/server/shared/config/loader/loader.go @@ -402,6 +402,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) { res.OryApiKeyContextWrapper = func(ctx context.Context) context.Context { return context.WithValue(ctx, ory.ContextAccessToken, InstanceEnvConf.ServerConf.OryApiKey) } + res.OryActionKey = InstanceEnvConf.ServerConf.OryActionKey res.Logger.Info().Msg("Created Ory client") } From 911e0b5dc6f0f1aa7bc52c1f533cfe887ffee475 Mon Sep 17 00:00:00 2001 From: dgtown Date: Fri, 17 May 2024 16:25:57 -0400 Subject: [PATCH 2/6] add ory creation webhook --- api/server/handlers/user/create_ory.go | 21 ++++++++++++++------- api/server/router/base.go | 4 ++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/api/server/handlers/user/create_ory.go b/api/server/handlers/user/create_ory.go index 381cf31055..f51424a227 100644 --- a/api/server/handlers/user/create_ory.go +++ b/api/server/handlers/user/create_ory.go @@ -2,7 +2,6 @@ package user import ( "errors" - "fmt" "net/http" "github.com/porter-dev/porter/internal/telemetry" @@ -18,10 +17,12 @@ import ( "github.com/porter-dev/porter/api/server/shared/config" ) +// OryUserCreateHandler is the handler for user creation triggered by an ory action type OryUserCreateHandler struct { handlers.PorterHandlerReadWriter } +// NewOryUserCreateHandler generates a new OryUserCreateHandler func NewOryUserCreateHandler( config *config.Config, decoderValidator shared.RequestDecoderValidator, @@ -32,12 +33,14 @@ func NewOryUserCreateHandler( } } +// CreateOryUserRequest is the expected request body for creating a user type CreateOryUserRequest struct { - UserId string `json:"user_id"` + OryId string `json:"ory_id"` Email string `json:"email"` Referral string `json:"referral"` } +// ServeHTTP handles the user creation triggered by an ory action func (u *OryUserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx, span := telemetry.NewSpan(r.Context(), "serve-create-ory-user") defer span.End() @@ -67,13 +70,19 @@ func (u *OryUserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "email", Value: request.Email}, + telemetry.AttributeKV{Key: "ory-id", Value: request.OryId}, + telemetry.AttributeKV{Key: "referral", Value: request.Referral}, + ) + if request.Email == "" { err = telemetry.Error(ctx, span, nil, "email is required") u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) return } - if request.UserId == "" { - err = telemetry.Error(ctx, span, nil, "user_id is required") + if request.OryId == "" { + err = telemetry.Error(ctx, span, nil, "ory_id is required") u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) return } @@ -83,7 +92,7 @@ func (u *OryUserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) Email: request.Email, EmailVerified: false, AuthProvider: models.AuthProvider_Ory, - ExternalId: request.UserId, + ExternalId: request.OryId, } existingUser, err := u.Repo().User().ReadUserByEmail(user.Email) @@ -121,6 +130,4 @@ func (u *OryUserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) CompanyName: user.CompanyName, ReferralMethod: request.Referral, })) - - fmt.Println("triggered by ory") } diff --git a/api/server/router/base.go b/api/server/router/base.go index f3ce2ca9ce..f8ab2d2c8d 100644 --- a/api/server/router/base.go +++ b/api/server/router/base.go @@ -200,8 +200,8 @@ func GetBaseRoutes( // POST /api/users/ory -> user.NewOryUserCreateHandler createOryUserEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ - Verb: types.APIVerbGet, - Method: types.HTTPVerbGet, + Verb: types.APIVerbUpdate, + Method: types.HTTPVerbPost, Path: &types.Path{ Parent: basePath, RelativePath: "/users/ory", From e6cda9ebfc4e140337133e5035eca68006c2f858 Mon Sep 17 00:00:00 2001 From: dgtown Date: Fri, 17 May 2024 16:41:10 -0400 Subject: [PATCH 3/6] add ory creation webhook --- api/server/handlers/user/create_ory.go | 27 +++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/api/server/handlers/user/create_ory.go b/api/server/handlers/user/create_ory.go index f51424a227..70d05ca493 100644 --- a/api/server/handlers/user/create_ory.go +++ b/api/server/handlers/user/create_ory.go @@ -4,12 +4,13 @@ import ( "errors" "net/http" + "github.com/porter-dev/porter/internal/analytics" + "github.com/porter-dev/porter/internal/telemetry" "gorm.io/gorm" "github.com/porter-dev/porter/api/server/shared/apierrors" - "github.com/porter-dev/porter/internal/analytics" "github.com/porter-dev/porter/internal/models" "github.com/porter-dev/porter/api/server/handlers" @@ -109,9 +110,20 @@ func (u *OryUserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) u.HandleAPIError(w, r, apierrors.NewErrInternal(err)) return } + + u.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user)) + + u.Config().AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{ + UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID), + Email: user.Email, + FirstName: user.FirstName, + LastName: user.LastName, + CompanyName: user.CompanyName, + ReferralMethod: request.Referral, + })) } else { existingUser.AuthProvider = models.AuthProvider_Ory - existingUser.ExternalId = request.UserId + existingUser.ExternalId = request.OryId _, err = u.Repo().User().UpdateUser(existingUser) if err != nil { err = telemetry.Error(ctx, span, err, "error updating user") @@ -119,15 +131,4 @@ func (u *OryUserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } } - - u.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user)) - - u.Config().AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{ - UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID), - Email: user.Email, - FirstName: user.FirstName, - LastName: user.LastName, - CompanyName: user.CompanyName, - ReferralMethod: request.Referral, - })) } From 2cf55a47681982545145c20b658b9d3a717cd8a8 Mon Sep 17 00:00:00 2001 From: dgtown Date: Fri, 17 May 2024 16:55:11 -0400 Subject: [PATCH 4/6] address comments --- api/server/handlers/user/create_ory.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/server/handlers/user/create_ory.go b/api/server/handlers/user/create_ory.go index 70d05ca493..27c99e1f08 100644 --- a/api/server/handlers/user/create_ory.go +++ b/api/server/handlers/user/create_ory.go @@ -51,15 +51,13 @@ func (u *OryUserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) oryActionCookie, err := r.Cookie("ory_action") if err != nil { err = telemetry.Error(ctx, span, err, "invalid ory action cookie") - reqErr := apierrors.NewErrForbidden(err) - apierrors.HandleAPIError(u.Config().Logger, u.Config().Alerter, w, r, reqErr, true) + u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden)) return } if oryActionCookie.Value != u.Config().OryActionKey { err = telemetry.Error(ctx, span, nil, "cookie does not match") - reqErr := apierrors.NewErrForbidden(err) - apierrors.HandleAPIError(u.Config().Logger, u.Config().Alerter, w, r, reqErr, true) + u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden)) return } From cf3d3b5828e6e512e75f7b83c101c94b7ecd04e6 Mon Sep 17 00:00:00 2001 From: dgtown Date: Fri, 17 May 2024 17:03:10 -0400 Subject: [PATCH 5/6] address comments --- api/server/handlers/user/create_ory.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/server/handlers/user/create_ory.go b/api/server/handlers/user/create_ory.go index 27c99e1f08..377bdf8381 100644 --- a/api/server/handlers/user/create_ory.go +++ b/api/server/handlers/user/create_ory.go @@ -34,7 +34,7 @@ func NewOryUserCreateHandler( } } -// CreateOryUserRequest is the expected request body for creating a user +// CreateOryUserRequest is the expected request body for user creation triggered by an ory action type CreateOryUserRequest struct { OryId string `json:"ory_id"` Email string `json:"email"` From dd15c5a62b07b91965cc5d6badec0820d8218183 Mon Sep 17 00:00:00 2001 From: dgtown Date: Fri, 17 May 2024 17:06:39 -0400 Subject: [PATCH 6/6] fix lint --- api/server/handlers/user/create_ory.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/server/handlers/user/create_ory.go b/api/server/handlers/user/create_ory.go index 377bdf8381..db0b1f8dbc 100644 --- a/api/server/handlers/user/create_ory.go +++ b/api/server/handlers/user/create_ory.go @@ -109,9 +109,9 @@ func (u *OryUserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - u.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user)) + _ = u.Config().AnalyticsClient.Identify(analytics.CreateSegmentIdentifyUser(user)) - u.Config().AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{ + _ = u.Config().AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{ UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID), Email: user.Email, FirstName: user.FirstName,