From abccc1f658c82bd1e7d6e26302ce3542f6aaa63d Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Sat, 22 Jun 2019 10:05:03 -0800 Subject: [PATCH] Splitting our users and accounts, creating new user_account package --- README.md | 2 +- example-project/CONTRIBUTORS | 35 - example-project/internal/account/models.go | 127 +++ .../internal/platform/auth/auth.go | 1 + example-project/internal/user/user.go | 638 ----------- example-project/internal/user/user_test.go | 1010 ----------------- .../internal/{user => user_account}/auth.go | 0 .../{user => user_account}/auth_test.go | 0 .../internal/{user => user_account}/models.go | 80 +- .../{user => user_account}/user_account.go | 120 +- .../user_account_test.go | 12 +- 11 files changed, 222 insertions(+), 1803 deletions(-) delete mode 100644 example-project/CONTRIBUTORS create mode 100644 example-project/internal/account/models.go delete mode 100644 example-project/internal/user/user.go delete mode 100644 example-project/internal/user/user_test.go rename example-project/internal/{user => user_account}/auth.go (100%) rename example-project/internal/{user => user_account}/auth_test.go (100%) rename example-project/internal/{user => user_account}/models.go (67%) rename example-project/internal/{user => user_account}/user_account.go (70%) rename example-project/internal/{user => user_account}/user_account_test.go (97%) diff --git a/README.md b/README.md index 9dddb8a..2704f12 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This project should not be considered a web framework. It is a starter toolkit t There are five areas of expertise that an engineer or her engineering team must do for a project to grow and scale. Based on our experience, a few core decisions were made for each of these areas that help you focus initially on writing the business logic. 1. Micro level - Since SaaS requires transactions, project implements Postgres. Implementation facilitates the data semantics that define the data being captured and their relationships. -2. Macro level - Uses POD architecture and design that provides the project foundation. +2. Macro level - The project architecture and design, provides basic project structure and foundation for development. 3. Business logic - Defines an example Golang package that helps illustrate where value generating activities should reside and how the code will be delivered to clients. 4. Deployment and Operations - Integrates with GitLab for CI/CD and AWS for serverless deployments with AWS Fargate. 5. Observability - Implements Datadog to facilitate exposing metrics, logs and request tracing that ensure stable and responsive service for clients. diff --git a/example-project/CONTRIBUTORS b/example-project/CONTRIBUTORS deleted file mode 100644 index 98d3bb6..0000000 --- a/example-project/CONTRIBUTORS +++ /dev/null @@ -1,35 +0,0 @@ -# This is the official list of people who can contribute -# (and typically have contributed) code to the gotraining repository. -# -# Names should be added to this file only after verifying that -# the individual or the individual's organization has agreed to -# the appropriate Contributor License Agreement, found here: -# -# http://code.google.com/legal/individual-cla-v1.0.html -# http://code.google.com/legal/corporate-cla-v1.0.html -# -# The agreement for individuals can be filled out on the web. - -# Names should be added to this file like so: -# Name -# -# An entry with two email addresses specifies that the -# first address should be used in the submit logs and -# that the second address should be recognized as the -# same person when interacting with Rietveld. - -# Please keep the list sorted. - -Arash Bina -Askar Sagyndyk -Bob Cao <3308031+bobintornado@users.noreply.github.com> -Ed Gonzo -Farrukh Kurbanov -Jacob Walker -Jeremy Stone -Nick Stogner -William Kennedy -Wyatt Johnson -Zachary Johnson -Lee Brown -Lucas Brown diff --git a/example-project/internal/account/models.go b/example-project/internal/account/models.go new file mode 100644 index 0000000..1ba9775 --- /dev/null +++ b/example-project/internal/account/models.go @@ -0,0 +1,127 @@ +package account + +import ( + "database/sql" + "database/sql/driver" + "time" + + "github.com/lib/pq" + "gopkg.in/go-playground/validator.v9" + "github.com/pkg/errors" +) + +// Account represents someone with access to our system. +type Account struct { + ID string `json:"id"` + Name string `json:"name"` + Address1 string `json:"address1"` + Address2 string `json:"address2"` + City string `json:"city"` + Region string `json:"region"` + Country string `json:"country"` + Zipcode string `json:"zipcode"` + Status AccountStatus `json:"status"` + Timezone string `json:"timezone"` + SignupUserID sql.NullString `json:"signup_user_id"` + BillingUserID sql.NullString `json:"billing_user_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ArchivedAt pq.NullTime `json:"archived_at"` +} + +// CreateAccountRequest contains information needed to create a new Account. +type CreateAccountRequest struct { + Name string `json:"name" validate:"required,unique"` + Address1 string `json:"address1" validate:"required"` + Address2 string `json:"address2" validate:"omitempty"` + City string `json:"city" validate:"required"` + Region string `json:"region" validate:"required"` + Country string `json:"country" validate:"required"` + Zipcode string `json:"zipcode" validate:"required"` + Status *AccountStatus `json:"status" validate:"omitempty,oneof=active pending disabled"` + Timezone *string `json:"timezone" validate:"omitempty"` + SignupUserID *string `json:"signup_user_id" validate:"omitempty,uuid"` + BillingUserID *string `json:"billing_user_id" validate:"omitempty,uuid"` +} + +// UpdateAccountRequest defines what information may be provided to modify an existing +// Account. All fields are optional so clients can send just the fields they want +// changed. It uses pointer fields so we can differentiate between a field that +// was not provided and a field that was provided as explicitly blank. Normally +// we do not want to use pointers to basic types but we make exceptions around +// marshalling/unmarshalling. +type UpdateAccountRequest struct { + ID string `validate:"required,uuid"` + Name *string `json:"name" validate:"omitempty,unique"` + Address1 *string `json:"address1" validate:"omitempty"` + Address2 *string `json:"address2" validate:"omitempty"` + City *string `json:"city" validate:"omitempty"` + Region *string `json:"region" validate:"omitempty"` + Country *string `json:"country" validate:"omitempty"` + Zipcode *string `json:"zipcode" validate:"omitempty"` + Status *AccountStatus `json:"status" validate:"omitempty,oneof=active pending disabled"` + Timezone *string `json:"timezone" validate:"omitempty"` + SignupUserID *string `json:"signup_user_id" validate:"omitempty,uuid"` + BillingUserID *string `json:"billing_user_id" validate:"omitempty,uuid"` +} + +// AccountFindRequest defines the possible options to search for accounts. By default +// archived accounts will be excluded from response. +type AccountFindRequest struct { + Where *string + Args []interface{} + Order []string + Limit *uint + Offset *uint + IncludedArchived bool +} + +// AccountStatus represents the status of an account. +type AccountStatus string + +// AccountStatus values define the status field of a user account. +const ( + // AccountStatus_Active defines the state when a user can access an account. + AccountStatus_Active AccountStatus = "active" + // AccountStatus_Pending defined the state when an account was created but + // not activated. + AccountStatus_Pending AccountStatus = "pending" + // AccountStatus_Disabled defines the state when a user has been disabled from + // accessing an account. + AccountStatus_Disabled AccountStatus = "disabled" +) + +// AccountStatus_Values provides list of valid AccountStatus values. +var AccountStatus_Values = []AccountStatus{ + AccountStatus_Active, + AccountStatus_Pending, + AccountStatus_Disabled, +} + +// Scan supports reading the AccountStatus value from the database. +func (s *AccountStatus) Scan(value interface{}) error { + asBytes, ok := value.([]byte) + if !ok { + return errors.New("Scan source is not []byte") + } + *s = AccountStatus(string(asBytes)) + return nil +} + +// Value converts the AccountStatus value to be stored in the database. +func (s AccountStatus) Value() (driver.Value, error) { + v := validator.New() + + errs := v.Var(s, "required,oneof=active invited disabled") + if errs != nil { + return nil, errs + } + + return string(s), nil +} + +// String converts the AccountStatus value to a string. +func (s AccountStatus) String() string { + return string(s) +} + diff --git a/example-project/internal/platform/auth/auth.go b/example-project/internal/platform/auth/auth.go index 772fe54..164cfba 100644 --- a/example-project/internal/platform/auth/auth.go +++ b/example-project/internal/platform/auth/auth.go @@ -50,6 +50,7 @@ type Authenticator struct { parser *jwt.Parser } + // NewAuthenticator creates an *Authenticator for use. // key expiration is optional to filter out old keys // It will error if: diff --git a/example-project/internal/user/user.go b/example-project/internal/user/user.go deleted file mode 100644 index f374c8b..0000000 --- a/example-project/internal/user/user.go +++ /dev/null @@ -1,638 +0,0 @@ -package user - -import ( - "context" - "database/sql" - "time" - - "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" - "github.com/huandu/go-sqlbuilder" - "github.com/jmoiron/sqlx" - "github.com/pborman/uuid" - "github.com/pkg/errors" - "golang.org/x/crypto/bcrypt" - "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" - "gopkg.in/go-playground/validator.v9" -) - -// The database table for User -const usersTableName = "users" - -var ( - // ErrNotFound abstracts the mgo not found error. - ErrNotFound = errors.New("Entity not found") - - // ErrInvalidID occurs when an ID is not in a valid form. - ErrInvalidID = errors.New("ID is not in its proper form") - - // ErrAuthenticationFailure occurs when a user attempts to authenticate but - // anything goes wrong. - ErrAuthenticationFailure = errors.New("Authentication failed") - - // ErrForbidden occurs when a user tries to do something that is forbidden to them according to our access control policies. - ErrForbidden = errors.New("Attempted action is not allowed") -) - -// usersMapColumns is the list of columns needed for mapRowsToUser -var usersMapColumns = "id,name,email,password_salt,password_hash,password_reset,timezone,created_at,updated_at,archived_at" - -// mapRowsToUser takes the SQL rows and maps it to the UserAccount struct -// with the columns defined by usersMapColumns -func mapRowsToUser(rows *sql.Rows) (*User, error) { - var ( - u User - err error - ) - err = rows.Scan(&u.ID, &u.Name, &u.Email, &u.PasswordSalt, &u.PasswordHash, &u.PasswordReset, &u.Timezone, &u.CreatedAt, &u.UpdatedAt, &u.ArchivedAt) - if err != nil { - return nil, errors.WithStack(err) - } - - return &u, nil -} - -// CanReadUser determines if claims has the authority to access the specified user ID. -func CanReadUser(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error { - // If the request has claims from a specific user, ensure that the user - // has the correct access to the user. - if claims.Subject != "" { - // When the claims Subject - UserId - does not match the requested user, the - // claims audience - AccountId - should have a record. - if claims.Subject != userID { - query := sqlbuilder.NewSelectBuilder().Select("id").From(usersAccountsTableName) - query.Where(query.Or( - query.Equal("account_id", claims.Audience), - query.Equal("user_id", userID), - )) - queryStr, args := query.Build() - queryStr = dbConn.Rebind(queryStr) - - var userAccountId string - err := dbConn.QueryRowContext(ctx, queryStr, args...).Scan(&userAccountId) - if err != nil && err != sql.ErrNoRows { - err = errors.Wrapf(err, "query - %s", query.String()) - return err - } - - // When there is now userAccount ID returned, then the current user does not have access - // to the specified user. - if userAccountId == "" { - return errors.WithStack(ErrForbidden) - } - } - } - - return nil -} - -// CanModifyUser determines if claims has the authority to modify the specified user ID. -func CanModifyUser(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error { - // First check to see if claims can read the user ID - err := CanReadUser(ctx, claims, dbConn, userID) - if err != nil { - return err - } - - // If the request has claims from a specific user, ensure that the user - // has the correct role for updating an existing user. - if claims.Subject != "" { - if claims.Subject == userID { - // All users are allowed to update their own record - } else if claims.HasRole(auth.RoleAdmin) { - // Admin users can update users they have access to. - } else { - return errors.WithStack(ErrForbidden) - } - } - - return nil -} - -// claimsSql applies a sub-query to the provided query to enforce ACL based on -// the claims provided. -// 1. All role types can access their user ID -// 2. Any user with the same account ID -// 3. No claims, request is internal, no ACL applied -func applyClaimsUserSelect(ctx context.Context, claims auth.Claims, query *sqlbuilder.SelectBuilder) error { - // Claims are empty, don't apply any ACL - if claims.Audience == "" && claims.Subject == "" { - return nil - } - - // Build select statement for users_accounts table - subQuery := sqlbuilder.NewSelectBuilder().Select("user_id").From(usersAccountsTableName) - - var or []string - if claims.Audience != "" { - or = append(or, subQuery.Equal("account_id", claims.Audience)) - } - if claims.Subject != "" { - or = append(or, subQuery.Equal("user_id", claims.Subject)) - } - subQuery.Where(subQuery.Or(or...)) - - // Append sub query - query.Where(query.In("id", subQuery)) - - return nil -} - -// selectQuery constructs a base select query for User -func selectQuery() *sqlbuilder.SelectBuilder { - query := sqlbuilder.NewSelectBuilder() - query.Select(usersMapColumns) - query.From(usersTableName) - return query -} - -// userFindRequestQuery generates the select query for the given find request. -// TODO: Need to figure out why can't parse the args when appending the where -// to the query. -func userFindRequestQuery(req UserFindRequest) (*sqlbuilder.SelectBuilder, []interface{}) { - query := selectQuery() - if req.Where != nil { - query.Where(query.And(*req.Where)) - } - if len(req.Order) > 0 { - query.OrderBy(req.Order...) - } - if req.Limit != nil { - query.Limit(int(*req.Limit)) - } - if req.Offset != nil { - query.Offset(int(*req.Offset)) - } - - return query, req.Args -} - -// Find gets all the users from the database based on the request params. -func Find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserFindRequest) ([]*User, error) { - query, args := userFindRequestQuery(req) - return find(ctx, claims, dbConn, query, args, req.IncludedArchived) -} - -// find internal method for getting all the users from the database using a select query. -func find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbuilder.SelectBuilder, args []interface{}, includedArchived bool) ([]*User, error) { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Find") - defer span.Finish() - - query.Select(usersMapColumns) - query.From(usersTableName) - - if !includedArchived { - query.Where(query.IsNull("archived_at")) - } - - // Check to see if a sub query needs to be applied for the claims - err := applyClaimsUserSelect(ctx, claims, query) - if err != nil { - return nil, err - } - queryStr, queryArgs := query.Build() - queryStr = dbConn.Rebind(queryStr) - args = append(args, queryArgs...) - - // fetch all places from the db - rows, err := dbConn.QueryContext(ctx, queryStr, args...) - if err != nil { - err = errors.Wrapf(err, "query - %s", query.String()) - err = errors.WithMessage(err, "find users failed") - return nil, err - } - - // iterate over each row - resp := []*User{} - for rows.Next() { - u, err := mapRowsToUser(rows) - if err != nil { - err = errors.Wrapf(err, "query - %s", query.String()) - return nil, err - } - resp = append(resp, u) - } - - return resp, nil -} - -// Retrieve gets the specified user from the database. -func FindById(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, includedArchived bool) (*User, error) { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.FindById") - defer span.Finish() - - // Filter base select query by ID - query := selectQuery() - query.Where(query.Equal("id", id)) - - res, err := find(ctx, claims, dbConn, query, []interface{}{}, includedArchived) - if err != nil { - return nil, err - } else if res == nil || len(res) == 0 { - err = errors.WithMessagef(ErrNotFound, "user %s not found", id) - return nil, err - } - u := res[0] - - return u, nil -} - -// Validation an email address is unique excluding the current user ID. -func uniqueEmail(ctx context.Context, dbConn *sqlx.DB, email, userId string) (bool, error) { - query := sqlbuilder.NewSelectBuilder().Select("id").From(usersTableName) - query.Where(query.And( - query.Equal("email", email), - query.NotEqual("id", userId), - )) - queryStr, args := query.Build() - queryStr = dbConn.Rebind(queryStr) - - var existingId string - err := dbConn.QueryRowContext(ctx, queryStr, args...).Scan(&existingId) - if err != nil && err != sql.ErrNoRows { - err = errors.Wrapf(err, "query - %s", query.String()) - return false, err - } - - // When an ID was found in the db, the email is not unique. - if existingId != "" { - return false, nil - } - - return true, nil -} - -// Create inserts a new user into the database. -func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req CreateUserRequest, now time.Time) (*User, error) { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Create") - defer span.Finish() - - v := validator.New() - - // Validation email address is unique in the database. - uniq, err := uniqueEmail(ctx, dbConn, req.Email, "") - if err != nil { - return nil, err - } - f := func(fl validator.FieldLevel) bool { - if fl.Field().String() == "invalid" { - return false - } - return uniq - } - v.RegisterValidation("unique", f) - - // Validate the request. - err = v.Struct(req) - if err != nil { - return nil, err - } - - // If the request has claims from a specific user, ensure that the user - // has the correct role for creating a new user. - if claims.Subject != "" { - // Users with the role of admin are ony allows to create users. - if !claims.HasRole(auth.RoleAdmin) { - err = errors.WithStack(ErrForbidden) - return nil, err - } - } - - // If now empty set it to the current time. - if now.IsZero() { - now = time.Now() - } - - // Always store the time as UTC. - now = now.UTC() - - // Postgres truncates times to milliseconds when storing. We and do the same - // here so the value we return is consistent with what we store. - now = now.Truncate(time.Millisecond) - - passwordSalt := uuid.NewRandom().String() - saltedPassword := req.Password + passwordSalt - - passwordHash, err := bcrypt.GenerateFromPassword([]byte(saltedPassword), bcrypt.DefaultCost) - if err != nil { - return nil, errors.Wrap(err, "generating password hash") - } - - u := User{ - ID: uuid.NewRandom().String(), - Name: req.Name, - Email: req.Email, - PasswordHash: passwordHash, - PasswordSalt: passwordSalt, - Timezone: "America/Anchorage", - CreatedAt: now, - UpdatedAt: now, - } - - if req.Timezone != nil { - u.Timezone = *req.Timezone - } - - // Build the insert SQL statement. - query := sqlbuilder.NewInsertBuilder() - query.InsertInto(usersTableName) - query.Cols("id", "name", "email", "password_hash", "password_salt", "timezone", "created_at", "updated_at") - query.Values(u.ID, u.Name, u.Email, u.PasswordHash, u.PasswordSalt, u.Timezone, u.CreatedAt, u.UpdatedAt) - - // Execute the query with the provided context. - sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) - if err != nil { - err = errors.Wrapf(err, "query - %s", query.String()) - err = errors.WithMessage(err, "create user failed") - return nil, err - } - - return &u, nil -} - -// Update replaces a user in the database. -func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UpdateUserRequest, now time.Time) error { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Update") - defer span.Finish() - - v := validator.New() - - // Validation email address is unique in the database. - if req.Email != nil { - uniq, err := uniqueEmail(ctx, dbConn, *req.Email, req.ID) - if err != nil { - return err - } - f := func(fl validator.FieldLevel) bool { - if fl.Field().String() == "invalid" { - return false - } - return uniq - } - v.RegisterValidation("unique", f) - } - - // Validate the request. - err := v.Struct(req) - if err != nil { - return err - } - - // Ensure the claims can modify the user specified in the request. - err = CanModifyUser(ctx, claims, dbConn, req.ID) - if err != nil { - err = errors.WithMessagef(err, "Update %s failed", usersTableName) - return err - } - - // If now empty set it to the current time. - if now.IsZero() { - now = time.Now() - } - - // Always store the time as UTC. - now = now.UTC() - - // Postgres truncates times to milliseconds when storing. We and do the same - // here so the value we return is consistent with what we store. - now = now.Truncate(time.Millisecond) - - // Build the update SQL statement. - query := sqlbuilder.NewUpdateBuilder() - query.Update(usersTableName) - - var fields []string - if req.Name != nil { - fields = append(fields, query.Assign("name", req.Name)) - } - if req.Email != nil { - fields = append(fields, query.Assign("email", req.Email)) - } - if req.Timezone != nil { - fields = append(fields, query.Assign("timezone", req.Timezone)) - } - - // If there's nothing to update we can quit early. - if len(fields) == 0 { - return nil - } - - // Append the updated_at field - fields = append(fields, query.Assign("updated_at", now)) - - query.Set(fields...) - query.Where(query.Equal("id", req.ID)) - - // Execute the query with the provided context. - sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) - if err != nil { - err = errors.Wrapf(err, "query - %s", query.String()) - err = errors.WithMessagef(err, "update user %s failed", req.ID) - return err - } - - return nil -} - -// Update replaces a user in the database. -func UpdatePassword(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UpdatePasswordRequest, now time.Time) error { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Update") - defer span.Finish() - - // Validate the request. - err := validator.New().Struct(req) - if err != nil { - return err - } - - // Ensure the claims can modify the user specified in the request. - err = CanModifyUser(ctx, claims, dbConn, req.ID) - if err != nil { - return err - } - - // If now empty set it to the current time. - if now.IsZero() { - now = time.Now() - } - - // Always store the time as UTC. - now = now.UTC() - - // Postgres truncates times to milliseconds when storing. We and do the same - // here so the value we return is consistent with what we store. - now = now.Truncate(time.Millisecond) - - // Generate new password hash for the provided password. - passwordSalt := uuid.NewRandom() - saltedPassword := req.Password + passwordSalt.String() - passwordHash, err := bcrypt.GenerateFromPassword([]byte(saltedPassword), bcrypt.DefaultCost) - if err != nil { - return errors.Wrap(err, "generating password hash") - } - - // Build the update SQL statement. - query := sqlbuilder.NewUpdateBuilder() - query.Update(usersTableName) - query.Set( - query.Assign("password_hash", passwordHash), - query.Assign("password_salt", passwordSalt), - query.Assign("updated_at", now), - ) - query.Where(query.Equal("id", req.ID)) - - // Execute the query with the provided context. - sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) - if err != nil { - err = errors.Wrapf(err, "query - %s", query.String()) - err = errors.WithMessagef(err, "update password for user %s failed", req.ID) - return err - } - - return nil -} - -// Archive soft deleted the user from the database. -func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string, now time.Time) error { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Archive") - defer span.Finish() - - // Defines the struct to apply validation - req := struct { - ID string `validate:"required,uuid"` - }{ - ID: userID, - } - - // Validate the request. - err := validator.New().Struct(req) - if err != nil { - return err - } - - // Ensure the claims can modify the user specified in the request. - err = CanModifyUser(ctx, claims, dbConn, req.ID) - if err != nil { - return err - } - - // If now empty set it to the current time. - if now.IsZero() { - now = time.Now() - } - - // Always store the time as UTC. - now = now.UTC() - - // Postgres truncates times to milliseconds when storing. We and do the same - // here so the value we return is consistent with what we store. - now = now.Truncate(time.Millisecond) - - // Build the update SQL statement. - query := sqlbuilder.NewUpdateBuilder() - query.Update(usersTableName) - query.Set( - query.Assign("archived_at", now), - ) - query.Where(query.Equal("id", req.ID)) - - // Execute the query with the provided context. - sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) - if err != nil { - err = errors.Wrapf(err, "query - %s", query.String()) - err = errors.WithMessagef(err, "archive user %s failed", req.ID) - return err - } - - // Archive all the associated user accounts - { - // Build the update SQL statement. - query := sqlbuilder.NewUpdateBuilder() - query.Update(usersAccountsTableName) - query.Set(query.Assign("archived_at", now)) - query.Where(query.And( - query.Equal("user_id", req.ID), - )) - - // Execute the query with the provided context. - sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) - if err != nil { - err = errors.Wrapf(err, "query - %s", query.String()) - err = errors.WithMessagef(err, "archive accounts for user %s failed", req.ID) - return err - } - } - - return nil -} - -// Delete removes a user from the database. -func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Delete") - defer span.Finish() - - // Defines the struct to apply validation - req := struct { - ID string `validate:"required,uuid"` - }{ - ID: userID, - } - - // Validate the request. - err := validator.New().Struct(req) - if err != nil { - return err - } - - // Ensure the claims can modify the user specified in the request. - err = CanModifyUser(ctx, claims, dbConn, req.ID) - if err != nil { - return err - } - - // Build the delete SQL statement. - query := sqlbuilder.NewDeleteBuilder() - query.DeleteFrom(usersTableName) - query.Where(query.Equal("id", req.ID)) - - // Execute the query with the provided context. - sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) - if err != nil { - err = errors.Wrapf(err, "query - %s", query.String()) - err = errors.WithMessagef(err, "delete user %s failed", req.ID) - return err - } - - // Delete all the associated user accounts - { - // Build the delete SQL statement. - query := sqlbuilder.NewDeleteBuilder() - query.DeleteFrom(usersAccountsTableName) - query.Where(query.And( - query.Equal("user_id", req.ID), - )) - - // Execute the query with the provided context. - sql, args := query.Build() - sql = dbConn.Rebind(sql) - _, err = dbConn.ExecContext(ctx, sql, args...) - if err != nil { - err = errors.Wrapf(err, "query - %s", query.String()) - err = errors.WithMessagef(err, "delete accounts for user %s failed", req.ID) - return err - } - } - - return nil -} diff --git a/example-project/internal/user/user_test.go b/example-project/internal/user/user_test.go deleted file mode 100644 index 2732a1d..0000000 --- a/example-project/internal/user/user_test.go +++ /dev/null @@ -1,1010 +0,0 @@ -package user - -import ( - "math/rand" - "os" - "strings" - "testing" - "time" - - "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/auth" - "geeks-accelerator/oss/saas-starter-kit/example-project/internal/platform/tests" - "github.com/dgrijalva/jwt-go" - "github.com/google/go-cmp/cmp" - "github.com/huandu/go-sqlbuilder" - "github.com/pborman/uuid" - "github.com/pkg/errors" -) - -var test *tests.Test - -// TestMain is the entry point for testing. -func TestMain(m *testing.M) { - os.Exit(testMain(m)) -} - -func testMain(m *testing.M) int { - test = tests.New() - defer test.TearDown() - return m.Run() -} - -// TestUserFindRequestQuery validates userFindRequestQuery -func TestUserFindRequestQuery(t *testing.T) { - where := "name = ? or email = ?" - var ( - limit uint = 12 - offset uint = 34 - ) - - req := UserFindRequest{ - Where: &where, - Args: []interface{}{ - "lee brown", - "lee@geeksinthewoods.com", - }, - Order: []string{ - "id asc", - "created_at desc", - }, - Limit: &limit, - Offset: &offset, - } - expected := "SELECT " + usersMapColumns + " FROM " + usersTableName + " WHERE (name = ? or email = ?) ORDER BY id asc, created_at desc LIMIT 12 OFFSET 34" - - res, args := userFindRequestQuery(req) - - if diff := cmp.Diff(res.String(), expected); diff != "" { - t.Fatalf("\t%s\tExpected result query to match. Diff:\n%s", tests.Failed, diff) - } - if diff := cmp.Diff(args, req.Args); diff != "" { - t.Fatalf("\t%s\tExpected result query to match. Diff:\n%s", tests.Failed, diff) - } -} - -// TestApplyClaimsUserSelect validates applyClaimsUserSelect -func TestApplyClaimsUserSelect(t *testing.T) { - var claimTests = []struct { - name string - claims auth.Claims - expectedSql string - error error - }{ - {"EmptyClaims", - auth.Claims{}, - "SELECT " + usersMapColumns + " FROM " + usersTableName, - nil, - }, - {"RoleUser", - auth.Claims{ - Roles: []string{auth.RoleUser}, - StandardClaims: jwt.StandardClaims{ - Subject: "user1", - Audience: "acc1", - }, - }, - "SELECT " + usersMapColumns + " FROM " + usersTableName + " WHERE id IN (SELECT user_id FROM " + usersAccountsTableName + " WHERE (account_id = 'acc1' OR user_id = 'user1'))", - nil, - }, - {"RoleAdmin", - auth.Claims{ - Roles: []string{auth.RoleAdmin}, - StandardClaims: jwt.StandardClaims{ - Subject: "user1", - Audience: "acc1", - }, - }, - "SELECT " + usersMapColumns + " FROM " + usersTableName + " WHERE id IN (SELECT user_id FROM " + usersAccountsTableName + " WHERE (account_id = 'acc1' OR user_id = 'user1'))", - nil, - }, - } - - t.Log("Given the need to validate ACLs are enforced by claims to a select query.") - { - for i, tt := range claimTests { - t.Logf("\tTest: %d\tWhen running test: %s", i, tt.name) - { - ctx := tests.Context() - - query := selectQuery() - - err := applyClaimsUserSelect(ctx, tt.claims, query) - if err != tt.error { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", tt.error) - t.Fatalf("\t%s\tapplyClaimsUserSelect failed.", tests.Failed) - } - - sql, args := query.Build() - - // Use mysql flavor so placeholders will get replaced for comparison. - sql, err = sqlbuilder.MySQL.Interpolate(sql, args) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tapplyClaimsUserSelect failed.", tests.Failed) - } - - if diff := cmp.Diff(sql, tt.expectedSql); diff != "" { - t.Fatalf("\t%s\tExpected result query to match. Diff:\n%s", tests.Failed, diff) - } - - t.Logf("\t%s\tapplyClaimsUserSelect ok.", tests.Success) - } - } - } -} - -// TestCreateUser ensures all the validation tags work on Create -func TestCreateUserValidation(t *testing.T) { - - var userTests = []struct { - name string - req CreateUserRequest - expected func(req CreateUserRequest, res *User) *User - error error - }{ - {"Required Fields", - CreateUserRequest{}, - func(req CreateUserRequest, res *User) *User { - return nil - }, - errors.New("Key: 'CreateUserRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag\n" + - "Key: 'CreateUserRequest.Email' Error:Field validation for 'Email' failed on the 'required' tag\n" + - "Key: 'CreateUserRequest.Password' Error:Field validation for 'Password' failed on the 'required' tag"), - }, - {"Valid Email", - CreateUserRequest{ - Name: "Lee Brown", - Email: "xxxxxxxxxx", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - }, - func(req CreateUserRequest, res *User) *User { - return nil - }, - errors.New("Key: 'CreateUserRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag"), - }, - {"Passwords Match", - CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "W0rkL1fe#", - }, - func(req CreateUserRequest, res *User) *User { - return nil - }, - errors.New("Key: 'CreateUserRequest.PasswordConfirm' Error:Field validation for 'PasswordConfirm' failed on the 'eqfield' tag"), - }, - {"Default Timezone", - CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - }, - func(req CreateUserRequest, res *User) *User { - return &User{ - Name: req.Name, - Email: req.Email, - Timezone: "America/Anchorage", - - // Copy this fields from the result. - ID: res.ID, - PasswordSalt: res.PasswordSalt, - PasswordHash: res.PasswordHash, - PasswordReset: res.PasswordReset, - CreatedAt: res.CreatedAt, - UpdatedAt: res.UpdatedAt, - //ArchivedAt: nil, - } - }, - nil, - }, - } - - now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) - - t.Log("Given the need ensure all validation tags are working for user create.") - { - for i, tt := range userTests { - t.Logf("\tTest: %d\tWhen running test: %s", i, tt.name) - { - ctx := tests.Context() - - res, err := Create(ctx, auth.Claims{}, test.MasterDB, tt.req, now) - if err != tt.error { - // TODO: need a better way to handle validation errors as they are - // of type interface validator.ValidationErrorsTranslations - var errStr string - if err != nil { - errStr = err.Error() - } - var expectStr string - if tt.error != nil { - expectStr = tt.error.Error() - } - if errStr != expectStr { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", tt.error) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) - } - } - - // If there was an error that was expected, then don't go any further - if tt.error != nil { - t.Logf("\t%s\tCreate ok.", tests.Success) - continue - } - - expected := tt.expected(tt.req, res) - if diff := cmp.Diff(res, expected); diff != "" { - t.Fatalf("\t%s\tExpected result should match. Diff:\n%s", tests.Failed, diff) - } - - t.Logf("\t%s\tCreate ok.", tests.Success) - } - } - } -} - -// TestCreateUserValidationEmailUnique validates emails must be unique on Create. -func TestCreateUserValidationEmailUnique(t *testing.T) { - - now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) - - t.Log("Given the need ensure duplicate emails are not allowed for user create.") - { - ctx := tests.Context() - - req1 := CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - } - user1, err := Create(ctx, auth.Claims{}, test.MasterDB, req1, now) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) - } - - req2 := CreateUserRequest{ - Name: "Lucas Brown", - Email: user1.Email, - Password: "W0rkL1fe#", - PasswordConfirm: "W0rkL1fe#", - } - expectedErr := errors.New("Key: 'CreateUserRequest.Email' Error:Field validation for 'Email' failed on the 'unique' tag") - _, err = Create(ctx, auth.Claims{}, test.MasterDB, req2, now) - if err == nil { - t.Logf("\t\tWant: %+v", expectedErr) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) - } - - if err.Error() != expectedErr.Error() { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", expectedErr) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) - } - - t.Logf("\t%s\tCreate ok.", tests.Success) - } -} - -// TestCreateUserClaims validates ACLs are correctly applied to Create by claims. -func TestCreateUserClaims(t *testing.T) { - defer tests.Recover(t) - - var userTests = []struct { - name string - claims auth.Claims - req CreateUserRequest - error error - }{ - // Internal request, should bypass ACL. - {"EmptyClaims", - auth.Claims{}, - CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - }, - nil, - }, - // Role of user, only admins can create new users. - {"RoleUser", - auth.Claims{ - Roles: []string{auth.RoleUser}, - StandardClaims: jwt.StandardClaims{ - Subject: "user1", - Audience: "acc1", - }, - }, - CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - }, - ErrForbidden, - }, - // Role of admin, can create users. - {"RoleAdmin", - auth.Claims{ - Roles: []string{auth.RoleAdmin}, - StandardClaims: jwt.StandardClaims{ - Subject: "user1", - Audience: "acc1", - }, - }, - CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - }, - nil, - }, - } - - now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) - - t.Log("Given the need to ensure claims are applied as ACL for create user.") - { - for i, tt := range userTests { - t.Logf("\tTest: %d\tWhen running test: %s", i, tt.name) - { - ctx := tests.Context() - - _, err := Create(ctx, auth.Claims{}, test.MasterDB, tt.req, now) - if err != nil && errors.Cause(err) != tt.error { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", tt.error) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) - } - - t.Logf("\t%s\tCreate ok.", tests.Success) - } - } - } -} - -// TestUpdateUser ensures all the validation tags work on Update -func TestUpdateUserValidation(t *testing.T) { - // TODO: actually create the user so can test the output of findbyId - type userTest struct { - name string - req UpdateUserRequest - error error - } - - var userTests = []userTest{ - {"Required Fields", - UpdateUserRequest{}, - errors.New("Key: 'UpdateUserRequest.ID' Error:Field validation for 'ID' failed on the 'required' tag"), - }, - } - - invalidEmail := "xxxxxxxxxx" - userTests = append(userTests, userTest{"Valid Email", - UpdateUserRequest{ - ID: uuid.NewRandom().String(), - Email: &invalidEmail, - }, - errors.New("Key: 'UpdateUserRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag"), - }) - - now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) - - t.Log("Given the need ensure all validation tags are working for user update.") - { - for i, tt := range userTests { - t.Logf("\tTest: %d\tWhen running test: %s", i, tt.name) - { - ctx := tests.Context() - - err := Update(ctx, auth.Claims{}, test.MasterDB, tt.req, now) - if err != tt.error { - // TODO: need a better way to handle validation errors as they are - // of type interface validator.ValidationErrorsTranslations - var errStr string - if err != nil { - errStr = err.Error() - } - var expectStr string - if tt.error != nil { - expectStr = tt.error.Error() - } - if errStr != expectStr { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", tt.error) - t.Fatalf("\t%s\tUpdate failed.", tests.Failed) - } - } - - t.Logf("\t%s\tUpdate ok.", tests.Success) - } - } - } -} - -// TestUpdateUserValidationEmailUnique validates emails must be unique on Update. -func TestUpdateUserValidationEmailUnique(t *testing.T) { - - now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) - - t.Log("Given the need ensure duplicate emails are not allowed for user update.") - { - ctx := tests.Context() - - req1 := CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - } - user1, err := Create(ctx, auth.Claims{}, test.MasterDB, req1, now) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) - } - - req2 := CreateUserRequest{ - Name: "Lucas Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "W0rkL1fe#", - PasswordConfirm: "W0rkL1fe#", - } - user2, err := Create(ctx, auth.Claims{}, test.MasterDB, req2, now) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) - } - - // Try to set the email for user 1 on user 2 - updateReq := UpdateUserRequest{ - ID: user2.ID, - Email: &user1.Email, - } - expectedErr := errors.New("Key: 'UpdateUserRequest.Email' Error:Field validation for 'Email' failed on the 'unique' tag") - err = Update(ctx, auth.Claims{}, test.MasterDB, updateReq, now) - if err == nil { - t.Logf("\t\tWant: %+v", expectedErr) - t.Fatalf("\t%s\tUpdate failed.", tests.Failed) - } - - if err.Error() != expectedErr.Error() { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", expectedErr) - t.Fatalf("\t%s\tUpdate failed.", tests.Failed) - } - - t.Logf("\t%s\tUpdate ok.", tests.Success) - } -} - -// TestUpdateUserPassword validates update user password works. -func TestUpdateUserPassword(t *testing.T) { - - t.Log("Given the need ensure a user password can be updated.") - { - ctx := tests.Context() - - now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) - - tknGen := &mockTokenGenerator{} - - // Create a new user for testing. - initPass := uuid.NewRandom().String() - user, err := Create(ctx, auth.Claims{}, test.MasterDB, CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: initPass, - PasswordConfirm: initPass, - }, now) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) - } - - // Create a new random account and associate that with the user. - accountId := uuid.NewRandom().String() - _, err = AddAccount(tests.Context(), auth.Claims{}, test.MasterDB, AddAccountRequest{ - UserID: user.ID, - AccountID: accountId, - Roles: []UserAccountRole{UserAccountRole_User}, - }, now) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tAddAccount failed.", tests.Failed) - } - - // Verify that the user can be authenticated with the created user. - _, err = Authenticate(ctx, test.MasterDB, tknGen, user.Email, initPass, time.Hour, now) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tAuthenticate failed.", tests.Failed) - } - - // Ensure validation is working by trying UpdatePassword with an empty request. - expectedErr := errors.New("Key: 'UpdatePasswordRequest.ID' Error:Field validation for 'ID' failed on the 'required' tag\n" + - "Key: 'UpdatePasswordRequest.Password' Error:Field validation for 'Password' failed on the 'required' tag") - err = UpdatePassword(ctx, auth.Claims{}, test.MasterDB, UpdatePasswordRequest{}, now) - if err == nil { - t.Logf("\t\tWant: %+v", expectedErr) - t.Fatalf("\t%s\tUpdate failed.", tests.Failed) - } else if err.Error() != expectedErr.Error() { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", expectedErr) - t.Fatalf("\t%s\tValidation failed.", tests.Failed) - } - t.Logf("\t%s\tValidation ok.", tests.Success) - - // Update the users password. - newPass := uuid.NewRandom().String() - err = UpdatePassword(ctx, auth.Claims{}, test.MasterDB, UpdatePasswordRequest{ - ID: user.ID, - Password: newPass, - PasswordConfirm: newPass, - }, now) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) - } - t.Logf("\t%s\tUpdatePassword ok.", tests.Success) - - // Verify that the user can be authenticated with the updated password. - _, err = Authenticate(ctx, test.MasterDB, tknGen, user.Email, newPass, time.Hour, now) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tAuthenticate failed.", tests.Failed) - } - t.Logf("\t%s\tAuthenticate ok.", tests.Success) - } -} - -// TestUserCrud validates the full set of CRUD operations for users and ensures ACLs are correctly applied by claims. -func TestUserCrud(t *testing.T) { - defer tests.Recover(t) - - type userTest struct { - name string - claims func(*User, string) auth.Claims - create CreateUserRequest - update func(*User) UpdateUserRequest - updateErr error - expected func(*User, UpdateUserRequest) *User - findErr error - } - - var userTests []userTest - - // Internal request, should bypass ACL. - userTests = append(userTests, userTest{"EmptyClaims", - func(user *User, accountId string) auth.Claims { - return auth.Claims{} - }, - CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - }, - func(user *User) UpdateUserRequest { - email := uuid.NewRandom().String() + "@geeksinthewoods.com" - return UpdateUserRequest{ - ID: user.ID, - Email: &email, - } - }, - nil, - func(user *User, req UpdateUserRequest) *User { - return &User{ - Email: *req.Email, - // Copy this fields from the created user. - ID: user.ID, - Name: user.Name, - PasswordSalt: user.PasswordSalt, - PasswordHash: user.PasswordHash, - PasswordReset: user.PasswordReset, - Timezone: user.Timezone, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - //ArchivedAt: nil, - } - }, - nil, - }) - - // Role of user but claim user does not match update user so forbidden. - userTests = append(userTests, userTest{"RoleUserDiffUser", - func(user *User, accountId string) auth.Claims { - return auth.Claims{ - Roles: []string{auth.RoleUser}, - StandardClaims: jwt.StandardClaims{ - Subject: uuid.NewRandom().String(), - Audience: accountId, - }, - } - }, - CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - }, - func(user *User) UpdateUserRequest { - email := uuid.NewRandom().String() + "@geeksinthewoods.com" - return UpdateUserRequest{ - ID: user.ID, - Email: &email, - } - }, - ErrForbidden, - func(user *User, req UpdateUserRequest) *User { - return user - }, - ErrNotFound, - }) - - // Role of user AND claim user matches update user so OK. - userTests = append(userTests, userTest{"RoleUserSameUser", - func(user *User, accountId string) auth.Claims { - return auth.Claims{ - Roles: []string{auth.RoleUser}, - StandardClaims: jwt.StandardClaims{ - Subject: user.ID, - Audience: accountId, - }, - } - }, - CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - }, - func(user *User) UpdateUserRequest { - email := uuid.NewRandom().String() + "@geeksinthewoods.com" - return UpdateUserRequest{ - ID: user.ID, - Email: &email, - } - }, - nil, - func(user *User, req UpdateUserRequest) *User { - return &User{ - Email: *req.Email, - // Copy this fields from the created user. - ID: user.ID, - Name: user.Name, - PasswordSalt: user.PasswordSalt, - PasswordHash: user.PasswordHash, - PasswordReset: user.PasswordReset, - Timezone: user.Timezone, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - //ArchivedAt: nil, - } - }, - nil, - }) - - // Role of admin but claim account does not match update user so forbidden. - userTests = append(userTests, userTest{"RoleAdminDiffUser", - func(user *User, accountId string) auth.Claims { - return auth.Claims{ - Roles: []string{auth.RoleAdmin}, - StandardClaims: jwt.StandardClaims{ - Subject: uuid.NewRandom().String(), - Audience: uuid.NewRandom().String(), - }, - } - }, - CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - }, - func(user *User) UpdateUserRequest { - email := uuid.NewRandom().String() + "@geeksinthewoods.com" - return UpdateUserRequest{ - ID: user.ID, - Email: &email, - } - }, - ErrForbidden, - func(user *User, req UpdateUserRequest) *User { - return nil - }, - ErrNotFound, - }) - - // Role of admin and claim account matches update user so ok. - userTests = append(userTests, userTest{"RoleAdminSameAccount", - func(user *User, accountId string) auth.Claims { - return auth.Claims{ - Roles: []string{auth.RoleAdmin}, - StandardClaims: jwt.StandardClaims{ - Subject: uuid.NewRandom().String(), - Audience: accountId, - }, - } - }, - CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - }, - func(user *User) UpdateUserRequest { - email := uuid.NewRandom().String() + "@geeksinthewoods.com" - return UpdateUserRequest{ - ID: user.ID, - Email: &email, - } - }, - nil, - func(user *User, req UpdateUserRequest) *User { - return &User{ - Email: *req.Email, - // Copy this fields from the created user. - ID: user.ID, - Name: user.Name, - PasswordSalt: user.PasswordSalt, - PasswordHash: user.PasswordHash, - PasswordReset: user.PasswordReset, - Timezone: user.Timezone, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - //ArchivedAt: nil, - } - }, - nil, - }) - - t.Log("Given the need to ensure claims are applied as ACL for update user.") - { - now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) - - for i, tt := range userTests { - t.Logf("\tTest: %d\tWhen running test: %s", i, tt.name) - { - ctx := tests.Context() - - // Always create the new user with empty claims, testing claims for create user - // will be handled separately. - user, err := Create(tests.Context(), auth.Claims{}, test.MasterDB, tt.create, now) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) - } - - // Create a new random account and associate that with the user. - accountId := uuid.NewRandom().String() - _, err = AddAccount(tests.Context(), auth.Claims{}, test.MasterDB, AddAccountRequest{ - UserID: user.ID, - AccountID: accountId, - Roles: []UserAccountRole{UserAccountRole_User}, - }, now) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tAddAccount failed.", tests.Failed) - } - - // Update the user. - updateReq := tt.update(user) - err = Update(ctx, tt.claims(user, accountId), test.MasterDB, updateReq, now) - if err != nil && errors.Cause(err) != tt.updateErr { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", tt.updateErr) - t.Fatalf("\t%s\tUpdate failed.", tests.Failed) - } - t.Logf("\t%s\tUpdate ok.", tests.Success) - - // Find the user and make sure the updates where made. - findRes, err := FindById(ctx, tt.claims(user, accountId), test.MasterDB, user.ID, false) - if err != nil && errors.Cause(err) != tt.findErr { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", tt.findErr) - t.Fatalf("\t%s\tFindById failed.", tests.Failed) - } else { - findExpected := tt.expected(findRes, updateReq) - if diff := cmp.Diff(findRes, findExpected); diff != "" { - t.Fatalf("\t%s\tExpected find result to match update. Diff:\n%s", tests.Failed, diff) - } - t.Logf("\t%s\tFindById ok.", tests.Success) - } - - // Archive (soft-delete) the user. - err = Archive(ctx, tt.claims(user, accountId), test.MasterDB, user.ID, now) - if err != nil && errors.Cause(err) != tt.updateErr { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", tt.updateErr) - t.Fatalf("\t%s\tArchive failed.", tests.Failed) - } else if tt.updateErr == nil { - // Trying to find the archived user with the includeArchived false should result in not found. - _, err = FindById(ctx, tt.claims(user, accountId), test.MasterDB, user.ID, false) - if err != nil && errors.Cause(err) != ErrNotFound { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", ErrNotFound) - t.Fatalf("\t%s\tArchive FindById failed.", tests.Failed) - } - - // Trying to find the archived user with the includeArchived true should result no error. - _, err = FindById(ctx, tt.claims(user, accountId), test.MasterDB, user.ID, true) - if err != nil { - t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tArchive FindById failed.", tests.Failed) - } - } - t.Logf("\t%s\tArchive ok.", tests.Success) - - // Delete (hard-delete) the user. - err = Delete(ctx, tt.claims(user, accountId), test.MasterDB, user.ID) - if err != nil && errors.Cause(err) != tt.updateErr { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", tt.updateErr) - t.Fatalf("\t%s\tUpdate failed.", tests.Failed) - } else if tt.updateErr == nil { - // Trying to find the deleted user with the includeArchived true should result in not found. - _, err = FindById(ctx, tt.claims(user, accountId), test.MasterDB, user.ID, true) - if errors.Cause(err) != ErrNotFound { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", ErrNotFound) - t.Fatalf("\t%s\tDelete FindById failed.", tests.Failed) - } - } - t.Logf("\t%s\tDelete ok.", tests.Success) - } - } - } -} - -// TestUserFind validates all the request params are correctly parsed into a select query. -func TestUserFind(t *testing.T) { - - now := time.Now().Add(time.Hour * -1).UTC() - - startTime := now.Truncate(time.Millisecond) - var endTime time.Time - - var users []*User - for i := 0; i <= 4; i++ { - user, err := Create(tests.Context(), auth.Claims{}, test.MasterDB, CreateUserRequest{ - Name: "Lee Brown", - Email: uuid.NewRandom().String() + "@geeksinthewoods.com", - Password: "akTechFr0n!ier", - PasswordConfirm: "akTechFr0n!ier", - }, now.Add(time.Second*time.Duration(i))) - if err != nil { - t.Logf("\t\tGot : %+v", err) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) - } - users = append(users, user) - endTime = user.CreatedAt - } - - type userTest struct { - name string - req UserFindRequest - expected []*User - error error - } - - var userTests []userTest - - createdFilter := "created_at BETWEEN ? AND ?" - - // Test sort users. - userTests = append(userTests, userTest{"Find all order by created_at asc", - UserFindRequest{ - Where: &createdFilter, - Args: []interface{}{startTime, endTime}, - Order: []string{"created_at"}, - }, - users, - nil, - }) - - // Test reverse sorted users. - var expected []*User - for i := len(users) - 1; i >= 0; i-- { - expected = append(expected, users[i]) - } - userTests = append(userTests, userTest{"Find all order by created_at desc", - UserFindRequest{ - Where: &createdFilter, - Args: []interface{}{startTime, endTime}, - Order: []string{"created_at desc"}, - }, - expected, - nil, - }) - - // Test limit. - var limit uint = 2 - userTests = append(userTests, userTest{"Find limit", - UserFindRequest{ - Where: &createdFilter, - Args: []interface{}{startTime, endTime}, - Order: []string{"created_at"}, - Limit: &limit, - }, - users[0:2], - nil, - }) - - // Test offset. - var offset uint = 3 - userTests = append(userTests, userTest{"Find limit, offset", - UserFindRequest{ - Where: &createdFilter, - Args: []interface{}{startTime, endTime}, - Order: []string{"created_at"}, - Limit: &limit, - Offset: &offset, - }, - users[3:5], - nil, - }) - - // Test where filter. - whereParts := []string{} - whereArgs := []interface{}{startTime, endTime} - expected = []*User{} - for i := 0; i <= len(users); i++ { - if rand.Intn(100) < 50 { - continue - } - u := *users[i] - - whereParts = append(whereParts, "email = ?") - whereArgs = append(whereArgs, u.Email) - expected = append(expected, &u) - } - - where := createdFilter + " AND (" + strings.Join(whereParts, " OR ") + ")" - userTests = append(userTests, userTest{"Find where", - UserFindRequest{ - Where: &where, - Args: whereArgs, - Order: []string{"created_at"}, - }, - expected, - nil, - }) - - t.Log("Given the need to ensure find users returns the expected results.") - { - for i, tt := range userTests { - t.Logf("\tTest: %d\tWhen running test: %s", i, tt.name) - { - ctx := tests.Context() - - res, err := Find(ctx, auth.Claims{}, test.MasterDB, tt.req) - if err != nil && errors.Cause(err) != tt.error { - t.Logf("\t\tGot : %+v", err) - t.Logf("\t\tWant: %+v", tt.error) - t.Fatalf("\t%s\tFind failed.", tests.Failed) - } else if diff := cmp.Diff(res, tt.expected); diff != "" { - t.Logf("\t\tGot: %d items", len(res)) - t.Logf("\t\tWant: %d items", len(tt.expected)) - - for _, u := range res { - t.Logf("\t\tGot: %s ID", u.ID) - } - for _, u := range tt.expected { - t.Logf("\t\tExpected: %s ID", u.ID) - } - - t.Fatalf("\t%s\tExpected find result to match expected. Diff:\n%s", tests.Failed, diff) - } - t.Logf("\t%s\tFind ok.", tests.Success) - } - } - } -} diff --git a/example-project/internal/user/auth.go b/example-project/internal/user_account/auth.go similarity index 100% rename from example-project/internal/user/auth.go rename to example-project/internal/user_account/auth.go diff --git a/example-project/internal/user/auth_test.go b/example-project/internal/user_account/auth_test.go similarity index 100% rename from example-project/internal/user/auth_test.go rename to example-project/internal/user_account/auth_test.go diff --git a/example-project/internal/user/models.go b/example-project/internal/user_account/models.go similarity index 67% rename from example-project/internal/user/models.go rename to example-project/internal/user_account/models.go index 942303b..bc0a919 100644 --- a/example-project/internal/user/models.go +++ b/example-project/internal/user_account/models.go @@ -1,7 +1,6 @@ package user import ( - "database/sql" "database/sql/driver" "time" @@ -11,63 +10,6 @@ import ( "gopkg.in/go-playground/validator.v9" ) -// User represents someone with access to our system. -type User struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - - PasswordSalt string `json:"-"` - PasswordHash []byte `json:"-"` - PasswordReset sql.NullString `json:"-"` - - Timezone string `json:"timezone"` - - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ArchivedAt pq.NullTime `json:"archived_at"` -} - -// CreateUserRequest contains information needed to create a new User. -type CreateUserRequest struct { - Name string `json:"name" validate:"required"` - Email string `json:"email" validate:"required,email,unique"` - Password string `json:"password" validate:"required"` - PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password"` - Timezone *string `json:"timezone" validate:"omitempty"` -} - -// UpdateUserRequest defines what information may be provided to modify an existing -// User. All fields are optional so clients can send just the fields they want -// changed. It uses pointer fields so we can differentiate between a field that -// was not provided and a field that was provided as explicitly blank. Normally -// we do not want to use pointers to basic types but we make exceptions around -// marshalling/unmarshalling. -type UpdateUserRequest struct { - ID string `validate:"required,uuid"` - Name *string `json:"name" validate:"omitempty"` - Email *string `json:"email" validate:"omitempty,email,unique"` - Timezone *string `json:"timezone" validate:"omitempty"` -} - -// UpdatePassword defines what information is required to update a user password. -type UpdatePasswordRequest struct { - ID string `validate:"required,uuid"` - Password string `json:"password" validate:"required"` - PasswordConfirm string `json:"password_confirm" validate:"omitempty,eqfield=Password"` -} - -// UserFindRequest defines the possible options to search for users. By default -// archived users will be excluded from response. -type UserFindRequest struct { - Where *string - Args []interface{} - Order []string - Limit *uint - Offset *uint - IncludedArchived bool -} - // UserAccount defines the one to many relationship of an user to an account. This // will enable a single user access to multiple accounts without having duplicate // users. Each association of a user to an account has a set of roles and a status @@ -85,20 +27,20 @@ type UserAccount struct { ArchivedAt pq.NullTime `json:"archived_at"` } -// AddAccountRequest defines the information is needed to associate a user to an +// CreateUserAccountRequest defines the information is needed to associate a user to an // account. Users are global to the application and each users access can be managed // on an account level. If a current entry exists in the database but is archived, // it will be un-archived. -type AddAccountRequest struct { +type CreateUserAccountRequest struct { UserID string `validate:"required,uuid"` AccountID string `validate:"required,uuid"` Roles UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user"` Status *UserAccountStatus `json:"status" validate:"omitempty,oneof=active invited disabled"` } -// UpdateAccountRequest defines the information needed to update the roles or the +// UpdateUserAccountRequest defines the information needed to update the roles or the // status for an existing user account. -type UpdateAccountRequest struct { +type UpdateUserAccountRequest struct { UserID string `validate:"required,uuid"` AccountID string `validate:"required,uuid"` Roles *UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user"` @@ -106,16 +48,16 @@ type UpdateAccountRequest struct { unArchive bool `json:"-"` // Internal use only. } -// RemoveAccountRequest defines the information needed to remove an existing account +// ArchiveUserAccountRequest defines the information needed to remove an existing account // for a user. This will archive (soft-delete) the existing database entry. -type RemoveAccountRequest struct { +type ArchiveUserAccountRequest struct { UserID string `validate:"required,uuid"` AccountID string `validate:"required,uuid"` } -// DeleteAccountRequest defines the information needed to delete an existing account +// DeleteUserAccountRequest defines the information needed to delete an existing account // for a user. This will hard delete the existing database entry. -type DeleteAccountRequest struct { +type DeleteUserAccountRequest struct { UserID string `validate:"required,uuid"` AccountID string `validate:"required,uuid"` } @@ -238,9 +180,3 @@ func (s UserAccountRoles) Value() (driver.Value, error) { return arr.Value() } - -// Token is the payload we deliver to users when they authenticate. -type Token struct { - Token string `json:"token"` - claims auth.Claims `json:"-"` -} diff --git a/example-project/internal/user/user_account.go b/example-project/internal/user_account/user_account.go similarity index 70% rename from example-project/internal/user/user_account.go rename to example-project/internal/user_account/user_account.go index 0d68bd3..a6d22b4 100644 --- a/example-project/internal/user/user_account.go +++ b/example-project/internal/user_account/user_account.go @@ -3,6 +3,8 @@ package user import ( "context" "database/sql" + "geeks-accelerator/oss/saas-starter-kit/example-project/internal/account" + "geeks-accelerator/oss/saas-starter-kit/example-project/internal/user" "github.com/lib/pq" "time" @@ -15,14 +17,29 @@ import ( "gopkg.in/go-playground/validator.v9" ) +var ( + // ErrNotFound abstracts the mgo not found error. + ErrNotFound = errors.New("Entity not found") + + // ErrInvalidID occurs when an ID is not in a valid form. + ErrInvalidID = errors.New("ID is not in its proper form") + + // ErrAuthenticationFailure occurs when a user attempts to authenticate but + // anything goes wrong. + ErrAuthenticationFailure = errors.New("Authentication failed") + + // ErrForbidden occurs when a user tries to do something that is forbidden to them according to our access control policies. + ErrForbidden = errors.New("Attempted action is not allowed") +) + // The database table for UserAccount -const usersAccountsTableName = "users_accounts" +const userAccountTableName = "users_accounts" // The list of columns needed for mapRowsToUserAccount -var usersAccountsMapColumns = "id,user_id,account_id,roles,status,created_at,updated_at,archived_at" +var userAccountMapColumns = "id,user_id,account_id,roles,status,created_at,updated_at,archived_at" // mapRowsToUserAccount takes the SQL rows and maps it to the UserAccount struct -// with the columns defined by usersAccountsMapColumns +// with the columns defined by userAccountMapColumns func mapRowsToUserAccount(rows *sql.Rows) (*UserAccount, error) { var ( ua UserAccount @@ -36,10 +53,31 @@ func mapRowsToUserAccount(rows *sql.Rows) (*UserAccount, error) { return &ua, nil } +// CanReadUserAccount determines if claims has the authority to access the specified user account by user ID. +func CanReadUserAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID, accountID string) error { + // First check to see if claims can read the user ID + err := user.CanReadUser(ctx, claims, dbConn, userID) + if err != nil { + if claims.Audience != accountID { + return err + } + } + + // Second check to see if claims can read the account ID + err = account.CanReadAccount(ctx, claims, dbConn, accountID) + if err != nil { + if claims.Audience != accountID { + return err + } + } + + return nil +} + // CanModifyUserAccount determines if claims has the authority to modify the specified user ID. func CanModifyUserAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID, accountID string) error { // First check to see if claims can read the user ID - err := CanReadUser(ctx, claims, dbConn, userID) + err := CanReadUserAccount(ctx, claims, dbConn, userID, accountID) if err != nil { if claims.Audience != accountID { return err @@ -71,7 +109,7 @@ func applyClaimsUserAccountSelect(ctx context.Context, claims auth.Claims, query } // Build select statement for users_accounts table - subQuery := sqlbuilder.NewSelectBuilder().Select("user_id").From(usersAccountsTableName) + subQuery := sqlbuilder.NewSelectBuilder().Select("user_id").From(userAccountTableName) var or []string if claims.Audience != "" { @@ -89,18 +127,18 @@ func applyClaimsUserAccountSelect(ctx context.Context, claims auth.Claims, query } // AccountSelectQuery -func accountSelectQuery() *sqlbuilder.SelectBuilder { +func userAccountSelectQuery() *sqlbuilder.SelectBuilder { query := sqlbuilder.NewSelectBuilder() - query.Select(usersAccountsMapColumns) - query.From(usersAccountsTableName) + query.Select(userAccountMapColumns) + query.From(userAccountTableName) return query } // userFindRequestQuery generates the select query for the given find request. // TODO: Need to figure out why can't parse the args when appending the where // to the query. -func accountFindRequestQuery(req UserAccountFindRequest) (*sqlbuilder.SelectBuilder, []interface{}) { - query := accountSelectQuery() +func userAccountFindRequestQuery(req UserAccountFindRequest) (*sqlbuilder.SelectBuilder, []interface{}) { + query := userAccountSelectQuery() if req.Where != nil { query.Where(query.And(*req.Where)) } @@ -117,19 +155,19 @@ func accountFindRequestQuery(req UserAccountFindRequest) (*sqlbuilder.SelectBuil return query, req.Args } -// Find gets all the users from the database based on the request params -func FindAccounts(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAccountFindRequest) ([]*UserAccount, error) { - query, args := accountFindRequestQuery(req) - return findAccounts(ctx, claims, dbConn, query, args, req.IncludedArchived) +// Find gets all the user accounts from the database based on the request params +func Find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserAccountFindRequest) ([]*UserAccount, error) { + query, args := userAccountFindRequestQuery(req) + return find(ctx, claims, dbConn, query, args, req.IncludedArchived) } -// Find gets all the users from the database based on the select query -func findAccounts(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbuilder.SelectBuilder, args []interface{}, includedArchived bool) ([]*UserAccount, error) { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.FindAccounts") +// Find gets all the user accounts from the database based on the select query +func find(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, query *sqlbuilder.SelectBuilder, args []interface{}, includedArchived bool) ([]*UserAccount, error) { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.Find") defer span.Finish() - query.Select(usersAccountsMapColumns) - query.From(usersAccountsTableName) + query.Select(userAccountMapColumns) + query.From(userAccountTableName) if !includedArchived { query.Where(query.IsNull("archived_at")) @@ -148,7 +186,7 @@ func findAccounts(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, quer rows, err := dbConn.QueryContext(ctx, queryStr, args...) if err != nil { err = errors.Wrapf(err, "query - %s", query.String()) - err = errors.WithMessage(err, "find accounts failed") + err = errors.WithMessage(err, "find user accounts failed") return nil, err } @@ -167,8 +205,8 @@ func findAccounts(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, quer } // Retrieve gets the specified user from the database. -func FindAccountsByUserID(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string, includedArchived bool) ([]*UserAccount, error) { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.FindAccountsByUserId") +func FindByUserID(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string, includedArchived bool) ([]*UserAccount, error) { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.FindByUserID") defer span.Finish() // Filter base select query by ID @@ -177,7 +215,7 @@ func FindAccountsByUserID(ctx context.Context, claims auth.Claims, dbConn *sqlx. query.OrderBy("created_at") // Execute the find accounts method. - res, err := findAccounts(ctx, claims, dbConn, query, []interface{}{}, includedArchived) + res, err := find(ctx, claims, dbConn, query, []interface{}{}, includedArchived) if err != nil { return nil, err } else if res == nil || len(res) == 0 { @@ -189,8 +227,8 @@ func FindAccountsByUserID(ctx context.Context, claims auth.Claims, dbConn *sqlx. } // AddAccount an account for a given user with specified roles. -func AddAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req AddAccountRequest, now time.Time) (*UserAccount, error) { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.AddAccount") +func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req CreateUserAccountRequest, now time.Time) (*UserAccount, error) { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.Create") defer span.Finish() // Validate the request. @@ -218,25 +256,25 @@ func AddAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Ad now = now.Truncate(time.Millisecond) // Check to see if there is an existing user account, including archived. - existQuery := accountSelectQuery() + existQuery := userAccountSelectQuery() existQuery.Where(existQuery.And( existQuery.Equal("account_id", req.AccountID), existQuery.Equal("user_id", req.UserID), )) - existing, err := findAccounts(ctx, claims, dbConn, existQuery, []interface{}{}, true) + existing, err := find(ctx, claims, dbConn, existQuery, []interface{}{}, true) if err != nil { return nil, err } // If there is an existing entry, then update instead of insert. if len(existing) > 0 { - upReq := UpdateAccountRequest{ + upReq := UpdateUserAccountRequest{ UserID: req.UserID, AccountID: req.AccountID, Roles: &req.Roles, unArchive: true, } - err = UpdateAccount(ctx, claims, dbConn, upReq, now) + err = Update(ctx, claims, dbConn, upReq, now) if err != nil { return nil, err } @@ -265,7 +303,7 @@ func AddAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Ad // Build the insert SQL statement. query := sqlbuilder.NewInsertBuilder() - query.InsertInto(usersAccountsTableName) + query.InsertInto(userAccountTableName) query.Cols("id", "user_id", "account_id", "roles", "status", "created_at", "updated_at") query.Values(ua.ID, ua.UserID, ua.AccountID, ua.Roles, ua.Status.String(), ua.CreatedAt, ua.UpdatedAt) @@ -283,8 +321,8 @@ func AddAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Ad } // UpdateAccount... -func UpdateAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UpdateAccountRequest, now time.Time) error { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Update") +func Update(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UpdateUserAccountRequest, now time.Time) error { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.Update") defer span.Finish() // Validate the request. @@ -313,7 +351,7 @@ func UpdateAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req // Build the update SQL statement. query := sqlbuilder.NewUpdateBuilder() - query.Update(usersAccountsTableName) + query.Update(userAccountTableName) fields := []string{} if req.Roles != nil { @@ -351,9 +389,9 @@ func UpdateAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req return nil } -// RemoveAccount soft deleted the user account from the database. -func RemoveAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req RemoveAccountRequest, now time.Time) error { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.RemoveAccount") +// Archive soft deleted the user account from the database. +func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req ArchiveUserAccountRequest, now time.Time) error { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.Archive") defer span.Finish() // Validate the request. @@ -382,7 +420,7 @@ func RemoveAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req // Build the update SQL statement. query := sqlbuilder.NewUpdateBuilder() - query.Update(usersAccountsTableName) + query.Update(userAccountTableName) query.Set(query.Assign("archived_at", now)) query.Where(query.And( query.Equal("user_id", req.UserID), @@ -402,9 +440,9 @@ func RemoveAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req return nil } -// DeleteAccount removes a user account from the database. -func DeleteAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req DeleteAccountRequest) error { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.RemoveAccount") +// Delete removes a user account from the database. +func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req DeleteUserAccountRequest) error { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.Delete") defer span.Finish() // Validate the request. @@ -421,7 +459,7 @@ func DeleteAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req // Build the delete SQL statement. query := sqlbuilder.NewDeleteBuilder() - query.DeleteFrom(usersAccountsTableName) + query.DeleteFrom(userAccountTableName) query.Where(query.And( query.Equal("user_id", req.UserID), query.Equal("account_id", req.AccountID), diff --git a/example-project/internal/user/user_account_test.go b/example-project/internal/user_account/user_account_test.go similarity index 97% rename from example-project/internal/user/user_account_test.go rename to example-project/internal/user_account/user_account_test.go index df0f7b6..cdc41f7 100644 --- a/example-project/internal/user/user_account_test.go +++ b/example-project/internal/user_account/user_account_test.go @@ -37,9 +37,9 @@ func TestAccountFindRequestQuery(t *testing.T) { Limit: &limit, Offset: &offset, } - expected := "SELECT " + usersAccountsMapColumns + " FROM " + usersAccountsTableName + " WHERE (account_id = ? or user_id = ?) ORDER BY id asc, created_at desc LIMIT 12 OFFSET 34" + expected := "SELECT " + usersAccountsMapColumns + " FROM " + userAccountTableName + " WHERE (account_id = ? or user_id = ?) ORDER BY id asc, created_at desc LIMIT 12 OFFSET 34" - res, args := accountFindRequestQuery(req) + res, args := userAccountFindRequestQuery(req) if diff := cmp.Diff(res.String(), expected); diff != "" { t.Fatalf("\t%s\tExpected result query to match. Diff:\n%s", tests.Failed, diff) @@ -59,7 +59,7 @@ func TestApplyClaimsUserAccountSelect(t *testing.T) { }{ {"EmptyClaims", auth.Claims{}, - "SELECT " + usersAccountsMapColumns + " FROM " + usersAccountsTableName, + "SELECT " + usersAccountsMapColumns + " FROM " + userAccountTableName, nil, }, {"RoleUser", @@ -70,7 +70,7 @@ func TestApplyClaimsUserAccountSelect(t *testing.T) { Audience: "acc1", }, }, - "SELECT " + usersAccountsMapColumns + " FROM " + usersAccountsTableName + " WHERE user_id IN (SELECT user_id FROM " + usersAccountsTableName + " WHERE (account_id = 'acc1' OR user_id = 'user1'))", + "SELECT " + usersAccountsMapColumns + " FROM " + userAccountTableName + " WHERE user_id IN (SELECT user_id FROM " + userAccountTableName + " WHERE (account_id = 'acc1' OR user_id = 'user1'))", nil, }, {"RoleAdmin", @@ -81,7 +81,7 @@ func TestApplyClaimsUserAccountSelect(t *testing.T) { Audience: "acc1", }, }, - "SELECT " + usersAccountsMapColumns + " FROM " + usersAccountsTableName + " WHERE user_id IN (SELECT user_id FROM " + usersAccountsTableName + " WHERE (account_id = 'acc1' OR user_id = 'user1'))", + "SELECT " + usersAccountsMapColumns + " FROM " + userAccountTableName + " WHERE user_id IN (SELECT user_id FROM " + userAccountTableName + " WHERE (account_id = 'acc1' OR user_id = 'user1'))", nil, }, } @@ -93,7 +93,7 @@ func TestApplyClaimsUserAccountSelect(t *testing.T) { { ctx := tests.Context() - query := accountSelectQuery() + query := userAccountSelectQuery() err := applyClaimsUserAccountSelect(ctx, tt.claims, query) if err != tt.error {