diff --git a/management/server/account.go b/management/server/account.go index b4e0854d8..417c32c09 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -53,6 +53,7 @@ type AccountManager interface { SaveSetupKey(accountID string, key *SetupKey, userID string) (*SetupKey, error) CreateUser(accountID, initiatorUserID string, key *UserInfo) (*UserInfo, error) DeleteUser(accountID, initiatorUserID string, targetUserID string) error + InviteUser(accountID string, initiatorUserID string, targetUserID string) error ListSetupKeys(accountID, userID string) ([]*SetupKey, error) SaveUser(accountID, initiatorUserID string, update *User) (*UserInfo, error) GetSetupKey(accountID, userID, keyID string) (*SetupKey, error) diff --git a/management/server/http/api/openapi.yml b/management/server/http/api/openapi.yml index b795b4608..f7fbca769 100644 --- a/management/server/http/api/openapi.yml +++ b/management/server/http/api/openapi.yml @@ -1274,6 +1274,33 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$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: get: summary: List all Peers diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 79b8e37b3..c99b9b51f 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -113,6 +113,7 @@ func (apiHandler *apiHandler) addUsersEndpoint() { apiHandler.Router.HandleFunc("/users/{userId}", userHandler.UpdateUser).Methods("PUT", "OPTIONS") apiHandler.Router.HandleFunc("/users/{userId}", userHandler.DeleteUser).Methods("DELETE", "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() { diff --git a/management/server/http/users_handler.go b/management/server/http/users_handler.go index 4a9a4a423..45b2a7618 100644 --- a/management/server/http/users_handler.go +++ b/management/server/http/users_handler.go @@ -208,6 +208,37 @@ func (h *UsersHandler) GetAllUsers(w http.ResponseWriter, r *http.Request) { 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 { autoGroups := user.AutoGroups if autoGroups == nil { diff --git a/management/server/http/users_handler_test.go b/management/server/http/users_handler_test.go index c6d7dd4c6..a56507145 100644 --- a/management/server/http/users_handler_test.go +++ b/management/server/http/users_handler_test.go @@ -98,6 +98,17 @@ func initUsersTestData() *UsersHandler { } 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( 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) { tt := []struct { name string diff --git a/management/server/idp/auth0.go b/management/server/idp/auth0.go index f568f51de..517e169d0 100644 --- a/management/server/idp/auth0.go +++ b/management/server/idp/auth0.go @@ -98,6 +98,11 @@ type userExportJobStatusResponse struct { 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 type auth0Profile struct { AccountID string `json:"wt_account_id"` @@ -689,6 +694,48 @@ func (am *Auth0Manager) CreateUser(email string, name string, accountID string) 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. // If the status is "completed", then return the downloadLink func (am *Auth0Manager) checkExportJobStatus(jobID string) (bool, string, error) { diff --git a/management/server/idp/authentik.go b/management/server/idp/authentik.go index 5de3a9666..396d390e2 100644 --- a/management/server/idp/authentik.go +++ b/management/server/idp/authentik.go @@ -440,6 +440,12 @@ func (am *AuthentikManager) GetUserByEmail(email string) ([]*UserData, error) { 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) { jwtToken, err := am.credentials.Authenticate() if err != nil { diff --git a/management/server/idp/azure.go b/management/server/idp/azure.go index 56d2ca4e8..b70e87be1 100644 --- a/management/server/idp/azure.go +++ b/management/server/idp/azure.go @@ -448,6 +448,12 @@ func (am *AzureManager) UpdateUserAppMetadata(userID string, appMetadata AppMeta 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) { q := url.Values{} q.Add("$select", extensionFields) diff --git a/management/server/idp/google_workspace.go b/management/server/idp/google_workspace.go index e35a64c41..9a5d73f75 100644 --- a/management/server/idp/google_workspace.go +++ b/management/server/idp/google_workspace.go @@ -247,6 +247,12 @@ func (gm *GoogleWorkspaceManager) GetUserByEmail(email string) ([]*UserData, err 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. // 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. diff --git a/management/server/idp/idp.go b/management/server/idp/idp.go index 7eaefb213..a4d7c9bdf 100644 --- a/management/server/idp/idp.go +++ b/management/server/idp/idp.go @@ -17,6 +17,7 @@ type Manager interface { GetAllAccounts() (map[string][]*UserData, error) CreateUser(email string, name string, accountID string) (*UserData, error) GetUserByEmail(email string) ([]*UserData, error) + InviteUserByID(userID string) error } // ClientConfig defines common client configuration for all IdP manager diff --git a/management/server/idp/keycloak.go b/management/server/idp/keycloak.go index 90dbf94ff..d44396571 100644 --- a/management/server/idp/keycloak.go +++ b/management/server/idp/keycloak.go @@ -461,6 +461,12 @@ func (km *KeycloakManager) UpdateUserAppMetadata(userID string, appMetadata AppM 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) { attrs := keycloakUserAttributes{} attrs.Set(wtAccountID, appMetadata.WTAccountID) diff --git a/management/server/idp/okta.go b/management/server/idp/okta.go index cc0143464..85813138d 100644 --- a/management/server/idp/okta.go +++ b/management/server/idp/okta.go @@ -302,6 +302,12 @@ func (om *OktaManager) UpdateUserAppMetadata(userID string, appMetadata AppMetad 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, // wt_account_id and wt_pending_invite. func updateUserProfileSchema(client *okta.Client) error { diff --git a/management/server/idp/zitadel.go b/management/server/idp/zitadel.go index fedfc3d4a..4de5659be 100644 --- a/management/server/idp/zitadel.go +++ b/management/server/idp/zitadel.go @@ -441,6 +441,12 @@ func (zm *ZitadelManager) UpdateUserAppMetadata(userID string, appMetadata AppMe 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. func (zm *ZitadelManager) getUserMetadata(userID string) ([]zitadelMetadata, error) { resource := fmt.Sprintf("users/%s/metadata/_search", userID) diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 33f2a81f3..9482c5ec6 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -81,6 +81,7 @@ type MockAccountManager struct { UpdateAccountSettingsFunc func(accountID, userID string, newSettings *server.Settings) (*server.Account, error) LoginPeerFunc func(login server.PeerLogin) (*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 @@ -500,6 +501,13 @@ func (am *MockAccountManager) DeleteUser(accountID string, initiatorUserID strin 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 func (am *MockAccountManager) GetNameServerGroup(accountID, nsGroupID string) (*nbdns.NameServerGroup, error) { if am.GetNameServerGroupFunc != nil { diff --git a/management/server/user.go b/management/server/user.go index 0e4c12042..d30a8fa9e 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -318,6 +318,46 @@ func (am *DefaultAccountManager) DeleteUser(accountID, initiatorUserID string, t 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 func (am *DefaultAccountManager) CreatePAT(accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*PersonalAccessTokenGenerated, error) { unlock := am.Store.AcquireAccountLock(accountID)