From 46dae64e9740e66d8826b03113ed6499014c418f Mon Sep 17 00:00:00 2001 From: lakshayman Date: Fri, 16 Jan 2026 15:09:41 +0530 Subject: [PATCH] feat: Phase 1 Week 1 - User Service Foundation - Add User data model with role-based access (ADMIN, DEVELOPER, VIEWER) - Implement password hashing with bcrypt - Create user registration endpoint (POST /users/register) - Create user login endpoint (POST /users/login) with JWT token generation - Add user profile management endpoints (GET/PUT /users/{userId}) - Extend JWT utils to support private key loading from SSM for token generation - Add user table creation to DynamoDB setup script with email-index GSI - Add request/response models for user operations - Update SAM template with new Lambda functions and IAM policies --- getUserById/main.go | 118 ++++++++++++++++++++ go.mod | 2 +- layer/jwt/jwt.go | 61 ++++++++++- layer/models/user.go | 14 +++ layer/utils/Constants.go | 7 ++ layer/utils/ErrorOutput.go | 8 ++ layer/utils/RequestResponse.go | 25 +++++ layer/utils/password.go | 19 ++++ loginUser/main.go | 173 +++++++++++++++++++++++++++++ registerUser/main.go | 189 ++++++++++++++++++++++++++++++++ setup-dynamodb-tables.sh | 28 ++++- template.yaml | 144 +++++++++++++++++++++++++ updateUser/main.go | 192 +++++++++++++++++++++++++++++++++ 13 files changed, 977 insertions(+), 3 deletions(-) create mode 100644 getUserById/main.go create mode 100644 layer/models/user.go create mode 100644 layer/utils/password.go create mode 100644 loginUser/main.go create mode 100644 registerUser/main.go create mode 100644 updateUser/main.go diff --git a/getUserById/main.go b/getUserById/main.go new file mode 100644 index 0000000..127ba85 --- /dev/null +++ b/getUserById/main.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "net/http" + + "feature-flag-backend/layer/database" + "feature-flag-backend/layer/jwt" + middleware "feature-flag-backend/layer/middlewares" + "feature-flag-backend/layer/models" + "feature-flag-backend/layer/utils" + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +func getUserById(ctx context.Context, db *dynamodb.Client, userId string) (*models.User, error) { + input := &dynamodb.GetItemInput{ + TableName: aws.String(utils.USER_TABLE_NAME), + Key: map[string]types.AttributeValue{ + "id": &types.AttributeValueMemberS{ + Value: userId, + }, + }, + } + + result, err := db.GetItem(ctx, input) + if err != nil { + return nil, err + } + + if len(result.Item) == 0 { + return nil, nil + } + + var user models.User + err = database.UnmarshalMap(result.Item, &user) + if err != nil { + return nil, err + } + + return &user, nil +} + +func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + db := database.CreateDynamoDB() + + utils.CheckRequestAllowed(ctx, db, utils.ConcurrencyDisablingLambda) + + corsResponse, err, passed := middleware.HandleCORS(req) + if !passed { + return corsResponse, err + } + + response, _, err := jwt.JWTMiddleware()(req) + if err != nil || response.StatusCode != http.StatusOK { + return response, err + } + + corsHeaders := middleware.GetCORSHeadersV1(req.Headers) + + userId := req.PathParameters["userId"] + if userId == "" { + log.Println("userId is required") + clientErrorResponse, _ := utils.ClientError(http.StatusBadRequest, "userId is required") + return clientErrorResponse, nil + } + + // TODO: Add role-based access control in Week 3 + // Users can only view their own profile unless they're ADMIN + + user, err := getUserById(ctx, db, userId) + if err != nil { + log.Printf("Database error: %v", err) + serverErrorResponse, _ := utils.ServerError(err) + return serverErrorResponse, nil + } + + if user == nil { + log.Println("User not found") + clientErrorResponse, _ := utils.ClientError(http.StatusNotFound, "User not found") + clientErrorResponse.Headers = corsHeaders + return clientErrorResponse, nil + } + + // Don't return password hash + userResponse := utils.UserResponse{ + Id: user.Id, + Email: user.Email, + Role: user.Role, + IsActive: user.IsActive, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + + jsonResponse, err := json.Marshal(userResponse) + if err != nil { + log.Printf("Error converting User to JSON: %v", err) + serverErrorResponse, _ := utils.ServerError(err) + return serverErrorResponse, nil + } + + response = events.APIGatewayProxyResponse{ + StatusCode: http.StatusOK, + Headers: corsHeaders, + Body: string(jsonResponse), + } + return response, nil +} + +func main() { + lambda.Start(handler) +} + diff --git a/go.mod b/go.mod index 30361f3..993c22c 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/google/uuid v1.3.0 github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.8.2 + golang.org/x/crypto v0.7.0 ) require ( @@ -39,7 +40,6 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/crypto v0.7.0 // indirect golang.org/x/net v0.8.0 // indirect golang.org/x/sys v0.6.0 // indirect golang.org/x/text v0.8.0 // indirect diff --git a/layer/jwt/jwt.go b/layer/jwt/jwt.go index a765a29..8269465 100644 --- a/layer/jwt/jwt.go +++ b/layer/jwt/jwt.go @@ -29,7 +29,8 @@ var ( ) type JWTUtils struct { - publicKey *rsa.PublicKey + publicKey *rsa.PublicKey + privateKey *rsa.PrivateKey } type EnvConfig struct { @@ -113,9 +114,67 @@ func (j *JWTUtils) initialize() error { log.Printf("Successfully initialized JWT utils with public key") j.publicKey = rsaPublicKey + + privateKeyParameterName := "" + switch envConfig.Environment { + case utils.PROD: + privateKeyParameterName = utils.RDS_BACKEND_PRIVATE_KEY_NAME_PROD + case utils.DEV: + privateKeyParameterName = utils.RDS_BACKEND_PRIVATE_KEY_NAME_DEV + default: + privateKeyParameterName = utils.RDS_BACKEND_PRIVATE_KEY_NAME_LOCAL + } + + privateKeyString, err := getPublicKeyFromParameterStore(privateKeyParameterName) + if err == nil { + privateKeyString = strings.TrimSpace(privateKeyString) + block, _ := pem.Decode([]byte(privateKeyString)) + if block != nil { + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err == nil { + if rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey); ok { + j.privateKey = rsaPrivateKey + log.Printf("Successfully loaded private key for token generation") + } + } + } + } else { + log.Printf("Private key not available (this is OK if only validation is needed): %v", err) + } + return nil } +func (j *JWTUtils) GenerateToken(userId string, role string) (string, error) { + if j == nil { + return "", errors.New("internal server error") + } + + if j.privateKey == nil { + return "", errors.New("private key not available for token generation") + } + + now := time.Now() + claims := jwt.MapClaims{ + "userId": userId, + "role": role, + "iat": now.Unix(), + "exp": now.Add(24 * 365 * time.Hour).Unix(), // 1 year expiration + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tokenString, err := token.SignedString(j.privateKey) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return tokenString, nil +} + +func (j *JWTUtils) SetPrivateKey(privateKey *rsa.PrivateKey) { + j.privateKey = privateKey +} + func getPublicKeyFromParameterStore(parameterName string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/layer/models/user.go b/layer/models/user.go new file mode 100644 index 0000000..d28e8b9 --- /dev/null +++ b/layer/models/user.go @@ -0,0 +1,14 @@ +package models + +type User struct { + Id string `json:"id" dynamodbav:"id"` + Email string `json:"email" dynamodbav:"email"` + PasswordHash string `json:"passwordHash" dynamodbav:"passwordHash"` + Role string `json:"role" dynamodbav:"role"` // ADMIN, DEVELOPER, VIEWER + CreatedAt int64 `json:"createdAt" dynamodbav:"createdAt"` + CreatedBy string `json:"createdBy" dynamodbav:"createdBy"` + UpdatedAt int64 `json:"updatedAt" dynamodbav:"updatedAt"` + UpdatedBy string `json:"updatedBy" dynamodbav:"updatedBy"` + IsActive bool `json:"isActive" dynamodbav:"isActive"` +} + diff --git a/layer/utils/Constants.go b/layer/utils/Constants.go index a7691c0..d4d24b9 100644 --- a/layer/utils/Constants.go +++ b/layer/utils/Constants.go @@ -7,8 +7,12 @@ const ( TEST = "TESTING" FEATURE_FLAG_TABLE_NAME = "featureFlag" FEATURE_FLAG_USER_MAPPING_TABLE_NAME = "featureFlagUserMapping" + USER_TABLE_NAME = "user" ENABLED = "ENABLED" DISABLED = "DISABLED" + ROLE_ADMIN = "ADMIN" + ROLE_DEVELOPER = "DEVELOPER" + ROLE_VIEWER = "VIEWER" Id = "id" Name = "name" Description = "description" @@ -26,4 +30,7 @@ const ( RDS_BACKEND_PUBLIC_KEY_NAME_DEV = "STAGING_RDS_BACKEND_PUBLIC_KEY" RDS_BACKEND_PUBLIC_KEY_NAME_PROD = "PROD_RDS_BACKEND_PUBLIC_KEY" RDS_BACKEND_PUBLIC_KEY_NAME_LOCAL = "publickey" + RDS_BACKEND_PRIVATE_KEY_NAME_DEV = "STAGING_RDS_BACKEND_PRIVATE_KEY" + RDS_BACKEND_PRIVATE_KEY_NAME_PROD = "PROD_RDS_BACKEND_PRIVATE_KEY" + RDS_BACKEND_PRIVATE_KEY_NAME_LOCAL = "privatekey" ) diff --git a/layer/utils/ErrorOutput.go b/layer/utils/ErrorOutput.go index c3348ea..160bf35 100644 --- a/layer/utils/ErrorOutput.go +++ b/layer/utils/ErrorOutput.go @@ -57,3 +57,11 @@ func ValidateFeatureFlagStatus(status string) bool { _, found := allowedStatuses[strings.ToUpper(status)] return found } + +type UserExistsError struct { + Email string +} + +func (e *UserExistsError) Error() string { + return "user with email " + e.Email + " already exists" +} diff --git a/layer/utils/RequestResponse.go b/layer/utils/RequestResponse.go index 171aa02..3be5e20 100644 --- a/layer/utils/RequestResponse.go +++ b/layer/utils/RequestResponse.go @@ -41,3 +41,28 @@ type FeatureFlagUserMappingResponse struct { UpdatedAt int64 `json:"updatedAt"` UpdatedBy string `json:"updatedBy"` } + +type RegisterUserRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8"` + Role string `json:"role" validate:"omitempty,oneof=ADMIN DEVELOPER VIEWER"` +} + +type LoginUserRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} + +type UserResponse struct { + Id string `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + IsActive bool `json:"isActive"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +type LoginResponse struct { + Token string `json:"token"` + User UserResponse `json:"user"` +} diff --git a/layer/utils/password.go b/layer/utils/password.go new file mode 100644 index 0000000..77c0967 --- /dev/null +++ b/layer/utils/password.go @@ -0,0 +1,19 @@ +package utils + +import ( + "golang.org/x/crypto/bcrypt" +) + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + diff --git a/loginUser/main.go b/loginUser/main.go new file mode 100644 index 0000000..602be59 --- /dev/null +++ b/loginUser/main.go @@ -0,0 +1,173 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "net/http" + + "feature-flag-backend/layer/database" + "feature-flag-backend/layer/jwt" + middleware "feature-flag-backend/layer/middlewares" + "feature-flag-backend/layer/models" + "feature-flag-backend/layer/utils" + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/go-playground/validator/v10" +) + +var validate *validator.Validate + +func init() { + validate = validator.New() +} + +func getUserByEmail(ctx context.Context, db *dynamodb.Client, email string) (*models.User, error) { + input := &dynamodb.QueryInput{ + TableName: aws.String(utils.USER_TABLE_NAME), + IndexName: aws.String("email-index"), + KeyConditionExpression: aws.String("email = :email"), + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":email": &types.AttributeValueMemberS{ + Value: email, + }, + }, + Limit: aws.Int32(1), + } + + result, err := db.Query(ctx, input) + if err != nil { + return nil, err + } + + if len(result.Items) == 0 { + return nil, nil + } + + var user models.User + err = database.UnmarshalMap(result.Items[0], &user) + if err != nil { + return nil, err + } + + return &user, nil +} + +func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + var loginRequest utils.LoginUserRequest + + db := database.CreateDynamoDB() + + utils.CheckRequestAllowed(ctx, db, utils.ConcurrencyDisablingLambda) + + corsResponse, err, passed := middleware.HandleCORS(req) + if !passed { + return corsResponse, err + } + + corsHeaders := middleware.GetCORSHeadersV1(req.Headers) + + err = json.Unmarshal([]byte(req.Body), &loginRequest) + if err != nil { + log.Printf("Error unmarshal request body: %v", err) + return utils.ClientError(http.StatusUnprocessableEntity, "Error unmarshalling request body") + } + + if err := validate.Struct(&loginRequest); err != nil { + return events.APIGatewayProxyResponse{ + Body: "Invalid request: email and password are required.", + StatusCode: http.StatusBadRequest, + Headers: corsHeaders, + }, nil + } + + user, err := getUserByEmail(ctx, db, loginRequest.Email) + if err != nil { + log.Printf("Error getting user: %v", err) + return utils.ServerError(err) + } + + if user == nil { + return events.APIGatewayProxyResponse{ + Body: "Invalid email or password", + StatusCode: http.StatusUnauthorized, + Headers: corsHeaders, + }, nil + } + + // Check if user is active + if !user.IsActive { + return events.APIGatewayProxyResponse{ + Body: "User account is inactive", + StatusCode: http.StatusForbidden, + Headers: corsHeaders, + }, nil + } + + // Verify password + if !utils.CheckPasswordHash(loginRequest.Password, user.PasswordHash) { + return events.APIGatewayProxyResponse{ + Body: "Invalid email or password", + StatusCode: http.StatusUnauthorized, + Headers: corsHeaders, + }, nil + } + + // Generate JWT token + jwtUtils, err := jwt.GetInstance() + if err != nil { + log.Printf("Error getting JWT utils: %v", err) + return utils.ServerError(err) + } + + token, err := jwtUtils.GenerateToken(user.Id, user.Role) + if err != nil { + log.Printf("Error generating token: %v", err) + return events.APIGatewayProxyResponse{ + Body: "Error generating authentication token", + StatusCode: http.StatusInternalServerError, + Headers: corsHeaders, + }, nil + } + + response := events.APIGatewayProxyResponse{ + StatusCode: http.StatusOK, + Headers: corsHeaders, + } + + userResponse := utils.UserResponse{ + Id: user.Id, + Email: user.Email, + Role: user.Role, + IsActive: user.IsActive, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + + loginResponse := utils.LoginResponse{ + Token: token, + User: userResponse, + } + + responseBody, err := json.Marshal(map[string]interface{}{ + "message": "Login successful", + "data": loginResponse, + }) + + if err != nil { + log.Printf("Error marshalling response body: %v", err) + return utils.ServerError(err) + } + + response.Body = string(responseBody) + + return response, nil +} + +func main() { + lambda.Start(handler) +} + diff --git a/registerUser/main.go b/registerUser/main.go new file mode 100644 index 0000000..15416d0 --- /dev/null +++ b/registerUser/main.go @@ -0,0 +1,189 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "net/http" + "time" + + "feature-flag-backend/layer/database" + middleware "feature-flag-backend/layer/middlewares" + "feature-flag-backend/layer/models" + "feature-flag-backend/layer/utils" + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/go-playground/validator/v10" + "github.com/google/uuid" +) + +var validate *validator.Validate + +func init() { + validate = validator.New() +} + +func getUserByEmail(ctx context.Context, db *dynamodb.Client, email string) (*models.User, error) { + input := &dynamodb.QueryInput{ + TableName: aws.String(utils.USER_TABLE_NAME), + IndexName: aws.String("email-index"), + KeyConditionExpression: aws.String("email = :email"), + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":email": &types.AttributeValueMemberS{ + Value: email, + }, + }, + Limit: aws.Int32(1), + } + + result, err := db.Query(ctx, input) + if err != nil { + return nil, err + } + + if len(result.Items) == 0 { + return nil, nil + } + + var user models.User + err = database.UnmarshalMap(result.Items[0], &user) + if err != nil { + return nil, err + } + + return &user, nil +} + +func createUser(ctx context.Context, db *dynamodb.Client, registerRequest utils.RegisterUserRequest) (models.User, error) { + existingUser, err := getUserByEmail(ctx, db, registerRequest.Email) + if err != nil { + log.Printf("Error checking existing user: %v", err) + return models.User{}, err + } + if existingUser != nil { + return models.User{}, &utils.UserExistsError{Email: registerRequest.Email} + } + + passwordHash, err := utils.HashPassword(registerRequest.Password) + if err != nil { + log.Printf("Error hashing password: %v", err) + return models.User{}, err + } + + role := registerRequest.Role + if role == "" { + role = utils.ROLE_VIEWER + } + + now := time.Now().Unix() + userId := uuid.New().String() + + user := models.User{ + Id: userId, + Email: registerRequest.Email, + PasswordHash: passwordHash, + Role: role, + CreatedAt: now, + CreatedBy: userId, + UpdatedAt: now, + UpdatedBy: userId, + IsActive: true, + } + + item, err := database.MarshalMap(user) + if err != nil { + log.Printf("Error marshalling user to DynamoDB AttributeValue: %v", err) + return models.User{}, err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(utils.USER_TABLE_NAME), + Item: item, + } + + _, err = db.PutItem(ctx, input) + if err != nil { + log.Printf("Error putting user to Dynamodb: %v", err) + return models.User{}, err + } + + return user, nil +} + +func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + var registerRequest utils.RegisterUserRequest + + db := database.CreateDynamoDB() + + utils.CheckRequestAllowed(ctx, db, utils.ConcurrencyDisablingLambda) + + corsResponse, err, passed := middleware.HandleCORS(req) + if !passed { + return corsResponse, err + } + + // Registration endpoint doesn't require authentication + corsHeaders := middleware.GetCORSHeadersV1(req.Headers) + + err = json.Unmarshal([]byte(req.Body), ®isterRequest) + if err != nil { + log.Printf("Error unmarshal request body: %v", err) + return utils.ClientError(http.StatusUnprocessableEntity, "Error unmarshalling request body") + } + + if err := validate.Struct(®isterRequest); err != nil { + return events.APIGatewayProxyResponse{ + Body: "Invalid request: email and password are required. Password must be at least 8 characters.", + StatusCode: http.StatusBadRequest, + }, nil + } + + user, err := createUser(ctx, db, registerRequest) + if err != nil { + if _, ok := err.(*utils.UserExistsError); ok { + return events.APIGatewayProxyResponse{ + Body: "User with this email already exists", + StatusCode: http.StatusConflict, + Headers: corsHeaders, + }, nil + } + log.Printf("Error while creating user: %v", err) + return utils.ServerError(err) + } + + response := events.APIGatewayProxyResponse{ + StatusCode: http.StatusCreated, + Headers: corsHeaders, + } + + userResponse := utils.UserResponse{ + Id: user.Id, + Email: user.Email, + Role: user.Role, + IsActive: user.IsActive, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + + responseBody, err := json.Marshal(map[string]interface{}{ + "message": "User registered successfully", + "data": userResponse, + }) + + if err != nil { + log.Printf("Error marshalling response body: %v", err) + return utils.ServerError(err) + } + + response.Body = string(responseBody) + + return response, nil +} + +func main() { + lambda.Start(handler) +} + diff --git a/setup-dynamodb-tables.sh b/setup-dynamodb-tables.sh index 27c84fa..9a6d2ba 100755 --- a/setup-dynamodb-tables.sh +++ b/setup-dynamodb-tables.sh @@ -57,7 +57,28 @@ else fi echo "" -# Table 3: requestLimit +# Table 3: user +if ! table_exists "user"; then + echo "Creating table: user" + aws dynamodb create-table \ + --table-name user \ + --attribute-definitions \ + AttributeName=id,AttributeType=S \ + AttributeName=email,AttributeType=S \ + --key-schema \ + AttributeName=id,KeyType=HASH \ + --global-secondary-indexes \ + "[{\"IndexName\": \"email-index\", \"KeySchema\": [{\"AttributeName\": \"email\", \"KeyType\": \"HASH\"}], \"Projection\": {\"ProjectionType\": \"ALL\"}}]" \ + --billing-mode PAY_PER_REQUEST \ + --region "$REGION" \ + --no-cli-pager + echo "✅ Created user table" +else + echo "⏭️ Table user already exists, skipping creation" +fi +echo "" + +# Table 4: requestLimit if ! table_exists "requestLimit"; then echo "Creating table: requestLimit" aws dynamodb create-table \ @@ -85,6 +106,10 @@ aws dynamodb wait table-exists \ --table-name featureFlagUserMapping \ --region "$REGION" +aws dynamodb wait table-exists \ + --table-name user \ + --region "$REGION" + aws dynamodb wait table-exists \ --table-name requestLimit \ --region "$REGION" @@ -119,6 +144,7 @@ echo "" echo "Tables created:" echo " - featureFlag" echo " - featureFlagUserMapping" +echo " - user" echo " - requestLimit (with initial value)" echo "" echo "You can now test your API endpoints!" diff --git a/template.yaml b/template.yaml index 5d40f63..c2d5218 100644 --- a/template.yaml +++ b/template.yaml @@ -488,3 +488,147 @@ Resources: Properties: Path: /users/{userId}/feature-flags/{flagId} Method: PATCH + + RegisterUserFunction: + Type: 'AWS::Serverless::Function' + Metadata: + BuildMethod: go1.x + Properties: + CodeUri: registerUser/ + Handler: bootstrap + Runtime: provided.al2 + Architectures: + - x86_64 + Layers: + - !Ref SharedLayer + Policies: + - DynamoDBCrudPolicy: + TableName: user + - Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + Resource: + - !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/requestLimit" + - SSMParameterReadPolicy: + ParameterName: PROD_RDS_BACKEND_PUBLIC_KEY + - SSMParameterReadPolicy: + ParameterName: STAGING_RDS_BACKEND_PUBLIC_KEY + - SSMParameterReadPolicy: + ParameterName: PROD_RDS_BACKEND_PRIVATE_KEY + - SSMParameterReadPolicy: + ParameterName: STAGING_RDS_BACKEND_PRIVATE_KEY + Events: + CatchAll: + Type: Api + Properties: + Path: /users/register + Method: POST + + LoginUserFunction: + Type: 'AWS::Serverless::Function' + Metadata: + BuildMethod: go1.x + Properties: + CodeUri: loginUser/ + Handler: bootstrap + Runtime: provided.al2 + Architectures: + - x86_64 + Layers: + - !Ref SharedLayer + Policies: + - DynamoDBCrudPolicy: + TableName: user + - Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + Resource: + - !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/requestLimit" + - SSMParameterReadPolicy: + ParameterName: PROD_RDS_BACKEND_PUBLIC_KEY + - SSMParameterReadPolicy: + ParameterName: STAGING_RDS_BACKEND_PUBLIC_KEY + - SSMParameterReadPolicy: + ParameterName: PROD_RDS_BACKEND_PRIVATE_KEY + - SSMParameterReadPolicy: + ParameterName: STAGING_RDS_BACKEND_PRIVATE_KEY + Events: + CatchAll: + Type: Api + Properties: + Path: /users/login + Method: POST + + GetUserByIdFunction: + Type: 'AWS::Serverless::Function' + Metadata: + BuildMethod: go1.x + Properties: + CodeUri: getUserById/ + Handler: bootstrap + Runtime: provided.al2 + Architectures: + - x86_64 + Layers: + - !Ref SharedLayer + Policies: + - DynamoDBCrudPolicy: + TableName: user + - Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + Resource: + - !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/requestLimit" + - SSMParameterReadPolicy: + ParameterName: PROD_RDS_BACKEND_PUBLIC_KEY + - SSMParameterReadPolicy: + ParameterName: STAGING_RDS_BACKEND_PUBLIC_KEY + Events: + CatchAll: + Type: Api + Properties: + Path: /users/{userId} + Method: GET + + UpdateUserFunction: + Type: 'AWS::Serverless::Function' + Metadata: + BuildMethod: go1.x + Properties: + CodeUri: updateUser/ + Handler: bootstrap + Runtime: provided.al2 + Architectures: + - x86_64 + Layers: + - !Ref SharedLayer + Policies: + - DynamoDBCrudPolicy: + TableName: user + - Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + Resource: + - !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/requestLimit" + - SSMParameterReadPolicy: + ParameterName: PROD_RDS_BACKEND_PUBLIC_KEY + - SSMParameterReadPolicy: + ParameterName: STAGING_RDS_BACKEND_PUBLIC_KEY + Events: + CatchAll: + Type: Api + Properties: + Path: /users/{userId} + Method: PUT diff --git a/updateUser/main.go b/updateUser/main.go new file mode 100644 index 0000000..8ff150f --- /dev/null +++ b/updateUser/main.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "net/http" + "time" + + "feature-flag-backend/layer/database" + "feature-flag-backend/layer/jwt" + middleware "feature-flag-backend/layer/middlewares" + "feature-flag-backend/layer/models" + "feature-flag-backend/layer/utils" + "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/go-playground/validator/v10" +) + +var validate *validator.Validate + +func init() { + validate = validator.New() +} + +type UpdateUserRequest struct { + Email string `json:"email,omitempty" validate:"omitempty,email"` + Role string `json:"role,omitempty" validate:"omitempty,oneof=ADMIN DEVELOPER VIEWER"` + IsActive *bool `json:"isActive,omitempty"` +} + +func getUserById(ctx context.Context, db *dynamodb.Client, userId string) (*models.User, error) { + input := &dynamodb.GetItemInput{ + TableName: aws.String(utils.USER_TABLE_NAME), + Key: map[string]types.AttributeValue{ + "id": &types.AttributeValueMemberS{ + Value: userId, + }, + }, + } + + result, err := db.GetItem(ctx, input) + if err != nil { + return nil, err + } + + if len(result.Item) == 0 { + return nil, nil + } + + var user models.User + err = database.UnmarshalMap(result.Item, &user) + if err != nil { + return nil, err + } + + return &user, nil +} + +func updateUser(ctx context.Context, db *dynamodb.Client, userId string, updateRequest UpdateUserRequest, updatedBy string) (*models.User, error) { + // Get existing user + existingUser, err := getUserById(ctx, db, userId) + if err != nil { + return nil, err + } + if existingUser == nil { + return nil, nil + } + + // Update fields if provided + if updateRequest.Email != "" { + existingUser.Email = updateRequest.Email + } + if updateRequest.Role != "" { + existingUser.Role = updateRequest.Role + } + if updateRequest.IsActive != nil { + existingUser.IsActive = *updateRequest.IsActive + } + + existingUser.UpdatedBy = updatedBy + existingUser.UpdatedAt = time.Now().Unix() + + item, err := database.MarshalMap(existingUser) + if err != nil { + log.Printf("Error marshalling user to DynamoDB AttributeValue: %v", err) + return nil, err + } + + input := &dynamodb.PutItemInput{ + TableName: aws.String(utils.USER_TABLE_NAME), + Item: item, + } + + _, err = db.PutItem(ctx, input) + if err != nil { + log.Printf("Error updating user in Dynamodb: %v", err) + return nil, err + } + + return existingUser, nil +} + +func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + var updateRequest UpdateUserRequest + + db := database.CreateDynamoDB() + + utils.CheckRequestAllowed(ctx, db, utils.ConcurrencyDisablingLambda) + + corsResponse, err, passed := middleware.HandleCORS(req) + if !passed { + return corsResponse, err + } + + response, userIdFromToken, err := jwt.JWTMiddleware()(req) + if err != nil || response.StatusCode != http.StatusOK { + return response, err + } + + corsHeaders := middleware.GetCORSHeadersV1(req.Headers) + + userId := req.PathParameters["userId"] + if userId == "" { + log.Println("userId is required") + clientErrorResponse, _ := utils.ClientError(http.StatusBadRequest, "userId is required") + return clientErrorResponse, nil + } + + err = json.Unmarshal([]byte(req.Body), &updateRequest) + if err != nil { + log.Printf("Error unmarshal request body: %v", err) + return utils.ClientError(http.StatusUnprocessableEntity, "Error unmarshalling request body") + } + + if err := validate.Struct(&updateRequest); err != nil { + return events.APIGatewayProxyResponse{ + Body: "Invalid request: email must be valid, role must be one of ADMIN, DEVELOPER, or VIEWER", + StatusCode: http.StatusBadRequest, + Headers: corsHeaders, + }, nil + } + + user, err := updateUser(ctx, db, userId, updateRequest, userIdFromToken) + if err != nil { + log.Printf("Error while updating user: %v", err) + return utils.ServerError(err) + } + + if user == nil { + return events.APIGatewayProxyResponse{ + Body: "User not found", + StatusCode: http.StatusNotFound, + Headers: corsHeaders, + }, nil + } + + userResponse := utils.UserResponse{ + Id: user.Id, + Email: user.Email, + Role: user.Role, + IsActive: user.IsActive, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } + + responseBody, err := json.Marshal(map[string]interface{}{ + "message": "User updated successfully", + "data": userResponse, + }) + + if err != nil { + log.Printf("Error marshalling response body: %v", err) + return utils.ServerError(err) + } + + response = events.APIGatewayProxyResponse{ + StatusCode: http.StatusOK, + Headers: corsHeaders, + Body: string(responseBody), + } + + return response, nil +} + +func main() { + lambda.Start(handler) +} +