Skip to content

Commit d39fcfd

Browse files
authored
[management] Add user approval (#4411)
This PR adds user approval functionality to the management system, allowing administrators to manually approve new users joining via domain matching. When enabled, users are blocked with pending approval status until explicitly approved by an admin. Adds UserApprovalRequired setting to control manual user approval requirement Introduces user approval and rejection endpoints with corresponding business logic Prevents pending approval users from adding peers or logging in
1 parent 21368b3 commit d39fcfd

File tree

19 files changed

+842
-48
lines changed

19 files changed

+842
-48
lines changed

management/server/account.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,7 +1136,18 @@ func (am *DefaultAccountManager) addNewPrivateAccount(ctx context.Context, domai
11361136
func (am *DefaultAccountManager) addNewUserToDomainAccount(ctx context.Context, domainAccountID string, userAuth nbcontext.UserAuth) (string, error) {
11371137
newUser := types.NewRegularUser(userAuth.UserId)
11381138
newUser.AccountID = domainAccountID
1139-
err := am.Store.SaveUser(ctx, newUser)
1139+
1140+
settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, domainAccountID)
1141+
if err != nil {
1142+
return "", err
1143+
}
1144+
1145+
if settings != nil && settings.Extra != nil && settings.Extra.UserApprovalRequired {
1146+
newUser.Blocked = true
1147+
newUser.PendingApproval = true
1148+
}
1149+
1150+
err = am.Store.SaveUser(ctx, newUser)
11401151
if err != nil {
11411152
return "", err
11421153
}
@@ -1146,7 +1157,11 @@ func (am *DefaultAccountManager) addNewUserToDomainAccount(ctx context.Context,
11461157
return "", err
11471158
}
11481159

1149-
am.StoreEvent(ctx, userAuth.UserId, userAuth.UserId, domainAccountID, activity.UserJoined, nil)
1160+
if newUser.PendingApproval {
1161+
am.StoreEvent(ctx, userAuth.UserId, userAuth.UserId, domainAccountID, activity.UserJoined, map[string]any{"pending_approval": true})
1162+
} else {
1163+
am.StoreEvent(ctx, userAuth.UserId, userAuth.UserId, domainAccountID, activity.UserJoined, nil)
1164+
}
11501165

11511166
return domainAccountID, nil
11521167
}
@@ -1795,6 +1810,9 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain string, dis
17951810
PeerInactivityExpirationEnabled: false,
17961811
PeerInactivityExpiration: types.DefaultPeerInactivityExpiration,
17971812
RoutingPeerDNSResolutionEnabled: true,
1813+
Extra: &types.ExtraSettings{
1814+
UserApprovalRequired: true,
1815+
},
17981816
},
17991817
Onboarding: types.AccountOnboarding{
18001818
OnboardingFlowPending: true,
@@ -1901,6 +1919,9 @@ func (am *DefaultAccountManager) GetOrCreateAccountByPrivateDomain(ctx context.C
19011919
PeerInactivityExpirationEnabled: false,
19021920
PeerInactivityExpiration: types.DefaultPeerInactivityExpiration,
19031921
RoutingPeerDNSResolutionEnabled: true,
1922+
Extra: &types.ExtraSettings{
1923+
UserApprovalRequired: true,
1924+
},
19041925
},
19051926
}
19061927

management/server/account/manager.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ type Manager interface {
3232
DeleteUser(ctx context.Context, accountID, initiatorUserID string, targetUserID string) error
3333
DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error
3434
InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error
35+
ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error)
36+
RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error
3537
ListSetupKeys(ctx context.Context, accountID, userID string) ([]*types.SetupKey, error)
3638
SaveUser(ctx context.Context, accountID, initiatorUserID string, update *types.User) (*types.UserInfo, error)
3739
SaveOrAddUser(ctx context.Context, accountID, initiatorUserID string, update *types.User, addIfNotExists bool) (*types.UserInfo, error)

management/server/account_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3606,3 +3606,93 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) {
36063606
require.Error(t, err, "should fail with invalid peer ID")
36073607
})
36083608
}
3609+
3610+
func TestAddNewUserToDomainAccountWithApproval(t *testing.T) {
3611+
manager, err := createManager(t)
3612+
if err != nil {
3613+
t.Fatal(err)
3614+
}
3615+
3616+
// Create a domain-based account with user approval enabled
3617+
existingAccountID := "existing-account"
3618+
account := newAccountWithId(context.Background(), existingAccountID, "owner-user", "example.com", false)
3619+
account.Settings.Extra = &types.ExtraSettings{
3620+
UserApprovalRequired: true,
3621+
}
3622+
err = manager.Store.SaveAccount(context.Background(), account)
3623+
require.NoError(t, err)
3624+
3625+
// Set the account as domain primary account
3626+
account.IsDomainPrimaryAccount = true
3627+
account.DomainCategory = types.PrivateCategory
3628+
err = manager.Store.SaveAccount(context.Background(), account)
3629+
require.NoError(t, err)
3630+
3631+
// Test adding new user to existing account with approval required
3632+
newUserID := "new-user-id"
3633+
userAuth := nbcontext.UserAuth{
3634+
UserId: newUserID,
3635+
Domain: "example.com",
3636+
DomainCategory: types.PrivateCategory,
3637+
}
3638+
3639+
acc, err := manager.Store.GetAccount(context.Background(), existingAccountID)
3640+
require.NoError(t, err)
3641+
require.True(t, acc.IsDomainPrimaryAccount, "Account should be primary for the domain")
3642+
require.Equal(t, "example.com", acc.Domain, "Account domain should match")
3643+
3644+
returnedAccountID, err := manager.getAccountIDWithAuthorizationClaims(context.Background(), userAuth)
3645+
require.NoError(t, err)
3646+
require.Equal(t, existingAccountID, returnedAccountID)
3647+
3648+
// Verify user was created with pending approval
3649+
user, err := manager.Store.GetUserByUserID(context.Background(), store.LockingStrengthNone, newUserID)
3650+
require.NoError(t, err)
3651+
assert.True(t, user.Blocked, "User should be blocked when approval is required")
3652+
assert.True(t, user.PendingApproval, "User should be pending approval")
3653+
assert.Equal(t, existingAccountID, user.AccountID)
3654+
}
3655+
3656+
func TestAddNewUserToDomainAccountWithoutApproval(t *testing.T) {
3657+
manager, err := createManager(t)
3658+
if err != nil {
3659+
t.Fatal(err)
3660+
}
3661+
3662+
// Create a domain-based account without user approval
3663+
ownerUserAuth := nbcontext.UserAuth{
3664+
UserId: "owner-user",
3665+
Domain: "example.com",
3666+
DomainCategory: types.PrivateCategory,
3667+
}
3668+
existingAccountID, err := manager.getAccountIDWithAuthorizationClaims(context.Background(), ownerUserAuth)
3669+
require.NoError(t, err)
3670+
3671+
// Modify the account to disable user approval
3672+
account, err := manager.Store.GetAccount(context.Background(), existingAccountID)
3673+
require.NoError(t, err)
3674+
account.Settings.Extra = &types.ExtraSettings{
3675+
UserApprovalRequired: false,
3676+
}
3677+
err = manager.Store.SaveAccount(context.Background(), account)
3678+
require.NoError(t, err)
3679+
3680+
// Test adding new user to existing account without approval required
3681+
newUserID := "new-user-id"
3682+
userAuth := nbcontext.UserAuth{
3683+
UserId: newUserID,
3684+
Domain: "example.com",
3685+
DomainCategory: types.PrivateCategory,
3686+
}
3687+
3688+
returnedAccountID, err := manager.getAccountIDWithAuthorizationClaims(context.Background(), userAuth)
3689+
require.NoError(t, err)
3690+
require.Equal(t, existingAccountID, returnedAccountID)
3691+
3692+
// Verify user was created without pending approval
3693+
user, err := manager.Store.GetUserByUserID(context.Background(), store.LockingStrengthNone, newUserID)
3694+
require.NoError(t, err)
3695+
assert.False(t, user.Blocked, "User should not be blocked when approval is not required")
3696+
assert.False(t, user.PendingApproval, "User should not be pending approval")
3697+
assert.Equal(t, existingAccountID, user.AccountID)
3698+
}

management/server/activity/codes.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ const (
177177

178178
AccountNetworkRangeUpdated Activity = 87
179179
PeerIPUpdated Activity = 88
180+
UserApproved Activity = 89
181+
UserRejected Activity = 90
180182

181183
AccountDeleted Activity = 99999
182184
)
@@ -284,6 +286,8 @@ var activityMap = map[Activity]Code{
284286
AccountNetworkRangeUpdated: {"Account network range updated", "account.network.range.update"},
285287

286288
PeerIPUpdated: {"Peer IP updated", "peer.ip.update"},
289+
UserApproved: {"User approved", "user.approve"},
290+
UserRejected: {"User rejected", "user.reject"},
287291
}
288292

289293
// StringCode returns a string code of the activity

management/server/http/handlers/accounts/accounts_handler.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import (
1111

1212
"github.com/netbirdio/netbird/management/server/account"
1313
nbcontext "github.com/netbirdio/netbird/management/server/context"
14+
"github.com/netbirdio/netbird/management/server/settings"
15+
"github.com/netbirdio/netbird/management/server/types"
1416
"github.com/netbirdio/netbird/shared/management/http/api"
1517
"github.com/netbirdio/netbird/shared/management/http/util"
16-
"github.com/netbirdio/netbird/management/server/settings"
1718
"github.com/netbirdio/netbird/shared/management/status"
18-
"github.com/netbirdio/netbird/management/server/types"
1919
)
2020

2121
const (
@@ -198,6 +198,7 @@ func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) {
198198
if req.Settings.Extra != nil {
199199
settings.Extra = &types.ExtraSettings{
200200
PeerApprovalEnabled: req.Settings.Extra.PeerApprovalEnabled,
201+
UserApprovalRequired: req.Settings.Extra.UserApprovalRequired,
201202
FlowEnabled: req.Settings.Extra.NetworkTrafficLogsEnabled,
202203
FlowGroups: req.Settings.Extra.NetworkTrafficLogsGroups,
203204
FlowPacketCounterEnabled: req.Settings.Extra.NetworkTrafficPacketCounterEnabled,
@@ -327,6 +328,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
327328
if settings.Extra != nil {
328329
apiSettings.Extra = &api.AccountExtraSettings{
329330
PeerApprovalEnabled: settings.Extra.PeerApprovalEnabled,
331+
UserApprovalRequired: settings.Extra.UserApprovalRequired,
330332
NetworkTrafficLogsEnabled: settings.Extra.FlowEnabled,
331333
NetworkTrafficLogsGroups: settings.Extra.FlowGroups,
332334
NetworkTrafficPacketCounterEnabled: settings.Extra.FlowPacketCounterEnabled,

management/server/http/handlers/accounts/accounts_handler_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import (
1515
"github.com/stretchr/testify/assert"
1616

1717
nbcontext "github.com/netbirdio/netbird/management/server/context"
18-
"github.com/netbirdio/netbird/shared/management/http/api"
1918
"github.com/netbirdio/netbird/management/server/mock_server"
2019
"github.com/netbirdio/netbird/management/server/settings"
21-
"github.com/netbirdio/netbird/shared/management/status"
2220
"github.com/netbirdio/netbird/management/server/types"
21+
"github.com/netbirdio/netbird/shared/management/http/api"
22+
"github.com/netbirdio/netbird/shared/management/status"
2323
)
2424

2525
func initAccountsTestData(t *testing.T, account *types.Account) *handler {

management/server/http/handlers/users/users_handler.go

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import (
99
log "github.com/sirupsen/logrus"
1010

1111
"github.com/netbirdio/netbird/management/server/account"
12+
"github.com/netbirdio/netbird/management/server/types"
13+
"github.com/netbirdio/netbird/management/server/users"
1214
"github.com/netbirdio/netbird/shared/management/http/api"
1315
"github.com/netbirdio/netbird/shared/management/http/util"
1416
"github.com/netbirdio/netbird/shared/management/status"
15-
"github.com/netbirdio/netbird/management/server/types"
16-
"github.com/netbirdio/netbird/management/server/users"
1717

1818
nbcontext "github.com/netbirdio/netbird/management/server/context"
1919
)
@@ -31,6 +31,8 @@ func AddEndpoints(accountManager account.Manager, router *mux.Router) {
3131
router.HandleFunc("/users/{userId}", userHandler.deleteUser).Methods("DELETE", "OPTIONS")
3232
router.HandleFunc("/users", userHandler.createUser).Methods("POST", "OPTIONS")
3333
router.HandleFunc("/users/{userId}/invite", userHandler.inviteUser).Methods("POST", "OPTIONS")
34+
router.HandleFunc("/users/{userId}/approve", userHandler.approveUser).Methods("POST", "OPTIONS")
35+
router.HandleFunc("/users/{userId}/reject", userHandler.rejectUser).Methods("DELETE", "OPTIONS")
3436
addUsersTokensEndpoint(accountManager, router)
3537
}
3638

@@ -323,17 +325,76 @@ func toUserResponse(user *types.UserInfo, currenUserID string) *api.User {
323325
}
324326

325327
isCurrent := user.ID == currenUserID
328+
326329
return &api.User{
327-
Id: user.ID,
328-
Name: user.Name,
329-
Email: user.Email,
330-
Role: user.Role,
331-
AutoGroups: autoGroups,
332-
Status: userStatus,
333-
IsCurrent: &isCurrent,
334-
IsServiceUser: &user.IsServiceUser,
335-
IsBlocked: user.IsBlocked,
336-
LastLogin: &user.LastLogin,
337-
Issued: &user.Issued,
330+
Id: user.ID,
331+
Name: user.Name,
332+
Email: user.Email,
333+
Role: user.Role,
334+
AutoGroups: autoGroups,
335+
Status: userStatus,
336+
IsCurrent: &isCurrent,
337+
IsServiceUser: &user.IsServiceUser,
338+
IsBlocked: user.IsBlocked,
339+
LastLogin: &user.LastLogin,
340+
Issued: &user.Issued,
341+
PendingApproval: user.PendingApproval,
342+
}
343+
}
344+
345+
// approveUser is a POST request to approve a user that is pending approval
346+
func (h *handler) approveUser(w http.ResponseWriter, r *http.Request) {
347+
if r.Method != http.MethodPost {
348+
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
349+
return
350+
}
351+
352+
vars := mux.Vars(r)
353+
targetUserID := vars["userId"]
354+
if len(targetUserID) == 0 {
355+
util.WriteErrorResponse("invalid user ID", http.StatusBadRequest, w)
356+
return
357+
}
358+
359+
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
360+
if err != nil {
361+
util.WriteError(r.Context(), err, w)
362+
return
363+
}
364+
user, err := h.accountManager.ApproveUser(r.Context(), userAuth.AccountId, userAuth.UserId, targetUserID)
365+
if err != nil {
366+
util.WriteError(r.Context(), err, w)
367+
return
368+
}
369+
370+
userResponse := toUserResponse(user, userAuth.UserId)
371+
util.WriteJSONObject(r.Context(), w, userResponse)
372+
}
373+
374+
// rejectUser is a DELETE request to reject a user that is pending approval
375+
func (h *handler) rejectUser(w http.ResponseWriter, r *http.Request) {
376+
if r.Method != http.MethodDelete {
377+
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
378+
return
379+
}
380+
381+
vars := mux.Vars(r)
382+
targetUserID := vars["userId"]
383+
if len(targetUserID) == 0 {
384+
util.WriteErrorResponse("invalid user ID", http.StatusBadRequest, w)
385+
return
386+
}
387+
388+
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
389+
if err != nil {
390+
util.WriteError(r.Context(), err, w)
391+
return
338392
}
393+
err = h.accountManager.RejectUser(r.Context(), userAuth.AccountId, userAuth.UserId, targetUserID)
394+
if err != nil {
395+
util.WriteError(r.Context(), err, w)
396+
return
397+
}
398+
399+
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
339400
}

0 commit comments

Comments
 (0)