Skip to content

Commit

Permalink
Allow user role updates without forceNew (#610)
Browse files Browse the repository at this point in the history
  • Loading branch information
bobbyiliev authored Jul 15, 2024
1 parent 37cb64e commit 4cce86b
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 14 deletions.
45 changes: 45 additions & 0 deletions mocks/frontegg/mock_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ func (app *App) routes() {
app.Router.HandleFunc("/identity/resources/users/v2", app.handleUserRequest).Methods("POST")
app.Router.HandleFunc("/identity/resources/roles/v2", app.handleRolesRequest).Methods("GET")
app.Router.HandleFunc("/identity/resources/users/v3", app.handleUserV3Request).Methods("GET")
app.Router.HandleFunc("/frontegg/team/resources/members/v1", app.handleUpdateUserRoles).Methods("PUT")
app.Router.HandleFunc("/frontegg/team/resources/sso/v1/configurations", app.handleSSOConfigRequest).Methods("GET", "POST")
app.Router.HandleFunc("/frontegg/team/resources/sso/v1/configurations/{id}", app.handleSSOConfigAndDomainRequest).Methods("GET", "PATCH", "DELETE")
app.Router.HandleFunc("/frontegg/team/resources/sso/v1/configurations/{id}/domains", app.handleDomainRequests).Methods("GET", "POST")
Expand Down Expand Up @@ -319,6 +320,10 @@ func (app *App) handleUserV3Request(w http.ResponseWriter, r *http.Request) {
app.getUsersV3(w, r)
}

func (app *App) handleUpdateUserRoles(w http.ResponseWriter, r *http.Request) {
app.updateUserRoles(w, r)
}

func (app *App) handleRolesRequest(w http.ResponseWriter, r *http.Request) {
roles := []FronteggRole{
{ID: "1", Name: "Organization Admin"},
Expand Down Expand Up @@ -700,6 +705,46 @@ func (app *App) getUsersV3(w http.ResponseWriter, r *http.Request) {
sendJSONResponse(w, http.StatusOK, response)
}

func (app *App) updateUserRoles(w http.ResponseWriter, r *http.Request) {
var req struct {
ID string `json:"id"`
Email string `json:"email"`
RoleIDs []string `json:"roleIds"`
}

if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

app.Store.Mu.Lock()
defer app.Store.Mu.Unlock()

user, exists := app.Store.Users[req.ID]
if !exists {
http.Error(w, "User not found", http.StatusNotFound)
return
}

user.Roles = []FronteggRole{}
for _, roleID := range req.RoleIDs {
roleName := ""
switch roleID {
case "1":
roleName = "Organization Admin"
case "2":
roleName = "Organization Member"
default:
roleName = "Unknown Role"
}
user.Roles = append(user.Roles, FronteggRole{ID: roleID, Name: roleName})
}

app.Store.Users[req.ID] = user

sendJSONResponse(w, http.StatusOK, user)
}

func (app *App) createSSOConfig(w http.ResponseWriter, r *http.Request) {
var newConfig SSOConfig
if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil {
Expand Down
37 changes: 34 additions & 3 deletions pkg/frontegg/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import (
)

const (
UsersApiPathV1 = "/identity/resources/users/v1"
UsersApiPathV2 = "/identity/resources/users/v2"
UsersApiPathV3 = "/identity/resources/users/v3"
UsersApiPathV1 = "/identity/resources/users/v1"
UsersApiPathV2 = "/identity/resources/users/v2"
UsersApiPathV3 = "/identity/resources/users/v3"
TeamMembersApiPathV1 = "/frontegg/team/resources/members/v1"
)

// UserRequest represents the request payload for creating or updating a user.
Expand Down Expand Up @@ -164,6 +165,36 @@ func GetUsers(ctx context.Context, client *clients.FronteggClient, params QueryU
return response.Items, nil
}

func UpdateUserRoles(ctx context.Context, client *clients.FronteggClient, userID string, email string, roleIDs []string) error {
payload := struct {
ID string `json:"id"`
Email string `json:"email"`
RoleIDs []string `json:"roleIds"`
}{
ID: userID,
Email: email,
RoleIDs: roleIDs,
}

requestBody, err := jsonEncode(payload)
if err != nil {
return err
}

endpoint := fmt.Sprintf("%s%s", client.Endpoint, TeamMembersApiPathV1)
resp, err := doRequest(ctx, client, "PUT", endpoint, requestBody)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return clients.HandleApiError(resp)
}

return nil
}

// DeleteUser deletes a user from Frontegg.
func DeleteUser(ctx context.Context, client *clients.FronteggClient, userID string) error {
endpoint := fmt.Sprintf("%s%s/%s", client.Endpoint, UsersApiPathV1, userID)
Expand Down
47 changes: 47 additions & 0 deletions pkg/frontegg/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,33 @@ func setupUserMockServer() *httptest.Server {
w.WriteHeader(http.StatusMethodNotAllowed)
})

// Endpoint for updating a user roles
handler.HandleFunc("/frontegg/team/resources/members/v1", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PUT" {
var updateRequest struct {
ID string `json:"id"`
Email string `json:"email"`
RoleIDs []string `json:"roleIds"`
}
if err := json.NewDecoder(r.Body).Decode(&updateRequest); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
updatedUser := UserResponse{
ID: updateRequest.ID,
Email: updateRequest.Email,
Roles: make([]UserRole, len(updateRequest.RoleIDs)),
}
for i, roleID := range updateRequest.RoleIDs {
updatedUser.Roles[i] = UserRole{ID: roleID, Name: "Role " + roleID}
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(updatedUser)
return
}
w.WriteHeader(http.StatusMethodNotAllowed)
})

return httptest.NewServer(handler)
}

Expand Down Expand Up @@ -206,3 +233,23 @@ func TestGetUsers(t *testing.T) {
t.Errorf("Expected 'no user found' error, got: %v", err)
}
}

func TestUpdateUserRoles(t *testing.T) {
mockServer := setupUserMockServer()
defer mockServer.Close()

client := &clients.FronteggClient{
HTTPClient: &http.Client{},
Endpoint: mockServer.URL,
Token: "mock-token",
}

userID := "test-user-id"
email := "test@example.com"
newRoleIDs := []string{"role1", "role2"}

err := UpdateUserRoles(context.Background(), client, userID, email, newRoleIDs)
if err != nil {
t.Fatalf("UpdateUserRoles returned an error: %v", err)
}
}
33 changes: 29 additions & 4 deletions pkg/resources/resource_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ func User() *schema.Resource {
Type: schema.TypeList,
Elem: &schema.Schema{Type: schema.TypeString},
Required: true,
ForceNew: true,
Description: "The roles to assign to the user. Allowed values are 'Member' and 'Admin'.",
},
"verified": {
Expand Down Expand Up @@ -150,9 +149,35 @@ func userRead(ctx context.Context, d *schema.ResourceData, meta interface{}) dia
}

func userUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
// TODO: Handle updating roles without `ForceNew`. This function exists as a
// no-op so that changing `send_activation_email` is a no-op update.
return nil
providerMeta, err := utils.GetProviderMeta(meta)
if err != nil {
return diag.FromErr(err)
}

client := providerMeta.Frontegg
userID := d.Id()
email := d.Get("email").(string)

if d.HasChange("roles") {
roleNames := convertToStringSlice(d.Get("roles").([]interface{}))
roleMap := providerMeta.FronteggRoles

var roleIDs []string
for _, roleName := range roleNames {
if roleID, ok := roleMap[roleName]; ok {
roleIDs = append(roleIDs, roleID)
} else {
return diag.Errorf("role not found: %s", roleName)
}
}

err := frontegg.UpdateUserRoles(ctx, client, userID, email, roleIDs)
if err != nil {
return diag.FromErr(err)
}
}

return userRead(ctx, d, meta)
}

// userDelete is the Terraform resource delete function for a Frontegg user.
Expand Down
41 changes: 41 additions & 0 deletions pkg/resources/resource_user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,44 @@ func TestUserResourceDelete(t *testing.T) {
r.Empty(d.Id())
})
}

func TestUserResourceUpdate(t *testing.T) {
r := require.New(t)

testhelpers.WithMockFronteggServer(t, func(serverURL string) {
client := &clients.FronteggClient{
Endpoint: serverURL,
HTTPClient: &http.Client{},
TokenExpiry: time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC),
}

providerMeta := &utils.ProviderMeta{
Frontegg: client,
FronteggRoles: map[string]string{
"Admin": "1",
"Member": "2",
},
}

d := schema.TestResourceDataRaw(t, User().Schema, map[string]interface{}{
"email": "test@example.com",
"roles": []interface{}{"Member"},
})
d.SetId("mock-user-id")

d.Set("roles", []interface{}{"Admin", "Member"})

if err := userUpdate(context.TODO(), d, providerMeta); err != nil {
t.Fatal(err)
}

if err := userRead(context.TODO(), d, providerMeta); err != nil {
t.Fatal(err)
}

roles := d.Get("roles").([]interface{})
r.Equal(2, len(roles), "Expected 2 roles after update")
r.Contains(roles, "Admin", "Expected 'Admin' role after update")
r.Contains(roles, "Member", "Expected 'Member' role after update")
})
}
49 changes: 42 additions & 7 deletions pkg/testhelpers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ func WithMockFronteggServer(t *testing.T, f func(url string)) {
handleUsersV3Request(w, req, r)
case strings.HasPrefix(req.URL.Path, "/identity/resources/roles/v2"):
handleRolesRequests(w, req, r)
case req.URL.Path == "/frontegg/team/resources/members/v1":
handleUpdateUserRoles(w, req, r)
case req.URL.Path == "/frontegg/team/resources/sso/v1/configurations":
switch req.Method {
case http.MethodPost:
Expand Down Expand Up @@ -264,18 +266,16 @@ func handleUserRequests(w http.ResponseWriter, req *http.Request, r *require.Ass
case http.MethodGet:
if userID != "" {
// Mock response for a specific user
mockUser := struct {
ID string `json:"id"`
Email string `json:"email"`
ProfilePictureURL string `json:"profilePictureUrl"`
Verified bool `json:"verified"`
Metadata string `json:"metadata"`
}{
mockUser := frontegg.UserResponse{
ID: userID,
Email: "test@example.com",
ProfilePictureURL: "http://example.com/picture.jpg",
Verified: true,
Metadata: "{}",
Roles: []frontegg.UserRole{
{ID: "1", Name: "Organization Admin"},
{ID: "2", Name: "Organization Member"},
},
}
json.NewEncoder(w).Encode(mockUser)
} else {
Expand Down Expand Up @@ -375,6 +375,41 @@ func handleUsersV3Request(w http.ResponseWriter, req *http.Request, r *require.A
}
}

func handleUpdateUserRoles(w http.ResponseWriter, req *http.Request, r *require.Assertions) {
if req.Method != http.MethodPut {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}

var updateRequest struct {
ID string `json:"id"`
Email string `json:"email"`
RoleIDs []string `json:"roleIds"`
}

err := json.NewDecoder(req.Body).Decode(&updateRequest)
r.NoError(err)

updatedUser := frontegg.UserResponse{
ID: updateRequest.ID,
Email: updateRequest.Email,
Roles: make([]frontegg.UserRole, len(updateRequest.RoleIDs)),
}

for i, roleID := range updateRequest.RoleIDs {
roleName := "Unknown Role"
if roleID == "1" {
roleName = "Organization Admin"
} else if roleID == "2" {
roleName = "Organization Member"
}
updatedUser.Roles[i] = frontegg.UserRole{ID: roleID, Name: roleName}
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(updatedUser)
}

func handleRolesRequests(w http.ResponseWriter, req *http.Request, r *require.Assertions) {
if req.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
Expand Down

0 comments on commit 4cce86b

Please sign in to comment.