diff --git a/management/server/account.go b/management/server/account.go index 2c3fb4233..5ee127cc7 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -39,6 +39,7 @@ type AccountManager interface { autoGroups []string, ) (*SetupKey, error) SaveSetupKey(accountID string, key *SetupKey) (*SetupKey, error) + SaveUser(accountID string, key *User) (*UserInfo, error) GetSetupKey(accountID, keyID string) (*SetupKey, error) GetAccountById(accountId string) (*Account, error) GetAccountByUserOrAccountId(userId, accountId, domain string) (*Account, error) @@ -107,10 +108,11 @@ type Account struct { } type UserInfo struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Role string `json:"role"` + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + AutoGroups []string `json:"auto_groups"` } func (a *Account) Copy() *Account { @@ -300,19 +302,25 @@ func (am *DefaultAccountManager) updateIDPMetadata(userId, accountID string) err return nil } -func mergeLocalAndQueryUser(queried idp.UserData, local User) *UserInfo { - return &UserInfo{ - ID: local.Id, - Email: queried.Email, - Name: queried.Name, - Role: string(local.Role), - } -} - func (am *DefaultAccountManager) loadFromCache(_ context.Context, accountID interface{}) (interface{}, error) { return am.idpManager.GetAccount(fmt.Sprintf("%v", accountID)) } +func (am *DefaultAccountManager) lookupUserInCache(user *User, accountID string) (*idp.UserData, error) { + userData, err := am.lookupCache(map[string]*User{user.Id: user}, accountID) + if err != nil { + return nil, err + } + + for _, datum := range userData { + if datum.ID == user.Id { + return datum, nil + } + } + + return nil, status.Errorf(codes.NotFound, "user %s not found in the IdP", user.Id) +} + func (am *DefaultAccountManager) lookupCache(accountUsers map[string]*User, accountID string) ([]*idp.UserData, error) { data, err := am.cacheManager.Get(am.ctx, accountID) if err != nil { @@ -352,46 +360,6 @@ func (am *DefaultAccountManager) lookupCache(accountUsers map[string]*User, acco return userData, err } -// GetUsersFromAccount performs a batched request for users from IDP by account id -func (am *DefaultAccountManager) GetUsersFromAccount(accountID string) ([]*UserInfo, error) { - account, err := am.GetAccountById(accountID) - if err != nil { - return nil, err - } - - queriedUsers := make([]*idp.UserData, 0) - if !isNil(am.idpManager) { - queriedUsers, err = am.lookupCache(account.Users, accountID) - if err != nil { - return nil, err - } - } - - userInfo := make([]*UserInfo, 0) - - // in case of self-hosted, or IDP doesn't return anything, we will return the locally stored userInfo - if len(queriedUsers) == 0 { - for _, user := range account.Users { - userInfo = append(userInfo, &UserInfo{ - ID: user.Id, - Email: "", - Name: "", - Role: string(user.Role), - }) - } - return userInfo, nil - } - - for _, queriedUser := range queriedUsers { - if localUser, contains := account.Users[queriedUser.ID]; contains { - userInfo = append(userInfo, mergeLocalAndQueryUser(*queriedUser, *localUser)) - log.Debugf("Merged userinfo to send back; %v", userInfo) - } - } - - return userInfo, nil -} - // updateAccountDomainAttributes updates the account domain attributes and then, saves the account func (am *DefaultAccountManager) updateAccountDomainAttributes( account *Account, diff --git a/management/server/http/api/openapi.yml b/management/server/http/api/openapi.yml index c76bc9c9b..783e1421b 100644 --- a/management/server/http/api/openapi.yml +++ b/management/server/http/api/openapi.yml @@ -31,13 +31,33 @@ components: description: User's name from idp provider type: string role: - description: User's Netbird account role + description: User's NetBird account role type: string + auto_groups: + description: Groups to auto-assign to peers registered by this user + type: array + items: + type: string required: - id - email - name - role + - auto_groups + UserRequest: + type: object + properties: + auto_groups: + description: Groups to auto-assign to peers registered by this user + type: array + items: + type: string + required: + - name + - type + - expires_in + - revoked + - auto_groups PeerMinimum: type: object properties: @@ -409,6 +429,40 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" + /api/users/{id}: + put: + summary: Update information about a User + tags: [ Users] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The User ID + requestBody: + description: User update + content: + 'application/json': + schema: + $ref: '#/components/schemas/UserRequest' + responses: + '200': + description: A User object + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '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: Returns a list of all peers diff --git a/management/server/http/api/types.gen.go b/management/server/http/api/types.gen.go index 8811ae348..d2ba729b9 100644 --- a/management/server/http/api/types.gen.go +++ b/management/server/http/api/types.gen.go @@ -356,6 +356,9 @@ type SetupKeyRequest struct { // User defines model for User. type User struct { + // Groups to auto-assign to peers registered by this user + AutoGroups []string `json:"auto_groups"` + // User's email address Email string `json:"email"` @@ -365,10 +368,16 @@ type User struct { // User's name from idp provider Name string `json:"name"` - // User's Netbird account role + // User's NetBird account role Role string `json:"role"` } +// UserRequest defines model for UserRequest. +type UserRequest struct { + // Groups to auto-assign to peers registered by this user + AutoGroups []string `json:"auto_groups"` +} + // PostApiGroupsJSONBody defines parameters for PostApiGroups. type PostApiGroupsJSONBody struct { Name string `json:"name"` @@ -442,6 +451,9 @@ type PostApiSetupKeysJSONBody = SetupKeyRequest // PutApiSetupKeysIdJSONBody defines parameters for PutApiSetupKeysId. type PutApiSetupKeysIdJSONBody = SetupKeyRequest +// PutApiUsersIdJSONBody defines parameters for PutApiUsersId. +type PutApiUsersIdJSONBody = UserRequest + // PostApiGroupsJSONRequestBody defines body for PostApiGroups for application/json ContentType. type PostApiGroupsJSONRequestBody PostApiGroupsJSONBody @@ -477,3 +489,6 @@ type PostApiSetupKeysJSONRequestBody = PostApiSetupKeysJSONBody // PutApiSetupKeysIdJSONRequestBody defines body for PutApiSetupKeysId for application/json ContentType. type PutApiSetupKeysIdJSONRequestBody = PutApiSetupKeysIdJSONBody + +// PutApiUsersIdJSONRequestBody defines body for PutApiUsersId for application/json ContentType. +type PutApiUsersIdJSONRequestBody = PutApiUsersIdJSONBody diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 2d536cd3c..1a61fd04e 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -39,6 +39,7 @@ func APIHandler(accountManager s.AccountManager, authIssuer string, authAudience apiHandler.HandleFunc("/api/peers/{id}", peersHandler.HandlePeer). Methods("GET", "PUT", "DELETE", "OPTIONS") apiHandler.HandleFunc("/api/users", userHandler.GetUsers).Methods("GET", "OPTIONS") + apiHandler.HandleFunc("/api/users/{id}", userHandler.UpdateUser).Methods("PUT", "OPTIONS") apiHandler.HandleFunc("/api/setup-keys", keysHandler.GetAllSetupKeysHandler).Methods("GET", "OPTIONS") apiHandler.HandleFunc("/api/setup-keys", keysHandler.CreateSetupKeyHandler).Methods("POST", "OPTIONS") diff --git a/management/server/http/users.go b/management/server/http/users.go index d5f5fef03..93b614a14 100644 --- a/management/server/http/users.go +++ b/management/server/http/users.go @@ -1,7 +1,12 @@ package http import ( + "encoding/json" + "fmt" + "github.com/gorilla/mux" "github.com/netbirdio/netbird/management/server/http/api" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "net/http" log "github.com/sirupsen/logrus" @@ -24,6 +29,52 @@ func NewUserHandler(accountManager server.AccountManager, authAudience string) * } } +// UpdateUser is a PUT requests to update User data +func (h *UserHandler) UpdateUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "", http.StatusBadRequest) + } + + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) + if err != nil { + log.Error(err) + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + vars := mux.Vars(r) + userID := vars["id"] + if len(userID) == 0 { + http.Error(w, "invalid key Id", http.StatusBadRequest) + return + } + + req := &api.PutApiUsersIdJSONRequestBody{} + err = json.NewDecoder(r.Body).Decode(&req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + newUser, err := h.accountManager.SaveUser(account.Id, &server.User{ + Id: userID, + AutoGroups: req.AutoGroups, + }) + if err != nil { + if e, ok := status.FromError(err); ok { + switch e.Code() { + case codes.NotFound: + http.Error(w, fmt.Sprintf("couldn't find a user for ID %s", userID), http.StatusNotFound) + default: + http.Error(w, "failed to update user", http.StatusInternalServerError) + } + } + return + } + writeJSONObject(w, toUserResponse(newUser)) + +} + // GetUsers returns a list of users of the account this user belongs to. // It also gathers additional user data (like email and name) from the IDP manager. func (h *UserHandler) GetUsers(w http.ResponseWriter, r *http.Request) { @@ -52,10 +103,17 @@ func (h *UserHandler) GetUsers(w http.ResponseWriter, r *http.Request) { } func toUserResponse(user *server.UserInfo) *api.User { + + autoGroups := user.AutoGroups + if autoGroups == nil { + autoGroups = []string{} + } + return &api.User{ - Id: user.ID, - Name: user.Name, - Email: user.Email, - Role: user.Role, + Id: user.ID, + Name: user.Name, + Email: user.Email, + Role: user.Role, + AutoGroups: autoGroups, } } diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 44e1519ae..501567c99 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -52,6 +52,7 @@ type MockAccountManager struct { ListRoutesFunc func(accountID string) ([]*route.Route, error) SaveSetupKeyFunc func(accountID string, key *server.SetupKey) (*server.SetupKey, error) ListSetupKeysFunc func(accountID string) ([]*server.SetupKey, error) + SaveUserFunc func(accountID string, user *server.User) (*server.UserInfo, error) } // GetUsersFromAccount mock implementation of GetUsersFromAccount from server.AccountManager interface @@ -421,3 +422,11 @@ func (am *MockAccountManager) ListSetupKeys(accountID string) ([]*server.SetupKe return nil, status.Errorf(codes.Unimplemented, "method ListSetupKeys is not implemented") } + +// SaveUser mocks SaveUser of the AccountManager interface +func (am *MockAccountManager) SaveUser(accountID string, user *server.User) (*server.UserInfo, error) { + if am.SaveUserFunc != nil { + return am.SaveUserFunc(accountID, user) + } + return nil, status.Errorf(codes.Unimplemented, "method SaveUser is not implemented") +} diff --git a/management/server/peer.go b/management/server/peer.go index b43060ee1..615137f79 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -330,6 +330,13 @@ func (am *DefaultAccountManager) AddPeer( if err != nil { return nil, status.Errorf(codes.NotFound, "unable to register peer, unknown user with ID: %s", userID) } + user, ok := account.Users[userID] + if !ok { + return nil, status.Errorf(codes.NotFound, "unable to register peer, unknown user with ID: %s", userID) + } + + groupsToAdd = user.AutoGroups + } else { // Empty setup key and jwt fail return nil, status.Errorf(codes.InvalidArgument, "no setup key or user id provided") diff --git a/management/server/user.go b/management/server/user.go index f69ec40e4..e1032198c 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -2,10 +2,10 @@ package server import ( "fmt" - "strings" - + "github.com/netbirdio/netbird/management/server/idp" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "strings" "github.com/netbirdio/netbird/management/server/jwtclaims" ) @@ -22,20 +22,56 @@ type UserRole string type User struct { Id string Role UserRole + // AutoGroups is a list of Group IDs to auto-assign to peers registered by this user + AutoGroups []string } +// toUserInfo converts a User object to a UserInfo object. +func (u *User) toUserInfo(userData *idp.UserData) (*UserInfo, error) { + autoGroups := u.AutoGroups + if autoGroups == nil { + autoGroups = []string{} + } + + if userData == nil { + return &UserInfo{ + ID: u.Id, + Email: "", + Name: "", + Role: string(u.Role), + AutoGroups: u.AutoGroups, + }, nil + } + if userData.ID != u.Id { + return nil, fmt.Errorf("wrong UserData provided for user %s", u.Id) + } + + return &UserInfo{ + ID: u.Id, + Email: userData.Email, + Name: userData.Name, + Role: string(u.Role), + AutoGroups: autoGroups, + }, nil +} + +// Copy the user func (u *User) Copy() *User { + autoGroups := []string{} + autoGroups = append(autoGroups, u.AutoGroups...) return &User{ - Id: u.Id, - Role: u.Role, + Id: u.Id, + Role: u.Role, + AutoGroups: autoGroups, } } // NewUser creates a new user func NewUser(id string, role UserRole) *User { return &User{ - Id: id, - Role: role, + Id: id, + Role: role, + AutoGroups: []string{}, } } @@ -49,6 +85,54 @@ func NewAdminUser(id string) *User { return NewUser(id, UserRoleAdmin) } +// SaveUser saves updates a given user. If the user doesn't exit it will throw status.NotFound error. +// Only User.AutoGroups field is allowed to be updated for now. +func (am *DefaultAccountManager) SaveUser(accountID string, update *User) (*UserInfo, error) { + am.mux.Lock() + defer am.mux.Unlock() + + if update == nil { + return nil, status.Errorf(codes.InvalidArgument, "provided user update is nil") + } + + account, err := am.Store.GetAccount(accountID) + if err != nil { + return nil, status.Errorf(codes.NotFound, "account not found") + } + + for _, newGroupID := range update.AutoGroups { + if _, ok := account.Groups[newGroupID]; !ok { + return nil, + status.Errorf(codes.InvalidArgument, "provided group ID %s in the user %s update doesn't exist", + newGroupID, update.Id) + } + } + + oldUser := account.Users[update.Id] + if oldUser == nil { + return nil, status.Errorf(codes.NotFound, "update not found") + } + + // only auto groups, revoked status, and name can be updated for now + newUser := oldUser.Copy() + newUser.AutoGroups = update.AutoGroups + + account.Users[newUser.Id] = newUser + + if err = am.Store.SaveAccount(account); err != nil { + return nil, err + } + + if !isNil(am.idpManager) { + userData, err := am.lookupUserInCache(newUser, accountID) + if err != nil { + return nil, err + } + return newUser.toUserInfo(userData) + } + return newUser.toUserInfo(nil) +} + // GetOrCreateAccountByUser returns an existing account for a given user id or creates a new one if doesn't exist func (am *DefaultAccountManager) GetOrCreateAccountByUser(userId, domain string) (*Account, error) { am.mux.Lock() @@ -108,3 +192,46 @@ func (am *DefaultAccountManager) IsUserAdmin(claims jwtclaims.AuthorizationClaim return user.Role == UserRoleAdmin, nil } + +// GetUsersFromAccount performs a batched request for users from IDP by account ID +func (am *DefaultAccountManager) GetUsersFromAccount(accountID string) ([]*UserInfo, error) { + account, err := am.GetAccountById(accountID) + if err != nil { + return nil, err + } + + queriedUsers := make([]*idp.UserData, 0) + if !isNil(am.idpManager) { + queriedUsers, err = am.lookupCache(account.Users, accountID) + if err != nil { + return nil, err + } + } + + userInfos := make([]*UserInfo, 0) + + // in case of self-hosted, or IDP doesn't return anything, we will return the locally stored userInfo + if len(queriedUsers) == 0 { + for _, user := range account.Users { + info, err := user.toUserInfo(nil) + if err != nil { + return nil, err + } + userInfos = append(userInfos, info) + } + return userInfos, nil + } + + for _, queriedUser := range queriedUsers { + if localUser, contains := account.Users[queriedUser.ID]; contains { + + info, err := localUser.toUserInfo(queriedUser) + if err != nil { + return nil, err + } + userInfos = append(userInfos, info) + } + } + + return userInfos, nil +}