Add API Endpoint for Resending User Invitations in Auth0 (#989)

* add request handler for sending invite

* add InviteUser method to account manager interface

* add InviteUser mock

* add invite user endpoint to user handler

* add InviteUserByID to manager interface

* implement InviteUserByID in all idp managers

* resend user invitation

* add invite user handler tests

* refactor

* user userID for sending invitation

* fix typo

* refactor

* pass userId in url params
This commit is contained in:
Bethuel Mmbaga 2023-07-03 13:20:19 +03:00 committed by GitHub
parent 829ce6573e
commit bb9f6f6d0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 248 additions and 0 deletions

View File

@ -53,6 +53,7 @@ type AccountManager interface {
SaveSetupKey(accountID string, key *SetupKey, userID string) (*SetupKey, error) SaveSetupKey(accountID string, key *SetupKey, userID string) (*SetupKey, error)
CreateUser(accountID, initiatorUserID string, key *UserInfo) (*UserInfo, error) CreateUser(accountID, initiatorUserID string, key *UserInfo) (*UserInfo, error)
DeleteUser(accountID, initiatorUserID string, targetUserID string) error DeleteUser(accountID, initiatorUserID string, targetUserID string) error
InviteUser(accountID string, initiatorUserID string, targetUserID string) error
ListSetupKeys(accountID, userID string) ([]*SetupKey, error) ListSetupKeys(accountID, userID string) ([]*SetupKey, error)
SaveUser(accountID, initiatorUserID string, update *User) (*UserInfo, error) SaveUser(accountID, initiatorUserID string, update *User) (*UserInfo, error)
GetSetupKey(accountID, userID, keyID string) (*SetupKey, error) GetSetupKey(accountID, userID, keyID string) (*SetupKey, error)

View File

@ -1274,6 +1274,33 @@ paths:
"$ref": "#/components/responses/forbidden" "$ref": "#/components/responses/forbidden"
'500': '500':
"$ref": "#/components/responses/internal_error" "$ref": "#/components/responses/internal_error"
/api/users/{userId}/invite:
post:
summary: Resend user invitation
description: Resend user invitation
tags: [ Users ]
security:
- BearerAuth: [ ]
- TokenAuth: [ ]
parameters:
- in: path
name: userId
required: true
schema:
type: string
description: The unique identifier of a user
responses:
'200':
description: Invite status code
content: {}
'400':
"$ref": "#/components/responses/bad_request"
'401':
"$ref": "#/components/responses/requires_authentication"
'403':
"$ref": "#/components/responses/forbidden"
'500':
"$ref": "#/components/responses/internal_error"
/api/peers: /api/peers:
get: get:
summary: List all Peers summary: List all Peers

View File

@ -113,6 +113,7 @@ func (apiHandler *apiHandler) addUsersEndpoint() {
apiHandler.Router.HandleFunc("/users/{userId}", userHandler.UpdateUser).Methods("PUT", "OPTIONS") apiHandler.Router.HandleFunc("/users/{userId}", userHandler.UpdateUser).Methods("PUT", "OPTIONS")
apiHandler.Router.HandleFunc("/users/{userId}", userHandler.DeleteUser).Methods("DELETE", "OPTIONS") apiHandler.Router.HandleFunc("/users/{userId}", userHandler.DeleteUser).Methods("DELETE", "OPTIONS")
apiHandler.Router.HandleFunc("/users", userHandler.CreateUser).Methods("POST", "OPTIONS") apiHandler.Router.HandleFunc("/users", userHandler.CreateUser).Methods("POST", "OPTIONS")
apiHandler.Router.HandleFunc("/users/{userId}/invite", userHandler.InviteUser).Methods("POST", "OPTIONS")
} }
func (apiHandler *apiHandler) addUsersTokensEndpoint() { func (apiHandler *apiHandler) addUsersTokensEndpoint() {

View File

@ -208,6 +208,37 @@ func (h *UsersHandler) GetAllUsers(w http.ResponseWriter, r *http.Request) {
util.WriteJSONObject(w, users) util.WriteJSONObject(w, users)
} }
// InviteUser resend invitations to users who haven't activated their accounts,
// prior to the expiration period.
func (h *UsersHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
return
}
claims := h.claimsExtractor.FromRequestContext(r)
account, user, err := h.accountManager.GetAccountFromToken(claims)
if err != nil {
util.WriteError(err, w)
return
}
vars := mux.Vars(r)
targetUserID := vars["userId"]
if len(targetUserID) == 0 {
util.WriteError(status.Errorf(status.InvalidArgument, "invalid user ID"), w)
return
}
err = h.accountManager.InviteUser(account.Id, user.Id, targetUserID)
if err != nil {
util.WriteError(err, w)
return
}
util.WriteJSONObject(w, emptyObject{})
}
func toUserResponse(user *server.UserInfo, currenUserID string) *api.User { func toUserResponse(user *server.UserInfo, currenUserID string) *api.User {
autoGroups := user.AutoGroups autoGroups := user.AutoGroups
if autoGroups == nil { if autoGroups == nil {

View File

@ -98,6 +98,17 @@ func initUsersTestData() *UsersHandler {
} }
return info, nil return info, nil
}, },
InviteUserFunc: func(accountID string, initiatorUserID string, targetUserID string) error {
if initiatorUserID != existingUserID {
return status.Errorf(status.NotFound, "user with ID %s does not exists", initiatorUserID)
}
if targetUserID == notFoundUserID {
return status.Errorf(status.NotFound, "user with ID %s does not exists", targetUserID)
}
return nil
},
}, },
claimsExtractor: jwtclaims.NewClaimsExtractor( claimsExtractor: jwtclaims.NewClaimsExtractor(
jwtclaims.WithFromRequestContext(func(r *http.Request) jwtclaims.AuthorizationClaims { jwtclaims.WithFromRequestContext(func(r *http.Request) jwtclaims.AuthorizationClaims {
@ -340,6 +351,51 @@ func TestCreateUser(t *testing.T) {
} }
} }
func TestInviteUser(t *testing.T) {
tt := []struct {
name string
expectedStatus int
requestType string
requestPath string
requestVars map[string]string
}{
{
name: "Invite User with Existing User",
requestType: http.MethodPost,
requestPath: "/api/users/" + existingUserID + "/invite",
expectedStatus: http.StatusOK,
requestVars: map[string]string{"userId": existingUserID},
},
{
name: "Invite User with missing user_id",
requestType: http.MethodPost,
requestPath: "/api/users/" + notFoundUserID + "/invite",
expectedStatus: http.StatusNotFound,
requestVars: map[string]string{"userId": notFoundUserID},
},
}
userHandler := initUsersTestData()
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.requestType, tc.requestPath, nil)
req = mux.SetURLVars(req, tc.requestVars)
rr := httptest.NewRecorder()
userHandler.InviteUser(rr, req)
res := rr.Result()
defer res.Body.Close()
if status := rr.Code; status != tc.expectedStatus {
t.Fatalf("handler returned wrong status code: got %v want %v",
status, tc.expectedStatus)
}
})
}
}
func TestDeleteUser(t *testing.T) { func TestDeleteUser(t *testing.T) {
tt := []struct { tt := []struct {
name string name string

View File

@ -98,6 +98,11 @@ type userExportJobStatusResponse struct {
ID string `json:"id"` ID string `json:"id"`
} }
// userVerificationJobRequest is a user verification request struct
type userVerificationJobRequest struct {
UserID string `json:"user_id"`
}
// auth0Profile represents an Auth0 user profile response // auth0Profile represents an Auth0 user profile response
type auth0Profile struct { type auth0Profile struct {
AccountID string `json:"wt_account_id"` AccountID string `json:"wt_account_id"`
@ -689,6 +694,48 @@ func (am *Auth0Manager) CreateUser(email string, name string, accountID string)
return &createResp, nil return &createResp, nil
} }
// InviteUserByID resend invitations to users who haven't activated,
// their accounts prior to the expiration period.
func (am *Auth0Manager) InviteUserByID(userID string) error {
userVerificationReq := userVerificationJobRequest{
UserID: userID,
}
payload, err := am.helper.Marshal(userVerificationReq)
if err != nil {
return err
}
req, err := am.createPostRequest("/api/v2/jobs/verification-email", string(payload))
if err != nil {
return err
}
resp, err := am.httpClient.Do(req)
if err != nil {
log.Debugf("Couldn't get job response %v", err)
if am.appMetrics != nil {
am.appMetrics.IDPMetrics().CountRequestError()
}
return err
}
defer func() {
err = resp.Body.Close()
if err != nil {
log.Errorf("error while closing invite user response body: %v", err)
}
}()
if !(resp.StatusCode == 200 || resp.StatusCode == 201) {
if am.appMetrics != nil {
am.appMetrics.IDPMetrics().CountRequestStatusError()
}
return fmt.Errorf("unable to invite user, statusCode %d", resp.StatusCode)
}
return nil
}
// checkExportJobStatus checks the status of the job created at CreateExportUsersJob. // checkExportJobStatus checks the status of the job created at CreateExportUsersJob.
// If the status is "completed", then return the downloadLink // If the status is "completed", then return the downloadLink
func (am *Auth0Manager) checkExportJobStatus(jobID string) (bool, string, error) { func (am *Auth0Manager) checkExportJobStatus(jobID string) (bool, string, error) {

View File

@ -440,6 +440,12 @@ func (am *AuthentikManager) GetUserByEmail(email string) ([]*UserData, error) {
return users, nil return users, nil
} }
// InviteUserByID resend invitations to users who haven't activated,
// their accounts prior to the expiration period.
func (am *AuthentikManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented")
}
func (am *AuthentikManager) authenticationContext() (context.Context, error) { func (am *AuthentikManager) authenticationContext() (context.Context, error) {
jwtToken, err := am.credentials.Authenticate() jwtToken, err := am.credentials.Authenticate()
if err != nil { if err != nil {

View File

@ -448,6 +448,12 @@ func (am *AzureManager) UpdateUserAppMetadata(userID string, appMetadata AppMeta
return nil return nil
} }
// InviteUserByID resend invitations to users who haven't activated,
// their accounts prior to the expiration period.
func (am *AzureManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented")
}
func (am *AzureManager) getUserExtensions() ([]azureExtension, error) { func (am *AzureManager) getUserExtensions() ([]azureExtension, error) {
q := url.Values{} q := url.Values{}
q.Add("$select", extensionFields) q.Add("$select", extensionFields)

View File

@ -247,6 +247,12 @@ func (gm *GoogleWorkspaceManager) GetUserByEmail(email string) ([]*UserData, err
return users, nil return users, nil
} }
// InviteUserByID resend invitations to users who haven't activated,
// their accounts prior to the expiration period.
func (gm *GoogleWorkspaceManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented")
}
// getGoogleCredentials retrieves Google credentials based on the provided serviceAccountKey. // getGoogleCredentials retrieves Google credentials based on the provided serviceAccountKey.
// It decodes the base64-encoded serviceAccountKey and attempts to obtain credentials using it. // It decodes the base64-encoded serviceAccountKey and attempts to obtain credentials using it.
// If that fails, it falls back to using the default Google credentials path. // If that fails, it falls back to using the default Google credentials path.

View File

@ -17,6 +17,7 @@ type Manager interface {
GetAllAccounts() (map[string][]*UserData, error) GetAllAccounts() (map[string][]*UserData, error)
CreateUser(email string, name string, accountID string) (*UserData, error) CreateUser(email string, name string, accountID string) (*UserData, error)
GetUserByEmail(email string) ([]*UserData, error) GetUserByEmail(email string) ([]*UserData, error)
InviteUserByID(userID string) error
} }
// ClientConfig defines common client configuration for all IdP manager // ClientConfig defines common client configuration for all IdP manager

View File

@ -461,6 +461,12 @@ func (km *KeycloakManager) UpdateUserAppMetadata(userID string, appMetadata AppM
return nil return nil
} }
// InviteUserByID resend invitations to users who haven't activated,
// their accounts prior to the expiration period.
func (km *KeycloakManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented")
}
func buildKeycloakCreateUserRequestPayload(email string, name string, appMetadata AppMetadata) (string, error) { func buildKeycloakCreateUserRequestPayload(email string, name string, appMetadata AppMetadata) (string, error) {
attrs := keycloakUserAttributes{} attrs := keycloakUserAttributes{}
attrs.Set(wtAccountID, appMetadata.WTAccountID) attrs.Set(wtAccountID, appMetadata.WTAccountID)

View File

@ -302,6 +302,12 @@ func (om *OktaManager) UpdateUserAppMetadata(userID string, appMetadata AppMetad
return nil return nil
} }
// InviteUserByID resend invitations to users who haven't activated,
// their accounts prior to the expiration period.
func (om *OktaManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented")
}
// updateUserProfileSchema updates the Okta user schema to include custom fields, // updateUserProfileSchema updates the Okta user schema to include custom fields,
// wt_account_id and wt_pending_invite. // wt_account_id and wt_pending_invite.
func updateUserProfileSchema(client *okta.Client) error { func updateUserProfileSchema(client *okta.Client) error {

View File

@ -441,6 +441,12 @@ func (zm *ZitadelManager) UpdateUserAppMetadata(userID string, appMetadata AppMe
return nil return nil
} }
// InviteUserByID resend invitations to users who haven't activated,
// their accounts prior to the expiration period.
func (zm *ZitadelManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented")
}
// getUserMetadata requests user metadata from zitadel via ID. // getUserMetadata requests user metadata from zitadel via ID.
func (zm *ZitadelManager) getUserMetadata(userID string) ([]zitadelMetadata, error) { func (zm *ZitadelManager) getUserMetadata(userID string) ([]zitadelMetadata, error) {
resource := fmt.Sprintf("users/%s/metadata/_search", userID) resource := fmt.Sprintf("users/%s/metadata/_search", userID)

View File

@ -81,6 +81,7 @@ type MockAccountManager struct {
UpdateAccountSettingsFunc func(accountID, userID string, newSettings *server.Settings) (*server.Account, error) UpdateAccountSettingsFunc func(accountID, userID string, newSettings *server.Settings) (*server.Account, error)
LoginPeerFunc func(login server.PeerLogin) (*server.Peer, *server.NetworkMap, error) LoginPeerFunc func(login server.PeerLogin) (*server.Peer, *server.NetworkMap, error)
SyncPeerFunc func(sync server.PeerSync) (*server.Peer, *server.NetworkMap, error) SyncPeerFunc func(sync server.PeerSync) (*server.Peer, *server.NetworkMap, error)
InviteUserFunc func(accountID string, initiatorUserID string, targetUserEmail string) error
} }
// GetUsersFromAccount mock implementation of GetUsersFromAccount from server.AccountManager interface // GetUsersFromAccount mock implementation of GetUsersFromAccount from server.AccountManager interface
@ -500,6 +501,13 @@ func (am *MockAccountManager) DeleteUser(accountID string, initiatorUserID strin
return status.Errorf(codes.Unimplemented, "method DeleteUser is not implemented") return status.Errorf(codes.Unimplemented, "method DeleteUser is not implemented")
} }
func (am *MockAccountManager) InviteUser(accountID string, initiatorUserID string, targetUserID string) error {
if am.InviteUserFunc != nil {
return am.InviteUserFunc(accountID, initiatorUserID, targetUserID)
}
return status.Errorf(codes.Unimplemented, "method InviteUser is not implemented")
}
// GetNameServerGroup mocks GetNameServerGroup of the AccountManager interface // GetNameServerGroup mocks GetNameServerGroup of the AccountManager interface
func (am *MockAccountManager) GetNameServerGroup(accountID, nsGroupID string) (*nbdns.NameServerGroup, error) { func (am *MockAccountManager) GetNameServerGroup(accountID, nsGroupID string) (*nbdns.NameServerGroup, error) {
if am.GetNameServerGroupFunc != nil { if am.GetNameServerGroupFunc != nil {

View File

@ -318,6 +318,46 @@ func (am *DefaultAccountManager) DeleteUser(accountID, initiatorUserID string, t
return nil return nil
} }
// InviteUser resend invitations to users who haven't activated their accounts prior to the expiration period.
func (am *DefaultAccountManager) InviteUser(accountID string, initiatorUserID string, targetUserID string) error {
unlock := am.Store.AcquireAccountLock(accountID)
defer unlock()
if am.idpManager == nil {
return status.Errorf(status.PreconditionFailed, "IdP manager must be enabled to send user invites")
}
account, err := am.Store.GetAccount(accountID)
if err != nil {
return status.Errorf(status.NotFound, "account %s doesn't exist", accountID)
}
// check if the user is already registered with this ID
user, err := am.lookupUserInCache(targetUserID, account)
if err != nil {
return err
}
if user == nil {
return status.Errorf(status.NotFound, "user account %s doesn't exist", targetUserID)
}
// check if user account is already invited and account is not activated
pendingInvite := user.AppMetadata.WTPendingInvite
if pendingInvite == nil || !*pendingInvite {
return status.Errorf(status.PreconditionFailed, "can't invite a user with an activated NetBird account")
}
err = am.idpManager.InviteUserByID(user.ID)
if err != nil {
return err
}
am.storeEvent(initiatorUserID, user.ID, accountID, activity.UserInvited, nil)
return nil
}
// CreatePAT creates a new PAT for the given user // CreatePAT creates a new PAT for the given user
func (am *DefaultAccountManager) CreatePAT(accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*PersonalAccessTokenGenerated, error) { func (am *DefaultAccountManager) CreatePAT(accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*PersonalAccessTokenGenerated, error) {
unlock := am.Store.AcquireAccountLock(accountID) unlock := am.Store.AcquireAccountLock(accountID)