From 723375b334d7e7c42304d6dabb521b98d37c5fea Mon Sep 17 00:00:00 2001 From: Tobias Cudnik Date: Wed, 31 May 2023 09:41:54 +0200 Subject: [PATCH] NET-152 enrollment keys for non admins (#2346) * return 401 instead of 403 * fixed http.StatusForbidden * Tagged build version (temp) * Unauthorized_Err when applicable * untagged version * fixed PUT /api/users/networks/user1 * - expired token redirs to login - added `/api/enrollment_keys` for non-admins - unit test for enrollment keys for non-admins * handle user perms in `/hosts` * removed debug * misc * - support masteradmin - return hosts with partial access * added `ismaster` to middleware --- cli/functions/http_client.go | 1 + controllers/enrollmentkeys.go | 25 +++++++++--- controllers/hosts.go | 35 +++++++++++++++-- controllers/network.go | 5 +-- controllers/server.go | 2 +- controllers/user.go | 11 ++++++ logic/enrollmentkey.go | 15 +++++++ logic/enrollmentkey_test.go | 74 +++++++++++++++++++++++++++++++++++ logic/security.go | 6 +++ logic/users.go | 1 + 10 files changed, 161 insertions(+), 14 deletions(-) diff --git a/cli/functions/http_client.go b/cli/functions/http_client.go index e7f48e2b..c4c984f3 100644 --- a/cli/functions/http_client.go +++ b/cli/functions/http_client.go @@ -148,6 +148,7 @@ retry: if res.StatusCode == http.StatusUnauthorized && !retried && ctx.MasterKey == "" { req.Header.Set("Authorization", "Bearer "+getAuthToken(ctx, true)) retried = true + // TODO add a retry limit, drop goto goto retry } resBodyBytes, err := io.ReadAll(res.Body) diff --git a/controllers/enrollmentkeys.go b/controllers/enrollmentkeys.go index 2e62cff1..157a6f71 100644 --- a/controllers/enrollmentkeys.go +++ b/controllers/enrollmentkeys.go @@ -17,7 +17,7 @@ import ( func enrollmentKeyHandlers(r *mux.Router) { r.HandleFunc("/api/v1/enrollment-keys", logic.SecurityCheck(true, http.HandlerFunc(createEnrollmentKey))).Methods(http.MethodPost) - r.HandleFunc("/api/v1/enrollment-keys", logic.SecurityCheck(true, http.HandlerFunc(getEnrollmentKeys))).Methods(http.MethodGet) + r.HandleFunc("/api/v1/enrollment-keys", logic.SecurityCheck(false, http.HandlerFunc(getEnrollmentKeys))).Methods(http.MethodGet) r.HandleFunc("/api/v1/enrollment-keys/{keyID}", logic.SecurityCheck(true, http.HandlerFunc(deleteEnrollmentKey))).Methods(http.MethodDelete) r.HandleFunc("/api/v1/host/register/{token}", http.HandlerFunc(handleHostRegister)).Methods(http.MethodPost) } @@ -34,24 +34,37 @@ func enrollmentKeyHandlers(r *mux.Router) { // Responses: // 200: getEnrollmentKeysSlice func getEnrollmentKeys(w http.ResponseWriter, r *http.Request) { - currentKeys, err := logic.GetAllEnrollmentKeys() + keys, err := logic.GetAllEnrollmentKeys() if err != nil { logger.Log(0, r.Header.Get("user"), "failed to fetch enrollment keys: ", err.Error()) logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } - for i := range currentKeys { - currentKey := currentKeys[i] - if err = logic.Tokenize(currentKey, servercfg.GetAPIHost()); err != nil { + isMasterAdmin := r.Header.Get("ismaster") == "yes" + // regular user flow + user, err := logic.GetUser(r.Header.Get("user")) + if err != nil && !isMasterAdmin { + logger.Log(0, r.Header.Get("user"), "failed to fetch user: ", err.Error()) + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) + return + } + // TODO drop double pointer + ret := []*models.EnrollmentKey{} + for _, key := range keys { + if !isMasterAdmin && !logic.UserHasNetworksAccess(key.Networks, user) { + continue + } + if err = logic.Tokenize(key, servercfg.GetAPIHost()); err != nil { logger.Log(0, r.Header.Get("user"), "failed to get token values for keys:", err.Error()) logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + ret = append(ret, key) } // return JSON/API formatted keys logger.Log(2, r.Header.Get("user"), "fetched enrollment keys") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(currentKeys) + json.NewEncoder(w).Encode(ret) } // swagger:route DELETE /api/v1/enrollment-keys/{keyID} enrollmentKeys deleteEnrollmentKey diff --git a/controllers/hosts.go b/controllers/hosts.go index 833caa44..4ebca999 100644 --- a/controllers/hosts.go +++ b/controllers/hosts.go @@ -19,7 +19,7 @@ import ( ) func hostHandlers(r *mux.Router) { - r.HandleFunc("/api/hosts", logic.SecurityCheck(true, http.HandlerFunc(getHosts))).Methods(http.MethodGet) + r.HandleFunc("/api/hosts", logic.SecurityCheck(false, http.HandlerFunc(getHosts))).Methods(http.MethodGet) r.HandleFunc("/api/hosts/keys", logic.SecurityCheck(true, http.HandlerFunc(updateAllKeys))).Methods(http.MethodPut) r.HandleFunc("/api/hosts/{hostid}/keys", logic.SecurityCheck(true, http.HandlerFunc(updateKeys))).Methods(http.MethodPut) r.HandleFunc("/api/hosts/{hostid}", logic.SecurityCheck(true, http.HandlerFunc(updateHost))).Methods(http.MethodPut) @@ -52,12 +52,41 @@ func getHosts(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + isMasterAdmin := r.Header.Get("ismaster") == "yes" + user, err := logic.GetUser(r.Header.Get("user")) + if err != nil && !isMasterAdmin { + logger.Log(0, r.Header.Get("user"), "failed to fetch user: ", err.Error()) + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) + return + } // return JSON/API formatted hosts + ret := []models.ApiHost{} apiHosts := logic.GetAllHostsAPI(currentHosts[:]) logger.Log(2, r.Header.Get("user"), "fetched all hosts") - logic.SortApiHosts(apiHosts[:]) + for _, host := range apiHosts { + nodes := host.Nodes + // work on the copy + host.Nodes = []string{} + for _, nid := range nodes { + node, err := logic.GetNodeByID(nid) + if err != nil { + logger.Log(0, r.Header.Get("user"), "failed to fetch node: ", err.Error()) + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) + return + } + if !isMasterAdmin && !logic.UserHasNetworksAccess([]string{node.Network}, user) { + continue + } + host.Nodes = append(host.Nodes, nid) + } + // add to the response only if has perms to some nodes / networks + if len(host.Nodes) > 0 { + ret = append(ret, host) + } + } + logic.SortApiHosts(ret[:]) w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(apiHosts) + json.NewEncoder(w).Encode(ret) } // swagger:route GET /api/v1/host pull pullHost diff --git a/controllers/network.go b/controllers/network.go index 5f09286f..faee5d54 100644 --- a/controllers/network.go +++ b/controllers/network.go @@ -40,10 +40,7 @@ func networkHandlers(r *mux.Router) { // Responses: // 200: getNetworksSliceResponse func getNetworks(w http.ResponseWriter, r *http.Request) { - - headerNetworks := r.Header.Get("networks") - networksSlice := []string{} - marshalErr := json.Unmarshal([]byte(headerNetworks), &networksSlice) + networksSlice, marshalErr := getHeaderNetworks(r) if marshalErr != nil { logger.Log(0, r.Header.Get("user"), "error unmarshalling networks: ", marshalErr.Error()) diff --git a/controllers/server.go b/controllers/server.go index 110c792d..a5d6ef66 100644 --- a/controllers/server.go +++ b/controllers/server.go @@ -56,7 +56,7 @@ func getStatus(w http.ResponseWriter, r *http.Request) { func allowUsers(next http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var errorResponse = models.ErrorResponse{ - Code: http.StatusInternalServerError, Message: logic.Forbidden_Msg, + Code: http.StatusUnauthorized, Message: logic.Unauthorized_Msg, } bearerToken := r.Header.Get("Authorization") var tokenSplit = strings.Split(bearerToken, " ") diff --git a/controllers/user.go b/controllers/user.go index 3182e0e0..32fabcc1 100644 --- a/controllers/user.go +++ b/controllers/user.go @@ -491,3 +491,14 @@ func socketHandler(w http.ResponseWriter, r *http.Request) { // Start handling the session go auth.SessionHandler(conn) } + +// getHeaderNetworks returns a slice of networks parsed form the request header. +func getHeaderNetworks(r *http.Request) ([]string, error) { + headerNetworks := r.Header.Get("networks") + networksSlice := []string{} + err := json.Unmarshal([]byte(headerNetworks), &networksSlice) + if err != nil { + return nil, err + } + return networksSlice, nil +} diff --git a/logic/enrollmentkey.go b/logic/enrollmentkey.go index 4909aa11..53c43320 100644 --- a/logic/enrollmentkey.go +++ b/logic/enrollmentkey.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "golang.org/x/exp/slices" "time" "github.com/gravitl/netmaker/database" @@ -68,6 +69,7 @@ func CreateEnrollmentKey(uses int, expiration time.Time, networks, tags []string } // GetAllEnrollmentKeys - fetches all enrollment keys from DB +// TODO drop double pointer func GetAllEnrollmentKeys() ([]*models.EnrollmentKey, error) { currentKeys, err := getEnrollmentKeysMap() if err != nil { @@ -222,3 +224,16 @@ func getEnrollmentKeysMap() (map[string]*models.EnrollmentKey, error) { } return currentKeys, nil } + +// UserHasNetworksAccess - checks if a user `u` has access to all `networks` +func UserHasNetworksAccess(networks []string, u *models.User) bool { + if u.IsAdmin { + return true + } + for _, n := range networks { + if !slices.Contains(u.Networks, n) { + return false + } + } + return true +} diff --git a/logic/enrollmentkey_test.go b/logic/enrollmentkey_test.go index ace8ef9a..5cf36586 100644 --- a/logic/enrollmentkey_test.go +++ b/logic/enrollmentkey_test.go @@ -204,3 +204,77 @@ func TestDeTokenize_EnrollmentKeys(t *testing.T) { removeAllEnrollments() } + +func TestHasNetworksAccess(t *testing.T) { + type Case struct { + // network names + n []string + u models.User + } + pass := []Case{ + { + n: []string{"n1", "n2"}, + u: models.User{ + Networks: []string{"n1", "n2"}, + IsAdmin: false, + }, + }, + { + n: []string{"n1", "n2"}, + u: models.User{ + Networks: []string{}, + IsAdmin: true, + }, + }, + { + n: []string{"n1", "n2"}, + u: models.User{ + Networks: []string{"n1", "n2", "n3"}, + IsAdmin: false, + }, + }, + { + n: []string{"n2"}, + u: models.User{ + Networks: []string{"n2"}, + IsAdmin: false, + }, + }, + } + deny := []Case{ + { + n: []string{"n1", "n2"}, + u: models.User{ + Networks: []string{"n2"}, + IsAdmin: false, + }, + }, + { + n: []string{"n1", "n2"}, + u: models.User{ + Networks: []string{}, + IsAdmin: false, + }, + }, + { + n: []string{"n1", "n2"}, + u: models.User{ + Networks: []string{"n3"}, + IsAdmin: false, + }, + }, + { + n: []string{"n2"}, + u: models.User{ + Networks: []string{"n1"}, + IsAdmin: false, + }, + }, + } + for _, tc := range pass { + assert.True(t, UserHasNetworksAccess(tc.n, &tc.u)) + } + for _, tc := range deny { + assert.False(t, UserHasNetworksAccess(tc.n, &tc.u)) + } +} diff --git a/logic/security.go b/logic/security.go index 3f216a60..f1374324 100644 --- a/logic/security.go +++ b/logic/security.go @@ -31,6 +31,7 @@ func SecurityCheck(reqAdmin bool, next http.Handler) http.HandlerFunc { var errorResponse = models.ErrorResponse{ Code: http.StatusForbidden, Message: Forbidden_Msg, } + r.Header.Set("ismaster", "no") var params = mux.Vars(r) bearerToken := r.Header.Get("Authorization") @@ -53,6 +54,10 @@ func SecurityCheck(reqAdmin bool, next http.Handler) http.HandlerFunc { ReturnErrorResponse(w, r, errorResponse) return } + // detect masteradmin + if len(networks) > 0 && networks[0] == ALL_NETWORK_ACCESS { + r.Header.Set("ismaster", "yes") + } networksJson, err := json.Marshal(&networks) if err != nil { ReturnErrorResponse(w, r, errorResponse) @@ -147,6 +152,7 @@ func UserPermissions(reqAdmin bool, netname string, token string) ([]string, str } //all endpoints here require master so not as complicated if authenticateMaster(authToken) { + // TODO log in as an actual admin user return []string{ALL_NETWORK_ACCESS}, master_uname, nil } username, networks, isadmin, err := VerifyUserToken(authToken) diff --git a/logic/users.go b/logic/users.go index 4c86f3c1..4581d852 100644 --- a/logic/users.go +++ b/logic/users.go @@ -12,6 +12,7 @@ import ( ) // GetUser - gets a user +// TODO support "masteradmin" func GetUser(username string) (*models.User, error) { var user models.User