From 217e085529feb07feeb5fb489a3394b27ee59749 Mon Sep 17 00:00:00 2001 From: Maksym Naichuk Date: Wed, 20 Jul 2022 16:27:04 +0200 Subject: [PATCH 1/9] feat(finex): add labels table --- .../20220720000812_create_labels_table.up.sql | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 migrations/20220720000812_create_labels_table.up.sql diff --git a/migrations/20220720000812_create_labels_table.up.sql b/migrations/20220720000812_create_labels_table.up.sql new file mode 100644 index 000000000..c94fcf756 --- /dev/null +++ b/migrations/20220720000812_create_labels_table.up.sql @@ -0,0 +1,13 @@ +-- adds lables table + +CREATE TABLE IF NOT EXISTS auth.labels ( + id :bigint NOT NULL + user_id :uuid NOT NULL + label enum(`email`,`asym-key`,`phone`,`address`,...) NOT NULL + state enum(`unverified`,`pending`,`verified`,`expired`) NOT NULL default `unverified` + created_at timestamptz NOT NULL + updated_at timestamptz NOT NULL + CONSTRAINT labels_pkey PRIMARY KEY (id), + CONSTRAINT labels_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE +); +COMMENT ON TABLE auth.labels is 'Auth: Stores labels associated to a user.'; From ebc879301c00961740bd060ec58c8ce38a86a5eb Mon Sep 17 00:00:00 2001 From: Max Pushkarov Date: Tue, 26 Jul 2022 17:23:45 +0300 Subject: [PATCH 2/9] feat: parse base64-encoded user levels data from config --- conf/configuration.go | 36 +++++++++++++++++++ example.env | 5 ++- hack/test.env | 1 + .../20220720000812_create_labels_table.up.sql | 2 +- 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 72ecf2794..e863cb819 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -179,6 +179,13 @@ type SecurityConfiguration struct { RefreshTokenRotationEnabled bool `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"` } +type userLabel struct { + Level uint `json:"level"` + Labels []string `json:"labels"` +} + +type UserLabels map[uint][]string + // Configuration holds all the per-instance configuration. type Configuration struct { SiteURL string `json:"site_url" split_words:"true" required:"true"` @@ -198,6 +205,7 @@ type Configuration struct { Domain string `json:"domain"` Duration int `json:"duration"` } `json:"cookies"` + UserLabels UserLabels `json:"user_labels" split_words:"true"` } func loadEnvironment(filename string) error { @@ -343,6 +351,14 @@ func (config *Configuration) ApplyDefaults() { config.PasswordMinLength = defaultMinPasswordLength } + if len(config.UserLabels) == 0 { + config.UserLabels = UserLabels{ + 1: {"email", "phone"}, + 2: {"profile"}, + 3: {"documents"}, + } + } + config.JWT.InitializeSigningSecret() } @@ -371,6 +387,26 @@ func (config *Configuration) Scan(src interface{}) error { return json.Unmarshal(source, &config) } +func (ul *UserLabels) Decode(value string) error { + raw, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return err + } + + var decodedLabels []userLabel + err = json.Unmarshal([]byte(raw), &decodedLabels) + if err != nil { + return err + } + + ul = &UserLabels{} + for _, l := range decodedLabels { + (*ul)[l.Level] = l.Labels + } + + return nil +} + func (o *OAuthProviderConfiguration) Validate() error { if !o.Enabled { return errors.New("Provider is not enabled") diff --git a/example.env b/example.env index ec83beeac..fb37294cd 100644 --- a/example.env +++ b/example.env @@ -192,4 +192,7 @@ GOTRUE_WEBHOOK_EVENTS=validate,signup,login # Cookie config GOTRUE_COOKIE_KEY: "sb" -GOTRUE_COOKIE_DOMAIN: "localhost" \ No newline at end of file +GOTRUE_COOKIE_DOMAIN: "localhost" + +# Labels config +GOTRUE_USER_LABELS=W3sibGV2ZWwiOjEsImxhYmVscyI6WyJlbWFpbCIsICJwaG9uZSJdfSx7ImxldmVsIjoyLCJsYWJlbHMiOlsicHJvZmlsZSJdfSx7ImxldmVsIjozLCJsYWJlbHMiOlsiZG9jdW1lbnQiXX1d diff --git a/hack/test.env b/hack/test.env index 9b5d9b89c..6d9a9fb95 100644 --- a/hack/test.env +++ b/hack/test.env @@ -84,3 +84,4 @@ GOTRUE_TRACING_TAGS="env:test" GOTRUE_SECURITY_CAPTCHA_ENABLED="false" GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha" GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000" +GOTRUE_USER_LABELS=W3sibGV2ZWwiOjEsImxhYmVscyI6WyJlbWFpbCIsICJwaG9uZSJdfSx7ImxldmVsIjoyLCJsYWJlbHMiOlsicHJvZmlsZSJdfSx7ImxldmVsIjozLCJsYWJlbHMiOlsiZG9jdW1lbnQiXX1d diff --git a/migrations/20220720000812_create_labels_table.up.sql b/migrations/20220720000812_create_labels_table.up.sql index c94fcf756..c25009b2c 100644 --- a/migrations/20220720000812_create_labels_table.up.sql +++ b/migrations/20220720000812_create_labels_table.up.sql @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS auth.labels ( id :bigint NOT NULL user_id :uuid NOT NULL - label enum(`email`,`asym-key`,`phone`,`address`,...) NOT NULL + label enum(`email`,`phone`,`profile`,`document`) NOT NULL state enum(`unverified`,`pending`,`verified`,`expired`) NOT NULL default `unverified` created_at timestamptz NOT NULL updated_at timestamptz NOT NULL From 134da19c30d22892d35826d2eda526422edf6a7a Mon Sep 17 00:00:00 2001 From: Max Pushkarov Date: Tue, 26 Jul 2022 17:24:53 +0300 Subject: [PATCH 3/9] feat: add endpoint to create or update user labels --- api/admin.go | 72 +++++++++++++++++++++++++++++++++++++++ api/api.go | 6 ++++ api/context.go | 15 ++++++++ models/asymmetric_key.go | 5 +-- models/audit_log_entry.go | 8 +++-- models/labels.go | 50 +++++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 models/labels.go diff --git a/api/admin.go b/api/admin.go index 8022cda8f..9c0019c82 100644 --- a/api/admin.go +++ b/api/admin.go @@ -345,3 +345,75 @@ func (a *API) adminUserDelete(w http.ResponseWriter, r *http.Request) error { return sendJSON(w, http.StatusOK, map[string]interface{}{}) } + +type adminUserLabelsParams struct { + Label string `json:"label"` + State string `json:"state"` +} + +func (a *API) loadUserLabels(w http.ResponseWriter, req *http.Request) (context.Context, error) { + ctx := req.Context() + user := getUser(ctx) + params, err := a.getAdminUserLabelsParams(req) + if err != nil { + return ctx, internalServerError("Error loading user labels").WithInternalError(err) + } + + label, err := models.FindUserLabel(a.db, user.ID, params.Label) + if err != nil { + return ctx, internalServerError("Error loading user labels").WithInternalError(err) + } + + return withLabel(ctx, label), nil +} + +func (a *API) getAdminUserLabelsParams(r *http.Request) (*adminUserLabelsParams, error) { + params := &adminUserLabelsParams{} + err := json.NewDecoder(r.Body).Decode(¶ms) + if err != nil { + return nil, badRequestError("Could not decode admin user label params: %v", err) + } + return params, nil +} + +func (a *API) adminUserLabelCreateOrUpdate(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + user := getUser(ctx) + instanceID := getInstanceID(ctx) + adminUser := getAdminUser(ctx) + existingLabel := getLabel(ctx) + params, err := a.getAdminUserLabelsParams(r) + if err != nil { + return err + } + + err = a.db.Transaction(func(tx *storage.Connection) error { + var action models.AuditAction + + if existingLabel != nil { + action = models.UserLabelModifiedAction + + if terr := existingLabel.UpdateState(tx, params.State); terr != nil { + return terr + } + } else { + action = models.UserLabelCreatedAction + label := models.NewUserLabel(user.ID, params.Label, params.State) + + if terr := tx.Create(label); terr != nil { + return terr + } + } + + if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, action, map[string]interface{}{ + "user_id": user.ID, + "user_email": user.Email, + "user_phone": user.Phone, + }); terr != nil { + return internalServerError("Error recording audit log entry").WithInternalError(terr) + } + return nil + }) + + return err +} diff --git a/api/api.go b/api/api.go index a4169c275..9bca88f27 100644 --- a/api/api.go +++ b/api/api.go @@ -166,6 +166,12 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati r.Get("/", api.adminUserGet) r.Put("/", api.adminUserUpdate) r.Delete("/", api.adminUserDelete) + + r.Route("/labels", func(r *router) { + r.Use(api.loadUserLabels) + + r.Post("/", api.adminUserLabelCreateOrUpdate) + }) }) }) diff --git a/api/context.go b/api/context.go index f1c4a78c1..522cc10f6 100644 --- a/api/context.go +++ b/api/context.go @@ -26,6 +26,7 @@ const ( netlifyIDKey = contextKey("netlify_id") externalProviderTypeKey = contextKey("external_provider_type") userKey = contextKey("user") + labelsKey = contextKey("labels") externalReferrerKey = contextKey("external_referrer") functionHooksKey = contextKey("function_hooks") adminUserKey = contextKey("admin_user") @@ -126,6 +127,20 @@ func getUser(ctx context.Context) *models.User { return obj.(*models.User) } +// withLabel adds the user label to the context. +func withLabel(ctx context.Context, label *models.UserLabel) context.Context { + return context.WithValue(ctx, labelsKey, label) +} + +// getLabel reads the user label from the context. +func getLabel(ctx context.Context) *models.UserLabel { + obj := ctx.Value(labelsKey) + if obj == nil { + return nil + } + return obj.(*models.UserLabel) +} + // withSignature adds the provided request ID to the context. func withSignature(ctx context.Context, id string) context.Context { return context.WithValue(ctx, signatureKey, id) diff --git a/models/asymmetric_key.go b/models/asymmetric_key.go index a310bcba4..1d4cea2e0 100644 --- a/models/asymmetric_key.go +++ b/models/asymmetric_key.go @@ -3,13 +3,14 @@ package models import ( "database/sql" "fmt" + "time" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto" "github.com/gofrs/uuid" "github.com/netlify/gotrue/storage" "github.com/pkg/errors" - "time" ) const challengeTokenExpirationDuration = 30 * time.Minute @@ -140,7 +141,7 @@ func (a *AsymmetricKey) verifyEthKeySignature(rawSignature string) error { return nil } -// verifyKeyAndAlgorithm verifies public key format for specific algorithm. +// VerifyKeyAndAlgorithm verifies public key format for specific algorithm. // If key satisfies conditions, nil is returned func VerifyKeyAndAlgorithm(pubkey, algorithm string) error { var err error diff --git a/models/audit_log_entry.go b/models/audit_log_entry.go index 3aa39b742..500f7d1ec 100644 --- a/models/audit_log_entry.go +++ b/models/audit_log_entry.go @@ -24,6 +24,8 @@ const ( UserRecoveryRequestedAction AuditAction = "user_recovery_requested" UserConfirmationRequestedAction AuditAction = "user_confirmation_requested" UserRepeatedSignUpAction AuditAction = "user_repeated_signup" + UserLabelCreatedAction AuditAction = "user_label_created" + UserLabelModifiedAction AuditAction = "user_label_modified" TokenRevokedAction AuditAction = "token_revoked" TokenRefreshedAction AuditAction = "token_refreshed" @@ -40,12 +42,14 @@ var actionLogTypeMap = map[AuditAction]auditLogType{ UserSignedUpAction: team, UserInvitedAction: team, UserDeletedAction: team, - TokenRevokedAction: token, - TokenRefreshedAction: token, UserModifiedAction: user, UserRecoveryRequestedAction: user, UserConfirmationRequestedAction: user, UserRepeatedSignUpAction: user, + UserLabelCreatedAction: user, + UserLabelModifiedAction: user, + TokenRevokedAction: token, + TokenRefreshedAction: token, } // AuditLogEntry is the database model for audit log entries. diff --git a/models/labels.go b/models/labels.go new file mode 100644 index 000000000..5f2ce5f5a --- /dev/null +++ b/models/labels.go @@ -0,0 +1,50 @@ +package models + +import ( + "time" + + "github.com/gofrs/uuid" + "github.com/netlify/gotrue/storage" +) + +type UserLabel struct { + ID string `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Label string `json:"label" db:"label"` + State string `json:"state" db:"state"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +func (UserLabel) TableName() string { + tableName := "labels" + return tableName +} + +func NewUserLabel(userID uuid.UUID, label string, state string) *UserLabel { + userLabel := &UserLabel{ + UserID: userID, + Label: label, + State: state, + } + return userLabel +} + +// UpdateState updates the state column of a user label +func (ul *UserLabel) UpdateState(tx *storage.Connection, state string) error { + ul.State = state + return tx.UpdateOnly(ul, "state") +} + +// FindUserLabel finds a user labels matching the provided ID and label name +func FindUserLabel(tx *storage.Connection, userID uuid.UUID, label string) (*UserLabel, error) { + res := &UserLabel{} + q := tx.Q().Where("user_id = ? AND label = ?", userID, label) + + err := q.First(res) + if err != nil { + return nil, err + } + + return res, nil +} From 20718530ca82eb85e16d829e550d3ac619b4bbb0 Mon Sep 17 00:00:00 2001 From: Max Pushkarov Date: Tue, 26 Jul 2022 23:46:46 +0300 Subject: [PATCH 4/9] feat: recalculate user level after finishing operations on labels --- api/admin.go | 44 +++++++++++++++++++++++++++++-------------- api/context.go | 12 ++++++------ conf/configuration.go | 16 +++++----------- models/labels.go | 27 ++++++++++++++++---------- 4 files changed, 58 insertions(+), 41 deletions(-) diff --git a/api/admin.go b/api/admin.go index 9c0019c82..59970f2ac 100644 --- a/api/admin.go +++ b/api/admin.go @@ -354,17 +354,13 @@ type adminUserLabelsParams struct { func (a *API) loadUserLabels(w http.ResponseWriter, req *http.Request) (context.Context, error) { ctx := req.Context() user := getUser(ctx) - params, err := a.getAdminUserLabelsParams(req) - if err != nil { - return ctx, internalServerError("Error loading user labels").WithInternalError(err) - } - label, err := models.FindUserLabel(a.db, user.ID, params.Label) + labels, err := models.FindUserLabels(a.db, user.ID) if err != nil { return ctx, internalServerError("Error loading user labels").WithInternalError(err) } - return withLabel(ctx, label), nil + return withLabels(ctx, labels), nil } func (a *API) getAdminUserLabelsParams(r *http.Request) (*adminUserLabelsParams, error) { @@ -381,30 +377,50 @@ func (a *API) adminUserLabelCreateOrUpdate(w http.ResponseWriter, r *http.Reques user := getUser(ctx) instanceID := getInstanceID(ctx) adminUser := getAdminUser(ctx) - existingLabel := getLabel(ctx) + existingLabels := getLabels(ctx) params, err := a.getAdminUserLabelsParams(r) - if err != nil { - return err - } + config := a.getConfig(ctx) err = a.db.Transaction(func(tx *storage.Connection) error { + // perform update var action models.AuditAction - if existingLabel != nil { + if label, ok := existingLabels[params.Label]; ok { action = models.UserLabelModifiedAction - if terr := existingLabel.UpdateState(tx, params.State); terr != nil { + if terr := label.UpdateState(tx, params.State); terr != nil { return terr } } else { action = models.UserLabelCreatedAction - label := models.NewUserLabel(user.ID, params.Label, params.State) + newLabel := models.NewUserLabel(user.ID, params.Label, params.State) - if terr := tx.Create(label); terr != nil { + if terr := tx.Create(newLabel); terr != nil { return terr } + + existingLabels[newLabel.Label] = newLabel + } + + // recalculate user level + newLevel := uint64(0) + levelsLoop: + for _, levelEntry := range config.UserLabels { + for _, label := range levelEntry.Labels { + if _, ok := existingLabels[label]; !ok { + break levelsLoop + } + } + newLevel++ + } + + if terr := user.UpdateUserMetaData(tx, map[string]interface{}{ + models.UserLevelKey: newLevel, + }); terr != nil { + return terr } + // display logs if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, action, map[string]interface{}{ "user_id": user.ID, "user_email": user.Email, diff --git a/api/context.go b/api/context.go index 522cc10f6..c822ef0f2 100644 --- a/api/context.go +++ b/api/context.go @@ -127,18 +127,18 @@ func getUser(ctx context.Context) *models.User { return obj.(*models.User) } -// withLabel adds the user label to the context. -func withLabel(ctx context.Context, label *models.UserLabel) context.Context { - return context.WithValue(ctx, labelsKey, label) +// withLabels adds the user labels to the context. +func withLabels(ctx context.Context, labels map[string]*models.UserLabel) context.Context { + return context.WithValue(ctx, labelsKey, labels) } -// getLabel reads the user label from the context. -func getLabel(ctx context.Context) *models.UserLabel { +// getLabels reads the user labels from the context. +func getLabels(ctx context.Context) map[string]*models.UserLabel { obj := ctx.Value(labelsKey) if obj == nil { return nil } - return obj.(*models.UserLabel) + return obj.(map[string]*models.UserLabel) } // withSignature adds the provided request ID to the context. diff --git a/conf/configuration.go b/conf/configuration.go index e863cb819..7aaef7746 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -184,7 +184,7 @@ type userLabel struct { Labels []string `json:"labels"` } -type UserLabels map[uint][]string +type UserLabels []userLabel // Configuration holds all the per-instance configuration. type Configuration struct { @@ -353,9 +353,9 @@ func (config *Configuration) ApplyDefaults() { if len(config.UserLabels) == 0 { config.UserLabels = UserLabels{ - 1: {"email", "phone"}, - 2: {"profile"}, - 3: {"documents"}, + {Level: 1, Labels: []string{"email", "phone"}}, + {Level: 2, Labels: []string{"profile"}}, + {Level: 3, Labels: []string{"documents"}}, } } @@ -393,17 +393,11 @@ func (ul *UserLabels) Decode(value string) error { return err } - var decodedLabels []userLabel - err = json.Unmarshal([]byte(raw), &decodedLabels) + err = json.Unmarshal([]byte(raw), &ul) if err != nil { return err } - ul = &UserLabels{} - for _, l := range decodedLabels { - (*ul)[l.Level] = l.Labels - } - return nil } diff --git a/models/labels.go b/models/labels.go index 5f2ce5f5a..199aa9d12 100644 --- a/models/labels.go +++ b/models/labels.go @@ -7,6 +7,8 @@ import ( "github.com/netlify/gotrue/storage" ) +const UserLevelKey string = "level" + type UserLabel struct { ID string `json:"id" db:"id"` UserID uuid.UUID `json:"user_id" db:"user_id"` @@ -16,11 +18,6 @@ type UserLabel struct { UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } -func (UserLabel) TableName() string { - tableName := "labels" - return tableName -} - func NewUserLabel(userID uuid.UUID, label string, state string) *UserLabel { userLabel := &UserLabel{ UserID: userID, @@ -30,21 +27,31 @@ func NewUserLabel(userID uuid.UUID, label string, state string) *UserLabel { return userLabel } +func (UserLabel) TableName() string { + tableName := "labels" + return tableName +} + // UpdateState updates the state column of a user label func (ul *UserLabel) UpdateState(tx *storage.Connection, state string) error { ul.State = state return tx.UpdateOnly(ul, "state") } -// FindUserLabel finds a user labels matching the provided ID and label name -func FindUserLabel(tx *storage.Connection, userID uuid.UUID, label string) (*UserLabel, error) { - res := &UserLabel{} - q := tx.Q().Where("user_id = ? AND label = ?", userID, label) +// FindUserLabels finds all user labels matching the provided user ID +func FindUserLabels(tx *storage.Connection, userID uuid.UUID) (map[string]*UserLabel, error) { + var labels []*UserLabel - err := q.First(res) + q := tx.Q().Where("user_id = ?", userID) + err := q.All(labels) if err != nil { return nil, err } + res := make(map[string]*UserLabel) + for _, label := range labels { + res[label.Label] = label + } + return res, nil } From 1f0305059a4b7c93e35c8df9a5a3366a61f06008 Mon Sep 17 00:00:00 2001 From: Max Pushkarov Date: Wed, 27 Jul 2022 11:06:30 +0300 Subject: [PATCH 5/9] feat: assign labels on user signup via email or phone --- api/signup.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/signup.go b/api/signup.go index 7fcb0bb91..14b0c6e31 100644 --- a/api/signup.go +++ b/api/signup.go @@ -98,6 +98,11 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { if err := user.UpdateUserMetaData(tx, params.Data); err != nil { return internalServerError("Database error updating user").WithInternalError(err) } + + label := models.NewUserLabel(user.ID, params.Provider, "verified") + if terr := tx.Create(label); terr != nil { + return internalServerError("Database error creating user label").WithInternalError(terr) + } } else { user, terr = a.signupNewUser(ctx, tx, params) if terr != nil { From d9e297c6ec0b2c880dd30ee1745ee1a549bffed7 Mon Sep 17 00:00:00 2001 From: Max Pushkarov Date: Wed, 27 Jul 2022 11:10:48 +0300 Subject: [PATCH 6/9] refactor: move user level calculation to transaction callback --- api/admin.go | 22 +++------------------- models/labels.go | 46 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/api/admin.go b/api/admin.go index 59970f2ac..b43e76624 100644 --- a/api/admin.go +++ b/api/admin.go @@ -379,7 +379,9 @@ func (a *API) adminUserLabelCreateOrUpdate(w http.ResponseWriter, r *http.Reques adminUser := getAdminUser(ctx) existingLabels := getLabels(ctx) params, err := a.getAdminUserLabelsParams(r) - config := a.getConfig(ctx) + if err != nil { + return err + } err = a.db.Transaction(func(tx *storage.Connection) error { // perform update @@ -402,24 +404,6 @@ func (a *API) adminUserLabelCreateOrUpdate(w http.ResponseWriter, r *http.Reques existingLabels[newLabel.Label] = newLabel } - // recalculate user level - newLevel := uint64(0) - levelsLoop: - for _, levelEntry := range config.UserLabels { - for _, label := range levelEntry.Labels { - if _, ok := existingLabels[label]; !ok { - break levelsLoop - } - } - newLevel++ - } - - if terr := user.UpdateUserMetaData(tx, map[string]interface{}{ - models.UserLevelKey: newLevel, - }); terr != nil { - return terr - } - // display logs if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, action, map[string]interface{}{ "user_id": user.ID, diff --git a/models/labels.go b/models/labels.go index 199aa9d12..c23eeaa7f 100644 --- a/models/labels.go +++ b/models/labels.go @@ -3,11 +3,16 @@ package models import ( "time" + "github.com/gobuffalo/pop/v5" "github.com/gofrs/uuid" + "github.com/netlify/gotrue/conf" "github.com/netlify/gotrue/storage" ) -const UserLevelKey string = "level" +const ( + UserLevelKey string = "level" + configFile string = "" +) type UserLabel struct { ID string `json:"id" db:"id"` @@ -32,6 +37,45 @@ func (UserLabel) TableName() string { return tableName } +// AfterSave is invoked afterk the user label is saved to +// the database to recalculate the user level +func (ul *UserLabel) AfterSave(tx *pop.Connection) error { + wrappedTx := &storage.Connection{Connection: tx} + + config, err := conf.LoadConfig(configFile) + if err != nil { + return err + } + + existingLabels, err := FindUserLabels(wrappedTx, ul.UserID) + if err != nil { + return err + } + + user, err := FindUserByID(wrappedTx, ul.UserID) + if err != nil { + return err + } + + newLevel := uint64(0) +levelsLoop: + for _, levelEntry := range config.UserLabels { + for _, label := range levelEntry.Labels { + if _, ok := existingLabels[label]; !ok { + break levelsLoop + } + } + newLevel++ + } + + if terr := user.UpdateUserMetaData(wrappedTx, map[string]interface{}{ + UserLevelKey: newLevel, + }); terr != nil { + return terr + } + return nil +} + // UpdateState updates the state column of a user label func (ul *UserLabel) UpdateState(tx *storage.Connection, state string) error { ul.State = state From 77ae9f19c87d7614676518780d4c69f60df618cd Mon Sep 17 00:00:00 2001 From: Max Pushkarov Date: Thu, 28 Jul 2022 16:01:57 +0300 Subject: [PATCH 7/9] fix: invalid syntax in labels SQL migration script --- .../20220720000812_create_labels_table.up.sql | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/migrations/20220720000812_create_labels_table.up.sql b/migrations/20220720000812_create_labels_table.up.sql index c25009b2c..5d0a07700 100644 --- a/migrations/20220720000812_create_labels_table.up.sql +++ b/migrations/20220720000812_create_labels_table.up.sql @@ -1,12 +1,15 @@ -- adds lables table +CREATE TYPE label_name AS ENUM ('email','phone','profile','document'); +CREATE TYPE label_state AS ENUM ('unverified','pending','verified','expired'); + CREATE TABLE IF NOT EXISTS auth.labels ( - id :bigint NOT NULL - user_id :uuid NOT NULL - label enum(`email`,`phone`,`profile`,`document`) NOT NULL - state enum(`unverified`,`pending`,`verified`,`expired`) NOT NULL default `unverified` - created_at timestamptz NOT NULL - updated_at timestamptz NOT NULL + id bigint NOT NULL, + user_id uuid NOT NULL, + label label_name NOT NULL, + state label_state NOT NULL DEFAULT 'unverified', + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, CONSTRAINT labels_pkey PRIMARY KEY (id), CONSTRAINT labels_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE ); From c6e15dfa5879e7393979b8481e9e452b6afd4ae5 Mon Sep 17 00:00:00 2001 From: Max Pushkarov Date: Fri, 29 Jul 2022 10:46:10 +0300 Subject: [PATCH 8/9] test: add tests for label model and API handler --- api/admin.go | 28 ++++-- api/admin_test.go | 87 ++++++++++++++++++- api/api.go | 6 +- .../20220720000812_create_labels_table.up.sql | 12 +-- models/labels.go | 4 +- models/labels_test.go | 65 ++++++++++++++ 6 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 models/labels_test.go diff --git a/api/admin.go b/api/admin.go index b43e76624..8dbc7e9dc 100644 --- a/api/admin.go +++ b/api/admin.go @@ -68,7 +68,7 @@ func (a *API) adminUsers(w http.ResponseWriter, r *http.Request) error { 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}}) + sortParams, err := sort(r, map[string]bool{models.CreatedAt: true}, []models.SortField{{Name: models.CreatedAt, Dir: models.Descending}}) if err != nil { return badRequestError("Bad Sort Parameters: %v", err) } @@ -372,19 +372,34 @@ func (a *API) getAdminUserLabelsParams(r *http.Request) (*adminUserLabelsParams, return params, nil } -func (a *API) adminUserLabelCreateOrUpdate(w http.ResponseWriter, r *http.Request) error { +func (a *API) adminUserLabelsCreateOrUpdate(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() user := getUser(ctx) instanceID := getInstanceID(ctx) adminUser := getAdminUser(ctx) existingLabels := getLabels(ctx) + config := getConfig(ctx) params, err := a.getAdminUserLabelsParams(r) if err != nil { return err } + // check if requested label is in the list of configured labels + exists := false + for _, level := range config.UserLabels { + for _, label := range level.Labels { + if label == params.Label { + exists = true + } + } + } + + if !exists { + return badRequestError("Label '%s' is not defined in the config", params.Label) + } + + // perform update err = a.db.Transaction(func(tx *storage.Connection) error { - // perform update var action models.AuditAction if label, ok := existingLabels[params.Label]; ok { @@ -404,11 +419,10 @@ func (a *API) adminUserLabelCreateOrUpdate(w http.ResponseWriter, r *http.Reques existingLabels[newLabel.Label] = newLabel } - // display logs if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, action, map[string]interface{}{ - "user_id": user.ID, - "user_email": user.Email, - "user_phone": user.Phone, + "user_id": user.ID, + "label_name": params.Label, + "label_state": params.State, }); terr != nil { return internalServerError("Error recording audit log entry").WithInternalError(terr) } diff --git a/api/admin_test.go b/api/admin_test.go index a5de623c0..9b1654f92 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "math" "net/http" "net/http/httptest" "testing" @@ -54,7 +55,6 @@ func (ts *AdminTestSuite) makeSuperAdmin(email string) string { u.Role = "supabase_admin" - key, err := models.FindMainAsymmetricKeyByUser(ts.API.db, u) require.NoError(ts.T(), err, "Error finding keys") @@ -604,3 +604,88 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() { }) } } + +// TestAdminUserLabelsCreateOrUpdate tests API /admin/user/labels route (POST) +func (ts *AdminTestSuite) TestAdminUserLabelsCreateOrUpdate() { + cases := []struct { + desc string + params map[string]interface{} + expected map[string]interface{} + }{ + { + desc: "Create new label", + params: map[string]interface{}{ + "label": "email", + "state": "pending", + }, + expected: map[string]interface{}{ + "httpStatusCode": http.StatusOK, + "new_labels": 1, + }, + }, + { + desc: "Update existing label", + params: map[string]interface{}{ + "label": "email", + "state": "verified", + }, + expected: map[string]interface{}{ + "httpStatusCode": http.StatusOK, + "new_labels": 0, + }, + }, + { + desc: "Label does not exist", + params: map[string]interface{}{ + "label": "test", + "state": "verified", + }, + expected: map[string]interface{}{ + "httpStatusCode": http.StatusBadRequest, + "new_labels": 0, + }, + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.params)) + + u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, nil) + require.NoError(ts.T(), err, "Error making new user") + if err := ts.API.db.Create(u); err != nil { + u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test1@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err, "Error finding user") + } + + beforeLabels, err := models.FindUserLabels(ts.API.db, u.ID) + require.NoError(ts.T(), err, "Error loading user labels") + + // Setup request + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/admin/users/%v/labels", u.ID), &buffer) + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token)) + ts.Config.External.Phone.Enabled = true + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), c.expected["httpStatusCode"], w.Code) + + // verify request results + afterLabels, err := models.FindUserLabels(ts.API.db, u.ID) + require.NoError(ts.T(), err, "Error loading user labels") + + labelsDelta := len(beforeLabels) - len(afterLabels) + require.Equal(ts.T(), int(math.Abs(float64(labelsDelta))), + c.expected["new_labels"].(int), + fmt.Sprintf("Expected %d label", c.expected["new_labels"])) + + if c.expected["new_labels"].(int) > 0 { + labelName := c.params["label"].(string) + require.Equal(ts.T(), afterLabels[labelName].Label, labelName) + require.Equal(ts.T(), afterLabels[labelName].State, c.params["state"].(string)) + } + }) + } +} diff --git a/api/api.go b/api/api.go index 9bca88f27..2b33d09fa 100644 --- a/api/api.go +++ b/api/api.go @@ -51,7 +51,9 @@ func (a *API) ListenAndServe(hostAndPort string) { waitForTermination(log, done) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - server.Shutdown(ctx) + if err := server.Shutdown(ctx); err != nil { + log.WithError(err).Fatal("http server shutdown failed") + } }() if err := server.ListenAndServe(); err != http.ErrServerClosed { @@ -170,7 +172,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati r.Route("/labels", func(r *router) { r.Use(api.loadUserLabels) - r.Post("/", api.adminUserLabelCreateOrUpdate) + r.Post("/", api.adminUserLabelsCreateOrUpdate) }) }) }) diff --git a/migrations/20220720000812_create_labels_table.up.sql b/migrations/20220720000812_create_labels_table.up.sql index 5d0a07700..ee81d7520 100644 --- a/migrations/20220720000812_create_labels_table.up.sql +++ b/migrations/20220720000812_create_labels_table.up.sql @@ -4,12 +4,12 @@ CREATE TYPE label_name AS ENUM ('email','phone','profile','document'); CREATE TYPE label_state AS ENUM ('unverified','pending','verified','expired'); CREATE TABLE IF NOT EXISTS auth.labels ( - id bigint NOT NULL, - user_id uuid NOT NULL, - label label_name NOT NULL, - state label_state NOT NULL DEFAULT 'unverified', - created_at timestamptz NOT NULL, - updated_at timestamptz NOT NULL, + id uuid NOT NULL, + user_id uuid NOT NULL, + label label_name NOT NULL, + state label_state NOT NULL DEFAULT 'unverified', + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, CONSTRAINT labels_pkey PRIMARY KEY (id), CONSTRAINT labels_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE ); diff --git a/models/labels.go b/models/labels.go index c23eeaa7f..00768cae1 100644 --- a/models/labels.go +++ b/models/labels.go @@ -15,7 +15,7 @@ const ( ) type UserLabel struct { - ID string `json:"id" db:"id"` + ID uuid.UUID `json:"id" db:"id"` UserID uuid.UUID `json:"user_id" db:"user_id"` Label string `json:"label" db:"label"` State string `json:"state" db:"state"` @@ -87,7 +87,7 @@ func FindUserLabels(tx *storage.Connection, userID uuid.UUID) (map[string]*UserL var labels []*UserLabel q := tx.Q().Where("user_id = ?", userID) - err := q.All(labels) + err := q.All(&labels) if err != nil { return nil, err } diff --git a/models/labels_test.go b/models/labels_test.go new file mode 100644 index 000000000..4eddde28e --- /dev/null +++ b/models/labels_test.go @@ -0,0 +1,65 @@ +package models + +import ( + "testing" + + "github.com/gofrs/uuid" + "github.com/netlify/gotrue/conf" + "github.com/netlify/gotrue/storage" + "github.com/netlify/gotrue/storage/test" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type UserLabelsTestSuite struct { + suite.Suite + db *storage.Connection + Config *conf.GlobalConfiguration + user *User + label *UserLabel +} + +func TestUserLabelsTestSuite(t *testing.T) { + globalConfig, err := conf.LoadGlobal(modelsTestConfig) + require.NoError(t, err) + + conn, err := test.SetupDBConnection(globalConfig) + require.NoError(t, err) + + ts := &UserLabelsTestSuite{ + db: conn, + } + defer ts.db.Close() + + suite.Run(t, ts) +} + +func (ts *UserLabelsTestSuite) SetupTest() { + err := TruncateAll(ts.db) + require.NoError(ts.T(), err, "Failed to truncate tables") + + // Create user + u, err := NewUser(uuid.Nil, "test@example.com", "secret", "test", nil) + ts.user = u + + require.NoError(ts.T(), err, "Error creating test user model") + require.NoError(ts.T(), ts.db.Create(u), "Error saving new test user") + + // Create label + l := NewUserLabel(u.ID, "email", "pending") + require.NoError(ts.T(), ts.db.Create(l), "Error saving new test label") + ts.label = l +} + +func (ts *UserLabelsTestSuite) TestFindUserLabels() { + labels, err := FindUserLabels(ts.db, ts.user.ID) + require.NoError(ts.T(), err, "Error finding user labels") + require.Len(ts.T(), labels, 1, "Expected 1 user label") + require.Equal(ts.T(), "email", labels["email"].Label, "Expected user label name to match") + require.Equal(ts.T(), "pending", labels["email"].State, "Expected user label state to match") +} + +func (ts *UserLabelsTestSuite) TestUpdateState() { + err := ts.label.UpdateState(ts.db, "verified") + require.NoError(ts.T(), err, "Error updating user label state") +} From 75f05cf34cd8540488d07d8b4e0a65d833daea38 Mon Sep 17 00:00:00 2001 From: Max Pushkarov Date: Fri, 29 Jul 2022 12:40:45 +0300 Subject: [PATCH 9/9] test: fix gotrue config overwritten but not recovered after test --- api/admin.go | 1 + api/admin_test.go | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/api/admin.go b/api/admin.go index 8dbc7e9dc..58d47e398 100644 --- a/api/admin.go +++ b/api/admin.go @@ -390,6 +390,7 @@ func (a *API) adminUserLabelsCreateOrUpdate(w http.ResponseWriter, r *http.Reque for _, label := range level.Labels { if label == params.Label { exists = true + break } } } diff --git a/api/admin_test.go b/api/admin_test.go index 9b1654f92..3839881e9 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -44,7 +44,7 @@ func TestAdmin(t *testing.T) { } func (ts *AdminTestSuite) SetupTest() { - models.TruncateAll(ts.API.db) + _ = models.TruncateAll(ts.API.db) ts.Config.External.Email.Enabled = true ts.token = ts.makeSuperAdmin("") } @@ -587,6 +587,8 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() { }, } + configBackup := *ts.Config + for _, c := range cases { ts.Run(c.desc, func() { // Initialize user data @@ -603,6 +605,8 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() { require.Equal(ts.T(), c.expected, w.Code) }) } + + *ts.Config = configBackup } // TestAdminUserLabelsCreateOrUpdate tests API /admin/user/labels route (POST)