From 3ed7040cdbe80df1deb594d9542565b65debc99e Mon Sep 17 00:00:00 2001
From: Ramon Snir <r@mon.dev>
Date: Tue, 17 Dec 2024 11:48:35 -0500
Subject: [PATCH] export users route

---
 README.md             |  4 +++
 api/admin.go          | 38 ++++++++++++++++++++++++++-
 api/api.go            |  3 +++
 conf/configuration.go |  1 +
 models/user.go        | 61 +++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 106 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 127231ee6..638a82530 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,10 @@ Controls what endpoint Netlify can access this API on.
 
 If you wish to inherit a request ID from the incoming request, specify the name in this value.
 
+`API_EXPORT_SECRET` - `string`
+
+A secret that, if set, will allow exporting users for a migration to a different service.
+
 ### Database
 
 ```properties
diff --git a/api/admin.go b/api/admin.go
index e8d3c400d..f8a50387e 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -6,9 +6,9 @@ import (
 	"net/http"
 
 	"github.com/go-chi/chi"
+	"github.com/gobuffalo/uuid"
 	"github.com/netlify/gotrue/models"
 	"github.com/netlify/gotrue/storage"
-	"github.com/gobuffalo/uuid"
 )
 
 type adminUserParams struct {
@@ -80,6 +80,42 @@ func (a *API) adminUsers(w http.ResponseWriter, r *http.Request) error {
 	})
 }
 
+// adminUsers responds with a list of all users in a given audience
+func (a *API) adminExportUsers(exportSecret string) func(w http.ResponseWriter, r *http.Request) error {
+	return func(w http.ResponseWriter, r *http.Request) error {
+		if r.Header.Get("EXPORT_SECRET") != exportSecret {
+			return unauthorizedError("Invalid export secret")
+		}
+
+		ctx := r.Context()
+		instanceID := getInstanceID(ctx)
+		aud := a.requestAud(ctx, r)
+
+		pageParams, err := paginate(r)
+		if err != nil {
+			return badRequestError("Bad Pagination Parameters: %v", err)
+		}
+
+		sortParams, err := sort(r, map[string]bool{models.CreatedAt: true}, []models.SortField{models.SortField{Name: models.CreatedAt, Dir: models.Descending}})
+		if err != nil {
+			return badRequestError("Bad Sort Parameters: %v", err)
+		}
+
+		filter := r.URL.Query().Get("filter")
+
+		users, err := models.FindUsersForExportInAudience(a.db, instanceID, aud, pageParams, sortParams, filter)
+		if err != nil {
+			return internalServerError("Database error finding users").WithInternalError(err)
+		}
+		addPaginationHeaders(w, r, pageParams)
+
+		return sendJSON(w, http.StatusOK, map[string]interface{}{
+			"users": users,
+			"aud":   aud,
+		})
+	}
+}
+
 // adminUserGet returns information about a single user
 func (a *API) adminUserGet(w http.ResponseWriter, r *http.Request) error {
 	user := getUser(r.Context())
diff --git a/api/api.go b/api/api.go
index 50bef8265..8428e0688 100644
--- a/api/api.go
+++ b/api/api.go
@@ -142,6 +142,9 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 			r.Route("/users", func(r *router) {
 				r.Get("/", api.adminUsers)
+				if globalConfig.API.ExportSecret != "" {
+					r.Get("/export", api.adminExportUsers(globalConfig.API.ExportSecret))
+				}
 				r.With(api.requireEmailProvider).Post("/", api.adminUserCreate)
 
 				r.Route("/{user_id}", func(r *router) {
diff --git a/conf/configuration.go b/conf/configuration.go
index ac0b5fb9d..bf16ed01a 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -57,6 +57,7 @@ type GlobalConfiguration struct {
 		Port            int `envconfig:"PORT" default:"8081"`
 		Endpoint        string
 		RequestIDHeader string `envconfig:"REQUEST_ID_HEADER"`
+		ExportSecret    string
 	}
 	DB                DBConfiguration
 	External          ProviderConfiguration
diff --git a/models/user.go b/models/user.go
index 1fcd10734..2f1acd0e2 100644
--- a/models/user.go
+++ b/models/user.go
@@ -50,6 +50,39 @@ type User struct {
 	UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
 }
 
+// User respresents a registered user with email/password authentication
+type UserForExport struct {
+	InstanceID uuid.UUID `json:"-" db:"instance_id"`
+	ID         uuid.UUID `json:"id" db:"id"`
+
+	Aud               string     `json:"aud" db:"aud"`
+	Role              string     `json:"role" db:"role"`
+	Email             string     `json:"email" db:"email"`
+	EncryptedPassword string     `json:"encrypted_password" db:"encrypted_password"` // Exposing the encrypted password for an export.
+	ConfirmedAt       *time.Time `json:"confirmed_at,omitempty" db:"confirmed_at"`
+	InvitedAt         *time.Time `json:"invited_at,omitempty" db:"invited_at"`
+
+	ConfirmationToken  string     `json:"-" db:"confirmation_token"`
+	ConfirmationSentAt *time.Time `json:"confirmation_sent_at,omitempty" db:"confirmation_sent_at"`
+
+	RecoveryToken  string     `json:"-" db:"recovery_token"`
+	RecoverySentAt *time.Time `json:"recovery_sent_at,omitempty" db:"recovery_sent_at"`
+
+	EmailChangeToken  string     `json:"-" db:"email_change_token"`
+	EmailChange       string     `json:"new_email,omitempty" db:"email_change"`
+	EmailChangeSentAt *time.Time `json:"email_change_sent_at,omitempty" db:"email_change_sent_at"`
+
+	LastSignInAt *time.Time `json:"last_sign_in_at,omitempty" db:"last_sign_in_at"`
+
+	AppMetaData  JSONMap `json:"app_metadata" db:"raw_app_meta_data"`
+	UserMetaData JSONMap `json:"user_metadata" db:"raw_user_meta_data"`
+
+	IsSuperAdmin bool `json:"-" db:"is_super_admin"`
+
+	CreatedAt time.Time `json:"created_at" db:"created_at"`
+	UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
+}
+
 // NewUser initializes a new user from an email, password and user data.
 func NewUser(instanceID uuid.UUID, email, password, aud string, userData map[string]interface{}) (*User, error) {
 	id, err := uuid.NewV4()
@@ -320,6 +353,34 @@ func FindUsersInAudience(tx *storage.Connection, instanceID uuid.UUID, aud strin
 	return users, err
 }
 
+// FindUsersInAudience finds users with the matching audience.
+func FindUsersForExportInAudience(tx *storage.Connection, instanceID uuid.UUID, aud string, pageParams *Pagination, sortParams *SortParams, filter string) ([]*UserForExport, error) {
+	users := []*UserForExport{}
+	q := tx.Q().Where("instance_id = ? and aud = ?", instanceID, aud)
+
+	if filter != "" {
+		lf := "%" + filter + "%"
+		// we must specify the collation in order to get case insensitive search for the JSON column
+		q = q.Where("(email LIKE ? OR raw_user_meta_data->>'$.full_name' COLLATE utf8mb4_unicode_ci LIKE ?)", lf, lf)
+	}
+
+	if sortParams != nil && len(sortParams.Fields) > 0 {
+		for _, field := range sortParams.Fields {
+			q = q.Order(field.Name + " " + string(field.Dir))
+		}
+	}
+
+	var err error
+	if pageParams != nil {
+		err = q.Paginate(int(pageParams.Page), int(pageParams.PerPage)).All(&users)
+		pageParams.Count = uint64(q.Paginator.TotalEntriesSize)
+	} else {
+		err = q.All(&users)
+	}
+
+	return users, err
+}
+
 // IsDuplicatedEmail returns whether a user exists with a matching email and audience.
 func IsDuplicatedEmail(tx *storage.Connection, instanceID uuid.UUID, email, aud string) (bool, error) {
 	_, err := FindUserByEmailAndAudience(tx, instanceID, email, aud)