diff --git a/api/server/handlers/user/create_ory.go b/api/server/handlers/user/create_ory.go new file mode 100644 index 0000000000..db0b1f8dbc --- /dev/null +++ b/api/server/handlers/user/create_ory.go @@ -0,0 +1,132 @@ +package user + +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/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" +) + +// 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, + writer shared.ResultWriter, +) *OryUserCreateHandler { + return &OryUserCreateHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + } +} + +// 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"` + 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() + + // 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") + 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") + u.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden)) + 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 + } + + 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.OryId == "" { + err = telemetry.Error(ctx, span, nil, "ory_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.OryId, + } + + 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 + } + + _ = 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.OryId + _, 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 + } + } +} diff --git a/api/server/router/base.go b/api/server/router/base.go index f89d236719..f8ab2d2c8d 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.APIVerbUpdate, + Method: types.HTTPVerbPost, + 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") }